pax_global_header00006660000000000000000000000064150732632110014512gustar00rootroot0000000000000052 comment=020ac7cb09e46d1d1ab2326855f5994249ce5a51 python-aioshelly-13.14.0/000077500000000000000000000000001507326321100151705ustar00rootroot00000000000000python-aioshelly-13.14.0/.devcontainer/000077500000000000000000000000001507326321100177275ustar00rootroot00000000000000python-aioshelly-13.14.0/.devcontainer/Dockerfile000066400000000000000000000016521507326321100217250ustar00rootroot00000000000000# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/python-3/.devcontainer/base.Dockerfile # [Choice] Python version: 3, 3.10, 3.11 ARG VARIANT="3.11" FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} # [Option] Install Node.js ARG INSTALL_NODE="false" ARG NODE_VERSION="lts/*" RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi # [Optional] Uncomment this section to install additional OS packages. # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ # && apt-get -y install --no-install-recommends # [Optional] Uncomment this line to install global node packages. # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 # Set the default shell to bash instead of sh ENV SHELL /bin/bash python-aioshelly-13.14.0/.devcontainer/devcontainer.json000066400000000000000000000026631507326321100233120ustar00rootroot00000000000000// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/python-3 { "name": "Python 3", "build": { "dockerfile": "Dockerfile", "context": "..", "args": { // Update 'VARIANT' to pick a Python version: 3, 3.10, 3.11 "VARIANT": "3.11", // Options "INSTALL_NODE": "false", "NODE_VERSION": "lts/*" } }, "customizations": { "vscode": { "settings": { "terminal.integrated.shell.linux": "/bin/bash", "python.pythonPath": "/usr/local/bin/python", "python.languageServer": "Pylance", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" } }, "extensions": [ "ms-python.python", "ms-python.vscode-pylance", "charliermarsh.ruff" ] } }, // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "script/environment.sh", // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "vscode" } python-aioshelly-13.14.0/.github/000077500000000000000000000000001507326321100165305ustar00rootroot00000000000000python-aioshelly-13.14.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001507326321100207135ustar00rootroot00000000000000python-aioshelly-13.14.0/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000014351507326321100227060ustar00rootroot00000000000000blank_issues_enabled: true contact_links: - name: Report a bug with Shelly url: https://github.com/home-assistant/core/issues about: Please report issues with Shelly in the Home Assistant core repository unless a developer told you otherwise. - name: I have a question or need support url: https://www.home-assistant.io/help about: We use GitHub for tracking bugs, check the Home Assistant website for resources on getting help. - name: Feature Request url: https://community.home-assistant.io/c/feature-requests about: Please use the Home Assistant Community Forum for making feature requests. - name: I'm unsure where to go url: https://www.home-assistant.io/join-chat about: If you are unsure where to go, then joining our chat is recommended; Just ask! python-aioshelly-13.14.0/.github/dependabot.yml000066400000000000000000000004551507326321100213640ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily open-pull-requests-limit: 10 - package-ecosystem: "pip" directory: "/" # Location of package manifests schedule: interval: "weekly" open-pull-requests-limit: 10 python-aioshelly-13.14.0/.github/release-drafter.yml000066400000000000000000000017061507326321100223240ustar00rootroot00000000000000name-template: "$RESOLVED_VERSION" tag-template: "$RESOLVED_VERSION" categories: - title: "🚨 Breaking changes" label: "breaking-change" - title: "✨ New features" label: "new-feature" - title: "🐛 Bug fixes" label: "bugfix" - title: "🚀 Enhancements" labels: - "enhancement" - "refactor" - "performance" - title: "🧰 Maintenance" labels: - "maintenance" - "ci" - title: "📚 Documentation" labels: - "documentation" - title: "⬆️ Dependency updates" collapse-after: 1 labels: - "dependencies" version-resolver: major: labels: - "major" - "breaking-change" minor: labels: - "minor" - "new-feature" patch: labels: - "bugfix" - "ci" - "dependencies" - "documentation" - "enhancement" - "performance" - "refactor" default: patch template: | ## What's Changed $CHANGES python-aioshelly-13.14.0/.github/workflows/000077500000000000000000000000001507326321100205655ustar00rootroot00000000000000python-aioshelly-13.14.0/.github/workflows/pr-labels.yml000066400000000000000000000012251507326321100231710ustar00rootroot00000000000000name: PR Labels on: pull_request_target: types: - opened - labeled - unlabeled - synchronize workflow_call: jobs: pr_labels: name: Verify runs-on: ubuntu-latest steps: - name: Verify PR has a valid label uses: jesusvasquez333/verify-pr-label-action@v1.4.0 with: pull-request-number: "${{ github.event.pull_request.number }}" github-token: "${{ secrets.GITHUB_TOKEN }}" valid-labels: >- breaking-change, bugfix, documentation, enhancement, refactor, performance, new-feature, maintenance, ci, dependencies disable-reviews: true python-aioshelly-13.14.0/.github/workflows/pre-commit-update.yml000066400000000000000000000016001507326321100246410ustar00rootroot00000000000000name: Pre-commit auto-update on: schedule: # Run on mondays at midnight - cron: "0 0 * * 1" jobs: auto-update: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5.0.0 - name: Set up Python uses: actions/setup-python@v6.0.0 with: python-version: "3.x" - name: Install pre-commit run: pip install pre-commit - name: Run pre-commit autoupdate run: pre-commit autoupdate - name: Create Pull Request uses: peter-evans/create-pull-request@v7.0.8 with: token: ${{ secrets.GITHUB_TOKEN }} branch: update/pre-commit-autoupdate title: Auto-update pre-commit hooks commit-message: Auto-update pre-commit hooks body: | Update versions of hooks in pre-commit configs to latest version labels: dependencies python-aioshelly-13.14.0/.github/workflows/pythonpublish.yml000066400000000000000000000020251507326321100242170ustar00rootroot00000000000000# 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: Upload Python Package on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5.0.0 - name: Set up Python uses: actions/setup-python@v6.0.0 with: python-version: "3.x" - name: Install dependencies run: | python -m pip install --upgrade pip pip install build twine - name: Set package version run: | version="${{ github.event.release.tag_name }}" sed -i "s/^version = \".*\"/version = \"${version}\"/" pyproject.toml - name: Build and publish env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | python -m build twine upload dist/* python-aioshelly-13.14.0/.github/workflows/release-drafter.yml000066400000000000000000000005101507326321100243510ustar00rootroot00000000000000name: Release Drafter on: push: branches: - main jobs: update_release_draft: runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@v6.1.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} python-aioshelly-13.14.0/.github/workflows/test.yml000066400000000000000000000017351507326321100222750ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Test on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: - "3.11" - "3.12" - "3.13" - "3.14" steps: - uses: actions/checkout@v5.0.0 with: fetch-depth: 2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | pip install uv tox tox-gh-actions tox-uv - name: Test with tox run: tox - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} python-aioshelly-13.14.0/.gitignore000066400000000000000000000020201507326321100171520ustar00rootroot00000000000000# Hide sublime text stuff *.sublime-project *.sublime-workspace # Hide some OS X stuff .DS_Store .AppleDouble .LSOverride Icon # Thumbnails ._* # IntelliJ IDEA .idea *.iml # pytest .pytest_cache .cache # GITHUB Proposed Python stuff: *.py[cod] # C extensions *.so # Packages *.egg *.egg-info dist build eggs .eggs parts bin var sdist develop-eggs .installed.cfg lib lib64 pip-wheel-metadata # Logs *.log pip-log.txt # Unit test / coverage reports .coverage .tox coverage.xml nosetests.xml htmlcov/ test-reports/ test-results.xml test-output.xml # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject .python-version # emacs auto backups *~ *# *.orig # venv stuff pyvenv.cfg pip-selfcheck.json venv .venv Pipfile* share/* Scripts/ # vimmy stuff *.swp *.swo tags ctags.tmp # vagrant stuff virtualization/vagrant/setup_done virtualization/vagrant/.vagrant virtualization/vagrant/config # Visual Studio Code .vscode/* !.vscode/cSpell.json !.vscode/extensions.json !.vscode/tasks.json # Typing .mypy_cache python-aioshelly-13.14.0/.pre-commit-config.yaml000066400000000000000000000030711507326321100214520ustar00rootroot00000000000000repos: - repo: https://github.com/asottile/pyupgrade rev: v3.21.0 hooks: - id: pyupgrade args: ["--py310-plus"] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.0 hooks: - id: ruff args: - --fix - id: ruff-format - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell args: - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing,iam,incomfort - --skip="./.*,*.csv,*.json" - --quiet-level=2 exclude_types: [csv, json] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-executables-have-shebangs stages: [manual] - id: no-commit-to-branch args: - --branch=main - id: trailing-whitespace - id: end-of-file-fixer - id: check-docstring-first - id: check-yaml - id: debug-statements - repo: https://github.com/PyCQA/pydocstyle rev: 6.3.0 hooks: - id: pydocstyle - repo: https://github.com/adrienverge/yamllint.git rev: v1.37.1 hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier stages: [manual] - repo: https://github.com/pre-commit/mirrors-mypy rev: "v1.18.2" hooks: - id: mypy additional_dependencies: [types-orjson] files: ^aioshelly/.+\.py$ python-aioshelly-13.14.0/.yamllint000066400000000000000000000022301507326321100170170ustar00rootroot00000000000000ignore: | release-drafter.yml rules: braces: level: error min-spaces-inside: 0 max-spaces-inside: 1 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 brackets: level: error min-spaces-inside: 0 max-spaces-inside: 0 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 colons: level: error max-spaces-before: 0 max-spaces-after: 1 commas: level: error max-spaces-before: 0 min-spaces-after: 1 max-spaces-after: 1 comments: level: error require-starting-space: true min-spaces-from-content: 2 comments-indentation: level: error document-end: level: error present: false document-start: level: error present: false empty-lines: level: error max: 1 max-start: 0 max-end: 1 hyphens: level: error max-spaces-after: 1 indentation: level: error spaces: 2 indent-sequences: true check-multi-line-strings: false key-duplicates: level: error line-length: disable new-line-at-end-of-file: level: error new-lines: level: error type: unix trailing-spaces: level: error truthy: disable python-aioshelly-13.14.0/LICENSE000066400000000000000000000261221507326321100162000ustar00rootroot00000000000000 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 2018 Paulus Schoutsen 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. python-aioshelly-13.14.0/README.md000066400000000000000000000071651507326321100164600ustar00rootroot00000000000000[![codecov](https://codecov.io/gh/home-assistant-libs/aioshelly/graph/badge.svg?token=DDH79OVIQ0)](https://codecov.io/gh/home-assistant-libs/aioshelly) [![ci](https://img.shields.io/github/actions/workflow/status/home-assistant-libs/aioshelly/test.yml?branch=main&label=CI&logo=github&style=flat-square)](https://github.com/home-assistant-libs/aioshelly/actions/workflows/test.yml?query=branch%3Amain) # Aioshelly Asynchronous library to control Shelly devices **This library is under development** ## Requirements - Python >= 3.11 - bluetooth-data-tools - aiohttp - orjson ## Install ```bash pip install aioshelly ``` ## Install from Source Run the following command inside this folder ```bash pip install --upgrade . ``` ## Install development requirements Run the following command inside this folder ```bash pip install .[dev] .[lint] ``` ## Examples ### Gen1 Device (Block/CoAP) example: ```python import asyncio from pprint import pprint import aiohttp from aioshelly.block_device import COAP, BlockDevice from aioshelly.common import ConnectionOptions from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError async def test_block_device(): """Test Gen1 Block (CoAP) based device.""" options = ConnectionOptions("192.168.1.165", "username", "password") async with aiohttp.ClientSession() as aiohttp_session, COAP() as coap_context: try: device = await BlockDevice.create(aiohttp_session, coap_context, options) except InvalidAuthError as err: print(f"Invalid or missing authorization, error: {repr(err)}") return except DeviceConnectionError as err: print(f"Error connecting to {options.ip_address}, error: {repr(err)}") return for block in device.blocks: print(block) pprint(block.current_values()) print() if __name__ == "__main__": asyncio.run(test_block_device()) ``` ### Gen2 and Gen3 (RPC/WebSocket) device example: ```python import asyncio from pprint import pprint import aiohttp from aioshelly.common import ConnectionOptions from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from aioshelly.rpc_device import RpcDevice, WsServer async def test_rpc_device(): """Test Gen2/Gen3 RPC (WebSocket) based device.""" options = ConnectionOptions("192.168.1.188", "username", "password") ws_context = WsServer() await ws_context.initialize(8123) async with aiohttp.ClientSession() as aiohttp_session: try: device = await RpcDevice.create(aiohttp_session, ws_context, options) except InvalidAuthError as err: print(f"Invalid or missing authorization, error: {repr(err)}") return except DeviceConnectionError as err: print(f"Error connecting to {options.ip_address}, error: {repr(err)}") return pprint(device.status) if __name__ == "__main__": asyncio.run(test_rpc_device()) ``` ## Example script The repository includes example script to quickly try it out. ### Connect to a device and print its status whenever we receive a state change: ``` python3 tools/example.py -ip [-u ] [-p dict[str, int]: """Get scripts by name.""" scripts = await device.script_list() return {script["name"]: script["id"] for script in scripts} async def async_stop_scanner(device: RpcDevice) -> None: """Stop scanner.""" script_name_to_id = await _async_get_scripts_by_name(device) if script_id := script_name_to_id.get(BLE_SCRIPT_NAME): await device.script_stop(script_id) async def async_start_scanner( device: RpcDevice, active: bool, event_type: str, data_version: int ) -> None: """Start scanner.""" script_name_to_id = await _async_get_scripts_by_name(device) if BLE_SCRIPT_NAME not in script_name_to_id: await device.script_create(BLE_SCRIPT_NAME) script_name_to_id = await _async_get_scripts_by_name(device) ble_script_id = script_name_to_id[BLE_SCRIPT_NAME] # Not using format strings here because the script # code contains curly braces code = ( BLE_CODE.replace(VAR_ACTIVE, "true" if active else "false") .replace(VAR_EVENT_TYPE, event_type) .replace(VAR_VERSION, str(data_version)) ) needs_putcode = False try: code_response = await device.script_getcode(ble_script_id) except RpcCallError: # Script has no code yet needs_putcode = True else: needs_putcode = code_response["data"] != code if needs_putcode: # Avoid writing the flash unless we actually need to # update the script await device.script_stop(ble_script_id) await device.script_putcode(ble_script_id, code) await device.script_start(ble_script_id) def create_scanner( source: str, name: str, requested_mode: BluetoothScanningMode | None = None, current_mode: BluetoothScanningMode | None = None, ) -> ShellyBLEScanner: """Create scanner.""" return ShellyBLEScanner( source, name, HaBluetoothConnector( # no active connections to shelly yet client=None, # type: ignore[arg-type] source=source, can_connect=lambda: False, ), False, requested_mode=requested_mode, current_mode=current_mode, ) async def async_ensure_ble_enabled(device: RpcDevice) -> bool: """Ensure BLE is enabled. Returns True if the device was restarted. Raises RpcCallError if BLE is not supported or could not be enabled. """ ble_config = await device.ble_getconfig() if ble_config["enable"]: return False ble_enable = await device.ble_setconfig(enable=True, enable_rpc=True) if not ble_enable["restart_required"]: return False LOGGER.info("BLE enabled, restarting device %s:%s", device.ip_address, device.port) await device.trigger_reboot(3500) return True python-aioshelly-13.14.0/aioshelly/ble/backend/000077500000000000000000000000001507326321100213125ustar00rootroot00000000000000python-aioshelly-13.14.0/aioshelly/ble/backend/__init__.py000066400000000000000000000000401507326321100234150ustar00rootroot00000000000000"""Bleak backend for shelly.""" python-aioshelly-13.14.0/aioshelly/ble/backend/scanner.py000066400000000000000000000023051507326321100233150ustar00rootroot00000000000000"""Bluetooth scanner for shelly.""" from __future__ import annotations import logging from typing import Any from bluetooth_data_tools import monotonic_time_coarse from habluetooth import BaseHaRemoteScanner from ..const import BLE_SCAN_RESULT_EVENT from ..parser import parse_ble_scan_result_event LOGGER = logging.getLogger(__name__) class ShellyBLEScanner(BaseHaRemoteScanner): """Scanner for shelly.""" def async_on_event(self, event: dict[str, Any]) -> None: """Process an event from the shelly and ignore if its not a ble.scan_result.""" if event.get("event") != BLE_SCAN_RESULT_EVENT: return try: parsed_advs = parse_ble_scan_result_event(event["data"]) except Exception as err: # Broad exception catch because we have no # control over the data that is coming in. LOGGER.error("Failed to parse BLE event: %s", err, exc_info=True) return now = monotonic_time_coarse() for address, rssi, raw in parsed_advs: self._async_on_raw_advertisement( address, rssi, raw, {}, now, ) python-aioshelly-13.14.0/aioshelly/ble/const.py000066400000000000000000000034061507326321100214260ustar00rootroot00000000000000"""Shelly Gen2+ BLE support.""" from __future__ import annotations BLE_SCAN_RESULT_EVENT = "ble.scan_result" BLE_SCRIPT_NAME = "aioshelly_ble_integration" BLE_SCAN_RESULT_VERSION = 2 VAR_EVENT_TYPE = "%event_type%" VAR_ACTIVE = "%active%" VAR_VERSION = "%version%" BLE_CODE = """ // aioshelly BLE script 2.0 // Script automatically installed by Home Assistant for Bluetooth proxy support // https://www.home-assistant.io/integrations/bluetooth/#remote-adapters-bluetooth-proxies const queueServeTimer = 100; // in ms, timer for events emitting const burstSendCount = 5; // number if events, emitted on timer event const maxQueue = 32; // if the queue exceeds the limit, all new events are ignored until it empties const packetsInSingleEvent = 16; // max number of packets in single event let queue = []; let timerHandler = null; function timerCallback() { for(let i = 0; i < burstSendCount; i++) { if (queue.length <= 0) { break; } Shelly.emitEvent( "%event_type%", [ %version%, queue.slice(0, packetsInSingleEvent), ] ); queue = queue.slice(packetsInSingleEvent); } timerHandler = null; if (queue.length > 0) { timerHandler = Timer.set(queueServeTimer, false, timerCallback); } } function bleCallback(event, res) { if (event !== BLE.Scanner.SCAN_RESULT) { return; } if (queue.length > maxQueue) { return; } queue.push([ res.addr, res.rssi, btoa(res.advData), btoa(res.scanRsp) ]); if(!timerHandler) { timerHandler = Timer.set(queueServeTimer, false, timerCallback); } } // Skip starting if scanner is active if (!BLE.Scanner.isRunning()) { BLE.Scanner.Start({ duration_ms: -1, active: %active%, }); } BLE.Scanner.Subscribe(bleCallback); """ # noqa: E501 python-aioshelly-13.14.0/aioshelly/ble/parser.py000066400000000000000000000025021507326321100215700ustar00rootroot00000000000000"""Shelly Gen2 BLE support.""" from __future__ import annotations import logging from binascii import a2b_base64 from typing import Any LOGGER = logging.getLogger(__name__) def parse_ble_scan_result_event( data: list[Any], ) -> list[tuple[str, int, bytes]]: """Parse BLE scan result event.""" version: int = data[0] if version == 1: return _parse_v1(data) if version == 2: # noqa: PLR2004 return _parse_v2(data[1]) raise ValueError(f"Unsupported BLE scan result version: {version}") def _parse_v1(adv: list[Any]) -> list[tuple[str, int, bytes]]: """Convert v1 format to a list of ble tuples.""" _, address, rssi, advertisement_data_b64, scan_response_b64 = adv return [ ( address.upper(), rssi, a2b_base64(advertisement_data_b64.encode("ascii")) + a2b_base64(scan_response_b64.encode("ascii")), ) ] def _parse_v2( advs: list[list[Any]], ) -> list[tuple[str, int, bytes]]: """Convert v2 format to a list of ble tuples.""" return [ ( address.upper(), rssi, a2b_base64(advertisement_data_b64.encode("ascii")) + a2b_base64(scan_response_b64.encode("ascii")), ) for address, rssi, advertisement_data_b64, scan_response_b64 in advs ] python-aioshelly-13.14.0/aioshelly/block_device/000077500000000000000000000000001507326321100215725ustar00rootroot00000000000000python-aioshelly-13.14.0/aioshelly/block_device/__init__.py000066400000000000000000000003461507326321100237060ustar00rootroot00000000000000"""Shelly Gen1 CoAP block based device.""" from .coap import COAP from .device import BLOCK_VALUE_UNIT, Block, BlockDevice, BlockUpdateType __all__ = ["BLOCK_VALUE_UNIT", "COAP", "Block", "BlockDevice", "BlockUpdateType"] python-aioshelly-13.14.0/aioshelly/block_device/coap.py000066400000000000000000000213441507326321100230720ustar00rootroot00000000000000"""COAP for Shelly.""" from __future__ import annotations import asyncio import logging import socket import struct from collections.abc import Callable from enum import Enum, auto from ipaddress import IPv4Address from types import TracebackType from typing import TYPE_CHECKING, Self, cast from ..const import DEFAULT_COAP_PORT, END_OF_OPTIONS_MARKER, PERIODIC_COAP_TYPE_CODE from ..json import JSONDecodeError, json_loads COAP_OPTION_DEVICE_ID = 3332 _LOGGER = logging.getLogger(__name__) class CoapError(Exception): """Base class for COAP errors.""" class InvalidMessage(CoapError): """Raised during COAP message parsing errors.""" class CoapType(Enum): """Coap message type.""" PERIODIC = auto() REPLY = auto() class CoapMessage: """Represents a received coap message.""" def __init__(self, sender_addr: tuple[str, int], payload: bytes) -> None: """Initialize a coap message.""" self.ip = sender_addr[0] self.port = sender_addr[1] self.options: dict[int, bytes] = {} self.coap_type = CoapType.REPLY try: self.vttkl, self.code, self.mid = struct.unpack("!BBH", payload[:4]) except struct.error as err: raise InvalidMessage("Message too short") from err if self.code not in (30, 69): raise InvalidMessage(f"Wrong type, {self.code}") raw_data = payload[4:] option_number = 0 data = b"" # parse options while raw_data: if raw_data[0] == END_OF_OPTIONS_MARKER: data = raw_data[1:] break delta = (raw_data[0] & 0xF0) >> 4 length = raw_data[0] & 0x0F (delta, raw_data) = self._read_extended_field_value(delta, raw_data[1:]) (length, raw_data) = self._read_extended_field_value(length, raw_data) option_number += delta if len(raw_data) < length: raise InvalidMessage("Option announced but absent") self.options[option_number] = raw_data[:length] raw_data = raw_data[length:] if not data: raise InvalidMessage("Received message without data") try: self.payload = json_loads(data.decode()) except (JSONDecodeError, UnicodeDecodeError) as err: raise InvalidMessage( f"Message type {self.code} is not a valid JSON format: {payload!s}" ) from err if self.code == PERIODIC_COAP_TYPE_CODE: self.coap_type = CoapType.PERIODIC _LOGGER.debug( "CoapMessage: ip=%s, type=%s(%s), options=%s, payload=%s", self.ip, self.coap_type, self.code, self.options, self.payload, ) @staticmethod def _read_extended_field_value(value: int, raw_data: bytes) -> tuple[int, bytes]: """Decode large values of option delta and option length.""" if 0 <= value < 13: # noqa: PLR2004 return (value, raw_data) if value == 13: # noqa: PLR2004 if len(raw_data) < 1: raise InvalidMessage("Option ended prematurely") return (raw_data[0] + 13, raw_data[1:]) if value == 14: # noqa: PLR2004 if len(raw_data) < 2: # noqa: PLR2004 raise InvalidMessage("Option ended prematurely") return (int.from_bytes(raw_data[:2], "big") + 269, raw_data[2:]) raise InvalidMessage("Option contained partial payload marker.") def socket_init( socket_port: int = DEFAULT_COAP_PORT, socket_ips: list[IPv4Address] | None = None, ) -> socket.socket: """Init UDP socket to send/receive data with Shelly devices.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("", socket_port)) sock.setblocking(False) multicast_ip_bytes = socket.inet_aton("224.0.1.187") if not socket_ips: _LOGGER.debug("Socket initialized on port %s (default interface)", socket_port) # INADDR_ANY indicates that the OS will chose an interface to join the given # multicast group. mreq = struct.pack("=4sl", multicast_ip_bytes, socket.INADDR_ANY) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) return sock had_successful_joins = False last_exception: OSError | None = None for address in socket_ips: _LOGGER.debug("Socket initialized on %s:%s", address, socket_port) mreq = multicast_ip_bytes + address.packed try: sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) had_successful_joins = True except OSError as ex: _LOGGER.warning("Failed to join multicast group on %s", address) last_exception = ex if not had_successful_joins and last_exception is not None: raise last_exception return sock class COAP(asyncio.DatagramProtocol): """COAP manager.""" def __init__(self, message_received: Callable | None = None) -> None: """Initialize COAP manager.""" self.sock: socket.socket | None = None # Will receive all updates self._message_received = message_received self.subscriptions: dict[str, Callable] = {} self.transport: asyncio.DatagramTransport | None = None async def initialize( self, socket_port: int = DEFAULT_COAP_PORT, socket_ips: list[IPv4Address] | None = None, ) -> None: """Initialize the COAP manager.""" loop = asyncio.get_running_loop() self.sock = socket_init(socket_port, socket_ips) await loop.create_datagram_endpoint(lambda: self, sock=self.sock) async def request(self, ip: str, path: str) -> None: """Request a CoAP message. Subscribe with `subscribe_updates` to receive answer. """ if TYPE_CHECKING: assert self.transport is not None msg = b"\x50\x01\x00\x0a\xb3cit\x01" + path.encode() + b"\xff\x00" _LOGGER.debug("Sending request 'cit/%s' to device %s", path, ip) self.transport.sendto(msg, (ip, 5683)) def close(self) -> None: """Close.""" if self.transport is not None: self.transport.close() def connection_made(self, transport: asyncio.BaseTransport) -> None: """When the socket is set up.""" self.transport = cast(asyncio.DatagramTransport, transport) def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Handle incoming datagram messages.""" host_ip = addr[0] try: msg = CoapMessage(addr, data) except InvalidMessage as err: _LOGGER.debug("Invalid Message from host %s: %s", host_ip, err) return if self._message_received: self._message_received(msg) if COAP_OPTION_DEVICE_ID not in msg.options: _LOGGER.debug("Message from host %s missing device id option", host_ip) return try: device_id = msg.options[COAP_OPTION_DEVICE_ID].decode().split("#")[1][-6:] except (UnicodeDecodeError, IndexError) as err: _LOGGER.debug("Invalid device id from host %s: %s", host_ip, err) return if device_id in self.subscriptions: _LOGGER.debug("Calling CoAP message update for device id %s", device_id) self.subscriptions[device_id](msg) return if msg.ip in self.subscriptions: _LOGGER.debug("Calling CoAP message update for host %s", msg.ip) self.subscriptions[msg.ip](msg) def subscribe_updates( self, ip_or_device_id: str, message_received: Callable ) -> Callable: """Subscribe to received updates.""" _LOGGER.debug("Adding device %s to CoAP message subscriptions", ip_or_device_id) self.subscriptions[ip_or_device_id] = message_received return lambda: self.subscriptions.pop(ip_or_device_id) async def __aenter__(self) -> Self: """Entering async context manager.""" await self.initialize() return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: """Leaving async context manager.""" self.close() async def discovery_dump() -> None: """Dump all discovery data as it comes in.""" async with COAP(lambda msg: print(msg.ip, msg.payload)): # noqa: T201 # This is for diagnostic purposes only. while True: # noqa: ASYNC110 await asyncio.sleep(0.1) if __name__ == "__main__": try: # noqa: SIM105 asyncio.run(discovery_dump()) except KeyboardInterrupt: pass python-aioshelly-13.14.0/aioshelly/block_device/device.py000066400000000000000000000507041507326321100234110ustar00rootroot00000000000000"""Shelly Gen1 CoAP block based device.""" from __future__ import annotations import asyncio import logging from collections.abc import Callable from enum import Enum, auto from http import HTTPStatus from typing import Any, ClassVar, cast from aiohttp import ClientResponse, ClientResponseError, ClientSession, ClientTimeout from yarl import URL from ..common import ( ConnectionOptions, IpOrOptionsType, get_info, is_firmware_supported, process_ip_or_options, ) from ..const import ( CIT_RETRIES, CONNECT_ERRORS, DEFAULT_HTTP_PORT, DEVICE_IO_TIMEOUT, FIRMWARE_PATTERN, GEN1_HTTP_CIT_D_MIN_FIRMWARE_DATE, HTTP_CALL_TIMEOUT, MODEL_RGBW2, ) from ..exceptions import ( CustomPortNotSupported, DeviceConnectionError, DeviceConnectionTimeoutError, InvalidAuthError, MacAddressMismatchError, NotInitialized, ShellyError, WrongShellyGen, ) from ..json import json_loads from .coap import COAP, CoapMessage, CoapType BLOCK_VALUE_UNIT = "U" BLOCK_VALUE_TYPE = "T" BLOCK_VALUE_TYPE_ALARM = "A" BLOCK_VALUE_TYPE_BATTERY_LEVEL = "B" BLOCK_VALUE_TYPE_CONCENTRATION = "C" BLOCK_VALUE_TYPE_ENERGY = "E" BLOCK_VALUE_TYPE_EVENT = "EV" BLOCK_VALUE_TYPE_EVENT_COUNTER = "EVC" BLOCK_VALUE_TYPE_HUMIDITY = "H" BLOCK_VALUE_TYPE_CURRENT = "I" BLOCK_VALUE_TYPE_LUMINOSITY = "L" BLOCK_VALUE_TYPE_POWER = "P" BLOCK_VALUE_TYPE_STATUS = "S" # (catch-all if no other fits) BLOCK_VALUE_TYPE_TEMPERATURE = "T" BLOCK_VALUE_TYPE_VOLTAGE = "V" HTTP_CALL_TIMEOUT_CLIENT_TIMEOUT = ClientTimeout(total=HTTP_CALL_TIMEOUT) _LOGGER = logging.getLogger(__name__) class BlockUpdateType(Enum): """Block Update type.""" COAP_PERIODIC = auto() COAP_REPLY = auto() INITIALIZED = auto() ONLINE = auto() class BlockDevice: """Shelly block device representation.""" def __init__( self, coap_context: COAP, aiohttp_session: ClientSession, options: ConnectionOptions, ) -> None: """Device init.""" self.coap_context: COAP = coap_context self.aiohttp_session: ClientSession = aiohttp_session self.options: ConnectionOptions = options self.coap_d: dict[str, Any] | None = None self.blocks: list[Block] = [] self.coap_s: dict[str, Any] | None = None self._settings: dict[str, Any] | None = None self._shelly: dict[str, Any] | None = None self._status: dict[str, Any] | None = None sub_id = options.ip_address if options.device_mac: sub_id = options.device_mac[-6:] self._unsub_coap: Callable | None = coap_context.subscribe_updates( sub_id, self._coap_message_received ) self._update_listener: Callable | None = None self._coap_response_events: dict[str, asyncio.Event] = {} self.initialized = False self._initializing = False self._last_error: ShellyError | None = None @classmethod async def create( cls: type[BlockDevice], aiohttp_session: ClientSession, coap_context: COAP, ip_or_options: IpOrOptionsType, ) -> BlockDevice: """Device creation.""" options = await process_ip_or_options(ip_or_options) # Try sending cit/s request to trigger a sleeping device try: await coap_context.request(options.ip_address, "s") except OSError as err: _LOGGER.debug("host %s: error: %r", options.ip_address, err) _LOGGER.debug( "host %s: block device create, MAC: %s", options.ip_address, options.device_mac, ) return cls(coap_context, aiohttp_session, options) @property def ip_address(self) -> str: """Device ip address.""" return self.options.ip_address async def initialize(self) -> None: """Device initialization.""" _LOGGER.debug("host %s: block device initialize", self.ip_address) if self._initializing: raise RuntimeError("Already initializing") # GEN1 cannot be configured behind a range extender as CoAP port cannot be # natted if self.options.port != DEFAULT_HTTP_PORT: raise CustomPortNotSupported self._initializing = True # First initialize may already have CoAP status from wakeup event # If device is initialized again we need to fetch new CoAP status if self.initialized: self.initialized = False self.coap_s = None ip = self.options.ip_address try: self._shelly = await get_info( self.aiohttp_session, self.options.ip_address, self.options.device_mac ) if self.requires_auth and not self.options.auth: raise InvalidAuthError("auth missing and required") async with asyncio.timeout(DEVICE_IO_TIMEOUT): await self.update_settings() await self.update_status() # Older devices has incompatible CoAP protocol (v1) # Skip CoAP to avoid parsing errors if self.firmware_supported: await self._update_cit_d() if self.coap_s is None: await self._update_cit_s() self.initialized = True except ClientResponseError as err: if err.status == HTTPStatus.UNAUTHORIZED: self._last_error = InvalidAuthError(err) else: self._last_error = DeviceConnectionError(err) _LOGGER.debug("host %s: error: %r", ip, self._last_error) raise self._last_error from err except MacAddressMismatchError as err: self._last_error = err _LOGGER.debug("host %s: error: %r", ip, err) raise except TimeoutError as err: self._last_error = DeviceConnectionTimeoutError(err) _LOGGER.debug("host %s: timeout error: %r", ip, self._last_error) raise self._last_error from err except CONNECT_ERRORS as err: self._last_error = DeviceConnectionError(err) _LOGGER.debug("host %s: error: %r", ip, self._last_error) raise self._last_error from err finally: self._initializing = False if self._update_listener: self._update_listener(self, BlockUpdateType.INITIALIZED) async def shutdown(self) -> None: """Shutdown device.""" _LOGGER.debug("host %s: block device shutdown", self.ip_address) self._update_listener = None if self._unsub_coap: try: self._unsub_coap() except KeyError as err: _LOGGER.error( "host %s: error during shutdown: %r", self.options.ip_address, err ) self._unsub_coap = None def _coap_message_received(self, msg: CoapMessage) -> None: """COAP message received.""" if not self._initializing and not self.initialized and self._update_listener: self._update_listener(self, BlockUpdateType.ONLINE) if not msg.payload: return if "G" in msg.payload: self._update_s(msg.payload, msg.coap_type) path = "s" elif "blk" in msg.payload: self._update_d(msg.payload) path = "d" else: # Unknown msg return event = self._coap_response_events.pop(path, None) if event is not None: event.set() async def update(self) -> None: """Device update.""" try: async with asyncio.timeout(DEVICE_IO_TIMEOUT): event = await self._coap_request("s") await event.wait() except TimeoutError as err: self._last_error = DeviceConnectionTimeoutError(err) raise self._last_error from err except CONNECT_ERRORS as err: self._last_error = DeviceConnectionError(err) raise self._last_error from err def _update_d(self, data: dict[str, Any]) -> None: """Device update from cit/d call.""" self.coap_d = data blocks = [] for blk in self.coap_d["blk"]: blk_index = blk["I"] blk_sensors = { val["I"]: val for val in self.coap_d["sen"] if ( val["L"] == blk_index if isinstance(val["L"], int) else blk_index in val["L"] ) } block = Block.create(self, blk, blk_sensors) if block: blocks.append(block) self.blocks = blocks def _update_s(self, data: dict[str, Any], coap_type: CoapType) -> None: """Device update from cit/s call.""" self.coap_s = {info[1]: info[2] for info in data["G"]} if self._update_listener and self.initialized: if coap_type is CoapType.PERIODIC: self._update_listener(self, BlockUpdateType.COAP_PERIODIC) return self._update_listener(self, BlockUpdateType.COAP_REPLY) def subscribe_updates(self, update_listener: Callable) -> None: """Subscribe to device status updates.""" self._update_listener = update_listener async def update_status(self) -> None: """Device update from /status (HTTP).""" self._status = await self.http_request("get", "status") async def update_settings(self) -> None: """Device update from /settings (HTTP).""" self._settings = await self.http_request("get", "settings") async def update_shelly(self) -> None: """Device update for /shelly (HTTP).""" self._shelly = await get_info(self.aiohttp_session, self.options.ip_address) async def _update_cit_d(self) -> None: """Update CoAP cit/d. cit/d via HTTP introduced in firmware 1.10 If device does not support cit/d via HTTP, fallback to cit/d via CoAP request. """ match = FIRMWARE_PATTERN.search(self.firmware_version) if match is not None and int(match[0]) >= GEN1_HTTP_CIT_D_MIN_FIRMWARE_DATE: cit_d_res = await self.http_request("get", "cit/d") self._update_d(cit_d_res) return await self._update_cit("d") async def _update_cit_s(self) -> None: """Update CoAP cit/s.""" await self._update_cit("s") async def _update_cit(self, path: str) -> None: """Update CoAP cit with retry.""" for retry in range(CIT_RETRIES): _LOGGER.debug( "host %s: CoAP cit/%s request (retries=%s)", self.ip_address, path, retry, ) try: async with asyncio.timeout(DEVICE_IO_TIMEOUT / 4): event = await self._coap_request(path) await event.wait() return except TimeoutError: if retry == CIT_RETRIES - 1: raise async def _coap_request(self, path: str) -> asyncio.Event: """Device CoAP request.""" if path not in self._coap_response_events: self._coap_response_events[path] = asyncio.Event() event: asyncio.Event = self._coap_response_events[path] await self.coap_context.request(self.ip_address, path) return event async def http_request( self, method: str, path: str, params: Any | None = None, retry: bool = True ) -> dict[str, Any]: """Device HTTP request.""" if self.options.auth is None and self.requires_auth: raise InvalidAuthError("auth missing and required") host = self.options.ip_address _LOGGER.debug("host %s: http request: /%s (params=%s)", host, path, params) try: resp: ClientResponse = await self.aiohttp_session.request( method, URL.build(scheme="http", host=host, path=f"/{path}"), params=params, auth=self.options.auth, raise_for_status=True, timeout=HTTP_CALL_TIMEOUT_CLIENT_TIMEOUT, ) except ClientResponseError as err: if err.status == HTTPStatus.UNAUTHORIZED: self._last_error = InvalidAuthError(err) raise InvalidAuthError(err) from err self._last_error = DeviceConnectionError(err) raise self._last_error from err except TimeoutError as err: self._last_error = DeviceConnectionTimeoutError(err) if retry: _LOGGER.debug( "host %s: http request timeout: %r", host, self._last_error ) return await self.http_request(method, path, params, retry=False) _LOGGER.debug( "host %s: http request retry timeout: %r", host, self._last_error ) raise self._last_error from err except CONNECT_ERRORS as err: self._last_error = DeviceConnectionError(err) if retry: _LOGGER.debug("host %s: http request error: %r", host, self._last_error) return await self.http_request(method, path, params, retry=False) _LOGGER.debug( "host %s: http request retry error: %r", host, self._last_error ) raise self._last_error from err resp_json = await resp.json(loads=json_loads) _LOGGER.debug("aiohttp response: %s", resp_json) return cast(dict, resp_json) async def switch_light_mode(self, mode: str) -> dict[str, Any]: """Change device mode color/white.""" return await self.http_request("get", "settings", {"mode": mode}) async def trigger_ota_update( self, beta: bool = False, url: str | None = None ) -> dict[str, Any]: """Trigger an ota update.""" params = {"update": "true"} if url: params = {"url": url} elif beta: params = {"beta": "true"} return await self.http_request("get", "ota", params=params) async def trigger_reboot(self) -> None: """Trigger a device reboot.""" await self.http_request("get", "reboot") async def trigger_shelly_gas_self_test(self) -> None: """Trigger a Shelly Gas self test.""" await self.http_request("get", "self_test") async def trigger_shelly_gas_mute(self) -> None: """Trigger a Shelly Gas mute action.""" await self.http_request("get", "mute") async def trigger_shelly_gas_unmute(self) -> None: """Trigger a Shelly Gas unmute action.""" await self.http_request("get", "unmute") async def set_shelly_motion_detection(self, enable: bool) -> None: """Enable or disable Shelly Motion motion detection.""" params = {"motion_enable": "true"} if enable else {"motion_enable": "false"} await self.http_request("get", "settings", params) async def set_thermostat_state(self, channel: int = 0, **kwargs: Any) -> None: """Set thermostat state (Shelly TRV).""" await self.http_request("get", f"thermostat/{channel}", kwargs) @property def requires_auth(self) -> bool: """Device check for authentication.""" if "auth" not in self.shelly: raise WrongShellyGen return bool(self.shelly["auth"]) @property def settings(self) -> dict[str, Any]: """Get device settings via HTTP.""" if not self.initialized: raise NotInitialized if self._settings is None: raise InvalidAuthError return self._settings @property def status(self) -> dict[str, Any]: """Get device status via HTTP.""" if not self.initialized: raise NotInitialized if self._status is None: raise InvalidAuthError return self._status @property def shelly(self) -> dict[str, Any]: """Device firmware version.""" if self._shelly is None: raise NotInitialized return self._shelly @property def gen(self) -> int: """Device generation: GEN1 - CoAP.""" return 1 @property def firmware_version(self) -> str: """Device firmware version.""" return cast(str, self.shelly["fw"]) @property def model(self) -> str: """Device model.""" return cast(str, self.shelly["type"]) @property def hostname(self) -> str: """Device hostname.""" return cast(str, self.settings["device"]["hostname"]) @property def name(self) -> str: """Device name.""" return cast(str, self.settings["name"] or self.hostname) @property def last_error(self) -> ShellyError | None: """Return the last error during async device init.""" return self._last_error @property def firmware_supported(self) -> bool: """Return True if device firmware version is supported.""" return is_firmware_supported(self.gen, self.model, self.firmware_version) class Block: """Shelly CoAP block.""" TYPES: ClassVar[dict] = {} type = None def __init_subclass__(cls, blk_type: str = "", **kwargs: Any) -> None: """Initialize a subclass, register if possible.""" super().__init_subclass__(**kwargs) Block.TYPES[blk_type] = cls @staticmethod def create(device: BlockDevice, blk: dict, sensors: dict[str, dict]) -> Any: """Block create.""" blk_type = blk["D"].split("_")[0] cls = Block.TYPES.get(blk_type, Block) return cls(device, blk_type, blk, sensors) def __init__( self, device: BlockDevice, blk_type: str, blk: dict, sensors: dict[str, dict] ) -> None: """Block initialize.""" self.type = blk_type self.device = device self.blk = blk self.sensors = sensors sensor_ids = {} for sensor in sensors.values(): if sensor["D"] not in sensor_ids: sensor_ids[sensor["D"]] = sensor["I"] continue if sensor[BLOCK_VALUE_TYPE] != BLOCK_VALUE_TYPE_TEMPERATURE: raise ValueError( "Found duplicate description for non-temperature sensor" ) if sensor[BLOCK_VALUE_UNIT] == device.options.temperature_unit: sensor_ids[sensor["D"]] = sensor["I"] self.sensor_ids = sensor_ids @property def index(self) -> str: """Block index.""" return cast(str, self.blk["I"]) @property def description(self) -> str: """Block description.""" return cast(str, self.blk["D"]) @property def channel(self) -> str | None: """Block description for channel.""" return self.description.split("_")[1] if "_" in self.description else None def info(self, attr: str) -> dict[str, Any]: """Return info over attribute.""" return self.sensors[self.sensor_ids[attr]] def current_values(self) -> dict[str, Any]: """Block values.""" if self.device.coap_s is None: return {} return { desc: self.device.coap_s.get(index) for desc, index in self.sensor_ids.items() } async def set_state(self, **kwargs: Any) -> dict[str, Any]: """Set state request (HTTP).""" return await self.device.http_request( "get", f"{self.type}/{self.channel}", kwargs ) async def toggle(self) -> dict[str, Any]: """Toggle status.""" return await self.set_state(turn="off" if self.output else "on") def __getattr__(self, attr: str) -> str | None: """Get attribute.""" if attr not in self.sensor_ids: msg = ( f"Device {self.device.model} with firmware " f"{self.device.firmware_version} has no attribute '{attr}' " f"in block {self.type}" ) raise AttributeError(msg) if self.device.coap_s is None: return None return self.device.coap_s.get(self.sensor_ids[attr]) def __str__(self) -> str: """Format string.""" return f"<{self.type} {self.blk}>" class LightBlock(Block, blk_type="light"): """Get light status.""" async def set_state(self, **kwargs: Any) -> dict[str, Any]: """Set light state.""" if self.device.settings["device"]["type"] == MODEL_RGBW2: path = f"{self.device.settings['mode']}/{self.channel}" else: path = f"{self.type}/{self.channel}" return await self.device.http_request("get", path, kwargs) python-aioshelly-13.14.0/aioshelly/common.py000066400000000000000000000076301507326321100210310ustar00rootroot00000000000000"""Common code for Shelly library.""" from __future__ import annotations import asyncio import ipaddress import logging from dataclasses import dataclass from socket import gethostbyname from typing import Any from aiohttp import BasicAuth, ClientSession, ClientTimeout from yarl import URL from .const import ( CONNECT_ERRORS, DEFAULT_HTTP_PORT, DEVICE_IO_TIMEOUT, DEVICES, FIRMWARE_PATTERN, MIN_FIRMWARE_DATES, ) from .exceptions import ( DeviceConnectionError, DeviceConnectionTimeoutError, InvalidHostError, MacAddressMismatchError, ShellyError, ) _LOGGER = logging.getLogger(__name__) DEVICE_IO_TIMEOUT_CLIENT_TIMEOUT = ClientTimeout(total=DEVICE_IO_TIMEOUT) @dataclass class ConnectionOptions: """Shelly options for connection.""" ip_address: str username: str | None = None password: str | None = None temperature_unit: str = "C" auth: BasicAuth | None = None device_mac: str | None = None port: int = DEFAULT_HTTP_PORT def __post_init__(self) -> None: """Call after initialization.""" if self.username is not None: if self.password is None: raise ValueError("Supply both username and password") object.__setattr__(self, "auth", BasicAuth(self.username, self.password)) IpOrOptionsType = str | ConnectionOptions async def process_ip_or_options(ip_or_options: IpOrOptionsType) -> ConnectionOptions: """Return ConnectionOptions class from ip str or ConnectionOptions.""" if isinstance(ip_or_options, str): options = ConnectionOptions(ip_or_options) else: options = ip_or_options try: ipaddress.ip_address(options.ip_address) except ValueError: loop = asyncio.get_running_loop() options.ip_address = await loop.run_in_executor( None, gethostbyname, options.ip_address ) return options async def get_info( aiohttp_session: ClientSession, ip_address: str, device_mac: str | None = None, port: int = DEFAULT_HTTP_PORT, ) -> dict[str, Any]: """Get info from device through REST call.""" error: ShellyError try: async with aiohttp_session.get( URL.build(scheme="http", host=ip_address, port=port, path="/shelly"), raise_for_status=True, timeout=DEVICE_IO_TIMEOUT_CLIENT_TIMEOUT, ) as resp: result: dict[str, Any] = await resp.json() except TimeoutError as err: error = DeviceConnectionTimeoutError(err) _LOGGER.debug("host %s:%s: timeout error: %r", ip_address, port, error) raise error from err except ValueError as err: error = InvalidHostError(err) _LOGGER.debug("host %s is invalid: %r", ip_address, error) raise error from err except CONNECT_ERRORS as err: error = DeviceConnectionError(err) _LOGGER.debug("host %s:%s: error: %r", ip_address, port, error) raise error from err mac = result["mac"] if device_mac and device_mac != mac: error = MacAddressMismatchError(f"Input MAC: {device_mac}, Shelly MAC: {mac}") _LOGGER.debug("host %s:%s: error: %r", ip_address, port, error) raise error return result def is_firmware_supported(gen: int, model: str, firmware_version: str) -> bool: """Return True if firmware is supported.""" fw_ver: int | None if device := DEVICES.get(model): # Specific model is known if not device.supported: return False fw_ver = device.min_fw_date elif not (fw_ver := MIN_FIRMWARE_DATES.get(gen)): # Protection against future generations of devices. return False match = FIRMWARE_PATTERN.search(firmware_version) if match is None: return False # We compare firmware release dates because Shelly version numbering is # inconsistent, sometimes the word is used as the version number. return int(match[0]) >= fw_ver python-aioshelly-13.14.0/aioshelly/const.py000066400000000000000000000757241507326321100207000ustar00rootroot00000000000000"""Constants for aioshelly.""" import re from dataclasses import dataclass from enum import Enum import aiohttp from .exceptions import DeviceConnectionError CONNECT_ERRORS = (aiohttp.ClientError, DeviceConnectionError, OSError) # Gen1 CoAP based models MODEL_1 = "SHSW-1" MODEL_1L = "SHSW-L" MODEL_1PM = "SHSW-PM" MODEL_2 = "SHSW-21" MODEL_25 = "SHSW-25" MODEL_2LED = "SH2LED-1" MODEL_4PRO = "SHSW-44" # CoAP v1, unsupported MODEL_AIR = "SHAIR-1" MODEL_BULB = "SHBLB-1" MODEL_BULB_RGBW = "SHCB-1" MODEL_BUTTON1 = "SHBTN-1" MODEL_BUTTON1_V2 = "SHBTN-2" # hw v2 MODEL_COLOR = "SHCL-255" MODEL_DIMMER = "SHDM-1" MODEL_DIMMER_2 = "SHDM-2" MODEL_DIMMER_W1 = "SHDIMW-1" MODEL_DUO = "SHBDUO-1" MODEL_DW = "SHDW-1" MODEL_DW_2 = "SHDW-2" MODEL_EM = "SHEM" MODEL_EM3 = "SHEM-3" MODEL_FLOOD = "SHWT-1" MODEL_GAS = "SHGS-1" MODEL_HT = "SHHT-1" MODEL_I3 = "SHIX3-1" MODEL_MOTION = "SHMOS-01" MODEL_MOTION_2 = "SHMOS-02" MODEL_PLUG = "SHPLG-1" MODEL_PLUG_E = "SHPLG2-1" MODEL_PLUG_S = "SHPLG-S" MODEL_PLUG_US = "SHPLG-U1" MODEL_RGBW = "SHRGBWW-01" MODEL_RGBW2 = "SHRGBW2" MODEL_SENSE = "SHSEN-1" # CoAP v1, unsupported MODEL_SMOKE = "SHSM-01" MODEL_SMOKE_2 = "SHSM-02" MODEL_SPOT = "SHSPOT-1" MODEL_SPOT_2 = "SHSPOT-2" MODEL_UNI = "SHUNI-1" MODEL_VALVE = "SHTRV-01" MODEL_VINTAGE = "SHBVIN-1" MODEL_VINTAGE_V2 = "SHVIN-1" # Gen2 RPC based models MODEL_BLU_GATEWAY = "SNGW-BT01" MODEL_PLUS_1 = "SNSW-001X16EU" MODEL_PLUS_1_MINI = "SNSW-001X8EU" MODEL_PLUS_1_UL = "SNSW-001X15UL" MODEL_PLUS_10V = "SNGW-0A11WW010" # pre-release of SNDM-00100WW MODEL_PLUS_10V_DIMMER = "SNDM-00100WW" MODEL_PLUS_1PM = "SNSW-001P16EU" MODEL_PLUS_1PM_MINI = "SNSW-001P8EU" MODEL_PLUS_1PM_UL = "SNSW-001P15UL" MODEL_PLUS_2PM = "SNSW-002P16EU" MODEL_PLUS_2PM_UL = "SNSW-002P15UL" MODEL_PLUS_2PM_V2 = "SNSW-102P16EU" MODEL_PLUS_HT = "SNSN-0013A" MODEL_PLUS_I4 = "SNSN-0024X" MODEL_PLUS_I4DC = "SNSN-0D24X" MODEL_PLUS_PLUG_IT = "SNPL-00110IT" MODEL_PLUS_PLUG_S = "SNPL-00112EU" MODEL_PLUS_PLUG_S_V2 = "SNPL-10112EU" # hw v2 MODEL_PLUS_PLUG_UK = "SNPL-00112UK" MODEL_PLUS_PLUG_US = "SNPL-00116US" MODEL_PLUS_PM_MINI = "SNPM-001PCEU16" MODEL_PLUS_RGBW_PM = "SNDC-0D4P10WW" MODEL_PLUS_SMOKE = "SNSN-0031Z" MODEL_PLUS_UNI = "SNSN-0043X" MODEL_PLUS_WALL_DIMMER = "SNDM-0013US" MODEL_PRO_1 = "SPSW-001XE16EU" MODEL_PRO_1_V2 = "SPSW-101XE16EU" MODEL_PRO_1_V3 = "SPSW-201XE16EU" MODEL_PRO_1PM = "SPSW-001PE16EU" MODEL_PRO_1PM_V2 = "SPSW-101PE16EU" MODEL_PRO_1PM_V3 = "SPSW-201PE16EU" MODEL_PRO_1PM_V3_UL = "SPSW-201PE15UL" MODEL_PRO_2 = "SPSW-002XE16EU" MODEL_PRO_2_V2 = "SPSW-102XE16EU" MODEL_PRO_2_V3 = "SPSW-202XE16EU" MODEL_PRO_2_V3_UL = "SPSW-202XE12UL" MODEL_PRO_2PM = "SPSW-002PE16EU" MODEL_PRO_2PM_V2 = "SPSW-102PE16EU" MODEL_PRO_2PM_V2 = "SPSW-202PE16EU" MODEL_PRO_3 = "SPSW-003XE16EU" MODEL_PRO_4PM = "SPSW-004PE16EU" MODEL_PRO_4PM_V2 = "SPSW-104PE16EU" MODEL_PRO_4PM_V3 = "SPSW-204PE16EU" MODEL_PRO_DIMMER_1PM = "SPDM-001PE01EU" MODEL_PRO_DIMMER_2PM = "SPDM-002PE01EU" MODEL_PRO_DUAL_COVER = "SPSH-002PE16EU" MODEL_PRO_EM = "SPEM-002CEBEU50" MODEL_PRO_EM3 = "SPEM-003CEBEU" MODEL_PRO_EM3_120 = "SPEM-003CEBEU120" MODEL_PRO_EM3_3CT63 = "SPEM-003CEBEU63" MODEL_PRO_EM3_400 = "SPEM-003CEBEU400" MODEL_PRO_RGBWW_PM = "SPDC-0D5PE16EU" MODEL_WALL_DISPLAY = "SAWD-0A1XX10EU1" MODEL_WALL_DISPLAY_X2 = "SAWD-2A1XX10EU1" MODEL_WALL_DISPLAY_XL = "SAWD-3A1XE10EU2" # Gen3 RPC based models MODEL_1_G3 = "S3SW-001X16EU" MODEL_1L_G3 = "S3SW-0A1X1EUL" MODEL_1_MINI_G3 = "S3SW-001X8EU" MODEL_1PM_G3 = "S3SW-001P16EU" MODEL_1PM_MINI_G3 = "S3SW-001P8EU" MODEL_2L_G3 = "S3SW-0A2X4EUL" MODEL_2PM_G3 = "S3SW-002P16EU" MODEL_3EM_63_G3 = "S3EM-003CXCEU63" MODEL_AZ_PLUG = "S3PL-10112EU" MODEL_BLU_GATEWAY_G3 = "S3GW-1DBT001" MODEL_DALI_DIMMER_G3 = "S3DM-0A1WW" MODEL_DIMMER_10V_G3 = "S3DM-0010WW" MODEL_DIMMER_G3 = "S3DM-0A101WWL" MODEL_DUO_BULB_G3 = "S3BL-D010009AEU" MODEL_EM_G3 = "S3EM-002CXCEU" MODEL_HT_G3 = "S3SN-0U12A" MODEL_I4_G3 = "S3SN-0024X" MODEL_MULTICOLOR_BULB_G3 = "S3BL-C010007AEU" MODEL_OUT_PLUG_S_G3 = "S3PL-20112EU" MODEL_PLUG_S_G3 = "S3PL-00112EU" MODEL_PM_MINI_G3 = "S3PM-001PCEU16" MODEL_X_MOD1 = "S3MX-0A" # Gen4 RPC based models MODEL_1_G4 = "S4SW-001X16EU" MODEL_1_MINI_G4 = "S4SW-001X8EU" MODEL_1PM_G4 = "S4SW-001P16EU" MODEL_1PM_MINI_G4 = "S4SW-001P8EU" MODEL_2PM_G4 = "S4SW-002P16EU" MODEL_DIMMER_G4 = "S4DM-0A101WWL" MODEL_FLOOD_G4 = "S4SN-0071A" MODEL_I4_G4 = "S4SN-0A24X" MODEL_PLUG_US_G4 = "S4PL-00116US" MODEL_POWER_STRIP_G4 = "S4PL-00416EU" MODEL_PRESENCE_G4 = "S4SN-0U61X" GEN1 = 1 GEN2 = 2 GEN3 = 3 GEN4 = 4 # Firmware 1.9.0 release date GEN1_MIN_FIRMWARE_DATE = 20201124 # Firmware 1.10.0 release date # Introduced cit/d via HTTP request GEN1_HTTP_CIT_D_MIN_FIRMWARE_DATE = 20210302 # Firmware 1.11.0 release date (introduction of light transition) # Due to date fluctuation for different models, # GEN1_LIGHT_TRANSITION_MIN_FIRMWARE_DATE was used. GEN1_LIGHT_TRANSITION_MIN_FIRMWARE_DATE = 20210710 # Firmware 1.0.0 release date GEN2_MIN_FIRMWARE_DATE = 20230803 # Firmware 2.3.0 release date GEN2_WALL_DISPLAY_MIN_FIRMWARE_DATE = 20250110 # Firmware 1.0.99 release date GEN3_MIN_FIRMWARE_DATE = 20231102 # Firmware 1.5.x release date # Temporary use beta release to allow BluTrv support GEN3_GATEWAY_MIN_FIRMWARE_DATE = 20250109 # Firmware 1.4.x release date GEN4_MIN_FIRMWARE_DATE = 20240902 # Fallback for unknown devices MIN_FIRMWARE_DATES = { GEN1: GEN1_MIN_FIRMWARE_DATE, GEN2: GEN2_MIN_FIRMWARE_DATE, GEN3: GEN3_MIN_FIRMWARE_DATE, GEN4: GEN4_MIN_FIRMWARE_DATE, } @dataclass(frozen=True, slots=True) class ShellyDevice: """Shelly device.""" model: str name: str min_fw_date: int gen: int supported: bool DEVICES = { MODEL_1: ShellyDevice( model=MODEL_1, name="Shelly 1", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_1L: ShellyDevice( model=MODEL_1L, name="Shelly 1L", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_1PM: ShellyDevice( model=MODEL_1PM, name="Shelly 1PM", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_2: ShellyDevice( model=MODEL_2, name="Shelly 2", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_25: ShellyDevice( model=MODEL_25, name="Shelly 2.5", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_2LED: ShellyDevice( model=MODEL_2LED, name="Shelly 2LED", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_4PRO: ShellyDevice( model=MODEL_4PRO, name="Shelly 4Pro", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=False, ), MODEL_AIR: ShellyDevice( model=MODEL_AIR, name="Shelly Air", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_BULB: ShellyDevice( model=MODEL_BULB, name="Shelly Bulb", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_BULB_RGBW: ShellyDevice( model=MODEL_BULB_RGBW, name="Shelly Bulb RGBW", min_fw_date=GEN1_LIGHT_TRANSITION_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_BUTTON1: ShellyDevice( model=MODEL_BUTTON1, name="Shelly Button1", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_BUTTON1_V2: ShellyDevice( model=MODEL_BUTTON1_V2, name="Shelly Button1", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_COLOR: ShellyDevice( model=MODEL_COLOR, name="Shelly Color", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_DIMMER: ShellyDevice( model=MODEL_DIMMER, name="Shelly Dimmer", min_fw_date=GEN1_LIGHT_TRANSITION_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_DIMMER_2: ShellyDevice( model=MODEL_DIMMER_2, name="Shelly Dimmer 2", min_fw_date=GEN1_LIGHT_TRANSITION_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_DIMMER_W1: ShellyDevice( model=MODEL_DIMMER_W1, name="Shelly Dimmer W1", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_DUO: ShellyDevice( model=MODEL_DUO, name="Shelly DUO", min_fw_date=GEN1_LIGHT_TRANSITION_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_DW: ShellyDevice( model=MODEL_DW, name="Shelly Door/Window", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_DW_2: ShellyDevice( model=MODEL_DW_2, name="Shelly Door/Window 2", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_EM: ShellyDevice( model=MODEL_EM, name="Shelly EM", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_EM3: ShellyDevice( model=MODEL_EM3, name="Shelly 3EM", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_FLOOD: ShellyDevice( model=MODEL_FLOOD, name="Shelly Flood", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_GAS: ShellyDevice( model=MODEL_GAS, name="Shelly Gas", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_HT: ShellyDevice( model=MODEL_HT, name="Shelly H&T", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_I3: ShellyDevice( model=MODEL_I3, name="Shelly i3", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_MOTION: ShellyDevice( model=MODEL_MOTION, name="Shelly Motion", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_MOTION_2: ShellyDevice( model=MODEL_MOTION_2, name="Shelly Motion 2", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_PLUG: ShellyDevice( model=MODEL_PLUG, name="Shelly Plug", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_PLUG_E: ShellyDevice( model=MODEL_PLUG_E, name="Shelly Plug E", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_PLUG_S: ShellyDevice( model=MODEL_PLUG_S, name="Shelly Plug S", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_PLUG_US: ShellyDevice( model=MODEL_PLUG_US, name="Shelly Plug US", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_RGBW: ShellyDevice( model=MODEL_RGBW, name="Shelly RGBW", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_RGBW2: ShellyDevice( model=MODEL_RGBW2, name="Shelly RGBW2", min_fw_date=GEN1_LIGHT_TRANSITION_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_SENSE: ShellyDevice( model=MODEL_SENSE, name="Shelly Sense", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=False, ), MODEL_SMOKE: ShellyDevice( model=MODEL_SMOKE, name="Shelly Smoke", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_SMOKE_2: ShellyDevice( model=MODEL_SMOKE_2, name="Shelly Smoke 2", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_SPOT: ShellyDevice( model=MODEL_SPOT, name="Shelly Spot", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_SPOT_2: ShellyDevice( model=MODEL_SPOT_2, name="Shelly Spot 2", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_UNI: ShellyDevice( model=MODEL_UNI, name="Shelly UNI", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_VALVE: ShellyDevice( model=MODEL_VALVE, name="Shelly Valve", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_VINTAGE: ShellyDevice( model=MODEL_VINTAGE, name="Shelly Vintage", min_fw_date=GEN1_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_VINTAGE_V2: ShellyDevice( model=MODEL_VINTAGE_V2, name="Shelly Vintage", min_fw_date=GEN1_LIGHT_TRANSITION_MIN_FIRMWARE_DATE, gen=GEN1, supported=True, ), MODEL_BLU_GATEWAY: ShellyDevice( model=MODEL_BLU_GATEWAY, name="Shelly BLU Gateway", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_BLU_GATEWAY_G3: ShellyDevice( model=MODEL_BLU_GATEWAY_G3, name="Shelly BLU Gateway Gen3", min_fw_date=GEN3_GATEWAY_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_PLUS_1: ShellyDevice( model=MODEL_PLUS_1, name="Shelly Plus 1", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_1_MINI: ShellyDevice( model=MODEL_PLUS_1_MINI, name="Shelly Plus 1 Mini", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_1_UL: ShellyDevice( model=MODEL_PLUS_1_UL, name="Shelly Plus 1 UL", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_10V: ShellyDevice( model=MODEL_PLUS_10V, name="Shelly Plus 10V", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_10V_DIMMER: ShellyDevice( model=MODEL_PLUS_10V_DIMMER, name="Shelly Plus 0-10V Dimmer", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_1PM: ShellyDevice( model=MODEL_PLUS_1PM, name="Shelly Plus 1PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_1PM_MINI: ShellyDevice( model=MODEL_PLUS_1PM_MINI, name="Shelly Plus 1PM Mini", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_1PM_UL: ShellyDevice( model=MODEL_PLUS_1PM_UL, name="Shelly Plus 1PM UL", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_2PM: ShellyDevice( model=MODEL_PLUS_2PM, name="Shelly Plus 2PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_2PM_UL: ShellyDevice( model=MODEL_PLUS_2PM_UL, name="Shelly Plus 2PM UL", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_2PM_V2: ShellyDevice( model=MODEL_PLUS_2PM_V2, name="Shelly Plus 2PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_HT: ShellyDevice( model=MODEL_PLUS_HT, name="Shelly Plus H&T", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_I4: ShellyDevice( model=MODEL_PLUS_I4, name="Shelly Plus I4", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_I4DC: ShellyDevice( model=MODEL_PLUS_I4DC, name="Shelly Plus I4DC", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_PLUG_IT: ShellyDevice( model=MODEL_PLUS_PLUG_IT, name="Shelly Plus Plug IT", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_PLUG_S: ShellyDevice( model=MODEL_PLUS_PLUG_S, name="Shelly Plus Plug S", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_PLUG_S_V2: ShellyDevice( model=MODEL_PLUS_PLUG_S_V2, name="Shelly Plus Plug S", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_PLUG_UK: ShellyDevice( model=MODEL_PLUS_PLUG_UK, name="Shelly Plus Plug UK", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_PLUG_US: ShellyDevice( model=MODEL_PLUS_PLUG_US, name="Shelly Plus Plug US", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_PM_MINI: ShellyDevice( model=MODEL_PLUS_PM_MINI, name="Shelly Plus PM Mini", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_RGBW_PM: ShellyDevice( model=MODEL_PLUS_RGBW_PM, name="Shelly Plus RGBW PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_SMOKE: ShellyDevice( model=MODEL_PLUS_SMOKE, name="Shelly Plus Smoke", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_UNI: ShellyDevice( model=MODEL_PLUS_UNI, name="Shelly Plus Uni", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PLUS_WALL_DIMMER: ShellyDevice( model=MODEL_PLUS_WALL_DIMMER, name="Shelly Plus Wall Dimmer", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_1: ShellyDevice( model=MODEL_PRO_1, name="Shelly Pro 1", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_1_V2: ShellyDevice( model="SPSW-101XE16EU", name="Shelly Pro 1", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_1_V3: ShellyDevice( model=MODEL_PRO_1_V3, name="Shelly Pro 1", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_1PM: ShellyDevice( model=MODEL_PRO_1PM, name="Shelly Pro 1PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_1PM_V2: ShellyDevice( model=MODEL_PRO_1PM_V2, name="Shelly Pro 1PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_1PM_V3: ShellyDevice( model=MODEL_PRO_1PM_V3, name="Shelly Pro 1PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_1PM_V3_UL: ShellyDevice( model=MODEL_PRO_1PM_V3_UL, name="Shelly Pro 1PM UL", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_2: ShellyDevice( model=MODEL_PRO_2, name="Shelly Pro 2", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_2_V2: ShellyDevice( model=MODEL_PRO_2_V2, name="Shelly Pro 2", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_2_V3: ShellyDevice( model=MODEL_PRO_2_V3, name="Shelly Pro 2", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_2_V3_UL: ShellyDevice( model=MODEL_PRO_2_V3_UL, name="Shelly Pro 2 UL", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_2PM: ShellyDevice( model=MODEL_PRO_2PM, name="Shelly Pro 2PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_2PM_V2: ShellyDevice( model=MODEL_PRO_2PM_V2, name="Shelly Pro 2PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_3: ShellyDevice( model=MODEL_PRO_3, name="Shelly Pro 3", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_4PM: ShellyDevice( model=MODEL_PRO_4PM, name="Shelly Pro 4PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_4PM_V2: ShellyDevice( model=MODEL_PRO_4PM_V2, name="Shelly Pro 4PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_4PM_V3: ShellyDevice( model=MODEL_PRO_4PM_V3, name="Shelly Pro 4PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_DIMMER_1PM: ShellyDevice( model=MODEL_PRO_DIMMER_1PM, name="Shelly Pro Dimmer 1PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_DIMMER_2PM: ShellyDevice( model=MODEL_PRO_DIMMER_2PM, name="Shelly Pro Dimmer 2PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_DUAL_COVER: ShellyDevice( model=MODEL_PRO_DUAL_COVER, name="Shelly Pro Dual Cover PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_EM: ShellyDevice( model=MODEL_PRO_EM, name="Shelly Pro EM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_EM3: ShellyDevice( model=MODEL_PRO_EM3, name="Shelly Pro 3EM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_EM3_120: ShellyDevice( model=MODEL_PRO_EM3_120, name="Shelly Pro 3EM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_EM3_400: ShellyDevice( model=MODEL_PRO_EM3_400, name="Shelly Pro 3EM-400", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_EM3_3CT63: ShellyDevice( model=MODEL_PRO_EM3_3CT63, name="Shelly Pro 3EM 3CT63", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_PRO_RGBWW_PM: ShellyDevice( model=MODEL_PRO_RGBWW_PM, name="Shelly Pro RGBWW PM", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_WALL_DISPLAY: ShellyDevice( model=MODEL_WALL_DISPLAY, name="Shelly Wall Display", min_fw_date=GEN2_WALL_DISPLAY_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_WALL_DISPLAY_X2: ShellyDevice( model=MODEL_WALL_DISPLAY_X2, name="Shelly Wall Display X2", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_WALL_DISPLAY_XL: ShellyDevice( model=MODEL_WALL_DISPLAY_XL, name="Shelly Wall Display XL", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN2, supported=True, ), MODEL_1_G3: ShellyDevice( model=MODEL_1_G3, name="Shelly 1 Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_1L_G3: ShellyDevice( model=MODEL_1L_G3, name="Shelly 1L Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_1_MINI_G3: ShellyDevice( model=MODEL_1_MINI_G3, name="Shelly 1 Mini Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_1PM_G3: ShellyDevice( model=MODEL_1PM_G3, name="Shelly 1PM Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_1PM_MINI_G3: ShellyDevice( model=MODEL_1PM_MINI_G3, name="Shelly 1PM Mini Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_2L_G3: ShellyDevice( model=MODEL_2L_G3, name="Shelly 2L Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_2PM_G3: ShellyDevice( model=MODEL_2PM_G3, name="Shelly 2PM Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_3EM_63_G3: ShellyDevice( model=MODEL_3EM_63_G3, name="Shelly 3EM-63 Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_AZ_PLUG: ShellyDevice( model=MODEL_AZ_PLUG, name="Shelly AZ Plug", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_DALI_DIMMER_G3: ShellyDevice( model=MODEL_DALI_DIMMER_G3, name="Shelly DALI Dimmer Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_DIMMER_10V_G3: ShellyDevice( model=MODEL_DIMMER_10V_G3, name="Shelly Dimmer 0/1-10V PM Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_DIMMER_G3: ShellyDevice( model=MODEL_DIMMER_G3, name="Shelly Dimmer Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_DUO_BULB_G3: ShellyDevice( model=MODEL_DUO_BULB_G3, name="Shelly Duo Bulb Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_EM_G3: ShellyDevice( model=MODEL_EM_G3, name="Shelly EM Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_HT_G3: ShellyDevice( model=MODEL_HT_G3, name="Shelly H&T Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_I4_G3: ShellyDevice( model=MODEL_I4_G3, name="Shelly I4 Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_MULTICOLOR_BULB_G3: ShellyDevice( model=MODEL_MULTICOLOR_BULB_G3, name="Shelly Multicolor Bulb Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_OUT_PLUG_S_G3: ShellyDevice( model=MODEL_OUT_PLUG_S_G3, name="Shelly Outdoor Plug S Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_PM_MINI_G3: ShellyDevice( model=MODEL_PM_MINI_G3, name="Shelly PM Mini Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_PLUG_S_G3: ShellyDevice( model=MODEL_PLUG_S_G3, name="Shelly Plug S Gen3", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_X_MOD1: ShellyDevice( model=MODEL_X_MOD1, name="Shelly X MOD1", min_fw_date=GEN3_MIN_FIRMWARE_DATE, gen=GEN3, supported=True, ), MODEL_1_G4: ShellyDevice( model=MODEL_1_G4, name="Shelly 1 Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_1_MINI_G4: ShellyDevice( model=MODEL_1_MINI_G4, name="Shelly 1 Mini Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_1PM_G4: ShellyDevice( model=MODEL_1PM_G4, name="Shelly 1PM Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_1PM_MINI_G4: ShellyDevice( model=MODEL_1PM_MINI_G4, name="Shelly 1PM Mini Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_2PM_G4: ShellyDevice( model=MODEL_2PM_G4, name="Shelly 2PM Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_DIMMER_G4: ShellyDevice( model=MODEL_DIMMER_G4, name="Shelly Dimmer Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_I4_G4: ShellyDevice( model=MODEL_I4_G4, name="Shelly I4 Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_FLOOD_G4: ShellyDevice( model=MODEL_FLOOD_G4, name="Shelly Flood Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_PLUG_US_G4: ShellyDevice( model=MODEL_PLUG_US_G4, name="Shelly Plug US Gen4", min_fw_date=GEN2_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_POWER_STRIP_G4: ShellyDevice( model=MODEL_POWER_STRIP_G4, name="Shelly Power Strip Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), MODEL_PRESENCE_G4: ShellyDevice( model=MODEL_PRESENCE_G4, name="Shelly Presence Gen4", min_fw_date=GEN4_MIN_FIRMWARE_DATE, gen=GEN4, supported=True, ), } GEN1_MODELS_SUPPORTING_LIGHT_TRANSITION = { MODEL_DUO, MODEL_BULB_RGBW, MODEL_DIMMER, MODEL_DIMMER_2, MODEL_RGBW2, MODEL_VINTAGE_V2, } BLU_TRV_IDENTIFIER = "blutrv" BLU_TRV_MODEL_ID = {8: "SBTR-001AEU"} BLU_TRV_MODEL_NAME = {"SBTR-001AEU": "Shelly BLU TRV"} class UndefinedType(Enum): """Singleton type for use with not set sentinel values.""" _singleton = 0 UNDEFINED = UndefinedType._singleton # noqa: SLF001 MODEL_NAMES = {data.model: data.name for data in DEVICES.values()} # Number of retries for cit requests CIT_RETRIES = 3 # Timeout used for Device IO DEVICE_IO_TIMEOUT = 10.0 # Timeout used for polling DEVICE_POLL_TIMEOUT = 45.0 # Timeout used for initial connection calls # after the connection has been established DEVICE_INIT_TIMEOUT = 30.0 # Timeout used for HTTP calls HTTP_CALL_TIMEOUT = 10.0 WS_HEARTBEAT = 55 # Default network settings for gen1 devices ( CoAP ) DEFAULT_COAP_PORT = 5683 # Default Gen2 outbound websocket API URL WS_API_URL = "/api/shelly/ws" # Notification sent by RPC device in case of WebSocket close NOTIFY_WS_CLOSED = "NotifyWebSocketClosed" BLOCK_GENERATIONS = {GEN1} RPC_GENERATIONS = {GEN2, GEN3, GEN4} DEFAULT_HTTP_PORT = 80 PERIODIC_COAP_TYPE_CODE = 30 END_OF_OPTIONS_MARKER = 0xFF FIRMWARE_PATTERN = re.compile(r"^(\d{8})") # Firmware 1.2.0 release date VIRTUAL_COMPONENTS_MIN_FIRMWARE = 20240213 # value confirmed by Shelly team BLU_TRV_TIMEOUT = 60 python-aioshelly-13.14.0/aioshelly/exceptions.py000066400000000000000000000032701507326321100217160ustar00rootroot00000000000000"""Shelly exceptions.""" from __future__ import annotations # Internal or run time errors: # Errors not needed to be handled by the caller # 'NotInitialized' & 'WrongShellyGen' indicate runtime errors class ShellyError(Exception): """Base class for aioshelly errors.""" class ConnectionClosed(ShellyError): """Exception raised when the connection is closed.""" class InvalidMessage(ShellyError): """Exception raised when an invalid message is received.""" class NotInitialized(ShellyError): """Raised if device is not initialized.""" class WrongShellyGen(ShellyError): """Exception raised to indicate wrong Shelly generation.""" # Errors to be handled by the caller: # Errors that are expected to happen and should be handled by the caller. class DeviceConnectionError(ShellyError): """Exception indicates device connection errors.""" class DeviceConnectionTimeoutError(DeviceConnectionError): """Exception indicates device connection timeout errors.""" class InvalidAuthError(ShellyError): """Raised to indicate invalid or missing authentication error.""" class InvalidHostError(ShellyError): """Raised to indicate invalid host error.""" class MacAddressMismatchError(ShellyError): """Raised if input MAC address does not match the device MAC address.""" class CustomPortNotSupported(ShellyError): """Raise if GEN1 devices are access with custom port.""" class RpcCallError(ShellyError): """Raised to indicate errors in RPC call.""" def __init__(self, code: int, message: str = "") -> None: """Initialize JSON RPC errors.""" self.code = code self.message = message super().__init__(code, message) python-aioshelly-13.14.0/aioshelly/json.py000066400000000000000000000012371507326321100205070ustar00rootroot00000000000000"""JSON helper.""" from typing import Any import orjson JSONDecodeError = orjson.JSONDecodeError json_loads = orjson.loads def json_encoder_default(obj: Any) -> Any: """Convert objects.""" if isinstance(obj, set | tuple): return list(obj) raise TypeError def json_dumps(data: Any) -> str: """Dump json string.""" return orjson.dumps( data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default, ).decode("utf-8") def json_bytes(data: Any) -> bytes: """Dump json bytes.""" return orjson.dumps( data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default, ) python-aioshelly-13.14.0/aioshelly/py.typed000066400000000000000000000000001507326321100206460ustar00rootroot00000000000000python-aioshelly-13.14.0/aioshelly/rpc_device/000077500000000000000000000000001507326321100212645ustar00rootroot00000000000000python-aioshelly-13.14.0/aioshelly/rpc_device/__init__.py000066400000000000000000000003771507326321100234040ustar00rootroot00000000000000"""Shelly Gen2 RPC based device.""" from .device import RpcDevice, RpcUpdateType from .utils import bluetooth_mac_from_primary_mac from .wsrpc import WsServer __all__ = ["RpcDevice", "RpcUpdateType", "WsServer", "bluetooth_mac_from_primary_mac"] python-aioshelly-13.14.0/aioshelly/rpc_device/device.py000066400000000000000000000733701507326321100231070ustar00rootroot00000000000000"""Shelly Gen2 RPC based device.""" from __future__ import annotations import asyncio import logging from collections.abc import Callable, Iterable from enum import Enum, auto from functools import partial from typing import Any, cast from aiohttp import ClientSession from ..common import ( ConnectionOptions, IpOrOptionsType, is_firmware_supported, process_ip_or_options, ) from ..const import ( BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_ID, BLU_TRV_TIMEOUT, CONNECT_ERRORS, DEVICE_INIT_TIMEOUT, DEVICE_IO_TIMEOUT, DEVICE_POLL_TIMEOUT, FIRMWARE_PATTERN, GEN4, MODEL_BLU_GATEWAY_G3, NOTIFY_WS_CLOSED, VIRTUAL_COMPONENTS_MIN_FIRMWARE, ) from ..exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, NotInitialized, RpcCallError, ShellyError, ) from .models import ( ShellyBLEConfig, ShellyBLESetConfig, ShellyScript, ShellyScriptCode, ShellyWsConfig, ShellyWsSetConfig, ) from .wsrpc import RPCSource, WsRPC, WsServer MAX_ITERATIONS = 10 RPC_CALL_ERR_METHOD_NOT_FOUND = -114 RPC_CALL_ERR_INVALID_ARG = -105 RPC_CALL_ERR_NO_HANDLER = 404 _LOGGER = logging.getLogger(__name__) def mergedicts(dest: dict, source: dict) -> None: """Deep dicts merge. The destination dict is updated with the source dict. """ for k, v in source.items(): if k in dest and type(v) is dict: # - only accepts `dict` type if (target := dest[k]) is None: target = dest[k] = {} mergedicts(target, v) else: dest[k] = v class RpcUpdateType(Enum): """RPC Update type.""" EVENT = auto() STATUS = auto() INITIALIZED = auto() DISCONNECTED = auto() UNKNOWN = auto() ONLINE = auto() class RpcDevice: """Shelly RPC device representation.""" def __init__( self, ws_context: WsServer, aiohttp_session: ClientSession, options: ConnectionOptions, ) -> None: """Device init.""" self.aiohttp_session: ClientSession = aiohttp_session self.options: ConnectionOptions = options self._shelly: dict[str, Any] | None = None self._status: dict[str, Any] | None = None self._event: dict[str, Any] | None = None self._config: dict[str, Any] | None = None self._dynamic_components: list[dict[str, Any]] = [] self._wsrpc = WsRPC( options.ip_address, self._on_notification, port=options.port ) sub_id = options.ip_address if options.device_mac: sub_id = options.device_mac self._unsub_ws: Callable | None = ws_context.subscribe_updates( sub_id, partial(self._wsrpc.handle_frame, RPCSource.SERVER) ) self._update_listener: Callable | None = None self._initialize_lock = asyncio.Lock() self.initialized: bool = False self._initializing: bool = False self._last_error: ShellyError | None = None @classmethod async def create( cls: type[RpcDevice], aiohttp_session: ClientSession, ws_context: WsServer, ip_or_options: IpOrOptionsType, ) -> RpcDevice: """Device creation.""" options = await process_ip_or_options(ip_or_options) _LOGGER.debug( "host %s:%s: RPC device create, MAC: %s", options.ip_address, options.port, options.device_mac, ) return cls(ws_context, aiohttp_session, options) def _on_notification( self, source: RPCSource, method: str, params: dict[str, Any] | None = None ) -> None: """Received status notification from device. If source is RPCSource.SERVER than the Shelly device connected back to the library and sent us the message If source is RPCSource.CLIENT than the library connected to the Shelly device and received the message """ if not self._update_listener: return update_type = RpcUpdateType.UNKNOWN if params is not None: if method == "NotifyFullStatus": self._status = params update_type = RpcUpdateType.STATUS elif method == "NotifyStatus" and self._status is not None: mergedicts(self._status, params) update_type = RpcUpdateType.STATUS elif method == "NotifyEvent": self._event = params update_type = RpcUpdateType.EVENT elif method == NOTIFY_WS_CLOSED: update_type = RpcUpdateType.DISCONNECTED # Battery operated device, inform listener that device is online if ( source is RPCSource.SERVER and not self.initialized and not self._initializing ): self._update_listener(self, RpcUpdateType.ONLINE) return # If the device isn't initialized, avoid sending updates # as it may be in the process of initializing. if self.initialized: self._update_listener(self, update_type) @property def ip_address(self) -> str: """Device ip address.""" return self.options.ip_address @property def port(self) -> int: """Device port.""" return self.options.port async def initialize(self) -> None: """Device initialization.""" _LOGGER.debug("host %s:%s: RPC device initialize", self.ip_address, self.port) if self._initialize_lock.locked(): raise RuntimeError("Already initializing") async with self._initialize_lock: self._initializing = True # First initialize may already have status from wakeup event # If device is initialized again we need to fetch new status if self.initialized: self.initialized = False self._status = None try: await self._connect_websocket() finally: self._initializing = False if self._update_listener and self.initialized: self._update_listener(self, RpcUpdateType.INITIALIZED) async def _connect_websocket(self) -> None: """Connect device websocket.""" ip = self.options.ip_address port = self.options.port try: async with asyncio.timeout(DEVICE_IO_TIMEOUT): await self._wsrpc.connect(self.aiohttp_session) await self._init_calls() except InvalidAuthError as err: self._last_error = InvalidAuthError(err) _LOGGER.debug("host %s:%s: error: %r", ip, port, self._last_error) await self._wsrpc.disconnect() raise except MacAddressMismatchError as err: self._last_error = err _LOGGER.debug("host %s:%s: error: %r", ip, port, err) await self._wsrpc.disconnect() raise except (*CONNECT_ERRORS, RpcCallError) as err: self._last_error = DeviceConnectionError(err) _LOGGER.debug("host %s:%s: error: %r", ip, port, self._last_error) await self._wsrpc.disconnect() raise self._last_error from err else: _LOGGER.debug("host %s:%s: RPC device init finished", ip, port) self.initialized = True async def shutdown(self) -> None: """Shutdown device and remove the listener. This method will unsubscribe the update listener and disconnect the websocket. """ _LOGGER.debug("host %s:%s: RPC device shutdown", self.ip_address, self.port) self._update_listener = None await self._disconnect_websocket() async def _disconnect_websocket(self) -> None: """Disconnect websocket.""" if self._unsub_ws: try: self._unsub_ws() except KeyError as err: _LOGGER.error( "host %s:%s error during shutdown: %r", self.ip_address, self.port, err, ) self._unsub_ws = None await self._wsrpc.disconnect() def subscribe_updates(self, update_listener: Callable) -> None: """Subscribe to device status updates.""" self._update_listener = update_listener async def trigger_ota_update(self, beta: bool = False) -> None: """Trigger an ota update.""" params = {"stage": "beta"} if beta else {"stage": "stable"} await self.call_rpc("Shelly.Update", params) async def trigger_reboot(self, delay_ms: int = 1000) -> None: """Trigger a device reboot.""" await self.call_rpc("Shelly.Reboot", {"delay_ms": delay_ms}) async def trigger_blu_trv_calibration(self, trv_id: int) -> None: """Trigger calibration for BLU TRV.""" params = { "id": trv_id, "method": "Trv.Calibrate", "params": {"id": 0}, } await self.call_rpc("BluTRV.Call", params=params, timeout=BLU_TRV_TIMEOUT) async def blu_trv_set_target_temperature( self, trv_id: int, temperature: float ) -> None: """Set the target temperatire for BLU TRV.""" params = { "id": trv_id, "method": "Trv.SetTarget", "params": {"id": 0, "target_C": temperature}, } await self.call_rpc("BluTRV.Call", params=params, timeout=BLU_TRV_TIMEOUT) async def blu_trv_set_external_temperature( self, trv_id: int, temperature: float ) -> None: """Set the external temperatire for BLU TRV.""" params = { "id": trv_id, "method": "Trv.SetExternalTemperature", "params": {"id": 0, "t_C": temperature}, } await self.call_rpc("BluTRV.Call", params=params, timeout=BLU_TRV_TIMEOUT) async def blu_trv_set_valve_position(self, trv_id: int, position: float) -> None: """Set the valve position for BLU TRV.""" params = { "id": trv_id, "method": "Trv.SetPosition", "params": {"id": 0, "pos": int(position)}, } await self.call_rpc("BluTRV.Call", params=params, timeout=BLU_TRV_TIMEOUT) async def blu_trv_set_boost(self, trv_id: int, duration: int | None = None) -> None: """Start boost mode for BLU TRV.""" params = { "id": trv_id, "method": "Trv.SetBoost", } params["params"] = ( {"id": 0} if duration is None else {"id": 0, "duration": duration} ) await self.call_rpc("BluTRV.Call", params=params, timeout=BLU_TRV_TIMEOUT) async def blu_trv_clear_boost(self, trv_id: int) -> None: """Clear boost mode for BLU TRV.""" params = { "id": trv_id, "method": "Trv.ClearBoost", "params": {"id": 0}, } await self.call_rpc("BluTRV.Call", params=params, timeout=BLU_TRV_TIMEOUT) async def boolean_set(self, id_: int, value: bool) -> None: """Set the value for the boolean component.""" params = { "id": id_, "value": value, } await self.call_rpc("Boolean.Set", params=params) async def button_trigger(self, id_: int, event: str) -> None: """Trigger the button component.""" params = { "id": id_, "event": event, } await self.call_rpc("Button.Trigger", params=params) async def climate_set_target_temperature( self, id_: int, temperature: float ) -> None: """Set climate target temperature.""" params = { "config": { "id": id_, "target_C": temperature, } } await self.call_rpc("Thermostat.SetConfig", params=params) async def climate_set_hvac_mode(self, id_: int, hvac_mode: str) -> None: """Set climate hvac mode.""" mode = hvac_mode in ("cool", "heat") params = { "config": { "id": id_, "enable": mode, } } await self.call_rpc("Thermostat.SetConfig", params=params) async def cover_get_status(self, id_: int) -> dict[str, Any]: """Get cover status.""" return await self.call_rpc("Cover.GetStatus", {"id": id_}) async def cover_calibrate(self, id_: int) -> None: """Calibrate cover.""" await self.call_rpc("Cover.Calibrate", {"id": id_}) async def cover_open(self, id_: int) -> None: """Open cover.""" await self.call_rpc("Cover.Open", {"id": id_}) async def cover_close(self, id_: int) -> None: """Close cover.""" await self.call_rpc("Cover.Close", {"id": id_}) async def cover_stop(self, id_: int) -> None: """Stop cover.""" await self.call_rpc("Cover.Stop", {"id": id_}) async def cover_set_position( self, id_: int, pos: int | None = None, slat_pos: int | None = None, ) -> None: """Set cover position.""" params = {"id": id_} if pos is not None: params["pos"] = pos if slat_pos is not None: params["slat_pos"] = slat_pos await self.call_rpc("Cover.GoToPosition", params=params) async def cury_boost( self, id_: int, slot: str, ) -> None: """Start boost mode for Cury.""" params = { "id": id_, "slot": slot, } await self.call_rpc("Cury.Boost", params=params) async def cury_stop_boost( self, id_: int, slot: str, ) -> None: """Stop boost mode for Cury.""" params = { "id": id_, "slot": slot, } await self.call_rpc("Cury.StopBoost", params=params) async def cury_set( self, id_: int, slot: str, value: bool | None = None, intensity: int | None = None, ) -> None: """Set parameters for cury component.""" params = { "id": id_, "slot": slot, } if value is not None: params["on"] = value if intensity is not None: params["intensity"] = intensity await self.call_rpc("Cury.Set", params=params) async def enum_set(self, id_: int, value: str) -> None: """Set the value for the enum component.""" params = { "id": id_, "value": value, } await self.call_rpc("Enum.Set", params=params) async def number_set(self, id_: int, value: float) -> None: """Set the value for the number component.""" params = { "id": id_, "value": value, } await self.call_rpc("Number.Set", params=params) async def switch_set(self, id_: int, value: bool) -> None: """Set the value for the switch component.""" params = { "id": id_, "on": value, } await self.call_rpc("Switch.Set", params=params) async def text_set(self, id_: int, value: str) -> None: """Set the value for the text component.""" params = { "id": id_, "value": value, } await self.call_rpc("Text.Set", params=params) async def update_status(self) -> None: """Get device status from 'Shelly.GetStatus'.""" self._status = await self.call_rpc("Shelly.GetStatus") async def update_config(self) -> None: """Get device config from 'Shelly.GetConfig'.""" self._config = await self.call_rpc("Shelly.GetConfig") async def update_cover_status(self, id_: int) -> None: """Update cover status. This method will update only the status of the specified cover component in the device status if it exists in the current status. """ key = f"cover:{id_}" if self._status is None or key not in self._status: return cover_status = await self.cover_get_status(id_) self._status[key].update(cover_status) async def poll(self) -> None: """Poll device for calls that do not receive push updates.""" calls: list[tuple[str, dict[str, Any] | None]] = [("Shelly.GetStatus", None)] if has_dynamic := bool(self._dynamic_components): # Only poll dynamic components if we have them calls.append(("Shelly.GetComponents", {"dynamic_only": True})) results = await self.call_rpc_multiple(calls, DEVICE_POLL_TIMEOUT) if (status := results[0]) is None: raise RpcCallError(0, "empty response to Shelly.GetStatus") if self._status is None: raise NotInitialized self._status.update(status) if has_dynamic: if (dynamic := results[1]) is None: raise RpcCallError(0, "empty response to Shelly.GetComponents") self._parse_dynamic_components(dynamic) await self._retrieve_blutrv_components(dynamic) async def _init_calls(self) -> None: """Make calls needed to initialize the device.""" # Shelly.GetDeviceInfo is the only RPC call that does not # require auth, so we must do a separate call here to get # the auth_domain/id so we can enable auth for the rest of the calls self._shelly = await self.call_rpc("Shelly.GetDeviceInfo") if self.options.username and self.options.password: self._wsrpc.set_auth_data( self.shelly.get("auth_domain") or self.shelly["id"], self.options.username, self.options.password, ) mac = self.shelly["mac"] device_mac = self.options.device_mac if device_mac and device_mac != mac: raise MacAddressMismatchError(f"Input MAC: {device_mac}, Shelly MAC: {mac}") calls: list[tuple[str, dict[str, Any] | None]] = [("Shelly.GetConfig", None)] if fetch_status := self._status is None: calls.append(("Shelly.GetStatus", None)) if fetch_dynamic := self._supports_dynamic_components(): calls.append(("Shelly.GetComponents", {"dynamic_only": True})) results = await self.call_rpc_multiple(calls, DEVICE_INIT_TIMEOUT) self._config = results.pop(0) if fetch_status: self._status = results.pop(0) if fetch_dynamic: all_pages = await self.get_all_pages(results.pop(0)) self._parse_dynamic_components(all_pages) await self._retrieve_blutrv_components(all_pages) async def get_all_pages(self, first_page: dict[str, Any]) -> dict[str, Any]: """Get all pages of paginated response to GetComponents.""" total = first_page["total"] counter = 0 while len(first_page["components"]) < total and counter < MAX_ITERATIONS: counter += 1 offset = len(first_page["components"]) next_page = await self.call_rpc( "Shelly.GetComponents", {"dynamic_only": True, "offset": offset} ) first_page["components"].extend(next_page["components"]) return first_page async def script_list(self) -> list[ShellyScript]: """Get a list of scripts from 'Script.List'.""" data = await self.call_rpc("Script.List") scripts: list[ShellyScript] = data["scripts"] return scripts async def script_getcode( self, script_id: int, offset: int = 0, bytes_to_read: int | None = None ) -> ShellyScriptCode: """Get script code from 'Script.GetCode'. offset: The offset in bytes to start reading from. bytes_to_read: The number of bytes to read from the script. If None, read the whole script. """ params = {"id": script_id, "offset": offset} if bytes_to_read is not None: params["len"] = bytes_to_read return cast(ShellyScriptCode, await self.call_rpc("Script.GetCode", params)) async def script_putcode(self, script_id: int, code: str) -> None: """Set script code from 'Script.PutCode'.""" await self.call_rpc("Script.PutCode", {"id": script_id, "code": code}) async def script_create(self, name: str) -> None: """Create a script using 'Script.Create'.""" await self.call_rpc("Script.Create", {"name": name}) async def script_start(self, script_id: int) -> None: """Start a script using 'Script.Start'.""" await self.call_rpc("Script.Start", {"id": script_id}) async def script_stop(self, script_id: int) -> None: """Stop a script using 'Script.Stop'.""" await self.call_rpc("Script.Stop", {"id": script_id}) async def ble_setconfig(self, enable: bool, enable_rpc: bool) -> ShellyBLESetConfig: """Enable or disable ble with BLE.SetConfig.""" return cast( ShellyBLESetConfig, await self.call_rpc( "BLE.SetConfig", {"config": {"enable": enable, "rpc": {"enable": enable_rpc}}}, ), ) async def ble_getconfig(self) -> ShellyBLEConfig: """Get the BLE config with BLE.GetConfig.""" return cast(ShellyBLEConfig, await self.call_rpc("BLE.GetConfig")) async def ws_setconfig( self, enable: bool, server: str, ssl_ca: str = "*" ) -> ShellyWsSetConfig: """Set the outbound websocket config.""" return cast( ShellyWsSetConfig, await self.call_rpc( "Ws.SetConfig", {"config": {"enable": enable, "server": server, "ssl_ca": ssl_ca}}, ), ) async def ws_getconfig(self) -> ShellyWsConfig: """Get the outbound websocket config.""" return cast(ShellyWsConfig, await self.call_rpc("Ws.GetConfig")) async def update_outbound_websocket(self, server: str) -> bool: """Update the outbound websocket (if needed). Returns True if the device was restarted. Raises RpcCallError if set failed. """ ws_config = await self.ws_getconfig() if ws_config["enable"] and ws_config["server"] == server: return False ws_enable = await self.ws_setconfig(enable=True, server=server) if not ws_enable["restart_required"]: return False _LOGGER.info( "Outbound websocket enabled, restarting device %s", self.ip_address ) await self.trigger_reboot(3500) return True @property def requires_auth(self) -> bool: """Device check for authentication.""" return bool(self.shelly["auth_en"]) async def call_rpc( self, method: str, params: dict[str, Any] | None = None, timeout: float = DEVICE_IO_TIMEOUT, ) -> dict[str, Any]: """Call RPC method.""" return (await self.call_rpc_multiple(((method, params),), timeout))[0] async def call_rpc_multiple( self, calls: Iterable[tuple[str, dict[str, Any] | None]], timeout: float = DEVICE_IO_TIMEOUT, ) -> list[dict[str, Any]]: """Call RPC method.""" try: return await self._wsrpc.calls(calls, timeout) except (InvalidAuthError, RpcCallError) as err: self._last_error = err raise except CONNECT_ERRORS as err: self._last_error = DeviceConnectionError(err) raise DeviceConnectionError from err @property def status(self) -> dict[str, Any]: """Get device status.""" if not self.initialized or self._status is None: raise NotInitialized return self._status @property def event(self) -> dict[str, Any] | None: """Get device event.""" if not self.initialized: raise NotInitialized return self._event @property def config(self) -> dict[str, Any]: """Get device config.""" if not self.initialized or self._config is None: raise NotInitialized return self._config @property def shelly(self) -> dict[str, Any]: """Device firmware version.""" if self._shelly is None: raise NotInitialized return self._shelly @property def gen(self) -> int: """Device generation: GEN2/3/4 - RPC.""" if self._shelly is None: raise NotInitialized return cast(int, self._shelly["gen"]) @property def firmware_version(self) -> str: """Device firmware version.""" return cast(str, self.shelly["fw_id"]) @property def version(self) -> str: """Device version.""" return cast(str, self.shelly["ver"]) @property def model(self) -> str: """Device model.""" return cast(str, self.shelly["model"]) @property def xmod_info(self) -> dict[str, Any]: """Device XMOD properties.""" return cast(dict, self.shelly.get("jwt", {})) @property def hostname(self) -> str: """Device hostname.""" return cast(str, self.shelly["id"]) @property def name(self) -> str: """Device name.""" return cast(str, self.config["sys"]["device"].get("name") or self.hostname) @property def connected(self) -> bool: """Return true if device is connected.""" return self._wsrpc.connected @property def last_error(self) -> ShellyError | None: """Return the last error during async device init.""" return self._last_error @property def firmware_supported(self) -> bool: """Return True if device firmware version is supported.""" return is_firmware_supported(self.gen, self.model, self.firmware_version) @property def zigbee_enabled(self) -> bool: """Return True if Zigbee is enabled.""" if self.gen != GEN4: return False if self._config is None: raise NotInitialized return bool(self._config.get("zigbee", {}).get("enable")) @property def zigbee_firmware(self) -> bool: """Return True if Zigbee firmware is active.""" if self.gen != GEN4: return False if self._config is None: raise NotInitialized return "zigbee" in self._config async def get_dynamic_components(self) -> None: """Return a list of dynamic components.""" if not self._supports_dynamic_components(): return first_page = await self.call_rpc("Shelly.GetComponents", {"dynamic_only": True}) all_pages = await self.get_all_pages(first_page) self._parse_dynamic_components(all_pages) await self._retrieve_blutrv_components(all_pages) def _supports_dynamic_components(self) -> bool: """Return True if device supports dynamic components.""" if self._status is not None and self._status["sys"].get("wakeup_period", 0) > 0: # Sleeping devices do not support dynamic components. return False match = FIRMWARE_PATTERN.search(self.firmware_version) return match is not None and int(match[0]) >= VIRTUAL_COMPONENTS_MIN_FIRMWARE def _parse_dynamic_components(self, components: dict[str, Any]) -> None: """Parse dynamic components.""" if not self._config or not self._status: raise NotInitialized self._dynamic_components = components.get("components", []) self._config.update( { item["key"]: {**item["config"], **item.get("attrs", {})} for item in self._dynamic_components } ) self._status.update( {item["key"]: {**item["status"]} for item in self._dynamic_components} ) async def _retrieve_blutrv_components(self, components: dict[str, Any]) -> None: """Retrieve BLU TRV components.""" if self.model != MODEL_BLU_GATEWAY_G3: return if not self._config or not self._status: raise NotInitialized for component in components.get("components", []): _key = component["key"].split(":") if _key[0] != BLU_TRV_IDENTIFIER: continue result = await self.call_rpc("BluTrv.GetRemoteConfig", {"id": int(_key[1])}) cfg: dict[str, Any] = result["config"]["trv:0"] # addr, name and model_id must be added from Shelly.GetComponents call _attrs = component.get("attrs", {}) cfg.update({"addr": component["config"]["addr"]}) cfg.update({"name": component["config"]["name"]}) cfg.update({"local_name": BLU_TRV_MODEL_ID.get(_attrs.get("model_id"))}) self._config.update({component["key"]: cfg}) status = component["status"] # if there are no errors, the response does not contain an errors object status.setdefault("errors", []) self._status.update({component["key"]: status}) async def supports_scripts(self) -> bool: """Check if the device supports scripts. Try to read 0 byte from a script to check if the device supports scripts, if it supports scripts, it should reply with '{"data":"", "left":0}' or a specific error code if the script does not exist. {"code":-105,"message":"Argument 'id', value 1 not found!"} Errors by devices that do not support scripts: Shelly Wall display: {"code":-114,"message":"Method Script.GetCode failed: Method not found!"} Shelly X MOD1 {"code":404,"message":"No handler for Script.GetCode"} """ try: await self.script_getcode(1, bytes_to_read=0) except RpcCallError as err: # The device supports scripts, but the script does not exist if err.code == RPC_CALL_ERR_INVALID_ARG: return True # The device does not support scripts if err.code in [ RPC_CALL_ERR_METHOD_NOT_FOUND, RPC_CALL_ERR_NO_HANDLER, ]: return False raise # The device returned a script response, it supports scripts return True python-aioshelly-13.14.0/aioshelly/rpc_device/models.py000066400000000000000000000016711507326321100231260ustar00rootroot00000000000000"""Shelly Gen2 RPC based device models.""" from __future__ import annotations from typing import TypedDict class ShellyScript(TypedDict, total=False): """Shelly Script.""" id: int name: str enable: bool running: bool class ShellyScriptCode(TypedDict, total=False): """Shelly Script Code.""" data: str class ShellyBLERpcConfig(TypedDict, total=False): """Shelly BLE RPC Config.""" enable: bool class ShellyBLEConfig(TypedDict, total=False): """Shelly BLE Config.""" enable: bool rpc: ShellyBLERpcConfig class ShellyBLESetConfig(TypedDict, total=False): """Shelly BLE Set Config.""" restart_required: bool class ShellyWsConfig(TypedDict, total=False): """Shelly Outbound Websocket Config.""" enable: bool server: str | None ssl_ca: str class ShellyWsSetConfig(TypedDict, total=False): """Shelly Outbound Websocket Set Config.""" restart_required: bool python-aioshelly-13.14.0/aioshelly/rpc_device/utils.py000066400000000000000000000010321507326321100227720ustar00rootroot00000000000000"""Utilities for RPC devices.""" from __future__ import annotations # The Bluetooth MAC address is the primary MAC address plus 2. # https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/misc_system_api.html#mac-address def bluetooth_mac_from_primary_mac(primary_mac: str) -> str: """Get Bluetooth MAC from primary MAC. MAC address must be in format "[0-F]{16}" :param primary_mac: Primary MAC address :return: Bluetooth MAC address """ return f"{(int(primary_mac, 16) + 2):012X}" python-aioshelly-13.14.0/aioshelly/rpc_device/wsrpc.py000066400000000000000000000470411507326321100230020ustar00rootroot00000000000000"""WsRpc for Shelly.""" from __future__ import annotations import asyncio import contextlib import hashlib import logging import socket import time from asyncio import Task, tasks from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass from enum import Enum, auto from http import HTTPStatus from typing import TYPE_CHECKING, Any from aiohttp import ( ClientSession, ClientWebSocketResponse, WSMessage, WSMsgType, client_exceptions, ) from aiohttp.web import ( Application, AppRunner, BaseRequest, TCPSite, WebSocketResponse, get, ) from yarl import URL from ..const import ( DEFAULT_HTTP_PORT, NOTIFY_WS_CLOSED, UNDEFINED, WS_API_URL, WS_HEARTBEAT, UndefinedType, ) from ..exceptions import ( ConnectionClosed, DeviceConnectionError, DeviceConnectionTimeoutError, InvalidAuthError, InvalidMessage, RpcCallError, ) from ..json import json_bytes, json_loads _LOGGER = logging.getLogger(__name__) BUFFER_SIZE = 1024 * 64 class RPCSource(Enum): """RPC message source.""" CLIENT = auto() SERVER = auto() def _receive_json_or_raise(msg: WSMessage) -> dict[str, Any]: """Receive json or raise.""" if msg.type is WSMsgType.TEXT: try: data: dict[str, Any] = json_loads(msg.data) except ValueError as err: raise InvalidMessage(f"Received invalid JSON: {msg.data}") from err return data if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): raise ConnectionClosed("Connection was closed.") if msg.type is WSMsgType.ERROR: raise InvalidMessage("Received message error") raise InvalidMessage(f"Received non-Text message: {msg.type}") def hex_hash(message: str) -> str: """Get hex representation of sha256 hash of string.""" return hashlib.sha256(message.encode("utf-8")).hexdigest() HA2 = hex_hash("dummy_method:dummy_uri") @dataclass class AuthData: """RPC Auth data class.""" realm: str username: str password: str def __post_init__(self) -> None: """Call after initialization.""" self.ha1 = hex_hash(f"{self.username}:{self.realm}:{self.password}") def get_auth(self, nonce: int | None = None, n_c: int = 1) -> dict[str, Any]: """Get auth for RPC calls.""" cnonce = int(time.time()) if nonce is None: nonce = cnonce - 1800 # https://shelly-api-docs.shelly.cloud/gen2/Overview/CommonDeviceTraits/#authentication-over-websocket hashed = hex_hash(f"{self.ha1}:{nonce}:{n_c}:{cnonce}:auth:{HA2}") return { "realm": self.realm, "username": self.username, "nonce": nonce, "cnonce": cnonce, "response": hashed, "algorithm": "SHA-256", } @dataclass class SessionData: """SessionData (src/dst/auth) class.""" src: str | None dst: str | None auth: dict[str, Any] | None class RPCCall: """RPCCall class.""" __slots__ = ( "auth", "call_id", "dst", "method", "params", "resolve", "result", "src", ) def __init__( self, call_id: int, method: str, params: dict[str, Any] | None, session: SessionData, resolve: asyncio.Future[dict[str, Any]], ) -> None: """Initialize RPC class.""" self.auth = session.auth self.call_id = call_id self.params = params self.method = method self.src = session.src self.dst = session.dst self.resolve = resolve self.result: dict[str, Any] | UndefinedType = UNDEFINED def __repr__(self) -> str: """Return representation of the call.""" return ( "" ) @property def request_frame(self) -> dict[str, Any]: """Request frame.""" msg = { "id": self.call_id, "method": self.method, "src": self.src, } for obj in ("params", "dst", "auth"): if getattr(self, obj) is not None: msg[obj] = getattr(self, obj) return msg class WsBase: """Base class for WebSocket handlers.""" def __init__(self) -> None: """Initialize WsBase class.""" self._background_tasks: set[Task] = set() def _create_and_track_task(self, func: Coroutine) -> None: """Create and and hold strong reference to the task.""" task = asyncio.create_task(func) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) class WsRPC(WsBase): """WsRPC class.""" def __init__( self, ip_address: str, on_notification: Callable[[RPCSource, str, dict | None], None], port: int = DEFAULT_HTTP_PORT, ) -> None: """Initialize WsRPC class.""" super().__init__() self._auth_data: AuthData | None = None self._ip_address = ip_address self._port = port self._on_notification = on_notification self._rx_task: tasks.Task[None] | None = None self._client: ClientWebSocketResponse | None = None self._calls: dict[int, RPCCall] = {} self._call_id = 0 self._session = SessionData(f"aios-{id(self)}", None, None) self._loop = asyncio.get_running_loop() @property def _next_id(self) -> int: self._call_id += 1 return self._call_id async def connect(self, aiohttp_session: ClientSession) -> None: """Connect to device.""" if self.connected: raise RuntimeError("Already connected") _LOGGER.debug("Trying to connect to device at %s", self._ip_address) try: self._client = await aiohttp_session.ws_connect( URL.build( scheme="http", host=self._ip_address, port=self._port, path="/rpc" ), heartbeat=WS_HEARTBEAT, ) except ( client_exceptions.WSServerHandshakeError, client_exceptions.ClientError, ) as err: raise DeviceConnectionError(err) from err # Try to reduce the pressure on shelly device as it measures # ram in bytes and we measure ram in megabytes. sock: socket.socket = self._client.get_extra_info("socket") try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, BUFFER_SIZE) except OSError as err: _LOGGER.warning( "%s:%s: Failed to set socket receive buffer size: %s", self._ip_address, self._port, err, ) self._rx_task = asyncio.create_task(self._rx_msgs()) _LOGGER.info("Connected to %s", self._ip_address) async def disconnect(self) -> None: """Disconnect all sessions.""" if self._rx_task is not None: self._rx_task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._rx_task self._rx_task = None if self._client is None: return await self._client.close() def set_auth_data(self, realm: str, username: str, password: str) -> None: """Set authentication data and generate session auth.""" self._auth_data = AuthData(realm, username, password) self._session.auth = self._auth_data.get_auth() async def _handle_call(self, frame_id: str) -> None: if TYPE_CHECKING: assert self._client await self._send_json( { "id": frame_id, "src": self._session.src, "error": {"code": 500, "message": "Not Implemented"}, } ) def handle_frame(self, source: RPCSource, frame: dict[str, Any]) -> None: """Handle RPC frame.""" if peer_src := frame.get("src"): if self._session.dst is not None and peer_src != self._session.dst: _LOGGER.warning( "Remote src changed: %s -> %s", self._session.dst, peer_src ) self._session.dst = peer_src frame_id = frame.get("id") if method := frame.get("method"): # peer is invoking a method params = frame.get("params") if frame_id: # and expects a response _LOGGER.debug("handle call for frame_id: %s", frame_id) self._create_and_track_task(self._handle_call(frame_id)) else: # this is a notification _LOGGER.debug("Notification: %s %s", method, params) try: self._on_notification(source, method, params) except Exception as err: _LOGGER.exception( "Error handling notification frame: %s", frame, exc_info=err ) return if frame_id: # looks like a response if (call := self._calls.pop(frame_id, None)) is None: _LOGGER.warning( "Response from (%s:%s) for an unknown request id: %s: %s", self._ip_address, self._port, frame_id, method, ) return if not call.resolve.cancelled(): call.resolve.set_result(frame) return _LOGGER.warning("Invalid frame: %s", frame) async def _rx_msgs(self) -> None: if TYPE_CHECKING: assert self._client try: while True: try: msg = await self._client.receive() frame = _receive_json_or_raise(msg) _LOGGER.debug( "recv(%s:%s): %s", self._ip_address, self._port, frame ) except InvalidMessage as err: _LOGGER.error( "Invalid Message from host %s:%s: %s", self._ip_address, self._port, err, ) except (ConnectionClosed, client_exceptions.ClientConnectionResetError): _LOGGER.debug( "Connection issue with device %s:%s", self._ip_address, self._port, ) break except Exception: _LOGGER.exception("Unexpected error while receiving message") raise if self._client.closed: break self.handle_frame(RPCSource.CLIENT, frame) finally: _LOGGER.debug( "Websocket client connection from %s:%s closed", self._ip_address, self._port, ) for call_item in self._calls.values(): if not call_item.resolve.done(): call_item.resolve.set_exception(DeviceConnectionError(call_item)) self._calls.clear() client = self._client self._client = None self._on_notification(RPCSource.CLIENT, NOTIFY_WS_CLOSED, None) # Close last since the await can yield # to the event loop and we want to minimize # race conditions if not client.closed: await client.close() @property def connected(self) -> bool: """Return if we're currently connected.""" return self._client is not None and not self._client.closed async def call( self, method: str, params: dict[str, Any] | None = None, timeout: int = 10, ) -> dict[str, Any]: """Websocket RPC call.""" return (await self.calls([(method, params)], timeout))[0] def _raise_for_unrecoverable_errors( self, resp: dict[str, Any], allow_auth_retry: bool ) -> None: """Raise for unrecoverable errors.""" try: error = resp["error"] code = error["code"] msg = error["message"] except KeyError as err: raise RpcCallError(0, f"bad response: {resp}") from err if code != HTTPStatus.UNAUTHORIZED.value: raise RpcCallError(code, msg) if allow_auth_retry and self._auth_data is not None: return raise InvalidAuthError(msg) async def calls( self, calls: Iterable[tuple[str, dict[str, Any] | None]], timeout: float = 10.0 ) -> list[dict[str, Any]]: """Websocket RPC calls.""" # Try request with initial/last call auth data all_successful, results = await self._rpc_calls(calls, timeout) if all_successful: # If all_successful, return results immediately # mypy does not know that .result is never # None when all_successful is True so we need # to ignore the type check here return [call.result for call in results] # type: ignore[misc] # Partial success, try to update auth and retry to_retry: list[RPCCall] = [] successful: list[dict[str, Any]] = [] for call in results: if (result := call.result) is not UNDEFINED: successful.append(result) continue resp = call.resolve.result() self._raise_for_unrecoverable_errors(resp, allow_auth_retry=True) if not to_retry: # Update auth from response and try with new auth data # If we have multiple calls, we only need to update auth once if TYPE_CHECKING: # _raise_for_unrecoverable_errors ensures that auth_data is not None assert self._auth_data is not None auth = json_loads(resp["error"]["message"]) self._session.auth = self._auth_data.get_auth( auth["nonce"], auth.get("nc", 1) ) to_retry.append(call) _, results = await self._rpc_calls( [(call.method, call.params) for call in to_retry], timeout ) for call in results: if (result := call.result) is UNDEFINED: resp = call.resolve.result() self._raise_for_unrecoverable_errors(resp, allow_auth_retry=False) else: successful.append(result) return successful async def _rpc_calls( self, rpc_calls: Iterable[tuple[str, dict[str, Any] | None]], timeout: float ) -> tuple[bool, list[RPCCall]]: """Websocket RPC call. calls is a tuple of tuples of ( (method, params), ... """ if self._client is None: raise RuntimeError("Not connected") sent_calls: list[RPCCall] = [] loop = self._loop all_successful: bool = True future: asyncio.Future[dict[str, Any]] try: async with asyncio.timeout(timeout): for method, params in rpc_calls: call_id = self._next_id future = loop.create_future() call = RPCCall(call_id, method, params, self._session, future) sent_calls.append(call) self._calls[call_id] = call await self._send_json(call.request_frame) # Wait for all the responses for call in sent_calls: response = await call.resolve if "result" not in response: all_successful = False continue call.result = response["result"] except TimeoutError as exc: for call in sent_calls: with contextlib.suppress(asyncio.CancelledError): call.resolve.cancel() await call.resolve # Ensure the call is removed from the calls dict # on failure self._calls.pop(call.call_id, None) raise DeviceConnectionTimeoutError(sent_calls) from exc if _LOGGER.isEnabledFor(logging.DEBUG): for call in sent_calls: _LOGGER.debug( "result(%s:%s): %s(%s) -> %s", self._ip_address, self._port, call.method, call.params, call.result, ) return all_successful, sent_calls async def _send_json(self, data: dict[str, Any]) -> None: """Send json frame to device.""" _LOGGER.debug("send(%s:%s): %s", self._ip_address, self._port, data) if TYPE_CHECKING: assert self._client await self._client.send_frame(json_bytes(data), WSMsgType.TEXT) class WsServer(WsBase): """WsServer class.""" def __init__(self) -> None: """Initialize WsServer class.""" super().__init__() self._runner: AppRunner | None = None self.subscriptions: dict[str, Callable] = {} async def initialize(self, port: int, api_url: str = WS_API_URL) -> None: """Initialize the websocket server, used only in standalone mode.""" app = Application() app.add_routes([get(api_url, self.websocket_handler)]) self._runner = AppRunner(app) await self._runner.setup() site = TCPSite(self._runner, port=port) await site.start() def close(self) -> None: """Stop the websocket server.""" if self._runner is not None: self._create_and_track_task(self._runner.cleanup()) async def websocket_handler(self, request: BaseRequest) -> WebSocketResponse: """Handle connections from sleeping devices.""" ip = request.remote _LOGGER.debug("Websocket server connection from %s starting", ip) ws_res = WebSocketResponse(protocols=["json-rpc"]) await ws_res.prepare(request) _LOGGER.debug("Websocket server connection from %s ready", ip) async for msg in ws_res: try: frame = _receive_json_or_raise(msg) _LOGGER.debug("recv(%s): %s", ip, frame) except ConnectionClosed: await ws_res.close() except InvalidMessage as err: _LOGGER.debug("Invalid Message from host %s: %s", ip, err) else: try: device_id = frame["src"].split("-")[1].upper() except (KeyError, IndexError) as err: _LOGGER.debug("Invalid device id from host %s: %s", ip, err) continue if device_id in self.subscriptions: _LOGGER.debug( "Calling WsRPC message update for device id %s", device_id ) self.subscriptions[device_id](frame) continue if ip in self.subscriptions: _LOGGER.debug("Calling WsRPC message update for host %s", ip) self.subscriptions[ip](frame) _LOGGER.debug("Websocket server connection from %s closed", ip) return ws_res def subscribe_updates(self, ip: str, message_received: Callable) -> Callable: """Subscribe to received updates.""" _LOGGER.debug("Adding device %s to WsServer message subscriptions", ip) self.subscriptions[ip] = message_received return lambda: self.subscriptions.pop(ip) python-aioshelly-13.14.0/fixtures/000077500000000000000000000000001507326321100170415ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures/gen1_Shelly Air_SHAIR-1_20230913-113804-v1.14.0-gcb84623.json000066400000000000000000000120731507326321100274570ustar00rootroot00000000000000{ "settings": { "actions": { "active": false, "names": [ "btn_on_url", "btn_off_url", "longpush_url", "shortpush_url", "out_on_url", "out_off_url" ] }, "allow_cross_origin": false, "ap_roaming": { "enabled": false, "threshold": -70 }, "build_info": { "build_id": "20230913-113804/v1.14.0-gcb84623", "build_timestamp": "2023-09-13T11:38:04Z", "build_version": "1.0" }, "cloud": { "connected": true, "enabled": true }, "coiot": { "enabled": true, "peer": "192.168.1.10:5683", "update_period": 15 }, "debug_enable": false, "device": { "hostname": "shellyair-AABBCCDDEEFF", "mac": "AABBCCDDEEFF", "num_meters": 1, "num_outputs": 1, "type": "SHAIR-1" }, "discoverable": false, "eco_mode_enabled": true, "ext_humidity": {}, "ext_sensors": { "temperature_unit": "C" }, "ext_temperature": { "0": { "offset_tC": 0.0, "offset_tF": 0.0, "overtemp_act": "disabled", "overtemp_threshold_tC": 0.0, "overtemp_threshold_tF": 32.0, "undertemp_act": "disabled", "undertemp_threshold_tC": 0.0, "undertemp_threshold_tF": 32.0 } }, "factory_reset_from_switch": true, "fw": "20230913-113804/v1.14.0-gcb84623", "hwinfo": { "batch_id": 0, "hw_revision": "dev-prototype" }, "lat": 45.472198, "lng": 9.1922, "login": { "enabled": false, "unprotected": false, "username": "admin" }, "longpush_time": 800, "mode": "relay", "mqtt": { "clean_session": true, "enable": false, "id": "shellyair-AABBCCDDEEFF", "keep_alive": 60, "max_qos": 0, "reconnect_timeout_max": 60.0, "reconnect_timeout_min": 2.0, "retain": false, "server": "192.168.33.3:1883", "update_period": 30, "user": "" }, "name": "Test Name", "pin_code": "", "pon_wifi_reset": false, "power_correction": 1.0, "relays": [ { "auto_off": 1800.0, "auto_on": 0.0, "btn_reverse": 0, "btn_type": "toggle", "default_state": "off", "has_timer": false, "ison": false, "name": null, "schedule": false, "schedule_rules": [] } ], "sntp": { "enabled": true, "server": "192.168.1.10" }, "time": "18:28", "timezone": "Europe/Rome", "tz_dst": false, "tz_dst_auto": true, "tz_utc_offset": 3600, "tzautodetect": true, "unixtime": 1707326928, "wifi_ap": { "enabled": false, "key": "", "ssid": "shellyair-AABBCCDDEEFF" }, "wifi_sta": { "dns": null, "enabled": true, "gw": null, "ip": null, "ipv4_method": "dhcp", "mask": null, "ssid": "Wifi-Network-Name" }, "wifi_sta1": { "dns": null, "enabled": false, "gw": null, "ip": null, "ipv4_method": "dhcp", "mask": null, "ssid": null } }, "shelly": { "auth": false, "discoverable": false, "fw": "20230913-113804/v1.14.0-gcb84623", "longid": 1, "mac": "AABBCCDDEEFF", "name": "Test Name", "num_meters": 1, "num_outputs": 1, "type": "SHAIR-1" }, "status": { "actions_stats": { "skipped": 0 }, "cfg_changed_cnt": 0, "cloud": { "connected": true, "enabled": true }, "ext_humidity": {}, "ext_sensors": { "temperature_unit": "C" }, "ext_temperature": { "0": { "hwID": "2843db970a000011", "tC": 23.06, "tF": 73.51 } }, "fs_free": 145831, "fs_size": 233681, "has_update": false, "inputs": [ { "event": "", "event_cnt": 0, "input": 0 } ], "mac": "AABBCCDDEEFF", "meters": [ { "counters": [ 0.0, 0.0, 0.0 ], "is_valid": true, "overpower": 0.0, "power": 0.0, "timestamp": 1707330528, "total": 0 } ], "mqtt": { "connected": false }, "overtemperature": false, "ram_free": 38656, "ram_total": 51536, "relays": [ { "has_timer": false, "ison": false, "overpower": false, "source": "input", "timer_duration": 0, "timer_remaining": 0, "timer_started": 0 } ], "serial": 34491, "temperature": 33.89, "time": "18:28", "tmp": { "is_valid": true, "tC": 33.89, "tF": 93.0 }, "total_work_time": 55697, "unixtime": 1707326928, "update": { "beta_version": "20231107-164540/v1.14.1-rc1-g0617c15", "has_update": false, "new_version": "20230913-113804/v1.14.0-gcb84623", "old_version": "20230913-113804/v1.14.0-gcb84623", "status": "idle" }, "uptime": 4572455, "wifi_sta": { "connected": true, "ip": "192.168.1.64", "rssi": -65, "ssid": "Wifi-Network-Name" } } } python-aioshelly-13.14.0/fixtures/gen1_Shelly Gas_SHGS-1_20230913-114427-v1.14.0-gcb84623.json000066400000000000000000000070441507326321100273600ustar00rootroot00000000000000{ "settings": { "actions": { "active": false, "names": [ "alarm_off_url", "alarm_mild_url", "alarm_heavy_url" ] }, "allow_cross_origin": false, "ap_roaming": { "enabled": true, "threshold": -70 }, "build_info": { "build_id": "20230913-114427/v1.14.0-gcb84623", "build_timestamp": "2023-09-13T11:44:27Z", "build_version": "1.0" }, "cloud": { "connected": true, "enabled": true }, "coiot": { "enabled": true, "peer": "192.168.1.10:5683", "update_period": 15 }, "debug_enable": false, "device": { "hostname": "shellygas-AABBCCDDEEFF", "mac": "AABBCCDDEEFF", "num_inputs": 0, "num_outputs": 0, "type": "SHGS-1" }, "discoverable": false, "eco_mode_enabled": true, "fw": "20230913-114427/v1.14.0-gcb84623", "hwinfo": { "batch_id": 1, "hw_revision": "prod-2020-04-09" }, "lat": 45.472198, "lng": 9.1922, "login": { "enabled": false, "unprotected": false, "username": "admin" }, "mqtt": { "clean_session": true, "enable": false, "id": "shellygas-AABBCCDDEEFF", "keep_alive": 60, "max_qos": 0, "reconnect_timeout_max": 60.0, "reconnect_timeout_min": 2.0, "retain": false, "server": "192.168.33.3:1883", "update_period": 30, "user": "" }, "name": "Test Name", "pin_code": "!Y7z@N", "pon_wifi_reset": false, "set_volume": 11, "sntp": { "enabled": true, "server": "192.168.1.10" }, "time": "18:28", "timezone": "Europe/Rome", "tz_dst": false, "tz_dst_auto": true, "tz_utc_offset": 3600, "tzautodetect": true, "unixtime": 1707326929, "wifi_ap": { "enabled": false, "key": "", "ssid": "shellygas-AABBCCDDEEFF" }, "wifi_sta": { "dns": null, "enabled": true, "gw": null, "ip": null, "ipv4_method": "dhcp", "mask": null, "ssid": "Wifi-Network-Name" }, "wifi_sta1": { "dns": null, "enabled": false, "gw": null, "ip": null, "ipv4_method": "dhcp", "mask": null, "ssid": null } }, "shelly": { "auth": false, "discoverable": false, "fw": "20230913-114427/v1.14.0-gcb84623", "longid": 1, "mac": "AABBCCDDEEFF", "name": "Test Name", "num_inputs": 0, "num_outputs": 0, "type": "SHGS-1" }, "status": { "actions_stats": { "skipped": 0 }, "cfg_changed_cnt": 0, "cloud": { "connected": true, "enabled": true }, "concentration": { "is_valid": true, "ppm": 0 }, "fs_free": 92117, "fs_size": 233681, "gas_sensor": { "alarm_state": "none", "self_test_state": "not_completed", "sensor_state": "normal" }, "has_update": false, "mac": "AABBCCDDEEFF", "mqtt": { "connected": false }, "ram_free": 40524, "ram_total": 52528, "serial": 790, "time": "18:28", "unixtime": 1707326929, "update": { "beta_version": "20231107-165146/v1.14.1-rc1-g0617c15", "has_update": false, "new_version": "20230913-114427/v1.14.0-gcb84623", "old_version": "20230913-114427/v1.14.0-gcb84623", "status": "idle" }, "uptime": 1468391, "valves": [ { "state": "not_connected" } ], "wifi_sta": { "connected": true, "ip": "192.168.1.66", "rssi": -62, "ssid": "Wifi-Network-Name" } } } python-aioshelly-13.14.0/fixtures/gen1_Shelly Plug S_SHPLG-S_20230913-113421-v1.14.0-gcb84623.json000066400000000000000000000103111507326321100300730ustar00rootroot00000000000000{ "settings": { "actions": { "active": false, "names": [ "btn_on_url", "out_on_url", "out_off_url" ] }, "allow_cross_origin": false, "ap_roaming": { "enabled": false, "threshold": -70 }, "build_info": { "build_id": "20230913-113421/v1.14.0-gcb84623", "build_timestamp": "2023-09-13T11:34:21Z", "build_version": "1.0" }, "cloud": { "connected": false, "enabled": false }, "coiot": { "enabled": true, "peer": "192.168.1.10:5683", "update_period": 15 }, "debug_enable": false, "device": { "hostname": "shellyplug-s-DDEEFF", "mac": "AABBCCDDEEFF", "num_meters": 1, "num_outputs": 1, "type": "SHPLG-S" }, "discoverable": true, "eco_mode_enabled": true, "fw": "20230913-113421/v1.14.0-gcb84623", "hwinfo": { "batch_id": 1, "hw_revision": "prod-190516" }, "lat": 45.472198, "led_power_disable": false, "led_status_disable": false, "lng": 9.1922, "login": { "enabled": false, "unprotected": false, "username": "admin" }, "max_power": 1800, "mqtt": { "clean_session": true, "enable": false, "id": "shellyplug-s-DDEEFF", "keep_alive": 60, "max_qos": 0, "reconnect_timeout_max": 60.0, "reconnect_timeout_min": 2.0, "retain": false, "server": "192.168.33.3:1883", "update_period": 30, "user": "" }, "name": "Test Name", "pin_code": "", "pon_wifi_reset": false, "relays": [ { "appliance_type": "Light", "auto_off": 0.0, "auto_on": 0.0, "default_state": "off", "has_timer": false, "ison": false, "max_power": 1800, "name": null, "schedule": false, "schedule_rules": [] } ], "sntp": { "enabled": true, "server": "192.168.1.10" }, "time": "19:57", "timezone": "Europe/Rome", "tz_dst": false, "tz_dst_auto": true, "tz_utc_offset": 3600, "tzautodetect": true, "unixtime": 1707332241, "wifi_ap": { "enabled": false, "key": "", "ssid": "shellyplug-s-DDEEFF" }, "wifi_sta": { "dns": null, "enabled": true, "gw": null, "ip": null, "ipv4_method": "dhcp", "mask": null, "ssid": "Wifi-Network-Name" }, "wifi_sta1": { "dns": null, "enabled": false, "gw": null, "ip": null, "ipv4_method": "dhcp", "mask": null, "ssid": null } }, "shelly": { "auth": false, "discoverable": true, "fw": "20230913-113421/v1.14.0-gcb84623", "mac": "AABBCCDDEEFF", "name": "Test Name", "num_meters": 1, "num_outputs": 1, "type": "SHPLG-S" }, "status": { "actions_stats": { "skipped": 0 }, "cfg_changed_cnt": 0, "cloud": { "connected": false, "enabled": false }, "fs_free": 166915, "fs_size": 233681, "has_update": false, "mac": "AABBCCDDEEFF", "meters": [ { "counters": [ 0.0, 0.0, 0.0 ], "is_valid": true, "overpower": 0.0, "power": 0.0, "timestamp": 1707335841, "total": 2632 } ], "mqtt": { "connected": false }, "overtemperature": false, "ram_free": 40428, "ram_total": 52056, "relays": [ { "has_timer": false, "ison": false, "overpower": false, "source": "http", "timer_duration": 0, "timer_remaining": 0, "timer_started": 0 } ], "serial": 16, "temperature": 24.14, "time": "19:57", "tmp": { "is_valid": true, "tC": 24.14, "tF": 75.45 }, "unixtime": 1707332241, "update": { "beta_version": "20231107-164219/v1.14.1-rc1-g0617c15", "has_update": false, "new_version": "20230913-113421/v1.14.0-gcb84623", "old_version": "20230913-113421/v1.14.0-gcb84623", "status": "idle" }, "uptime": 2589909, "wifi_sta": { "connected": true, "ip": "192.168.1.91", "rssi": -76, "ssid": "Wifi-Network-Name" } } } python-aioshelly-13.14.0/fixtures/gen1_Shelly Vintage_SHVIN-1_20230913-111730-v1.14.0-gcb84623.json000066400000000000000000000103531507326321100303550ustar00rootroot00000000000000{ "settings": { "actions": { "active": false, "names": [ "out_on_url", "out_off_url" ] }, "allow_cross_origin": false, "ap_roaming": { "enabled": true, "threshold": -70 }, "build_info": { "build_id": "20230913-111730/v1.14.0-gcb84623", "build_timestamp": "2023-09-13T11:17:30Z", "build_version": "1.0" }, "cloud": { "connected": true, "enabled": true }, "coiot": { "enabled": true, "peer": "192.168.1.10:5683", "update_period": 15 }, "debug_enable": false, "device": { "hostname": "ShellyVintage-AABBCCDDEEFF", "mac": "AABBCCDDEEFF", "num_outputs": 1, "type": "SHVIN-1" }, "discoverable": false, "eco_mode_enabled": true, "fw": "20230913-111730/v1.14.0-gcb84623", "hwinfo": { "batch_id": 0, "hw_revision": "prod-102020" }, "lat": 45.472198, "lights": [ { "auto_off": 0.0, "auto_on": 0.0, "brightness": 100, "default_state": "last", "ison": false, "name": null, "night_mode": { "brightness": 0, "enabled": false, "end_time": "00:00", "start_time": "00:00" }, "schedule": false, "schedule_rules": [], "transition": 1000 } ], "lng": 9.1922, "login": { "enabled": false, "unprotected": false, "username": "admin" }, "mode": "white", "mqtt": { "clean_session": true, "enable": false, "id": "ShellyVintage-AABBCCDDEEFF", "keep_alive": 60, "max_qos": 0, "reconnect_timeout_max": 60.0, "reconnect_timeout_min": 2.0, "retain": false, "server": "192.168.33.3:1883", "update_period": 30, "user": "" }, "name": "Test Name", "night_mode": { "brightness": 0, "enabled": false, "end_time": "00:00", "start_time": "00:00" }, "pin_code": "", "pon_wifi_reset": false, "sntp": { "enabled": true, "server": "192.168.1.10" }, "time": "18:28", "timezone": "Europe/Rome", "transition": 1000, "tz_dst": false, "tz_dst_auto": true, "tz_utc_offset": 3600, "tzautodetect": true, "unixtime": 1707326930, "wifi_ap": { "enabled": false, "key": "", "ssid": "ShellyVintage-AABBCCDDEEFF" }, "wifi_sta": { "dns": null, "enabled": true, "gw": null, "ip": null, "ipv4_method": "dhcp", "mask": null, "ssid": "Wifi-Network-Name" }, "wifi_sta1": { "dns": null, "enabled": false, "gw": null, "ip": null, "ipv4_method": "dhcp", "mask": null, "ssid": null } }, "shelly": { "auth": false, "discoverable": false, "fw": "20230913-111730/v1.14.0-gcb84623", "longid": 1, "mac": "AABBCCDDEEFF", "name": "Test Name", "num_outputs": 1, "type": "SHVIN-1" }, "status": { "actions_stats": { "skipped": 0 }, "cfg_changed_cnt": 0, "cloud": { "connected": true, "enabled": true }, "fs_free": 163903, "fs_size": 233681, "has_update": false, "lights": [ { "brightness": 100, "has_timer": false, "ison": false, "source": "http", "timer_duration": 0, "timer_remaining": 0, "timer_started": 0, "transition": 0 } ], "mac": "AABBCCDDEEFF", "meters": [ { "counters": [ 0.0, 0.0, 0.0 ], "is_valid": true, "power": 0.0, "timestamp": 1707330530, "total": 1 } ], "mqtt": { "connected": false }, "ram_free": 40444, "ram_total": 52344, "serial": 19777, "time": "18:28", "unixtime": 1707326930, "update": { "beta_version": "20231107-162609/v1.14.1-rc1-g0617c15", "has_update": false, "new_version": "20230913-111730/v1.14.0-gcb84623", "old_version": "20230913-111730/v1.14.0-gcb84623", "status": "idle" }, "uptime": 895103, "wifi_sta": { "connected": true, "ip": "192.168.1.80", "rssi": -41, "ssid": "Wifi-Network-Name" } } } gen2_Shelly BLU Gateway_SNGW-BT01_20231219-133956-1.1.0-g34b5d4f.json000066400000000000000000000053731507326321100307350ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures{ "config": { "ble": { "enable": true, "observer": { "enable": true }, "rpc": { "enable": true } }, "cloud": { "enable": true, "server": "shelly-9-eu.shelly.cloud:6022/jrpc" }, "mqtt": { "client_id": "shellyblugw-aabbccddeeff", "enable": false, "enable_control": true, "enable_rpc": true, "rpc_ntf": true, "server": "mqtt.test.server", "ssl_ca": null, "status_ntf": false, "topic_prefix": "shellyblugw-aabbccddeeff", "use_client_cert": false, "user": null }, "script:1": { "enable": false, "id": 1, "name": "Script Test Name 1" }, "sys": { "cfg_rev": 19, "debug": { "file_level": null, "level": 2, "mqtt": { "enable": false }, "udp": { "addr": null }, "websocket": { "enable": false } }, "device": { "discoverable": true, "eco_mode": false, "fw_id": "20231219-133956/1.1.0-g34b5d4f", "mac": "AABBCCDDEEFF", "name": "Test Name" }, "location": { "lat": 45.4722, "lon": 9.1922, "tz": "Europe/Rome" }, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "sntp.test.server" }, "ui_data": {} }, "wifi": "Wifi-Network-Name", "ws": { "enable": false, "server": null, "ssl_ca": "ca.pem" } }, "shelly": { "app": "BluGw", "auth_domain": null, "auth_en": false, "fw_id": "20231219-133956/1.1.0-g34b5d4f", "gen": 2, "id": "shellyblugw-aabbccddeeff", "mac": "AABBCCDDEEFF", "model": "SNGW-BT01", "name": "Test Name", "slot": 0, "ver": "1.1.0" }, "status": { "ble": {}, "cloud": { "connected": true }, "mqtt": { "connected": false }, "script:1": { "id": 1, "mem_free": 24626, "mem_peak": 2548, "mem_used": 574, "running": true }, "sys": { "available_updates": { "beta": { "version": "1.2.0-beta1" } }, "cfg_rev": 19, "fs_free": 147456, "fs_size": 458752, "kvs_rev": 0, "mac": "AABBCCDDEEFF", "ram_free": 117288, "ram_size": 261904, "reset_reason": 3, "restart_required": true, "schedule_rev": 0, "time": "14:31", "unixtime": 1707571873, "uptime": 913057, "webhook_rev": 0 }, "wifi": { "ap_client_count": 1, "rssi": -70, "ssid": "Wifi-Network-Name", "sta_ip": "192.168.1.94", "status": "got ip" }, "ws": { "connected": false } } } gen2_Shelly BLU Gateway_SNGW-BT01_20240213-140411-1.2.0-gb1b9aa8.json000066400000000000000000000053561507326321100307650ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures{ "config": { "ble": { "enable": true, "observer": { "enable": true }, "rpc": { "enable": true } }, "cloud": { "enable": true, "server": "shelly-9-eu.shelly.cloud:6022/jrpc" }, "mqtt": { "client_id": "shellyblugw-aabbccddeeff", "enable": false, "enable_control": true, "enable_rpc": true, "rpc_ntf": true, "server": "mqtt.test.server", "ssl_ca": null, "status_ntf": false, "topic_prefix": "shellyblugw-aabbccddeeff", "use_client_cert": false, "user": null }, "script:1": { "enable": false, "id": 1, "name": "script 1" }, "sys": { "cfg_rev": 20, "debug": { "file_level": null, "level": 2, "mqtt": { "enable": false }, "udp": { "addr": null }, "websocket": { "enable": false } }, "device": { "discoverable": true, "eco_mode": false, "fw_id": "20240213-140411/1.2.0-gb1b9aa8", "mac": "AABBCCDDEEFF", "name": "Test Name" }, "location": { "lat": 45.4722, "lon": 9.1922, "tz": "Europe/Rome" }, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "sntp.test.server" }, "ui_data": {} }, "wifi": "Wifi-Network-Name", "ws": { "enable": false, "server": null, "ssl_ca": "ca.pem" } }, "shelly": { "app": "BluGw", "auth_domain": null, "auth_en": false, "fw_id": "20240213-140411/1.2.0-gb1b9aa8", "gen": 2, "id": "shellyblugw-aabbccddeeff", "mac": "AABBCCDDEEFF", "model": "SNGW-BT01", "name": "Test Name", "slot": 1, "ver": "1.2.0" }, "status": { "ble": {}, "cloud": { "connected": true }, "mqtt": { "connected": false }, "script:1": { "id": 1, "mem_free": 24472, "mem_peak": 2842, "mem_used": 728, "running": true }, "sys": { "available_updates": { "stable": { "version": "1.1.0" } }, "cfg_rev": 20, "fs_free": 167936, "fs_size": 458752, "kvs_rev": 0, "mac": "AABBCCDDEEFF", "ram_free": 115116, "ram_size": 261524, "reset_reason": 1, "restart_required": false, "schedule_rev": 0, "time": "10:32", "unixtime": 1708507962, "uptime": 247876, "webhook_rev": 0 }, "wifi": { "ap_client_count": 1, "rssi": -83, "ssid": "Wifi-Network-Name", "sta_ip": "192.168.1.94", "status": "got ip" }, "ws": { "connected": false } } } gen2_Shelly Plus 1PM_SNSW-001P16EU_20231219-133934-1.1.0-g34b5d4f.json000066400000000000000000000073651507326321100306200ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures{ "config": { "ble": { "enable": true, "observer": { "enable": false }, "rpc": { "enable": true } }, "cloud": { "enable": true, "server": "shelly-9-eu.shelly.cloud:6022/jrpc" }, "input:0": { "enable": true, "factory_reset": true, "id": 0, "invert": false, "name": "Input Test Name 0", "type": "switch" }, "mqtt": { "client_id": "shellyplus1pm-aabbccddeeff", "enable": false, "enable_control": true, "enable_rpc": true, "rpc_ntf": true, "server": null, "ssl_ca": null, "status_ntf": false, "topic_prefix": null, "use_client_cert": false, "user": null }, "script:1": { "enable": false, "id": 1, "name": "aioshelly_ble_integration" }, "switch:0": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "autorecover_voltage_errors": false, "current_limit": 16.0, "id": 0, "in_mode": "flip", "initial_state": "off", "name": "Switch Test Name 0", "power_limit": 4480, "voltage_limit": 280 }, "sys": { "cfg_rev": 35, "debug": { "file_level": null, "level": 2, "mqtt": { "enable": false }, "udp": { "addr": null }, "websocket": { "enable": true } }, "device": { "addon_type": null, "discoverable": true, "eco_mode": true, "fw_id": "20231219-133934/1.1.0-g34b5d4f", "mac": "AABBCCDDEEFF", "name": "Test Name" }, "location": { "lat": 45.45803, "lon": 7.87121, "tz": "Europe/Rome" }, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "192.168.1.10" }, "ui_data": { "consumption_types": [ "Light" ] } }, "wifi": "Wifi-Network-Name", "ws": { "enable": true, "server": "ws://192.168.1.10:8123/api/shelly/ws", "ssl_ca": "ca.pem" } }, "shelly": { "app": "Plus1PM", "auth_domain": null, "auth_en": false, "fw_id": "20231219-133934/1.1.0-g34b5d4f", "gen": 2, "id": "shellyplus1pm-aabbccddeeff", "mac": "AABBCCDDEEFF", "model": "SNSW-001P16EU", "name": "Test Name", "slot": 0, "ver": "1.1.0" }, "status": { "ble": {}, "cloud": { "connected": true }, "input:0": { "id": 0, "state": false }, "mqtt": { "connected": false }, "script:1": { "id": 1, "mem_free": 25200, "running": false }, "switch:0": { "aenergy": { "by_minute": [ 0.0, 0.0, 0.0 ], "minute_ts": 1707326934, "total": 110.046 }, "apower": 0.0, "current": 0.0, "id": 0, "output": false, "source": "WS_in", "temperature": { "tC": 30.3, "tF": 86.6 }, "voltage": 232.8 }, "sys": { "available_updates": { "beta": { "version": "1.2.0-beta1" } }, "cfg_rev": 35, "fs_free": 135168, "fs_size": 458752, "kvs_rev": 4, "mac": "AABBCCDDEEFF", "ram_free": 122072, "ram_size": 260728, "reset_reason": 3, "restart_required": false, "schedule_rev": 0, "time": "18:28", "unixtime": 1707326936, "uptime": 2622735, "webhook_rev": 0 }, "wifi": { "rssi": -64, "ssid": "Wifi-Network-Name", "sta_ip": "192.168.1.52", "status": "got ip" }, "ws": { "connected": false } } } gen2_Shelly Plus Plug IT_SNPL-00110IT_20231219-133948-1.1.0-g34b5d4f.json000066400000000000000000000064351507326321100312700ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures{ "config": { "ble": { "enable": false, "observer": { "enable": false }, "rpc": { "enable": false } }, "cloud": { "enable": true, "server": "shelly-9-eu.shelly.cloud:6022/jrpc" }, "mqtt": { "client_id": "shellyplusplugit-aabbccddeeff", "enable": false, "enable_control": true, "enable_rpc": true, "rpc_ntf": true, "server": null, "ssl_ca": null, "status_ntf": false, "topic_prefix": "shellyplusplugit-aabbccddeeff", "use_client_cert": false, "user": null }, "switch:0": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "autorecover_voltage_errors": false, "current_limit": 10.0, "id": 0, "initial_state": "on", "name": "Switch Test Name 0", "power_limit": 2300, "voltage_limit": 280 }, "sys": { "cfg_rev": 27, "debug": { "file_level": null, "level": 2, "mqtt": { "enable": false }, "udp": { "addr": null }, "websocket": { "enable": false } }, "device": { "discoverable": true, "eco_mode": true, "fw_id": "20231219-133948/1.1.0-g34b5d4f", "mac": "AABBCCDDEEFF", "name": "Test Name" }, "location": { "lat": 45.45803, "lon": 7.87121, "tz": "Europe/Rome" }, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "192.168.1.10" }, "ui_data": { "consumption_types": [ "" ] } }, "wifi": "Wifi-Network-Name", "ws": { "enable": false, "server": null, "ssl_ca": "ca.pem" } }, "shelly": { "app": "PlusPlugIT", "auth_domain": null, "auth_en": false, "fw_id": "20231219-133948/1.1.0-g34b5d4f", "gen": 2, "id": "shellyplusplugit-aabbccddeeff", "mac": "AABBCCDDEEFF", "model": "SNPL-00110IT", "name": "Test Name", "slot": 0, "ver": "1.1.0" }, "status": { "ble": {}, "cloud": { "connected": true }, "mqtt": { "connected": false }, "switch:0": { "aenergy": { "by_minute": [ 10.3, 14.306, 13.734 ], "minute_ts": 1707326925, "total": 922.282 }, "apower": 0.8, "current": 0.0, "id": 0, "output": true, "source": "WS_in", "temperature": { "tC": 26.8, "tF": 80.3 }, "voltage": 222.6 }, "sys": { "available_updates": { "beta": { "version": "1.2.0-beta1" } }, "cfg_rev": 27, "fs_free": 151552, "fs_size": 458752, "kvs_rev": 4, "mac": "AABBCCDDEEFF", "ram_free": 143224, "ram_size": 246440, "reset_reason": 3, "restart_required": false, "schedule_rev": 0, "time": "18:28", "unixtime": 1707326927, "uptime": 4346058, "webhook_rev": 0 }, "wifi": { "rssi": -52, "ssid": "Wifi-Network-Name", "sta_ip": "192.168.1.53", "status": "got ip" }, "ws": { "connected": false } } } gen2_Shelly Plus Plug S_SNPL-00112EU_20231219-134003-1.1.0-g34b5d4f.json000066400000000000000000000077161507326321100311370ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures{ "config": { "ble": { "enable": false, "observer": { "enable": false }, "rpc": { "enable": false } }, "cloud": { "enable": true, "server": "shelly-9-eu.shelly.cloud:6022/jrpc" }, "mqtt": { "client_id": "shellyplusplugs-aabbccddeeff", "enable": false, "enable_control": true, "enable_rpc": true, "rpc_ntf": true, "server": null, "ssl_ca": null, "status_ntf": false, "topic_prefix": "shellyplusplugs-aabbccddeeff", "use_client_cert": false, "user": null }, "plugs_ui": { "controls": { "switch:0": { "in_mode": "momentary" } }, "leds": { "colors": { "power": { "brightness": 100.0 }, "switch:0": { "off": { "brightness": 100.0, "rgb": [ 100.0, 0.0, 0.0 ] }, "on": { "brightness": 100.0, "rgb": [ 0.0, 100.0, 0.0 ] } } }, "mode": "power", "night_mode": { "active_between": [], "brightness": 100.0, "enable": false } } }, "switch:0": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "current_limit": 12.0, "id": 0, "initial_state": "on", "name": "Switch Test Name 0", "power_limit": 2500, "voltage_limit": 280 }, "sys": { "cfg_rev": 25, "debug": { "file_level": null, "level": 2, "mqtt": { "enable": false }, "udp": { "addr": null }, "websocket": { "enable": false } }, "device": { "discoverable": true, "eco_mode": true, "fw_id": "20231219-134003/1.1.0-g34b5d4f", "mac": "AABBCCDDEEFF", "name": "Test Name" }, "location": { "lat": 45.45803, "lon": 7.87121, "tz": "Europe/Rome" }, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "time.google.com" }, "ui_data": {} }, "wifi": "Wifi-Network-Name", "ws": { "enable": false, "server": null, "ssl_ca": "ca.pem" } }, "shelly": { "app": "PlusPlugS", "auth_domain": null, "auth_en": false, "fw_id": "20231219-134003/1.1.0-g34b5d4f", "gen": 2, "id": "shellyplusplugs-aabbccddeeff", "mac": "AABBCCDDEEFF", "model": "SNPL-00112EU", "name": "Test Name", "slot": 0, "ver": "1.1.0" }, "status": { "ble": {}, "cloud": { "connected": true }, "mqtt": { "connected": false }, "plugs_ui": {}, "switch:0": { "aenergy": { "by_minute": [ 34.786, 45.552, 45.552 ], "minute_ts": 1707326925, "total": 7258.589 }, "apower": 2.7, "current": 0.121, "id": 0, "output": true, "source": "init", "temperature": { "tC": 32.1, "tF": 89.7 }, "voltage": 227.9 }, "sys": { "available_updates": { "beta": { "version": "1.2.0-beta1" } }, "cfg_rev": 25, "fs_free": 139264, "fs_size": 458752, "kvs_rev": 0, "mac": "AABBCCDDEEFF", "ram_free": 141748, "ram_size": 246084, "reset_reason": 3, "restart_required": false, "schedule_rev": 0, "time": "18:28", "unixtime": 1707326927, "uptime": 4319353, "webhook_rev": 0 }, "wifi": { "rssi": -53, "ssid": "Wifi-Network-Name", "sta_ip": "192.168.1.85", "status": "got ip" }, "ws": { "connected": false } } } gen2_Shelly Pro 3EM_SPEM-003CEBEU_20231219-134001-1.1.0-g34b5d4f.json000066400000000000000000000103311507326321100303700ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures{ "config": { "ble": { "enable": false, "observer": { "enable": false }, "rpc": { "enable": false } }, "cloud": { "enable": true, "server": "shelly-9-eu.shelly.cloud:6022/jrpc" }, "em:0": { "blink_mode_selector": "active_energy", "id": 0, "monitor_phase_sequence": false, "name": "Energy Monitor Test Name 0", "phase_selector": "all", "reverse": {} }, "emdata:0": {}, "eth": { "enable": true, "gw": null, "ip": null, "ipv4mode": "dhcp", "nameserver": null, "netmask": null }, "modbus": { "enable": true }, "mqtt": { "client_id": "shellypro3em-aabbccddeeff", "enable": false, "enable_control": true, "enable_rpc": true, "rpc_ntf": true, "server": null, "ssl_ca": null, "status_ntf": false, "topic_prefix": "shellypro3em-aabbccddeeff", "use_client_cert": false, "user": null }, "sys": { "cfg_rev": 29, "debug": { "file_level": null, "level": 2, "mqtt": { "enable": false }, "udp": { "addr": null }, "websocket": { "enable": false } }, "device": { "addon_type": null, "discoverable": true, "eco_mode": true, "fw_id": "20231219-134001/1.1.0-g34b5d4f", "mac": "AABBCCDDEEFF", "name": "Test Name", "profile": "triphase" }, "location": { "lat": 45.45803, "lon": 7.87121, "tz": "Europe/Rome" }, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "192.168.1.10" }, "ui_data": {} }, "temperature:0": { "id": 0, "name": null, "offset_C": 0.0, "report_thr_C": 5.0 }, "wifi": "Wifi-Network-Name", "ws": { "enable": false, "server": "ws://192.168.1.10:8123/api/shelly/ws", "ssl_ca": "*" } }, "shelly": { "app": "Pro3EM", "auth_domain": null, "auth_en": false, "fw_id": "20231219-134001/1.1.0-g34b5d4f", "gen": 2, "id": "shellypro3em-aabbccddeeff", "mac": "AABBCCDDEEFF", "model": "SPEM-003CEBEU", "name": "Test Name", "profile": "triphase", "slot": 0, "ver": "1.1.0" }, "status": { "ble": {}, "cloud": { "connected": true }, "em:0": { "a_act_power": 375.5, "a_aprt_power": 547.4, "a_current": 2.392, "a_freq": 50.0, "a_pf": 0.76, "a_voltage": 229.0, "b_act_power": 170.8, "b_aprt_power": 237.2, "b_current": 1.037, "b_freq": 50.0, "b_pf": 0.78, "b_voltage": 229.0, "c_act_power": 12.7, "c_aprt_power": 97.8, "c_current": 0.428, "c_freq": 50.0, "c_pf": 0.53, "c_voltage": 229.2, "id": 0, "n_current": null, "total_act_power": 558.965, "total_aprt_power": 882.413, "total_current": 3.856, "user_calibrated_phase": [] }, "emdata:0": { "a_total_act_energy": 2034616.71, "a_total_act_ret_energy": 0.0, "b_total_act_energy": 185686.89, "b_total_act_ret_energy": 40.7, "c_total_act_energy": 369469.8, "c_total_act_ret_energy": 985.91, "id": 0, "total_act": 2589773.39, "total_act_ret": 1026.61 }, "eth": { "ip": "192.168.1.105" }, "modbus": {}, "mqtt": { "connected": false }, "sys": { "available_updates": { "beta": { "version": "1.2.0-beta1" } }, "cfg_rev": 29, "fs_free": 184320, "fs_size": 524288, "kvs_rev": 0, "mac": "AABBCCDDEEFF", "ram_free": 125084, "ram_size": 241568, "reset_reason": 1, "restart_required": false, "schedule_rev": 0, "time": "18:28", "unixtime": 1707326927, "uptime": 2085615, "webhook_rev": 0 }, "temperature:0": { "id": 0, "tC": 46.6, "tF": 115.9 }, "wifi": { "rssi": 0, "ssid": null, "sta_ip": null, "status": "disconnected" }, "ws": { "connected": false } } } gen2_Shelly Pro 3_SPSW-003XE16EU_20231219-133956-1.1.0-g34b5d4f.json000066400000000000000000000106741507326321100303240ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures{ "config": { "ble": { "enable": false, "observer": { "enable": false }, "rpc": { "enable": true } }, "cloud": { "enable": true, "server": "shelly-9-eu.shelly.cloud:6022/jrpc" }, "eth": { "enable": true, "gw": null, "ip": null, "ipv4mode": "dhcp", "nameserver": null, "netmask": null }, "input:0": { "enable": true, "id": 0, "invert": false, "name": "Input Test Name 0", "type": "switch" }, "input:1": { "enable": true, "id": 1, "invert": false, "name": "Input Test Name 1", "type": "switch" }, "input:2": { "enable": true, "id": 2, "invert": false, "name": "Input Test Name 2", "type": "switch" }, "mqtt": { "client_id": "shellypro3-aabbccddeeff", "enable": false, "enable_control": true, "enable_rpc": true, "rpc_ntf": true, "server": null, "ssl_ca": null, "status_ntf": false, "topic_prefix": "shellypro3-aabbccddeeff", "use_client_cert": false, "user": null }, "switch:0": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "id": 0, "in_mode": "follow", "initial_state": "match_input", "name": "Switch Test Name 0" }, "switch:1": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "id": 1, "in_mode": "follow", "initial_state": "match_input", "name": "Switch Test Name 1" }, "switch:2": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "id": 2, "in_mode": "follow", "initial_state": "match_input", "name": "Switch Test Name 2" }, "sys": { "cfg_rev": 23, "debug": { "file_level": null, "level": 2, "mqtt": { "enable": false }, "udp": { "addr": null }, "websocket": { "enable": false } }, "device": { "discoverable": true, "eco_mode": true, "fw_id": "20231219-133956/1.1.0-g34b5d4f", "mac": "AABBCCDDEEFF", "name": "Test Name" }, "location": { "lat": 45.4722, "lon": 9.1922, "tz": "Europe/Rome" }, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "192.168.1.10" }, "ui_data": {} }, "wifi": "Wifi-Network-Name", "ws": { "enable": false, "server": null, "ssl_ca": "ca.pem" } }, "shelly": { "app": "Pro3", "auth_domain": null, "auth_en": false, "fw_id": "20231219-133956/1.1.0-g34b5d4f", "gen": 2, "id": "shellypro3-aabbccddeeff", "mac": "AABBCCDDEEFF", "model": "SPSW-003XE16EU", "name": "Test Name", "slot": 0, "ver": "1.1.0" }, "status": { "ble": {}, "cloud": { "connected": true }, "eth": { "ip": "192.168.1.106" }, "input:0": { "id": 0, "state": false }, "input:1": { "id": 1, "state": false }, "input:2": { "id": 2, "state": false }, "mqtt": { "connected": false }, "switch:0": { "id": 0, "output": false, "source": "WS_in", "temperature": { "tC": 37.5, "tF": 99.5 } }, "switch:1": { "id": 1, "output": false, "source": "WS_in", "temperature": { "tC": 37.5, "tF": 99.5 } }, "switch:2": { "id": 2, "output": false, "source": "WS_in", "temperature": { "tC": 37.5, "tF": 99.5 } }, "sys": { "available_updates": { "beta": { "version": "1.2.0-beta1" } }, "cfg_rev": 23, "fs_free": 212992, "fs_size": 524288, "kvs_rev": 0, "mac": "AABBCCDDEEFF", "ram_free": 145828, "ram_size": 244312, "reset_reason": 3, "restart_required": false, "schedule_rev": 0, "time": "18:28", "unixtime": 1707326928, "uptime": 2084007, "webhook_rev": 0 }, "wifi": { "rssi": 0, "ssid": null, "sta_ip": null, "status": "disconnected" }, "ws": { "connected": false } } } gen2_Shelly Pro 4PM_SPSW-004PE16EU_20230912-082358-1.0.3-g6176478.json000066400000000000000000000152241507326321100303420ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures{ "config": { "ble": { "enable": true, "observer": { "enable": false }, "rpc": { "enable": true } }, "cloud": { "enable": true, "server": "shelly-91-eu.shelly.cloud:6022/jrpc" }, "eth": { "enable": true, "gw": null, "ip": null, "ipv4mode": "dhcp", "nameserver": null, "netmask": null }, "input:0": { "id": 0, "invert": false, "name": "input 0", "type": "switch" }, "input:1": { "id": 1, "invert": false, "name": "input 1", "type": "switch" }, "input:2": { "id": 2, "invert": false, "name": "input 2", "type": "switch" }, "input:3": { "id": 3, "invert": false, "name": "input 3", "type": "switch" }, "mqtt": { "client_id": "shellypro4pm-aabbccddeeff", "enable": false, "enable_control": true, "enable_rpc": true, "rpc_ntf": true, "server": "mqtt.test.server", "status_ntf": false, "topic_prefix": null, "use_client_cert": false, "user": null }, "script:1": { "enable": false, "id": 1, "name": "script 1" }, "switch:0": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "autorecover_voltage_errors": false, "current_limit": 16.0, "id": 0, "in_mode": "follow", "initial_state": "match_input", "name": "switch 0", "power_limit": 4480, "undervoltage_limit": 0, "voltage_limit": 280 }, "switch:1": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "autorecover_voltage_errors": false, "current_limit": 16.0, "id": 1, "in_mode": "follow", "initial_state": "match_input", "name": "switch 1", "power_limit": 4480, "undervoltage_limit": 0, "voltage_limit": 280 }, "switch:2": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "autorecover_voltage_errors": false, "current_limit": 16.0, "id": 2, "in_mode": "follow", "initial_state": "match_input", "name": "switch 2", "power_limit": 4480, "undervoltage_limit": 0, "voltage_limit": 280 }, "switch:3": { "auto_off": false, "auto_off_delay": 60.0, "auto_on": false, "auto_on_delay": 60.0, "autorecover_voltage_errors": false, "current_limit": 16.0, "id": 3, "in_mode": "follow", "initial_state": "match_input", "name": "switch 3", "power_limit": 4480, "undervoltage_limit": 0, "voltage_limit": 280 }, "sys": { "cfg_rev": 7, "debug": { "file_level": null, "level": 2, "mqtt": { "enable": false }, "udp": { "addr": null }, "websocket": { "enable": false } }, "device": { "discoverable": true, "eco_mode": false, "fw_id": "20230912-082358/1.0.3-g6176478", "mac": "AABBCCDDEEFF", "name": "Test Name" }, "location": { "lat": 29.76078, "lon": -95.36952, "tz": "America/Chicago" }, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "sntp.test.server" }, "ui_data": {} }, "ui": { "idle_brightness": 30 }, "wifi": "Wifi-Network-Name", "ws": { "enable": false, "server": null, "ssl_ca": "ca.pem" } }, "shelly": { "app": "Pro4PM", "auth_domain": null, "auth_en": false, "fw_id": "20230912-082358/1.0.3-g6176478", "gen": 2, "id": "shellypro4pm-aabbccddeeff", "mac": "AABBCCDDEEFF", "model": "SPSW-004PE16EU", "name": "Test Name", "slot": 0, "ver": "1.0.3" }, "status": { "ble": {}, "cloud": { "connected": true }, "eth": { "ip": null }, "input:0": { "id": 0, "state": false }, "input:1": { "id": 1, "state": false }, "input:2": { "id": 2, "state": false }, "input:3": { "id": 3, "state": false }, "mqtt": { "connected": false }, "script:1": { "id": 1, "mem_free": 25200, "running": false }, "switch:0": { "aenergy": { "by_minute": [ 0.0, 0.0, 0.0 ], "minute_ts": 1708381882, "total": 0.0 }, "apower": 0.0, "current": 0.0, "freq": 60.0, "id": 0, "output": false, "pf": 0.0, "source": "init", "temperature": { "tC": 25.6, "tF": 78.0 }, "voltage": 121.7 }, "switch:1": { "aenergy": { "by_minute": [ 0.0, 0.0, 0.0 ], "minute_ts": 1708381882, "total": 0.0 }, "apower": 0.0, "current": 0.0, "freq": 60.0, "id": 1, "output": false, "pf": 0.0, "source": "SHC", "temperature": { "tC": 25.6, "tF": 78.0 }, "voltage": 121.8 }, "switch:2": { "aenergy": { "by_minute": [ 0.0, 0.0, 0.0 ], "minute_ts": 1708381882, "total": 0.0 }, "apower": 0.0, "current": 0.0, "freq": 60.0, "id": 2, "output": false, "pf": 0.0, "source": "init", "temperature": { "tC": 25.6, "tF": 78.0 }, "voltage": 121.8 }, "switch:3": { "aenergy": { "by_minute": [ 0.0, 0.0, 0.0 ], "minute_ts": 1708381882, "total": 0.0 }, "apower": 0.0, "current": 0.0, "freq": 60.0, "id": 3, "output": false, "pf": 0.0, "source": "init", "temperature": { "tC": 25.6, "tF": 78.0 }, "voltage": 121.8 }, "sys": { "available_updates": {}, "cfg_rev": 7, "fs_free": 204800, "fs_size": 524288, "kvs_rev": 0, "mac": "AABBCCDDEEFF", "ram_free": 80320, "ram_size": 256388, "restart_required": false, "schedule_rev": 0, "time": "16:31", "unixtime": 1708381883, "uptime": 522703, "webhook_rev": 0 }, "ui": {}, "wifi": { "rssi": -71, "ssid": "Wifi-Network-Name", "sta_ip": "192.168.212.172", "status": "got ip" }, "ws": { "connected": false } } } gen2_Shelly Wall Display_SAWD-0A1XX10EU1_20240115-105444-1.2.8-59063a9a.json000066400000000000000000000126311507326321100316320ustar00rootroot00000000000000python-aioshelly-13.14.0/fixtures{ "config": { "awaiting_auth_code": false, "ble": { "enable": false, "observer": { "enable": false }, "rpc": { "enable": true } }, "cloud": { "enable": true, "server": "shelly-9-eu.shelly.cloud:6022/jrpc" }, "humidity:0": { "id": 0, "name": null, "offset": 0, "report_thr": 1 }, "illuminance:0": { "bright_thr": 200, "dark_thr": 30, "id": 0, "name": null }, "input:0": { "factory_reset": true, "id": 0, "invert": false, "name": "input 0", "type": "switch" }, "mqtt": { "client_id": "ShellyWallDisplay-AABBCCDDEEFF", "enable": false, "server": "mqtt.test.server", "topic_prefix": "ShellyWallDisplay-AABBCCDDEEFF" }, "switch:0": { "auto_off": false, "auto_on_delay": 0, "id": 0, "in_mode": "follow", "initial_state": "off", "name": "switch 0" }, "sys": { "cfg_rev": 32, "debug": { "logs": { "Bluetooth": true, "Cloud": true, "Generic": true, "Interface": true, "Network": true, "RPC": true, "Screen": true, "Thermostat": true, "UART": true, "WebSocket": true, "Webhooks": true }, "mqtt": { "enable": false }, "websocket": { "enable": true } }, "device": { "discoverable": false, "eco_mode": true, "fw_id": "20240115-105444/1.2.8-59063a9a", "mac": "AABBCCDDEEFF", "name": "Test Name" }, "location": { "lat": 45.4722, "lon": 9.1922, "tz": "Europe/Rome" }, "sntp": { "server": "sntp.test.server" } }, "temperature:0": { "id": 0, "name": null, "offset_C": 0, "report_thr_C": 1 }, "thermostat:0": { "actuator": "shelly://shellywalldisplay-aabbccddeeff/c/switch:0", "display_unit": "C", "enable": true, "hysteresis": 1, "id": 0, "invert_output": false, "name": null, "sensor": "shelly://shellywalldisplay-aabbccddeeff/c/temperature:0", "target_C": 21, "type": "heating" }, "ui": { "brightness": { "auto": true, "auto_off": { "by_lux": false, "enable": true }, "level": 0 }, "disable_gestures_when_locked": false, "lock_type": "none", "relay_state_overlay": { "always_visible": false, "enable": true }, "screen_off_when_idle": true, "screen_saver": { "enable": true, "show_clock": true, "show_humidity": true, "show_temperature": true, "timeout": 10 }, "show_favourites": false, "show_main_sensor_graph": true, "use_F": false }, "wifi": "Wifi-Network-Name", "ws": { "enable": false, "ssl_ca": "ca.pem" } }, "shelly": { "app": "WallDisplay", "app_uptime": 104783, "auth_en": false, "available_updates": { "stable": { "build_id": "20240115-105444/1.2.8-59063a9a", "version": "1.2.8" } }, "cfg_rev": 32, "discoverable": false, "fw_id": "20240115-105444/1.2.8-59063a9a", "gen": 2, "id": "ShellyWallDisplay-AABBCCDDEEFF", "mac": "AABBCCDDEEFF", "model": "SAWD-0A1XX10EU1", "name": "Test Name", "platform": "vXD10000M2", "relay_in_thermostat": true, "restart_required": false, "schedule_rev": 0, "sensor_in_thermostat": true, "unixtime": 1708507954, "uptime": 3119412, "ver": "1.2.8", "webhook_rev": 0 }, "status": { "awaiting_auth_code": false, "ble": {}, "cloud": { "connected": true }, "humidity:0": { "id": 0, "rh": 43.8 }, "illuminance:0": { "id": 0, "illumination": "twilight", "lux": 30 }, "input:0": { "id": 0, "state": false }, "mqtt": { "connected": false }, "switch:0": { "id": 0, "output": false, "source": "THERMOSTAT" }, "sys": { "app": "WallDisplay", "app_uptime": 104783, "auth_en": false, "available_updates": { "stable": { "build_id": "20240115-105444/1.2.8-59063a9a", "version": "1.2.8" } }, "cfg_rev": 32, "discoverable": false, "fw_id": "20240115-105444/1.2.8-59063a9a", "gen": 2, "id": "ShellyWallDisplay-AABBCCDDEEFF", "mac": "AABBCCDDEEFF", "model": "SAWD-0A1XX10EU1", "platform": "vXD10000M2", "relay_in_thermostat": true, "restart_required": false, "schedule_rev": 0, "sensor_in_thermostat": true, "unixtime": 1708507954, "uptime": 3119412, "ver": "1.2.8", "webhook_rev": 0 }, "temperature:0": { "id": 0, "tC": 21.3, "tF": 70.4 }, "thermostat:0": { "current_C": 21.3, "enable": true, "id": 0, "output": false, "schedules": { "enable": false }, "target_C": 21 }, "wifi": { "gw": "192.168.1.1", "mac": "11:22:33:44:55:66", "nameserver": "192.168.1.10", "netmask": "255.255.255.0", "rssi": -59, "ssid": "Wifi-Network-Name", "sta_ip": "192.168.1.78", "status": "got ip" } } } python-aioshelly-13.14.0/pyproject.toml000066400000000000000000000105141507326321100201050ustar00rootroot00000000000000[build-system] build-backend = "setuptools.build_meta" requires = ["setuptools>=77.0"] [project] name = "aioshelly" version = "0.0.0" license = "Apache-2.0" description = "Asynchronous library to control Shelly devices." readme = "README.md" authors = [{ name = "Paulus Schoutsen", email = "paulus@home-assistant.io" }] requires-python = ">=3.11" classifiers = [ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ "aiohttp>=3.11.1", "bluetooth-data-tools>=1.28.0", "habluetooth>=3.42.0", "orjson>=3.8.1", "yarl", ] [project.optional-dependencies] lint = [ "mypy==1.18.2", "pydocstyle==6.3.0", "ruff==0.14.0", "types-requests", ] dev = [ "aioresponses==0.7.8", "pre-commit==4.3.0", "pytest-asyncio==1.2.0", "pytest-cov==7.0.0", "pytest==8.4.2", "requests", "tox==4.31.0", ] [project.urls] "Source code" = "https://github.com/home-assistant-libs/aioshelly" [tool.setuptools.packages.find] include = ["aioshelly*"] [tool.pytest.ini_options] asyncio_default_fixture_loop_scope = "function" [tool.mypy] python_version = "3.11" show_error_codes = true follow_imports = "silent" ignore_missing_imports = true strict_equality = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_decorators = true disallow_untyped_defs = true no_implicit_optional = true warn_return_any = true warn_unreachable = true [tool.ruff] target-version = "py311" lint.select = ["ALL"] lint.ignore = [ "ANN401", # Dynamically typed expressions (typing.Any) are disallowed "ASYNC109", # Async function definition with a `timeout` parameter "COM812", # Trailing comma missing (conflicts with formatter) "D203", # 1 blank line required before class docstring (conflicts with `no-blank-line-before-class` (D211)) "D213", # Multi-line docstring summary should start at the second line (conflicts with multi-line-summary-first-line` (D212)) "EM101", # Exception must not use a string literal, assign to variable first "EM102", # Exception must not use an f-string literal, assign to variable first "FBT001", # Boolean-typed positional argument in function definition "FBT002", # Boolean default positional argument in function definition "FBT003", # Boolean positional value in function call "G201", # Logging `.exception(...)` should be used instead of `.error(..., exc_info=True)` "N818", # Exception name should be named with an Error suffix "PLR0912", # Too many branches "TC001", # Move application import into a type-checking block "TC002", # Move third-party import into a type-checking block "TC003", # Move standard library import into a type-checking block "TC006", # Add quotes to type expression in typing.cast() "TID252", # Prefer absolute imports over relative imports from parent modules "TRY003", # Avoid specifying long messages outside the exception class "TRY400", # Use `logging.exception` instead of `logging.error` ] [tool.ruff.lint.per-file-ignores] "tools/*" = [ "T201", # `print` found ] "tests/**/*" = [ "D100", "PLR0913", "PLR2004", "S101", "SLF001", ] [tool.ruff.lint.mccabe] max-complexity = 18 [tool.tox] legacy_tox_ini = """ [tox] envlist = py311, py312, py313, py314, lint, mypy, tests skip_missing_interpreters = True [gh-actions] python = 3.11: py311, lint, mypy 3.12: py312, tests 3.13: py313, tests 3.14: py314, tests [testenv:lint] basepython = python3 ignore_errors = True commands = ruff format --check ./ ruff check ./ pydocstyle aioshelly deps = .[lint] [testenv:mypy] basepython = python3 ignore_errors = True commands = mypy aioshelly deps = .[lint] [testenv:tests] basepython = python3 ignore_errors = True commands = python -m pytest --cov=aioshelly --cov-report=xml --cov-report=term-missing deps = .[dev] """ python-aioshelly-13.14.0/script/000077500000000000000000000000001507326321100164745ustar00rootroot00000000000000python-aioshelly-13.14.0/script/environment.sh000077500000000000000000000001711507326321100213760ustar00rootroot00000000000000#!/bin/bash pip3 --disable-pip-version-check --no-cache-dir install .[dev] .[lint] pip3 install -e . pre-commit install python-aioshelly-13.14.0/tests/000077500000000000000000000000001507326321100163325ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/__init__.py000066400000000000000000000000331507326321100204370ustar00rootroot00000000000000"""Tests for aioshelly.""" python-aioshelly-13.14.0/tests/ble/000077500000000000000000000000001507326321100170745ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/ble/__init__.py000066400000000000000000000000301507326321100211760ustar00rootroot00000000000000"""Shelly BLE tests.""" python-aioshelly-13.14.0/tests/ble/backend/000077500000000000000000000000001507326321100204635ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/ble/backend/__init__.py000066400000000000000000000000461507326321100225740ustar00rootroot00000000000000"""Tests for Shelly bleak backend.""" python-aioshelly-13.14.0/tests/ble/backend/test_scanner.py000066400000000000000000000016261507326321100235320ustar00rootroot00000000000000import pytest from aioshelly.ble import create_scanner @pytest.mark.asyncio async def test_create_scanner_back_compat() -> None: """Test create scanner works without modes.""" scanner = create_scanner("AA:BB:CC:DD:EE:FF", "shelly") scanner.async_on_event( { "event": "ble.scan_result", "data": [ 2, [ [ "AA:BB:CC:DD:EE:FF", -50, "AQIDBAUGBwgJCg==", "AQIDBAUGBwgJCg==", ] ], ], } ) scanner_data = scanner.discovered_devices_and_advertisement_data assert "AA:BB:CC:DD:EE:FF" in scanner_data ble_device, advertisement_data = scanner_data["AA:BB:CC:DD:EE:FF"] assert advertisement_data.rssi == -50 assert ble_device.address == "AA:BB:CC:DD:EE:FF" python-aioshelly-13.14.0/tests/ble/conftest.py000066400000000000000000000004531507326321100212750ustar00rootroot00000000000000"""Tests for the BLE.""" from __future__ import annotations from unittest.mock import MagicMock import habluetooth import pytest_asyncio @pytest_asyncio.fixture(autouse=True) async def ha_manager() -> MagicMock: """Mock ha manager.""" await habluetooth.BluetoothManager().async_setup() python-aioshelly-13.14.0/tests/ble/test_init.py000066400000000000000000000022501507326321100214470ustar00rootroot00000000000000"""Tests for the BLE initialization.""" from __future__ import annotations import pytest from habluetooth import BluetoothScanningMode from aioshelly.ble import create_scanner @pytest.mark.asyncio @pytest.mark.parametrize( ("requested_mode", "current_mode"), [ (BluetoothScanningMode.ACTIVE, BluetoothScanningMode.ACTIVE), (BluetoothScanningMode.PASSIVE, BluetoothScanningMode.PASSIVE), (BluetoothScanningMode.ACTIVE, BluetoothScanningMode.PASSIVE), (BluetoothScanningMode.PASSIVE, BluetoothScanningMode.ACTIVE), ], ) async def test_create_scanner( requested_mode: BluetoothScanningMode, current_mode: BluetoothScanningMode ) -> None: """Test create scanner.""" scanner = create_scanner( "AA:BB:CC:DD:EE:FF", "shelly", requested_mode, current_mode ) assert scanner.requested_mode == requested_mode assert scanner.current_mode == current_mode @pytest.mark.asyncio async def test_create_scanner_back_compat() -> None: """Test create scanner works without modes.""" scanner = create_scanner("AA:BB:CC:DD:EE:FF", "shelly") assert scanner.requested_mode is None assert scanner.current_mode is None python-aioshelly-13.14.0/tests/ble/test_parser.py000066400000000000000000000026061507326321100220050ustar00rootroot00000000000000"""Tests for the BLE parser.""" from __future__ import annotations from aioshelly.ble.parser import parse_ble_scan_result_event def test_parse_v1() -> None: """Test parse v1.""" assert parse_ble_scan_result_event( [ 1, "AA:BB:CC:DD:EE:FF", -50, "AQIDBAUGBwgJCg==", "AQIDBAUGBwgJCg==", ] ) == [ ( "AA:BB:CC:DD:EE:FF", -50, b"\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x01\x02\x03\x04\x05\x06\x07\x08\t\n", ) ] def test_parse_v2() -> None: """Test parse v2.""" assert parse_ble_scan_result_event( [ 2, [ [ "AA:BB:CC:DD:EE:FF", -50, "AQIDBAUGBwgJCg==", "AQIDBAUGBwgJCg==", ], [ "AA:BB:CC:DD:EE:FF", -50, "AQIDBAUGBwgJCg==", "AQIDBAUGBwgJCg==", ], ], ] ) == [ ( "AA:BB:CC:DD:EE:FF", -50, b"\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x01\x02\x03\x04\x05\x06\x07\x08\t\n", ), ( "AA:BB:CC:DD:EE:FF", -50, b"\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x01\x02\x03\x04\x05\x06\x07\x08\t\n", ), ] python-aioshelly-13.14.0/tests/block_device/000077500000000000000000000000001507326321100207435ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/block_device/__init__.py000066400000000000000000000000361507326321100230530ustar00rootroot00000000000000"""Tests for Block device.""" python-aioshelly-13.14.0/tests/block_device/conftest.py000066400000000000000000000005331507326321100231430ustar00rootroot00000000000000"""Tests for Block device.""" from collections.abc import AsyncGenerator from unittest.mock import MagicMock import pytest_asyncio from aiohttp.client import ClientSession @pytest_asyncio.fixture async def client_session() -> AsyncGenerator[ClientSession, None]: """Fixture for a ClientSession.""" return MagicMock(spec=ClientSession) python-aioshelly-13.14.0/tests/block_device/test_device.py000066400000000000000000000022701507326321100236140ustar00rootroot00000000000000"""Tests for block_device.device module.""" import asyncio import socket from unittest.mock import Mock import pytest from aiohttp.client import ClientSession from aioshelly.block_device import COAP, BlockDevice from aioshelly.common import ConnectionOptions @pytest.mark.asyncio async def test_incorrect_shutdown( client_session: ClientSession, caplog: pytest.LogCaptureFixture, ) -> None: """Test multiple shutdown calls at incorrect order. https://github.com/home-assistant-libs/aioshelly/pull/535 """ coap_context = COAP() coap_context.sock = Mock(spec=socket.socket) coap_context.transport = Mock(spec=asyncio.DatagramTransport) options = ConnectionOptions("10.10.10.10", device_mac="AABBCCDDEEFF") block_device1 = await BlockDevice.create(client_session, coap_context, options) block_device2 = await BlockDevice.create(client_session, coap_context, options) # shutdown for device2 remove subscription for device1 from ws_context await block_device2.shutdown() assert "error during shutdown: KeyError('DDEEFF')" not in caplog.text await block_device1.shutdown() assert "error during shutdown: KeyError('DDEEFF')" in caplog.text python-aioshelly-13.14.0/tests/block_device/test_init.py000066400000000000000000000005601507326321100233200ustar00rootroot00000000000000from aioshelly import block_device def test_exports() -> None: """Test objects are available at top level of block_device.""" assert hasattr(block_device, "BLOCK_VALUE_UNIT") assert hasattr(block_device, "COAP") assert hasattr(block_device, "Block") assert hasattr(block_device, "BlockDevice") assert hasattr(block_device, "BlockUpdateType") python-aioshelly-13.14.0/tests/rpc_device/000077500000000000000000000000001507326321100204355ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/rpc_device/__init__.py000066400000000000000000000011501507326321100225430ustar00rootroot00000000000000"""Tests for RPC device.""" import asyncio import pathlib from typing import Any from orjson import loads def get_device_fixture_path(device: str, filename: str) -> pathlib.Path: """Get path of a device fixture.""" return pathlib.Path(__file__).parent.joinpath("fixtures", device, filename) async def load_device_fixture(device: str, filename: str) -> dict[str, Any]: """Load a device fixture.""" fixture_path = get_device_fixture_path(device, filename) json_bytes = await asyncio.get_running_loop().run_in_executor( None, fixture_path.read_bytes ) return loads(json_bytes) python-aioshelly-13.14.0/tests/rpc_device/conftest.py000066400000000000000000000124061507326321100226370ustar00rootroot00000000000000"""Tests for RPC device.""" import asyncio from collections.abc import AsyncGenerator, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest_asyncio from aiohttp.client import ClientSession from aiohttp.client_ws import ClientWebSocketResponse from aiohttp.http_websocket import WSMessage, WSMsgType from orjson import dumps from aioshelly.common import ConnectionOptions from aioshelly.rpc_device.device import RpcDevice, WsServer from aioshelly.rpc_device.wsrpc import DEFAULT_HTTP_PORT, AuthData, RPCSource, WsRPC class ResponseMocker: """Mocker for a WebSocket responses.""" def __init__(self) -> None: """Initialize the mocker.""" self.queue: asyncio.Queue[WSMessage] = asyncio.Queue() async def mock_ws_message(self, response: WSMessage) -> None: """Mock a WebSocket message.""" await self.queue.put(response) async def read(self) -> str: """Read a message.""" return await self.queue.get() class NotifyHistory: """History of notifications.""" def __init__(self) -> None: """Initialize the history.""" self.history = [] def save(self, rpc_source: RPCSource, method: str, data: dict | None) -> None: """Save a notification.""" self.history.append((rpc_source, method, data)) class WsRPCMocker(WsRPC): """RPC WebSocket mocker.""" def __init__( self, response_mocker: ResponseMocker, ip_address: str, on_notification: Callable[[RPCSource, str, dict | None], None], port: int = DEFAULT_HTTP_PORT, ) -> None: """Initialize the RPC WebSocket mocker.""" super().__init__(ip_address, on_notification, port) self.response_mocker = response_mocker self.responses: list[dict[str, Any]] = [] self.next_id_mock = 0 async def calls_with_mocked_responses( self, calls: list[tuple[str, dict[str, Any] | None]], responses: list[dict[str, Any]], ) -> list[str]: """Call methods with mocked responses.""" self.next_id_mock = self._call_id self.responses = responses return await self.calls(calls, 0.1) async def _send_json(self, data: dict[str, Any]) -> None: """Instrumented send JSON data to mock a response.""" await super()._send_json(data) await self._send_next_response() async def _send_next_response(self) -> None: """Send the next response.""" response = self.responses.pop(0) shallow_copy = response.copy() self.next_id_mock += 1 shallow_copy["id"] = self.next_id_mock response_with_correct_id = dumps(shallow_copy).decode() await self.response_mocker.mock_ws_message( WSMessage(WSMsgType.TEXT, response_with_correct_id, None) ) @pytest_asyncio.fixture async def rpc_websocket_responses() -> AsyncGenerator[ResponseMocker, None]: """Fixture for a WebSocket responses.""" return ResponseMocker() @pytest_asyncio.fixture async def rpc_websocket_response( rpc_websocket_responses: ResponseMocker, ) -> AsyncGenerator[ClientWebSocketResponse, None]: """Fixture for a WebSocket response.""" mock = MagicMock(spec=ClientWebSocketResponse) mock.receive = rpc_websocket_responses.read mock.closed = False return mock @pytest_asyncio.fixture async def client_session( rpc_websocket_response: ClientWebSocketResponse, ) -> AsyncGenerator[ClientSession, None]: """Fixture for a ClientSession.""" mock = MagicMock(spec=ClientSession) mock.ws_connect = AsyncMock(return_value=rpc_websocket_response) return mock @pytest_asyncio.fixture async def notify_history() -> AsyncGenerator[NotifyHistory, None]: """Fixture to track notify history.""" return NotifyHistory() @pytest_asyncio.fixture async def ws_rpc( rpc_websocket_response: ClientWebSocketResponse, client_session: ClientSession, notify_history: NotifyHistory, rpc_websocket_responses: ResponseMocker, ) -> AsyncGenerator[WsRPCMocker, None]: """Fixture for an RPC WebSocket.""" with patch("aioshelly.rpc_device.wsrpc.ClientWebSocketResponse") as mock: mock.return_value = rpc_websocket_response ws_rpc = WsRPCMocker(rpc_websocket_responses, "127.0.0.1", notify_history.save) await ws_rpc.connect(client_session) yield ws_rpc await ws_rpc.disconnect() @pytest_asyncio.fixture async def ws_rpc_with_auth(ws_rpc: WsRPCMocker) -> AsyncGenerator[WsRPCMocker, None]: """Fixture for an RPC WebSocket with authentication.""" ws_rpc._auth_data = AuthData("any", "any", "any") yield ws_rpc @pytest_asyncio.fixture async def ws_context() -> AsyncGenerator[WsServer, None]: """Fixture for a WsServer.""" mock = MagicMock(spec=WsServer) yield mock @pytest_asyncio.fixture async def rpc_device( client_session: ClientSession, ws_context: WsServer, ws_rpc: WsRPCMocker ) -> AsyncGenerator[RpcDevice, None]: """Fixture for RpcDevice.""" await ws_rpc.disconnect() options = ConnectionOptions( "10.10.10.10", "username", "password", ) rpc_device = await RpcDevice.create(client_session, ws_context, options) rpc_device._wsrpc = ws_rpc rpc_device.call_rpc_multiple = AsyncMock() yield rpc_device python-aioshelly-13.14.0/tests/rpc_device/fixtures/000077500000000000000000000000001507326321100223065ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/rpc_device/fixtures/shelly2pmg3/000077500000000000000000000000001507326321100244575ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/rpc_device/fixtures/shelly2pmg3/Cover.GetStatus000066400000000000000000000005371507326321100274070ustar00rootroot00000000000000{"id":0, "source":"WS_in", "state":"closing","move_timeout":60.00, "move_started_at":1760365457.96,"apower":123.1,"voltage":219.7,"current":0.557,"pf":0.99,"freq":50.0,"aenergy":{"total":314.801,"by_minute":[0.000,0.000,0.000],"minute_ts":1760365440},"temperature":{"tC":52.0, "tF":125.7},"pos_control":true,"last_direction":"close","current_pos":51} python-aioshelly-13.14.0/tests/rpc_device/fixtures/shelly2pmg3/Shelly.GetStatus000066400000000000000000000021501507326321100275620ustar00rootroot00000000000000{"ble":{},"bthome":{},"cloud":{"connected":false},"cover:0":{"aenergy":{"by_minute":[0.0,0.0,0.0],"minute_ts":1760364720,"total":314.614},"apower":0.0,"current":0.0,"current_pos":100,"freq":50.0,"id":0,"last_direction":"open","pf":0.0,"pos_control":true,"source":"limit_switch","state":"open","temperature":{"tC":52.0,"tF":125.7},"voltage":219.2},"input:0":{"id":0,"state":null},"input:1":{"id":1,"state":null},"knx":{},"matter":{"commissionable":false,"num_fabrics":0},"mqtt":{"connected":false},"number:200":{"value":4},"script:1":{"cpu":0,"id":1,"mem_free":25200,"running":false},"script:2":{"cpu":0,"id":2,"mem_free":25200,"running":false},"sys":{"available_updates":{},"bthc_rev":0,"btrelay_rev":0,"cfg_rev":37,"fs_free":290816,"fs_size":786432,"kvs_rev":0,"last_sync_ts":1760363153,"mac":"AABBCCDDEEFF","ram_free":81764,"ram_min_free":63092,"ram_size":257424,"reset_reason":3,"restart_required":false,"schedule_rev":1,"time":"17:12","unixtime":1760364737,"uptime":78962,"utc_offset":10800,"webhook_rev":1},"wifi":{"rssi":-38,"ssid":"Wifi-Network-Name","sta_ip":"192.168.2.186","status":"got ip"},"ws":{"connected":false}} python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyblugatewaygen3/000077500000000000000000000000001507326321100264505ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyblugatewaygen3/BluTrv.GetRemoteConfig000066400000000000000000000020441507326321100326310ustar00rootroot00000000000000{ "v": 14, "ts": 1739221213, "config": { "sys": { "device": { "name": "" }, "location": { "lat": 11.1111, "lon": 22.2222 }, "ui": { "lock": false, "t_units": "C", "flip": false, "brightness": 7 }, "ble": { "interval_ms": 333, "beacon_count": 5 }, "cfg_rev": 34 }, "temperature:0": { "id": 0, "offset_C": 0 }, "trv:0": { "id": 0, "enable": true, "override_enable": true, "min_valve_position": 0, "default_boost_duration": 900, "default_override_duration": 2147483647, "default_override_target_C": 8, "flags": [ "floor_heating", "auto_calibrate", "anticlog" ] } } } python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyblugatewaygen3/Shelly.GetComponents000066400000000000000000000055641507326321100324310ustar00rootroot00000000000000{ "components": [ { "key": "blutrv:200", "status": { "id": 200, "target_C": 19, "current_C": 21, "pos": 0, "rssi": -58, "battery": 100, "packet_id": 29, "last_updated_ts": 1739224517, "paired": true, "rpc": true, "rsv": 14 }, "config": { "id": 200, "addr": "aa:bb:cc:dd:ee:ff", "name": "Shelly BLU TRV [DDEEFF]", "key": null, "trv": "bthomedevice:200", "temp_sensors": [], "dw_sensors": [], "override_delay": 30, "meta": {} }, "attrs": { "flags": 17, "model_id": 8 } }, { "key": "bthomedevice:200", "status": { "id": 200, "rssi": -58, "battery": 100, "packet_id": 29, "last_updated_ts": 1739224517, "paired": true, "rpc": true, "rsv": 14 }, "config": { "id": 200, "addr": "aa:bb:cc:dd:ee:ff", "name": "Shelly BLU TRV [DDEEFF]", "key": null, "meta": null }, "attrs": { "flags": 17, "model_id": 8 } }, { "key": "bthomesensor:200", "status": { "id": 200, "value": 100, "last_updated_ts": 1739224517 }, "config": { "id": 200, "addr": "aa:bb:cc:dd:ee:ff", "name": null, "obj_id": 1, "idx": 0, "meta": null } }, { "key": "bthomesensor:202", "status": { "id": 202, "value": 19, "last_updated_ts": 1739224517 }, "config": { "id": 202, "addr": "aa:bb:cc:dd:ee:ff", "name": null, "obj_id": 69, "idx": 0, "meta": null } }, { "key": "bthomesensor:203", "status": { "id": 203, "value": 0, "last_updated_ts": 1739224517 }, "config": { "id": 203, "addr": "aa:bb:cc:dd:ee:ff", "name": null, "obj_id": 69, "idx": 1, "meta": null } } ], "cfg_rev": 32, "offset": 0, "total": 5 } python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyblugatewaygen3/Shelly.GetConfig000066400000000000000000000041771507326321100315100ustar00rootroot00000000000000{ "ble": { "enable": true, "rpc": { "enable": true } }, "blugw": { "sys_led_enable": true }, "blutrv:200": { "addr": "f8:44:77:1c:86:7e", "default_boost_duration": 900, "default_override_duration": 2147483647, "default_override_target_C": 8, "enable": true, "flags": [ "floor_heating", "auto_calibrate", "anticlog" ], "id": 0, "local_name": "SBTR-001AEU", "min_valve_position": 0, "name": "Shelly BLU TRV [7744F8]", "override_enable": true }, "bthome": {}, "cloud": { "enable": false, "server": "shelly-api-eu.shelly.cloud:6022/jrpc" }, "mqtt": { "client_id": "shelly-blu-gateway-gen3-78742c", "enable": false, "enable_control": true, "enable_rpc": true, "rpc_ntf": true, "server": "mqtt.test.server", "ssl_ca": null, "status_ntf": true, "topic_prefix": "shellies-gen3/shelly-blu-gateway-gen3-78742c", "use_client_cert": false, "user": "mqtt_client" }, "sys": { "cfg_rev": 32, "debug": { "file_level": null, "level": 2, "mqtt": { "enable": false }, "udp": { "addr": null }, "websocket": { "enable": false } }, "device": { "discoverable": true, "eco_mode": false, "fw_id": "20250203-144328/1.5.0-beta2-gbf89ed5", "mac": "AABBCCDDEEFF", "name": "Test Name" }, "location": { "lat": 52.2201, "lon": 21.0121, "tz": "Europe/Warsaw" }, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "sntp.test.server" }, "ui_data": {} }, "wifi": "Wifi-Network-Name", "ws": { "enable": false, "server": null, "ssl_ca": "ca.pem" } } python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyblugatewaygen3/Shelly.GetDeviceInfo000066400000000000000000000005331507326321100323060ustar00rootroot00000000000000{ "name": "Shelly BLU Gateway Gen3 [DDEEFF]", "id": "shellyblugwg3-aabbccddeeff", "mac": "AABBCCDDEEFF", "slot": 0, "model": "S3GW-1DBT001", "gen": 3, "fw_id": "20250203-144328/1.5.0-beta2-gbf89ed5", "ver": "1.5.0-beta2", "app": "BluGwG3", "auth_en": true, "auth_domain": "shellyblugwg3-aabbccddeeff" } python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyblugatewaygen3/Shelly.GetStatus000066400000000000000000000023661507326321100315640ustar00rootroot00000000000000{ "ble": {}, "blugw": {}, "blutrv:200": { "battery": 100, "current_C": 21, "errors": [], "id": 200, "last_updated_ts": 1739225228, "packet_id": 109, "paired": true, "pos": 0, "rpc": true, "rssi": -74, "rsv": 14, "target_C": 19 }, "bthome": {}, "cloud": { "connected": false }, "mqtt": { "connected": false }, "sys": { "available_updates": { "stable": { "version": "1.4.99-blugwg3prod4" } }, "btrelay_rev": 0, "cfg_rev": 32, "fs_free": 598016, "fs_size": 1048576, "kvs_rev": 0, "last_sync_ts": 1739224031, "mac": "AABBCCDDEEFF", "ram_free": 81164, "ram_min_free": 54072, "ram_size": 256492, "reset_reason": 3, "restart_required": false, "schedule_rev": 0, "time": "23:07", "unixtime": 1739225237, "uptime": 203997, "webhook_rev": 1 }, "wifi": { "rssi": -51, "ssid": "Wifi-Network-Name", "sta_ip": "192.168.2.210", "status": "got ip" }, "ws": { "connected": false } } python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellymini1gen4/000077500000000000000000000000001507326321100253225ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellymini1gen4/Shelly.GetComponents000066400000000000000000000001141507326321100312650ustar00rootroot00000000000000{ "components": [], "cfg_rev": 9, "offset": 0, "total": 0 } python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellymini1gen4/Shelly.GetConfig000066400000000000000000000053631507326321100303600ustar00rootroot00000000000000{ "ble": { "enable": true, "rpc": { "enable": true } }, "cloud": { "enable": false, "server": "iot.shelly.cloud:6012/jrpc" }, "input:0": { "id": 0, "name": null, "type": "switch", "enable": true, "invert": false, "factory_reset": true }, "knx": { "enable": false, "ia": "15.15.255", "routing": { "addr": "224.0.23.12:3671" } }, "mqtt": { "enable": false, "server": null, "client_id": "shelly1minig4-aabbccddeeff", "user": null, "ssl_ca": null, "topic_prefix": "shelly1minig4-aabbccddeeff", "rpc_ntf": true, "status_ntf": false, "use_client_cert": false, "enable_rpc": true, "enable_control": true }, "switch:0": { "id": 0, "name": null, "in_mode": "follow", "in_locked": false, "initial_state": "match_input", "auto_on": false, "auto_on_delay": 60, "auto_off": false, "auto_off_delay": 60 }, "sys": { "device": { "name": "Shelly 1 Mini Gen4 [DDEEFF]", "mac": "AABBCCDDEEFF", "fw_id": "20250214-121701/1.5.99-g4prod1-gc32c24b", "discoverable": true, "eco_mode": false }, "location": { "tz": "Europe/Warsaw", "lat": 41.2001, "lon": 19.3321 }, "debug": { "level": 2, "file_level": null, "mqtt": { "enable": false }, "websocket": { "enable": false }, "udp": { "addr": null } }, "ui_data": {}, "rpc_udp": { "dst_addr": null, "listen_port": null }, "sntp": { "server": "time.cloudflare.com" }, "cfg_rev": 9 }, "wifi": { "sta": { "ssid": "WiFi-Network-Name", "is_open": false, "enable": true, "ipv4mode": "dhcp", "ip": null, "netmask": null, "gw": null, "nameserver": null }, "sta1": { "ssid": null, "is_open": true, "enable": false, "ipv4mode": "dhcp", "ip": null, "netmask": null, "gw": null, "nameserver": null }, "roam": { "rssi_thr": -80, "interval": 60 } }, "ws": { "enable": false, "server": null, "ssl_ca": "ca.pem" }, "zigbee": { "enable": true } } python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellymini1gen4/Shelly.GetDeviceInfo000066400000000000000000000005071507326321100311610ustar00rootroot00000000000000{ "name": "Shelly 1 Mini Gen4 [DDEEFF]", "id": "shelly1minig4-aabbccddeeff", "mac": "AABBCCDDEEFF", "slot": 1, "model": "S4SW-001X8EU", "gen": 4, "fw_id": "20250214-121701/1.5.99-g4prod1-gc32c24b", "ver": "1.5.99-g4prod1", "app": "Mini1G4ZB", "auth_en": false, "auth_domain": null } python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellymini1gen4/Shelly.GetStatus000066400000000000000000000027641507326321100304400ustar00rootroot00000000000000{ "ble": {}, "cloud": { "connected": false }, "input:0": { "id": 0, "state": false }, "knx": {}, "mqtt": { "connected": false }, "switch:0": { "id": 0, "source": "WS_in", "output": false, "temperature": { "tC": 64.4, "tF": 147.9 } }, "sys": { "mac": "AABBCCDDEEFF", "restart_required": false, "time": "15:34", "unixtime": 1745588095, "last_sync_ts": 1745587071, "uptime": 72831, "ram_size": 336152, "ram_free": 151636, "ram_min_free": 132592, "fs_size": 917504, "fs_free": 479232, "cfg_rev": 9, "kvs_rev": 0, "schedule_rev": 0, "webhook_rev": 0, "available_updates": { "beta": { "version": "1.6.0-beta1" } }, "alt": { "Mini1G4": { "name": "Shelly Mini 1 Gen4", "desc": "Shelly Mini 1 Gen4 with Matter", "beta": { "version": "1.6.0-beta1", "build_id": "20250410-085758/1.6.0-beta1-g2554734" } } }, "reset_reason": 1, "utc_offset": 7200 }, "wifi": { "sta_ip": "192.168.2.186", "status": "got ip", "ssid": "WiFi-Network-Name", "rssi": -65 }, "ws": { "connected": false }, "zigbee": {} } python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyplugus/000077500000000000000000000000001507326321100250465ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyplugus/Shelly.GetConfig000066400000000000000000000033061507326321100300770ustar00rootroot00000000000000{"id": 2, "src": "shellyplugus-c049ef8c480c", "dst": "aios-6053742448", "result": {"ble": {"enable": false, "rpc": {"enable": true}, "observer": {"enable": false}}, "cloud": {"enable": true, "server": "shelly-91-eu.shelly.cloud:6022/jrpc"}, "mqtt": {"enable": false, "server": null, "client_id": "shellyplugus-c049ef8c480c", "user": null, "ssl_ca": null, "topic_prefix": "shellyplugus-c049ef8c480c", "rpc_ntf": true, "status_ntf": false, "use_client_cert": false, "enable_rpc": true, "enable_control": true}, "switch:0": {"id": 0, "name": null, "initial_state": "on", "auto_on": false, "auto_on_delay": 60.0, "auto_off": false, "auto_off_delay": 60.0, "power_limit": 4480, "voltage_limit": 280, "autorecover_voltage_errors": false, "current_limit": 16.0}, "sys": {"device": {"name": null, "mac": "C049EF8C480C", "fw_id": "20240819-074316/1.4.2-gc2639da", "discoverable": true, "eco_mode": true}, "location": {"tz": "Pacific/Honolulu", "lat": 20.8787, "lon": -156.6782}, "debug": {"level": 2, "file_level": null, "mqtt": {"enable": false}, "websocket": {"enable": false}, "udp": {"addr": null}}, "ui_data": {}, "rpc_udp": {"dst_addr": null, "listen_port": null}, "sntp": {"server": "time.google.com"}, "cfg_rev": 19}, "wifi": {"ap": {"ssid": "ShellyPlugUS-C049EF8C480C", "is_open": true, "enable": false, "range_extender": {"enable": false}}, "sta": {"ssid": "atlanticiot", "is_open": false, "enable": true, "ipv4mode": "dhcp", "ip": null, "netmask": null, "gw": null, "nameserver": null}, "sta1": {"ssid": null, "is_open": true, "enable": false, "ipv4mode": "dhcp", "ip": null, "netmask": null, "gw": null, "nameserver": null}, "roam": {"rssi_thr": -80, "interval": 60}}, "ws": {"enable": false, "server": null, "ssl_ca": "ca.pem"}}} python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyplus2pm/000077500000000000000000000000001507326321100251315ustar00rootroot00000000000000python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyplus2pm/Cover.Close_auth_failure000066400000000000000000000003601507326321100317250ustar00rootroot00000000000000{"id":18,"src":"shellyplus2pm-485519992590","dst":"aios-139803755626272","error":{"code":401,"message":"{\"auth_type\": \"digest\", \"nonce\": 1725185413, \"nc\": 1, \"realm\": \"shellyplus2pm-485519992590\", \"algorithm\": \"SHA-256\"}"}} python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyplus2pm/Cover.Close_success000066400000000000000000000001371507326321100307270ustar00rootroot00000000000000{"id": 19, "src": "shellyplus2pm-485519992590", "dst": "aios-139803755626272", "result": null} python-aioshelly-13.14.0/tests/rpc_device/fixtures/shellyplus2pm/shelly.json000066400000000000000000000005301507326321100273220ustar00rootroot00000000000000{ "name": "Shelly Plus 2PM", "id": "shellyplus2pm-aabbccddeeff", "mac": "AABBCCDDEEFF", "slot": 1, "model": "SNSW-102P16EU", "gen": 2, "fw_id": "20240819-074337/1.4.2-gc2639da", "ver": "1.4.2", "app": "Plus2PM", "auth_en": true, "auth_domain": "shellyplus2pm-aabbccddeeff", "profile": "switch" } python-aioshelly-13.14.0/tests/rpc_device/test_device.py000066400000000000000000001347251507326321100233210ustar00rootroot00000000000000"""Tests for rpc_device.device module.""" import re from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, Mock import pytest import pytest_asyncio from aiohttp import ClientError from aiohttp.client import ClientSession from aiohttp.client_exceptions import ServerDisconnectedError from aioshelly.common import ConnectionOptions from aioshelly.const import NOTIFY_WS_CLOSED from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, NotInitialized, RpcCallError, ) from aioshelly.rpc_device.device import RpcDevice, RpcUpdateType, mergedicts from aioshelly.rpc_device.wsrpc import RPCSource, WsRPC, WsServer from . import load_device_fixture VIRT_COMP_STATUS = {"value": 0} VIRT_COMP_CONFIG = { "id": 200, "name": "Test", "min": 0, "max": 100, "meta": {"ui": {"view": "slider", "unit": "%", "step": 1}}, } VIRT_COMP_ATTRS = {"role": "current_humidity"} WEBSOCKET_URL = "ws://10.10.10.10:8123/api/shelly/ws" @pytest_asyncio.fixture async def blu_gateway_device_info() -> AsyncGenerator[dict[str, Any], None]: """Fixture for BLU Gateway Gen3 device info.""" yield await load_device_fixture("shellyblugatewaygen3", "Shelly.GetDeviceInfo") @pytest_asyncio.fixture async def blu_gateway_config() -> AsyncGenerator[dict[str, Any], None]: """Fixture for BLU Gateway Gen3 config.""" yield await load_device_fixture("shellyblugatewaygen3", "Shelly.GetConfig") @pytest_asyncio.fixture async def blu_gateway_status() -> AsyncGenerator[dict[str, Any], None]: """Fixture for BLU Gateway Gen3 status.""" yield await load_device_fixture("shellyblugatewaygen3", "Shelly.GetStatus") @pytest_asyncio.fixture async def blu_gateway_components() -> AsyncGenerator[dict[str, Any], None]: """Fixture for BLU Gateway Gen3 components.""" yield await load_device_fixture("shellyblugatewaygen3", "Shelly.GetComponents") @pytest_asyncio.fixture async def blu_gateway_remote_config() -> AsyncGenerator[dict[str, Any], None]: """Fixture for BLU Gateway Gen3 remote config.""" yield await load_device_fixture("shellyblugatewaygen3", "BluTrv.GetRemoteConfig") @pytest_asyncio.fixture async def mini_1_g4_device_info() -> AsyncGenerator[dict[str, Any], None]: """Fixture for Mini 1 Gen4 device info.""" yield await load_device_fixture("shellymini1gen4", "Shelly.GetDeviceInfo") @pytest_asyncio.fixture async def mini_1_g4_config() -> AsyncGenerator[dict[str, Any], None]: """Fixture for Mini 1 Gen4 config.""" yield await load_device_fixture("shellymini1gen4", "Shelly.GetConfig") @pytest_asyncio.fixture async def mini_1_g4_status() -> AsyncGenerator[dict[str, Any], None]: """Fixture for Mini 1 Gen4 status.""" yield await load_device_fixture("shellymini1gen4", "Shelly.GetStatus") @pytest_asyncio.fixture async def mini_1_g4_components() -> AsyncGenerator[dict[str, Any], None]: """Fixture for Mini 1 Gen4 components.""" yield await load_device_fixture("shellymini1gen4", "Shelly.GetComponents") @pytest_asyncio.fixture async def shelly2pmg3_status() -> AsyncGenerator[dict[str, Any], None]: """Fixture for Shelly 2PM Gen3 status.""" yield await load_device_fixture("shelly2pmg3", "Shelly.GetStatus") @pytest_asyncio.fixture async def shelly2pmg3_cover_status() -> AsyncGenerator[dict[str, Any], None]: """Fixture for Shelly 2PM Gen3 cover status.""" yield await load_device_fixture("shelly2pmg3", "Cover.GetStatus") def test_mergedicts() -> None: """Test the recursive dict merge.""" dest = {"a": 1, "b": {"c": 2, "d": 3}} source = {"b": {"c": 4, "e": 5}} mergedicts(dest, source) assert dest == {"a": 1, "b": {"c": 4, "d": 3, "e": 5}} def test_mergedicts_to_none() -> None: """Test merge a dict to a None.""" # transition is None in dest dest = { "ts": 1740607224.75, "light:0": { "id": 0, "brightness": 0, "output": False, "source": "transition", "transition": None, }, "sensor:0": {"id": 0, "temperature": 0}, } # transition is dict in source source = { "ts": 1740607225.26, "light:0": { "id": 0, "brightness": 0, "output": False, "source": "HTTP_in", "transition": { "duration": 0.5, "started_at": 1740607225.26, "target": {"brightness": 0, "output": False}, }, }, } mergedicts(dest, source) assert dest == { "ts": 1740607225.26, "light:0": { "id": 0, "brightness": 0, "output": False, "source": "HTTP_in", "transition": { "duration": 0.5, "started_at": 1740607225.26, "target": {"brightness": 0, "output": False}, }, }, "sensor:0": {"id": 0, "temperature": 0}, } @pytest.mark.asyncio async def test_parse_dynamic_components(rpc_device: RpcDevice) -> None: """Test RPC device _parse_dynamic_components() method.""" rpc_device._status = {"ble": {}} rpc_device._config = {"ble": {"enable": True}} rpc_device._parse_dynamic_components( { "components": [ { "key": "number:200", "status": VIRT_COMP_STATUS, "config": VIRT_COMP_CONFIG, } ] } ) assert rpc_device._status["number:200"] == VIRT_COMP_STATUS assert rpc_device._config["number:200"] == VIRT_COMP_CONFIG @pytest.mark.asyncio async def test_parse_dynamic_components_with_attrs(rpc_device: RpcDevice) -> None: """Test RPC device _parse_dynamic_components() method with attrs.""" rpc_device._status = {"ble": {}} rpc_device._config = {"ble": {"enable": True}} rpc_device._parse_dynamic_components( { "components": [ { "key": "number:200", "status": VIRT_COMP_STATUS, "config": VIRT_COMP_CONFIG, "attrs": VIRT_COMP_ATTRS, } ] } ) assert rpc_device._status["number:200"] == VIRT_COMP_STATUS assert rpc_device._config["number:200"] == {**VIRT_COMP_CONFIG, **VIRT_COMP_ATTRS} @pytest.mark.asyncio async def test_parse_dynamic_components_not_initialized(rpc_device: RpcDevice) -> None: """Test RPC device _parse_dynamic_components method with not initialized device.""" with pytest.raises(NotInitialized): rpc_device._parse_dynamic_components({"lorem": "ipsum"}) @pytest.mark.asyncio async def test_retrieve_blutrv_components_wrong_device(rpc_device: RpcDevice) -> None: """Test _retrieve_blutrv_components method with wrong device.""" rpc_device._shelly = {"model": "Some Shelly device"} await rpc_device._retrieve_blutrv_components({"lorem": "ipsum"}) @pytest.mark.asyncio async def test_retrieve_blutrv_components_not_initialized( rpc_device: RpcDevice, ) -> None: """Test _retrieve_blutrv_components method with not initialized device.""" rpc_device._shelly = {"model": "S3GW-1DBT001"} with pytest.raises(NotInitialized): await rpc_device._retrieve_blutrv_components({"lorem": "ipsum"}) @pytest.mark.parametrize( ("firmware", "expected"), [ ("20250203-144328/1.5.0-beta2-gbf89ed5", True), ("20231209-144328/1.0.0-gbf89ed5", False), ("lorem-ipsum", False), ], ) @pytest.mark.asyncio async def test_supports_dynamic_components( rpc_device: RpcDevice, firmware: str, expected: bool ) -> None: """Test _supports_dynamic_components method with not initialized device.""" rpc_device._shelly = {"model": "Some Model", "fw_id": firmware} assert rpc_device._supports_dynamic_components() is expected @pytest.mark.asyncio async def test_supports_dynamic_components_sleeping_device( rpc_device: RpcDevice, ) -> None: """Test _supports_dynamic_components method with a sleeping device.""" rpc_device._shelly = { "model": "Some Model", "fw_id": "20250203-144328/1.5.0-beta2-gbf89ed5", } rpc_device._status = {"sys": {"wakeup_period": 60}} assert rpc_device._supports_dynamic_components() is False @pytest.mark.asyncio async def test_get_dynamic_components( rpc_device: RpcDevice, blu_gateway_device_info: dict[str, Any], blu_gateway_config: dict[str, Any], blu_gateway_status: dict[str, Any], blu_gateway_remote_config: dict[str, Any], blu_gateway_components: dict[str, Any], ) -> None: """Test get_dynamic_components method.""" rpc_device.initialized = True rpc_device._shelly = blu_gateway_device_info rpc_device._config = blu_gateway_config rpc_device._status = blu_gateway_status rpc_device.call_rpc_multiple.side_effect = [ [blu_gateway_components], [blu_gateway_remote_config], ] await rpc_device.get_dynamic_components() assert rpc_device.call_rpc_multiple.call_count == 2 assert ( rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "BluTrv.GetRemoteConfig" ) assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == {"id": 200} assert rpc_device.config["blutrv:200"]["local_name"] == "SBTR-001AEU" assert rpc_device.config["blutrv:200"]["name"] == "Shelly BLU TRV [DDEEFF]" assert rpc_device.config["blutrv:200"]["addr"] == "aa:bb:cc:dd:ee:ff" assert rpc_device.config["blutrv:200"]["enable"] is True assert rpc_device.status["blutrv:200"]["current_C"] == 21 assert rpc_device.status["blutrv:200"]["target_C"] == 19 assert rpc_device.status["blutrv:200"]["pos"] == 0 assert rpc_device.status["blutrv:200"]["rssi"] == -58 assert rpc_device.status["blutrv:200"]["errors"] == [] @pytest.mark.asyncio async def test_get_dynamic_components_not_supported(rpc_device: RpcDevice) -> None: """Test get_dynamic_components method when dynamic components are not supported.""" rpc_device.initialized = True rpc_device._shelly = {"fw_id": "20231209-144328/1.0.0-gbf89ed5"} await rpc_device.get_dynamic_components() assert rpc_device._dynamic_components == [] @pytest.mark.asyncio async def test_shelly_gen1(client_session: ClientSession, ws_context: WsServer) -> None: """Test Shelly Gen1 device.""" options = ConnectionOptions("10.10.10.10", device_mac="AABBCCDDEEFF") rpc_device = await RpcDevice.create(client_session, ws_context, options) rpc_device._wsrpc = AsyncMock(spec=WsRPC) rpc_device._wsrpc.connect.side_effect = ServerDisconnectedError with pytest.raises(DeviceConnectionError): await rpc_device.initialize() @pytest.mark.asyncio async def test_device_initialize_and_shutdown( rpc_device: RpcDevice, blu_gateway_device_info: dict[str, Any], blu_gateway_config: dict[str, Any], blu_gateway_status: dict[str, Any], blu_gateway_remote_config: dict[str, Any], blu_gateway_components: dict[str, Any], ) -> None: """Test RpcDevice initialize and shutdown methods.""" rpc_device.call_rpc_multiple.side_effect = [ [blu_gateway_device_info], [blu_gateway_config, blu_gateway_status, blu_gateway_components], [blu_gateway_remote_config], ] rpc_device.subscribe_updates(Mock()) await rpc_device.initialize() assert rpc_device._update_listener is not None assert rpc_device._unsub_ws is not None assert rpc_device.connected is True assert rpc_device.firmware_supported is True assert rpc_device.name == "Test Name" assert rpc_device.hostname == "shellyblugwg3-aabbccddeeff" assert rpc_device.version == "1.5.0-beta2" assert rpc_device.gen == 3 assert rpc_device.last_error is None assert rpc_device.xmod_info == {} assert rpc_device.requires_auth is True assert rpc_device.zigbee_enabled is False assert rpc_device.zigbee_firmware is False await rpc_device.shutdown() assert rpc_device._update_listener is None assert rpc_device._unsub_ws is None @pytest.mark.asyncio async def test_device_initialize_lock( rpc_device: RpcDevice, ) -> None: """Test RpcDevice initialize.""" rpc_device._initialize_lock = Mock(locked=Mock(return_value=True)) with pytest.raises(RuntimeError): await rpc_device.initialize() @pytest.mark.asyncio async def test_device_already_initialized( rpc_device: RpcDevice, blu_gateway_device_info: dict[str, Any], blu_gateway_config: dict[str, Any], blu_gateway_status: dict[str, Any], blu_gateway_remote_config: dict[str, Any], blu_gateway_components: dict[str, Any], ) -> None: """Test RpcDevice initialize.""" rpc_device.call_rpc_multiple.side_effect = [ [blu_gateway_device_info], [blu_gateway_config, blu_gateway_status, blu_gateway_components], [blu_gateway_remote_config], ] rpc_device._wsrpc = AsyncMock(spec=WsRPC) await rpc_device.initialize() assert rpc_device.initialized is True assert rpc_device.status is not None rpc_device.call_rpc_multiple.side_effect = [ [blu_gateway_device_info], [blu_gateway_config, blu_gateway_status, blu_gateway_components], [blu_gateway_remote_config], ] # call initialize() once again await rpc_device.initialize() assert rpc_device.initialized is True assert rpc_device.status is not None @pytest.mark.parametrize( ("exc", "result_exc", "result_str"), [ (InvalidAuthError, InvalidAuthError, None), (RpcCallError(404, "test error"), DeviceConnectionError, "test error"), (ClientError, DeviceConnectionError, None), (DeviceConnectionError, DeviceConnectionError, None), (OSError, DeviceConnectionError, None), ], ) @pytest.mark.asyncio async def test_device_exception_on_init( client_session: ClientSession, ws_context: WsServer, blu_gateway_device_info: dict[str, Any], exc: Exception, result_exc: Exception, result_str: str, ) -> None: """Test RpcDevice initialize with an exception.""" options = ConnectionOptions("10.10.10.10", device_mac="AABBCCDDEEFF") rpc_device = await RpcDevice.create(client_session, ws_context, options) rpc_device._wsrpc = AsyncMock(spec=WsRPC) rpc_device._wsrpc.calls.side_effect = [[blu_gateway_device_info], exc] with pytest.raises(result_exc, match=result_str): await rpc_device.initialize() @pytest.mark.asyncio async def test_device_not_initialized(rpc_device: RpcDevice) -> None: """Test RpcDevice not initialized.""" with pytest.raises(NotInitialized): hasattr(rpc_device, "gen") with pytest.raises(NotInitialized): hasattr(rpc_device, "shelly") with pytest.raises(NotInitialized): hasattr(rpc_device, "config") with pytest.raises(NotInitialized): hasattr(rpc_device, "status") with pytest.raises(NotInitialized): hasattr(rpc_device, "event") with pytest.raises(NotInitialized): hasattr(rpc_device, "zigbee_enabled") @pytest.mark.asyncio async def test_update_outbound_websocket(rpc_device: RpcDevice) -> None: """Test RpcDevice update_outbound_websocket method.""" result = await rpc_device.update_outbound_websocket(WEBSOCKET_URL) assert result is True assert rpc_device.call_rpc_multiple.call_count == 3 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Ws.GetConfig" assert call_args_list[1][0][0][0][0] == "Ws.SetConfig" assert call_args_list[2][0][0][0][0] == "Shelly.Reboot" @pytest.mark.asyncio async def test_update_outbound_websocket_not_needed(rpc_device: RpcDevice) -> None: """Test RpcDevice update_outbound_websocket method.""" rpc_device.call_rpc_multiple.side_effect = [ [{"enable": True, "server": WEBSOCKET_URL}] ] result = await rpc_device.update_outbound_websocket(WEBSOCKET_URL) assert result is False assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Ws.GetConfig" @pytest.mark.asyncio async def test_update_outbound_websocket_restart_not_needed( rpc_device: RpcDevice, ) -> None: """Test RpcDevice update_outbound_websocket method.""" rpc_device.call_rpc_multiple.side_effect = [ [{"enable": False}], [{"restart_required": False}], ] result = await rpc_device.update_outbound_websocket(WEBSOCKET_URL) assert result is False assert rpc_device.call_rpc_multiple.call_count == 2 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Ws.GetConfig" assert call_args_list[1][0][0][0][0] == "Ws.SetConfig" @pytest.mark.asyncio async def test_ble_getconfig(rpc_device: RpcDevice) -> None: """Test RpcDevice ble_getconfig method.""" rpc_device.call_rpc_multiple.return_value = [ {"enable": True, "rpc": {"enable": True}} ] result = await rpc_device.ble_getconfig() assert result == {"enable": True, "rpc": {"enable": True}} assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "BLE.GetConfig" @pytest.mark.asyncio async def test_ble_setconfig(rpc_device: RpcDevice) -> None: """Test RpcDevice ble_setconfig method.""" await rpc_device.ble_setconfig(True, True) assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "BLE.SetConfig" assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == { "config": {"enable": True, "rpc": {"enable": True}} } @pytest.mark.asyncio async def test_script_stop(rpc_device: RpcDevice) -> None: """Test RpcDevice script_stop method.""" await rpc_device.script_stop(12) assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Script.Stop" assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == {"id": 12} @pytest.mark.asyncio async def test_script_start(rpc_device: RpcDevice) -> None: """Test RpcDevice script_start method.""" await rpc_device.script_start(11) assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Script.Start" assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == {"id": 11} @pytest.mark.asyncio async def test_script_create(rpc_device: RpcDevice) -> None: """Test RpcDevice script_create method.""" await rpc_device.script_create("test_script") assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Script.Create" assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == {"name": "test_script"} @pytest.mark.asyncio async def test_script_putcode(rpc_device: RpcDevice) -> None: """Test RpcDevice script_putcode method.""" await rpc_device.script_putcode(9, "lorem ipsum") assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Script.PutCode" assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == { "id": 9, "code": "lorem ipsum", } @pytest.mark.asyncio async def test_script_getcode(rpc_device: RpcDevice) -> None: """Test RpcDevice script_getcode method.""" rpc_device.call_rpc_multiple.return_value = [{"data": "super duper script"}] result = await rpc_device.script_getcode(8) assert result == {"data": "super duper script"} assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Script.GetCode" assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == {"id": 8, "offset": 0} @pytest.mark.asyncio async def test_script_list(rpc_device: RpcDevice) -> None: """Test RpcDevice script_list method.""" rpc_device.call_rpc_multiple.return_value = [ { "scripts": [ {"id": 1, "name": "my_script", "enable": False, "running": True}, ] } ] result = await rpc_device.script_list() assert result == [{"id": 1, "name": "my_script", "enable": False, "running": True}] assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Script.List" @pytest.mark.asyncio async def test_get_all_pages(rpc_device: RpcDevice) -> None: """Test RpcDevice get_all_pages method.""" rpc_device.call_rpc_multiple.return_value = [ {"total": 2, "components": [{"key": "component2"}]} ] result = await rpc_device.get_all_pages( {"total": 2, "components": [{"key": "component1"}]} ) assert result == { "total": 2, "components": [{"key": "component1"}, {"key": "component2"}], } assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Shelly.GetComponents" assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == { "dynamic_only": True, "offset": 1, } @pytest.mark.asyncio async def test_on_notification_ws_closed(rpc_device: RpcDevice) -> None: """Test RpcDevice _on_notification method when WS is closed.""" rpc_device._update_listener = Mock() rpc_device.initialized = True rpc_device._on_notification(RPCSource.CLIENT, NOTIFY_WS_CLOSED) assert rpc_device._update_listener.call_count == 1 assert rpc_device._update_listener.call_args[0][1] is RpcUpdateType.DISCONNECTED @pytest.mark.asyncio async def test_on_notification_notify_full_status(rpc_device: RpcDevice) -> None: """Test RpcDevice _on_notification method with NotifyFullStatus.""" rpc_device._update_listener = Mock() rpc_device.initialized = True rpc_device._on_notification(RPCSource.CLIENT, "NotifyFullStatus", {"test": True}) assert rpc_device._update_listener.call_count == 1 assert rpc_device._update_listener.call_args[0][1] is RpcUpdateType.STATUS assert rpc_device.status == {"test": True} @pytest.mark.asyncio async def test_on_notification_notify_status(rpc_device: RpcDevice) -> None: """Test RpcDevice _on_notification method with NotifyStatus.""" rpc_device._update_listener = Mock() rpc_device.initialized = True rpc_device._status = {"sys": {}} rpc_device._on_notification(RPCSource.CLIENT, "NotifyStatus", {"test": True}) assert rpc_device._update_listener.call_count == 1 assert rpc_device._update_listener.call_args[0][1] is RpcUpdateType.STATUS assert rpc_device.status == {"sys": {}, "test": True} @pytest.mark.asyncio async def test_on_notification_notify_event(rpc_device: RpcDevice) -> None: """Test RpcDevice _on_notification method with NotifyEvent.""" rpc_device._update_listener = Mock() rpc_device.initialized = True rpc_device._on_notification(RPCSource.CLIENT, "NotifyEvent", {"test": True}) assert rpc_device._update_listener.call_count == 1 assert rpc_device._update_listener.call_args[0][1] is RpcUpdateType.EVENT assert rpc_device.event == {"test": True} @pytest.mark.asyncio async def test_on_notification_battery_device_online(rpc_device: RpcDevice) -> None: """Test RpcDevice _on_notification method with RpcUpdateType.ONLINE.""" rpc_device._update_listener = Mock() rpc_device._on_notification(RPCSource.SERVER, "NotifyStatus", {"test": True}) assert rpc_device._update_listener.call_count == 1 assert rpc_device._update_listener.call_args[0][1] is RpcUpdateType.ONLINE @pytest.mark.asyncio async def test_on_notification_no_listener(rpc_device: RpcDevice) -> None: """Test RpcDevice _on_notification without listener.""" # no listener rpc_device._update_listener = Mock() rpc_device._update_listener.__bool__ = lambda _: False rpc_device._on_notification(RPCSource.SERVER, "NotifyStatus", {"test": True}) rpc_device._update_listener.assert_not_called() # add listener and verify it is called update_listener = Mock() rpc_device.subscribe_updates(update_listener) rpc_device._on_notification(RPCSource.SERVER, "NotifyStatus", {"test": True}) assert update_listener.call_count == 1 assert update_listener.call_args[0][1] is RpcUpdateType.ONLINE @pytest.mark.asyncio async def test_device_mac_address_mismatch( client_session: ClientSession, ws_context: WsServer, blu_gateway_device_info: dict[str, Any], ) -> None: """Test RpcDevice initialize method.""" options = ConnectionOptions("10.10.10.10", device_mac="112233445566") rpc_device = await RpcDevice.create(client_session, ws_context, options) rpc_device.call_rpc_multiple = AsyncMock() rpc_device.call_rpc_multiple.return_value = [blu_gateway_device_info] with pytest.raises(MacAddressMismatchError): await rpc_device.initialize() @pytest.mark.asyncio async def test_cover_update_status( rpc_device: RpcDevice, shelly2pmg3_status: dict[str, Any], shelly2pmg3_cover_status: dict[str, Any], ) -> None: """Test RpcDevice cover_update_status method.""" rpc_device.initialized = True rpc_device.call_rpc_multiple.side_effect = [ [shelly2pmg3_cover_status], ] # no status, do not try to update cover status await rpc_device.update_cover_status(0) assert rpc_device.call_rpc_multiple.call_count == 0 rpc_device._status = shelly2pmg3_status # cover not found in status await rpc_device.update_cover_status(1) assert rpc_device.call_rpc_multiple.call_count == 0 # cover found in status and updated assert rpc_device.status["cover:0"]["state"] == "open" assert rpc_device.status["cover:0"]["current_pos"] == 100 await rpc_device.update_cover_status(0) assert rpc_device.status["cover:0"]["state"] == "closing" assert rpc_device.status["cover:0"]["current_pos"] == 51 assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cover.GetStatus" assert call_args_list[0][0][0][0][1] == {"id": 0} @pytest.mark.asyncio async def test_poll( rpc_device: RpcDevice, blu_gateway_device_info: dict[str, Any], blu_gateway_status: dict[str, Any], blu_gateway_remote_config: dict[str, Any], blu_gateway_components: dict[str, Any], ) -> None: """Test RpcDevice poll method.""" rpc_device.call_rpc_multiple.side_effect = [ [blu_gateway_status, blu_gateway_components], [blu_gateway_remote_config], ] rpc_device._shelly = blu_gateway_device_info rpc_device._status = {"lorem": "ipsum"} rpc_device._config = {"lorem": "ipsum"} rpc_device._dynamic_components = [{"key": "component1"}] await rpc_device.poll() assert rpc_device.call_rpc_multiple.call_count == 2 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Shelly.GetStatus" assert call_args_list[0][0][0][1][0] == "Shelly.GetComponents" assert call_args_list[1][0][0][0][0] == "BluTrv.GetRemoteConfig" @pytest.mark.asyncio async def test_poll_not_initialized( rpc_device: RpcDevice, ) -> None: """Test RpcDevice poll method when NotInitialized.""" with pytest.raises(NotInitialized): await rpc_device.poll() @pytest.mark.asyncio async def test_poll_call_error( rpc_device: RpcDevice, blu_gateway_status: dict[str, Any], ) -> None: """Test RpcDevice poll method when RpcCallError.""" rpc_device.call_rpc_multiple.return_value = [None] with pytest.raises( RpcCallError, match=re.escape("empty response to Shelly.GetStatus") ): await rpc_device.poll() rpc_device.call_rpc_multiple.return_value = [blu_gateway_status, None] rpc_device._dynamic_components = [{"key": "component1"}] rpc_device._status = {"lorem": "ipsum"} with pytest.raises( RpcCallError, match=re.escape("empty response to Shelly.GetComponents") ): await rpc_device.poll() @pytest.mark.asyncio async def test_update_config(rpc_device: RpcDevice) -> None: """Test RpcDevice update_config method.""" await rpc_device.update_config() assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Shelly.GetConfig" @pytest.mark.asyncio async def test_update_status(rpc_device: RpcDevice) -> None: """Test RpcDevice update_status method.""" await rpc_device.update_status() assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Shelly.GetStatus" @pytest.mark.asyncio async def test_trigger_ota_update(rpc_device: RpcDevice) -> None: """Test RpcDevice trigger_ota_update method.""" await rpc_device.trigger_ota_update(beta=True) assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Shelly.Update" assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == {"stage": "beta"} @pytest.mark.asyncio async def test_incorrect_shutdown( client_session: ClientSession, caplog: pytest.LogCaptureFixture, ) -> None: """Test multiple shutdown calls at incorrect order. https://github.com/home-assistant-libs/aioshelly/pull/535 """ ws_context = WsServer() options = ConnectionOptions("10.10.10.10", device_mac="AABBCCDDEEFF") rpc_device1 = await RpcDevice.create(client_session, ws_context, options) rpc_device2 = await RpcDevice.create(client_session, ws_context, options) # shutdown for device2 remove subscription for device1 from ws_context await rpc_device2.shutdown() assert "error during shutdown: KeyError('AABBCCDDEEFF')" not in caplog.text await rpc_device1.shutdown() assert "error during shutdown: KeyError('AABBCCDDEEFF')" in caplog.text @pytest.mark.parametrize( ("side_effect", "supports_scripts"), [ (RpcCallError(-105, "Argument 'id', value 1 not found!"), True), (RpcCallError(-114, "Method Script.GetCode failed: Method not found!"), False), (RpcCallError(404, "No handler for Script.GetCode"), False), ( [ { "id": 5, "src": "shellyplus2pm-a8032ab720ac", "dst": "aios-2293750469632", "result": {"data": "script"}, } ], True, ), ], ) @pytest.mark.asyncio async def test_supports_scripts( rpc_device: RpcDevice, side_effect: Exception | dict[str, Any], supports_scripts: bool, ) -> None: """Test supports_scripts method.""" rpc_device.call_rpc_multiple.side_effect = [side_effect] result = await rpc_device.supports_scripts() assert result == supports_scripts assert rpc_device.call_rpc_multiple.call_count == 1 assert rpc_device.call_rpc_multiple.call_args[0][0][0][0] == "Script.GetCode" assert rpc_device.call_rpc_multiple.call_args[0][0][0][1] == { "id": 1, "len": 0, "offset": 0, } @pytest.mark.asyncio async def test_supports_scripts_raises_unkown_errors(rpc_device: RpcDevice) -> None: """Test supports_scripts raises for unknown errors.""" message = "Missing required argument 'id'!" rpc_device.call_rpc_multiple.side_effect = [RpcCallError(-103, message)] with pytest.raises(RpcCallError, match=message): await rpc_device.supports_scripts() @pytest.mark.asyncio async def test_trigger_blu_trv_calibration( rpc_device: RpcDevice, ) -> None: """Test RpcDevice trigger_blu_trv_calibration() method.""" await rpc_device.trigger_blu_trv_calibration(200) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "BluTRV.Call" assert call_args_list[0][0][0][0][1] == { "id": 200, "method": "Trv.Calibrate", "params": {"id": 0}, } assert call_args_list[0][0][1] == 60 @pytest.mark.asyncio async def test_blu_trv_set_target_temperature( rpc_device: RpcDevice, ) -> None: """Test RpcDevice blu_trv_set_target_temperature() method.""" await rpc_device.blu_trv_set_target_temperature(200, 21.5) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "BluTRV.Call" assert call_args_list[0][0][0][0][1] == { "id": 200, "method": "Trv.SetTarget", "params": {"id": 0, "target_C": 21.5}, } assert call_args_list[0][0][1] == 60 @pytest.mark.asyncio async def test_blu_trv_set_external_temperature( rpc_device: RpcDevice, ) -> None: """Test RpcDevice blu_trv_set_external_temperature() method.""" await rpc_device.blu_trv_set_external_temperature(200, 22.6) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "BluTRV.Call" assert call_args_list[0][0][0][0][1] == { "id": 200, "method": "Trv.SetExternalTemperature", "params": {"id": 0, "t_C": 22.6}, } assert call_args_list[0][0][1] == 60 @pytest.mark.asyncio async def test_blu_trv_set_valve_position( rpc_device: RpcDevice, ) -> None: """Test RpcDevice blu_trv_set_valve_position() method.""" await rpc_device.blu_trv_set_valve_position(200, 55.0) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "BluTRV.Call" assert call_args_list[0][0][0][0][1] == { "id": 200, "method": "Trv.SetPosition", "params": {"id": 0, "pos": 55}, } # the valve position value should be an integer assert isinstance(call_args_list[0][0][0][0][1]["params"]["pos"], int) assert call_args_list[0][0][1] == 60 @pytest.mark.asyncio async def test_blu_trv_set_boost( rpc_device: RpcDevice, ) -> None: """Test RpcDevice blu_trv_set_boost() method.""" await rpc_device.blu_trv_set_boost(200) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "BluTRV.Call" assert call_args_list[0][0][0][0][1] == { "id": 200, "method": "Trv.SetBoost", "params": {"id": 0}, } assert call_args_list[0][0][1] == 60 @pytest.mark.asyncio async def test_blu_trv_set_boost_duration( rpc_device: RpcDevice, ) -> None: """Test RpcDevice blu_trv_set_boost() method with duration.""" await rpc_device.blu_trv_set_boost(200, 33) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "BluTRV.Call" assert call_args_list[0][0][0][0][1] == { "id": 200, "method": "Trv.SetBoost", "params": {"id": 0, "duration": 33}, } assert call_args_list[0][0][1] == 60 @pytest.mark.asyncio async def test_blu_trv_clear_boost( rpc_device: RpcDevice, ) -> None: """Test RpcDevice blu_trv_clear_boost() method.""" await rpc_device.blu_trv_clear_boost(200) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "BluTRV.Call" assert call_args_list[0][0][0][0][1] == { "id": 200, "method": "Trv.ClearBoost", "params": {"id": 0}, } assert call_args_list[0][0][1] == 60 @pytest.mark.asyncio async def test_number_set( rpc_device: RpcDevice, ) -> None: """Test RpcDevice number_set() method.""" await rpc_device.number_set(12, 33.2) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Number.Set" assert call_args_list[0][0][0][0][1] == { "id": 12, "value": 33.2, } @pytest.mark.asyncio async def test_boolean_set( rpc_device: RpcDevice, ) -> None: """Test RpcDevice button_trigger() method.""" await rpc_device.boolean_set(12, True) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Boolean.Set" assert call_args_list[0][0][0][0][1] == { "id": 12, "value": True, } @pytest.mark.asyncio async def test_button_trigger( rpc_device: RpcDevice, ) -> None: """Test RpcDevice button_trigger() method.""" await rpc_device.button_trigger(12, "single_push") assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Button.Trigger" assert call_args_list[0][0][0][0][1] == { "id": 12, "event": "single_push", } @pytest.mark.asyncio async def test_climate_set_target_temperature(rpc_device: RpcDevice) -> None: """Test RpcDevice climate_set_target_temperature() method.""" await rpc_device.climate_set_target_temperature(0, 22) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Thermostat.SetConfig" assert call_args_list[0][0][0][0][1] == {"config": {"id": 0, "target_C": 22}} @pytest.mark.asyncio async def test_climate_set_hvac_mode(rpc_device: RpcDevice) -> None: """Test RpcDevice climate_set_hvac_mode() method.""" await rpc_device.climate_set_hvac_mode(0, "heat") assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Thermostat.SetConfig" assert call_args_list[0][0][0][0][1] == {"config": {"id": 0, "enable": True}} @pytest.mark.asyncio async def test_cover_get_status(rpc_device: RpcDevice) -> None: """Test RpcDevice cover_get_status() method.""" rpc_device.call_rpc_multiple.return_value = [ {"id": 0, "pos_control": True, "last_direction": "open", "current_pos": 61} ] result = await rpc_device.cover_get_status(0) assert result == { "id": 0, "pos_control": True, "last_direction": "open", "current_pos": 61, } assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cover.GetStatus" assert call_args_list[0][0][0][0][1] == {"id": 0} @pytest.mark.asyncio async def test_cover_calibrate(rpc_device: RpcDevice) -> None: """Test RpcDevice cover_calibrate() method.""" await rpc_device.cover_calibrate(0) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cover.Calibrate" assert call_args_list[0][0][0][0][1] == {"id": 0} @pytest.mark.asyncio async def test_cover_open(rpc_device: RpcDevice) -> None: """Test RpcDevice cover_open() method.""" await rpc_device.cover_open(0) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cover.Open" assert call_args_list[0][0][0][0][1] == {"id": 0} @pytest.mark.asyncio async def test_cover_close(rpc_device: RpcDevice) -> None: """Test RpcDevice cover_close() method.""" await rpc_device.cover_close(0) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cover.Close" assert call_args_list[0][0][0][0][1] == {"id": 0} @pytest.mark.asyncio async def test_cover_stop(rpc_device: RpcDevice) -> None: """Test RpcDevice cover_stop() method.""" await rpc_device.cover_stop(0) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cover.Stop" assert call_args_list[0][0][0][0][1] == {"id": 0} @pytest.mark.asyncio async def test_cover_set_position(rpc_device: RpcDevice) -> None: """Test RpcDevice cover_set_position() method.""" await rpc_device.cover_set_position(0, 55) await rpc_device.cover_set_position(1, slat_pos=20) await rpc_device.cover_set_position(0, 45, slat_pos=10) assert rpc_device.call_rpc_multiple.call_count == 3 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cover.GoToPosition" assert call_args_list[0][0][0][0][1] == {"id": 0, "pos": 55} assert call_args_list[1][0][0][0][0] == "Cover.GoToPosition" assert call_args_list[1][0][0][0][1] == {"id": 1, "slat_pos": 20} assert call_args_list[2][0][0][0][0] == "Cover.GoToPosition" assert call_args_list[2][0][0][0][1] == {"id": 0, "pos": 45, "slat_pos": 10} @pytest.mark.asyncio async def test_cury_boost( rpc_device: RpcDevice, ) -> None: """Test RpcDevice cury_boost() method.""" await rpc_device.cury_boost(2, "left") await rpc_device.cury_boost(3, "right") assert rpc_device.call_rpc_multiple.call_count == 2 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cury.Boost" assert call_args_list[0][0][0][0][1] == { "id": 2, "slot": "left", } assert call_args_list[1][0][0][0][0] == "Cury.Boost" assert call_args_list[1][0][0][0][1] == { "id": 3, "slot": "right", } @pytest.mark.asyncio async def test_cury_stop_boost( rpc_device: RpcDevice, ) -> None: """Test RpcDevice cury_stop_boost() method.""" await rpc_device.cury_stop_boost(2, "left") await rpc_device.cury_stop_boost(3, "right") assert rpc_device.call_rpc_multiple.call_count == 2 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cury.StopBoost" assert call_args_list[0][0][0][0][1] == { "id": 2, "slot": "left", } assert call_args_list[1][0][0][0][0] == "Cury.StopBoost" assert call_args_list[1][0][0][0][1] == { "id": 3, "slot": "right", } @pytest.mark.asyncio async def test_cury_set( rpc_device: RpcDevice, ) -> None: """Test RpcDevice cury_set() method.""" await rpc_device.cury_set(2, "left", True) await rpc_device.cury_set(2, "right", intensity=75) await rpc_device.cury_set(2, "right", False, intensity=50) assert rpc_device.call_rpc_multiple.call_count == 3 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Cury.Set" assert call_args_list[0][0][0][0][1] == { "id": 2, "slot": "left", "on": True, } assert call_args_list[1][0][0][0][0] == "Cury.Set" assert call_args_list[1][0][0][0][1] == { "id": 2, "slot": "right", "intensity": 75, } assert call_args_list[2][0][0][0][0] == "Cury.Set" assert call_args_list[2][0][0][0][1] == { "id": 2, "slot": "right", "on": False, "intensity": 50, } @pytest.mark.asyncio async def test_enum_set( rpc_device: RpcDevice, ) -> None: """Test RpcDevice enum_set() method.""" await rpc_device.enum_set(12, "option 1") assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Enum.Set" assert call_args_list[0][0][0][0][1] == { "id": 12, "value": "option 1", } @pytest.mark.asyncio async def test_text_set( rpc_device: RpcDevice, ) -> None: """Test RpcDevice text_set() method.""" await rpc_device.text_set(12, "lorem ipsum") assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Text.Set" assert call_args_list[0][0][0][0][1] == { "id": 12, "value": "lorem ipsum", } @pytest.mark.asyncio async def test_device_gen4_zigbee( rpc_device: RpcDevice, mini_1_g4_device_info: dict[str, Any], mini_1_g4_config: dict[str, Any], mini_1_g4_status: dict[str, Any], mini_1_g4_components: dict[str, Any], ) -> None: """Test RpcDevice initialize and shutdown methods.""" rpc_device.call_rpc_multiple.side_effect = [ [mini_1_g4_device_info], [mini_1_g4_config, mini_1_g4_status, mini_1_g4_components], ] rpc_device.subscribe_updates(Mock()) await rpc_device.initialize() assert rpc_device._update_listener is not None assert rpc_device._unsub_ws is not None assert rpc_device.connected is True assert rpc_device.firmware_supported is True assert rpc_device.name == "Shelly 1 Mini Gen4 [DDEEFF]" assert rpc_device.hostname == "shelly1minig4-aabbccddeeff" assert rpc_device.version == "1.5.99-g4prod1" assert rpc_device.gen == 4 assert rpc_device.last_error is None assert rpc_device.xmod_info == {} assert rpc_device.requires_auth is False assert rpc_device.zigbee_enabled is True assert rpc_device.zigbee_firmware is True await rpc_device.shutdown() @pytest.mark.asyncio async def test_device_gen4_zigbee_disabled( rpc_device: RpcDevice, mini_1_g4_device_info: dict[str, Any], mini_1_g4_config: dict[str, Any], mini_1_g4_status: dict[str, Any], mini_1_g4_components: dict[str, Any], ) -> None: """Test RpcDevice with Zigbee firmware when Zigbee is disabled.""" mini_1_g4_config["zigbee"] = {"enable": False} rpc_device.call_rpc_multiple.side_effect = [ [mini_1_g4_device_info], [mini_1_g4_config, mini_1_g4_status, mini_1_g4_components], ] rpc_device.subscribe_updates(Mock()) await rpc_device.initialize() assert rpc_device.zigbee_enabled is False assert rpc_device.zigbee_firmware is True await rpc_device.shutdown() @pytest.mark.asyncio async def test_zigbee_properties_not_initialized( rpc_device: RpcDevice, mini_1_g4_device_info: dict[str, Any], ) -> None: """Test RpcDevice not initialized when accessing zigbee properties.""" rpc_device.initialized = True rpc_device._shelly = mini_1_g4_device_info with pytest.raises(NotInitialized): hasattr(rpc_device, "zigbee_enabled") with pytest.raises(NotInitialized): hasattr(rpc_device, "zigbee_firmware") @pytest.mark.asyncio async def test_switch_set( rpc_device: RpcDevice, ) -> None: """Test RpcDevice switch_set() method.""" await rpc_device.switch_set(2, True) assert rpc_device.call_rpc_multiple.call_count == 1 call_args_list = rpc_device.call_rpc_multiple.call_args_list assert call_args_list[0][0][0][0][0] == "Switch.Set" assert call_args_list[0][0][0][0][1] == {"id": 2, "on": True} python-aioshelly-13.14.0/tests/rpc_device/test_init.py000066400000000000000000000005101507326321100230050ustar00rootroot00000000000000from aioshelly import rpc_device def test_exports() -> None: """Test objects are available at top level of rpc_device.""" assert hasattr(rpc_device, "bluetooth_mac_from_primary_mac") assert hasattr(rpc_device, "RpcDevice") assert hasattr(rpc_device, "RpcUpdateType") assert hasattr(rpc_device, "WsServer") python-aioshelly-13.14.0/tests/rpc_device/test_utils.py000066400000000000000000000007321507326321100232100ustar00rootroot00000000000000from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac def test_bluetooth_mac_from_primary_mac() -> None: """Test bluetooth_mac_from_primary_mac.""" assert bluetooth_mac_from_primary_mac("0A1B2C3D4E5F") == "0A1B2C3D4E61" assert bluetooth_mac_from_primary_mac("0A1B2C3D4EA0") == "0A1B2C3D4EA2" assert bluetooth_mac_from_primary_mac("0A1B2C3D4EF0") == "0A1B2C3D4EF2" assert bluetooth_mac_from_primary_mac("0A1B2C3D4EC9") == "0A1B2C3D4ECB" python-aioshelly-13.14.0/tests/rpc_device/test_wsrpc.py000066400000000000000000000033451507326321100232110ustar00rootroot00000000000000"""Tests for rpc_device.wsrpc module.""" import pytest from aioshelly.exceptions import InvalidAuthError from . import load_device_fixture from .conftest import WsRPCMocker @pytest.mark.asyncio async def test_device_wscall_get_config(ws_rpc: WsRPCMocker) -> None: """Test wscall.""" config_response = await load_device_fixture("shellyplugus", "Shelly.GetConfig") calls = [("Shelly.GetConfig", None)] responses = [config_response] results = await ws_rpc.calls_with_mocked_responses(calls, responses) assert results[0] == config_response["result"] @pytest.mark.asyncio async def test_device_wscall_no_auth_retry(ws_rpc: WsRPCMocker) -> None: """Test wscall when auth is not set.""" cover_close_auth_fail = await load_device_fixture( "shellyplus2pm", "Cover.Close_auth_failure" ) cover_close_success = await load_device_fixture( "shellyplus2pm", "Cover.Close_success" ) calls = [("Cover.Close", {"id": 0})] responses = [cover_close_auth_fail, cover_close_success] with pytest.raises(InvalidAuthError): await ws_rpc.calls_with_mocked_responses(calls, responses) @pytest.mark.asyncio async def test_device_wscall_auth_retry(ws_rpc_with_auth: WsRPCMocker) -> None: """Test wscall when auth is set and a retry works.""" cover_close_auth_fail = await load_device_fixture( "shellyplus2pm", "Cover.Close_auth_failure" ) cover_close_success = await load_device_fixture( "shellyplus2pm", "Cover.Close_success" ) calls = [("Cover.Close", {"id": 0})] responses = [cover_close_auth_fail, cover_close_success] results = await ws_rpc_with_auth.calls_with_mocked_responses(calls, responses) assert results[0] == cover_close_success["result"] python-aioshelly-13.14.0/tests/test_common.py000066400000000000000000000111001507326321100212240ustar00rootroot00000000000000"""Tests for common module.""" import re from unittest.mock import patch import pytest from aiohttp import BasicAuth, ClientError, ClientSession from aioresponses import aioresponses from yarl import URL from aioshelly.common import ( ConnectionOptions, get_info, is_firmware_supported, process_ip_or_options, ) from aioshelly.const import DEFAULT_HTTP_PORT from aioshelly.exceptions import ( DeviceConnectionError, DeviceConnectionTimeoutError, InvalidHostError, MacAddressMismatchError, ) from .rpc_device import load_device_fixture @pytest.mark.parametrize( ("gen", "model", "firmware_version", "expected"), [ (5, "XYZ-G5", "20250913-112054/v1.0.0-gcb84623", False), (4, "XYZ-G4", "20240913-112054/v1.0.0-gcb84623", True), (1, "SHSW-44", "20230913-112054/v1.14.0-gcb84623", False), (1, "SHSW-1", "20230913-112054/v1.14.0-gcb84623", True), (2, "SNDC-0D4P10WW", "20230703-112054/0.99.0-gcb84623", False), (3, "UNKNOWN", "20240819-074343/1.4.20-gc2639da", True), (3, "S3SW-002P16EU", "strange-firmware-version", False), ], ) def test_is_firmware_supported( gen: int, model: str, firmware_version: str, expected: bool ) -> None: """Test is_firmware_supported function.""" assert is_firmware_supported(gen, model, firmware_version) is expected @pytest.mark.asyncio async def test_process_ip_or_options() -> None: """Test process_ip_or_options function.""" ip = "192.168.20.11" # Test string numeric IP address assert await process_ip_or_options(ip) == ConnectionOptions(ip) # Test string hostname IP address with patch("aioshelly.common.gethostbyname", return_value=ip): assert await process_ip_or_options("some_host") == ConnectionOptions(ip) # Test ConnectionOptions options = ConnectionOptions(ip, "user", "pass") assert await process_ip_or_options(options) == options assert options.auth == BasicAuth("user", "pass") # Test missing password with pytest.raises(ValueError, match="Supply both username and password"): options = ConnectionOptions(ip, "user") @pytest.mark.asyncio async def test_get_info() -> None: """Test get_info function.""" mock_response = await load_device_fixture("shellyplus2pm", "shelly.json") ip_address = "10.10.10.10" session = ClientSession() with aioresponses() as session_mock: session_mock.get( URL.build( scheme="http", host=ip_address, port=DEFAULT_HTTP_PORT, path="/shelly" ), payload=mock_response, ) result = await get_info(session, ip_address, "AABBCCDDEEFF") await session.close() assert result == mock_response @pytest.mark.asyncio async def test_get_info_mac_mismatch() -> None: """Test get_info function with MAC mismatch.""" mock_response = await load_device_fixture("shellyplus2pm", "shelly.json") ip_address = "10.10.10.10" session = ClientSession() with aioresponses() as session_mock: session_mock.get( URL.build( scheme="http", host=ip_address, port=DEFAULT_HTTP_PORT, path="/shelly" ), payload=mock_response, ) with pytest.raises( MacAddressMismatchError, match="Input MAC: 112233445566, Shelly MAC: AABBCCDDEEFF", ): await get_info(session, ip_address, "112233445566") await session.close() @pytest.mark.parametrize( ("exc", "expected_exc"), [ (TimeoutError, DeviceConnectionTimeoutError), (ClientError, DeviceConnectionError), (OSError, DeviceConnectionError), ], ) @pytest.mark.asyncio async def test_get_info_exc(exc: Exception, expected_exc: Exception) -> None: """Test get_info function with exception.""" ip_address = "10.10.10.10" session = ClientSession() with aioresponses() as session_mock: session_mock.get( URL.build( scheme="http", host=ip_address, port=DEFAULT_HTTP_PORT, path="/shelly" ), exception=exc, ) with pytest.raises(expected_exc): await get_info(session, ip_address, "AABBCCDDEEFF") await session.close() @pytest.mark.asyncio async def test_get_info_invalid_error() -> None: """Test get_info function with an invalid host exception.""" session = ClientSession() with pytest.raises( InvalidHostError, match=re.escape("Host 'http://10.10.10.10' cannot contain ':'"), ): await get_info(session, "http://10.10.10.10", "AABBCCDDEEFF") await session.close() python-aioshelly-13.14.0/tests/test_const.py000066400000000000000000000007511507326321100210740ustar00rootroot00000000000000"""Tests for const module.""" import pytest from aioshelly.const import DEVICES, MODEL_NAMES @pytest.mark.parametrize( ("model", "expected_name"), [ ("SHSW-1", "Shelly 1"), ("SHSW-21", "Shelly 2"), ("SHSW-25", "Shelly 2.5"), ("SHSW-44", "Shelly 4Pro"), ], ) def test_model_name(model: str, expected_name: str) -> None: """Test model name.""" assert MODEL_NAMES[model] == expected_name assert DEVICES[model].name == expected_name python-aioshelly-13.14.0/tests/test_exceptions.py000066400000000000000000000006031507326321100221230ustar00rootroot00000000000000"""Tests for exceptions module.""" import pytest from aioshelly.exceptions import DeviceConnectionError, DeviceConnectionTimeoutError def test_device_timeout_error() -> None: """Test DeviceConnectionTimeoutError.""" with pytest.raises(DeviceConnectionError): raise DeviceConnectionTimeoutError( "Ensure this inherits from DeviceConnectionError" ) python-aioshelly-13.14.0/tests/test_json.py000066400000000000000000000035271507326321100207230ustar00rootroot00000000000000"""Tests for JSON helper.""" import pytest from aioshelly.json import json_bytes, json_dumps def test_json_bytes_with_dict() -> None: """Test json_bytes with a dict and non-str keys.""" data = {"id": 1, "method": "Shelly.GetDeviceInfo", 3: "test"} result = json_bytes(data) assert isinstance(result, bytes) assert result == b'{"id":1,"method":"Shelly.GetDeviceInfo","3":"test"}' def test_json_bytes_with_set() -> None: """Test json_bytes with a set.""" data = {1, 2} result = json_bytes(data) assert isinstance(result, bytes) assert result in [b"[1,2]", b"[2,1]"] def test_json_bytes_with_tuple() -> None: """Test json_bytes with a tuple.""" data = (1, 2, 3) result = json_bytes(data) assert isinstance(result, bytes) assert result == b"[1,2,3]" def test_json_bytes_with_non_serializable() -> None: """Test json_bytes with non-serializable object.""" with pytest.raises(TypeError): json_bytes(object()) def test_json_dumps_with_dict() -> None: """Test json_dumps with a dict and non-str keys.""" data = {"id": 1, "method": "Shelly.GetDeviceInfo", 3: "test"} result = json_dumps(data) assert isinstance(result, str) assert result == '{"id":1,"method":"Shelly.GetDeviceInfo","3":"test"}' def test_json_dumps_with_set() -> None: """Test json_dumps with a set.""" data = {1, 2} result = json_dumps(data) assert isinstance(result, str) assert result in ["[1,2]", "[2,1]"] def test_json_dumps_with_tuple() -> None: """Test json_dumps with a tuple.""" data = (1, 2, 3) result = json_dumps(data) assert isinstance(result, str) assert result == "[1,2,3]" def test_json_dumps_with_non_serializable() -> None: """Test json_dumps with non-serializable object.""" with pytest.raises(TypeError): json_dumps(object()) python-aioshelly-13.14.0/tools/000077500000000000000000000000001507326321100163305ustar00rootroot00000000000000python-aioshelly-13.14.0/tools/__init__.py000066400000000000000000000000271507326321100204400ustar00rootroot00000000000000"""Aioshelly tools.""" python-aioshelly-13.14.0/tools/common/000077500000000000000000000000001507326321100176205ustar00rootroot00000000000000python-aioshelly-13.14.0/tools/common/__init__.py000066400000000000000000000143571507326321100217430ustar00rootroot00000000000000# Common tools methods """Methods for aioshelly cmdline tools.""" from __future__ import annotations import asyncio import signal import sys from collections.abc import Callable from datetime import UTC, datetime from functools import partial from typing import TYPE_CHECKING, Any, cast from aiohttp import ClientSession from aioshelly.block_device import BLOCK_VALUE_UNIT, COAP, BlockDevice, BlockUpdateType from aioshelly.common import ConnectionOptions, get_info from aioshelly.const import ( BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, DEVICES, RPC_GENERATIONS, ) from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, DeviceConnectionTimeoutError, InvalidAuthError, MacAddressMismatchError, ShellyError, WrongShellyGen, ) from aioshelly.rpc_device import RpcDevice, RpcUpdateType, WsServer coap_context = COAP() ws_context = WsServer() init_tasks_ref = set() async def create_device( aiohttp_session: ClientSession, options: ConnectionOptions, gen: int | None, ) -> Any: """Create a device.""" if gen is None: if info := await get_info( aiohttp_session, options.ip_address, port=options.port ): gen = info.get("gen", 1) else: raise ShellyError("Unknown Gen") if gen in BLOCK_GENERATIONS: return await BlockDevice.create(aiohttp_session, coap_context, options) if gen in RPC_GENERATIONS: return await RpcDevice.create(aiohttp_session, ws_context, options) raise ShellyError("Unknown Gen") async def init_device(device: BlockDevice | RpcDevice) -> bool: """Initialize Shelly device.""" port = getattr(device, "port", DEFAULT_HTTP_PORT) try: await device.initialize() except InvalidAuthError as err: print(f"Invalid or missing authorization, error: {err!r}") except DeviceConnectionTimeoutError as err: print(f"Timeout error connecting to {device.ip_address}:{port}, error: {err!r}") except DeviceConnectionError as err: print(f"Error connecting to {device.ip_address}:{port}, error: {err!r}") except MacAddressMismatchError as err: print(f"MAC address mismatch, error: {err!r}") except WrongShellyGen: print(f"Wrong Shelly generation for device {device.ip_address}:{port}") except CustomPortNotSupported: print("Custom port not supported for Gen1") else: return True return False async def connect_and_print_device( aiohttp_session: ClientSession, options: ConnectionOptions, init: bool, gen: int | None, ) -> bool: """Connect and print device data.""" device = await create_device(aiohttp_session, options, gen) if init and not await init_device(device): return False print_device(device) device.subscribe_updates(partial(device_updated, action=print_device)) return True def device_updated( cb_device: BlockDevice | RpcDevice, update_type: BlockUpdateType | RpcUpdateType, action: Callable[[BlockDevice | RpcDevice], None], ) -> None: """Device updated callback.""" print() print( f"{datetime.now(tz=UTC).strftime('%H:%M:%S')} Device updated! ({update_type})" ) if update_type in (BlockUpdateType.ONLINE, RpcUpdateType.ONLINE): loop = asyncio.get_running_loop() init_task = loop.create_task(init_device(cb_device)) init_tasks_ref.add(init_task) init_task.add_done_callback(init_tasks_ref.remove) return action(cb_device) def print_device(device: BlockDevice | RpcDevice) -> None: """Print device data.""" port = getattr(device, "port", DEFAULT_HTTP_PORT) if not device.initialized: print() print(f"** Device @ {device.ip_address}:{port} not initialized **") print() return if shelly_device := DEVICES.get(device.model): model_name = shelly_device.name else: model_name = f"Unknown ({device.model})" print(f"** {device.name} - {model_name} @ {device.ip_address}:{port} **") print() if not device.firmware_supported: print(f"Device firmware not supported: {device.firmware_version}") if device.gen in BLOCK_GENERATIONS: print_block_device(cast(BlockDevice, device)) elif device.gen in RPC_GENERATIONS: print_rpc_device(cast(RpcDevice, device)) def print_block_device(device: BlockDevice) -> None: """Print block (GEN1) device data.""" if TYPE_CHECKING: assert device.blocks for block in device.blocks: print(block) for attr, value in block.current_values().items(): info = block.info(attr) _value = value if value is not None else "-" unit = " " + info[BLOCK_VALUE_UNIT] if BLOCK_VALUE_UNIT in info else "" print(f"{attr.ljust(16)}{_value}{unit}") print() def print_rpc_device(device: RpcDevice) -> None: """Print RPC (GEN2/3/4) device data.""" print(f"Status: {device.status}") print(f"Event: {device.event}") print(f"Connected: {device.connected}") def close_connections(_exit_code: int = 0) -> None: """Close all connections before exiting.""" coap_context.close() ws_context.close() sys.exit(_exit_code) async def update_outbound_ws( options: ConnectionOptions, init: bool, ws_url: str ) -> None: """Update outbound WebSocket URL (Gen2/3).""" async with ClientSession() as aiohttp_session: device: RpcDevice = await create_device(aiohttp_session, options, init, 2) print(f"Updating outbound weboskcet URL to {ws_url}") print(f"Restart required: {await device.update_outbound_websocket(ws_url)}") async def wait_for_keyboard_interrupt() -> None: """Wait for keyboard interrupt (Ctrl-C).""" sig_event = asyncio.Event() signal.signal(signal.SIGINT, lambda _exit_code, _frame: sig_event.set()) await sig_event.wait() async def check_rpc_device_supports_scripts( options: ConnectionOptions, gen: int | None ) -> None: """Check if RPC device supports scripts.""" async with ClientSession() as aiohttp_session: device: RpcDevice = await create_device(aiohttp_session, options, gen) await device.initialize() print(f"Supports scripts: {await device.supports_scripts()}") await device.shutdown() python-aioshelly-13.14.0/tools/devices.json000066400000000000000000000002071507326321100206440ustar00rootroot00000000000000{"ip_address": "192.168.1.1", "username": "admin", "password": "password"} {"ip_address": "192.168.1.2"} {"ip_address": "192.168.1.3"} python-aioshelly-13.14.0/tools/example.py000066400000000000000000000153151507326321100203420ustar00rootroot00000000000000# Run with python3 example.py -h for help """aioshelly usage example.""" from __future__ import annotations import argparse import asyncio import json import logging import traceback from functools import partial from pathlib import Path from aiohttp import ClientSession from common import ( check_rpc_device_supports_scripts, close_connections, coap_context, connect_and_print_device, create_device, device_updated, init_device, print_device, update_outbound_ws, wait_for_keyboard_interrupt, ws_context, ) from aioshelly.common import ConnectionOptions from aioshelly.const import ( BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS, WS_API_URL, ) async def test_single(options: ConnectionOptions, init: bool, gen: int | None) -> None: """Test single device.""" async with ClientSession() as aiohttp_session: device = await create_device(aiohttp_session, options, gen) if init and not await init_device(device): return print_device(device) device.subscribe_updates(partial(device_updated, action=print_device)) await wait_for_keyboard_interrupt() await device.shutdown() close_connections() async def test_devices(init: bool, gen: int | None) -> None: """Test multiple devices.""" options: ConnectionOptions with Path.open(Path("devices.json"), encoding="utf8") as fp: device_options = [ConnectionOptions(**json.loads(line)) for line in fp] async with ClientSession() as aiohttp_session: results = await asyncio.gather( *[ asyncio.gather( connect_and_print_device(aiohttp_session, options, init, gen), ) for options in device_options ], return_exceptions=True, ) for options, result in zip(device_options, results, strict=False): if isinstance(result, bool) and not result: print(f"Error printing device @ {options.ip_address}:{options.port}") elif isinstance(result, Exception): print() traceback.print_tb(result.__traceback__) print(result) await wait_for_keyboard_interrupt() close_connections() def get_arguments() -> tuple[argparse.ArgumentParser, argparse.Namespace]: """Get parsed passed in arguments.""" parser = argparse.ArgumentParser(description="aioshelly example") parser.add_argument( "--ip_address", "-ip", type=str, help="Test single device by IP address" ) parser.add_argument( "--device_port", "-dp", type=int, default=DEFAULT_HTTP_PORT, help="Port to use when testing single device", ) parser.add_argument( "--coap_port", "-cp", type=int, default=5683, help="Specify CoAP UDP port (default=5683)", ) parser.add_argument( "--ws_port", "-wp", type=int, default=8123, help="Specify WebSocket TCP port (default=8123)", ) parser.add_argument( "--ws_api_url", "-au", type=str, default=WS_API_URL, help=f"Specify WebSocket API URL (default={WS_API_URL})", ) parser.add_argument( "--devices", "-d", action="store_true", help=( 'Connect to all the devices in "devices.json" ' "at once and print their status" ), ) parser.add_argument( "--init", "-i", action="store_true", help="Init device(s) at startup" ) parser.add_argument("--username", "-u", type=str, help="Set device username") parser.add_argument("--password", "-p", type=str, help="Set device password") gen = parser.add_mutually_exclusive_group() gen.add_argument( "--gen1", "-g1", action="store_const", const=1, dest="gen", help="Force Gen1 (CoAP) device", ) gen.add_argument( "--gen2", "-g2", action="store_const", const=2, dest="gen", help="Force Gen 2 (RPC) device", ) gen.add_argument( "--gen3", "-g3", action="store_const", const=3, dest="gen", help="Force Gen 3 (RPC) device", ) gen.add_argument( "--gen4", "-g4", action="store_const", const=4, dest="gen", help="Force Gen 4 (RPC) device", ) parser.add_argument( "--debug", "-deb", action="store_true", help="Enable debug level for logging" ) parser.add_argument( "--mac", "-m", type=str, help="Optional device MAC to subscribe for updates" ) parser.add_argument( "--update_ws", "-uw", type=str, help="Update outbound WebSocket (for RPC device) and exit", ) parser.add_argument( "--listen_ip_address", "-lip", type=str, nargs="*", default=None, help="Listen ip address for incoming CoAP packets", ) parser.add_argument( "--supports_scripts", "-ss", action="store_true", help="Check if device supports scripts", ) arguments = parser.parse_args() return parser, arguments async def main() -> None: """Run main.""" parser, args = get_arguments() await coap_context.initialize(args.coap_port, args.listen_ip_address) await ws_context.initialize(args.ws_port, args.ws_api_url) if not args.init and not args.gen: parser.error("specify gen if no device init at startup") if args.debug: logging.basicConfig(level=logging.DEBUG) # if gen is in args reduce logging for other gens if args.gen in BLOCK_GENERATIONS: logging.getLogger("aioshelly.rpc_device").setLevel(logging.INFO) elif args.gen in RPC_GENERATIONS: logging.getLogger("aioshelly.block_device").setLevel(logging.INFO) if args.devices: await test_devices(args.init, args.gen) elif args.ip_address: if args.username and args.password is None: parser.error("--username and --password must be used together") options = ConnectionOptions( args.ip_address, args.username, args.password, device_mac=args.mac, port=args.device_port, ) if args.update_ws: await update_outbound_ws(options, args.init, args.update_ws) elif args.supports_scripts: await check_rpc_device_supports_scripts(options, args.gen) else: await test_single(options, args.init, args.gen) else: parser.error("--ip_address or --devices must be specified") if __name__ == "__main__": asyncio.run(main()) python-aioshelly-13.14.0/tools/fixture.py000066400000000000000000000262401507326321100203740ustar00rootroot00000000000000# Run with python3 fixture.py -h for help """aioshelly usage example.""" from __future__ import annotations import argparse import asyncio import logging import signal import sys from functools import partial from pathlib import Path from types import FrameType from typing import Any import orjson from aiohttp import ClientSession from common import ( close_connections, coap_context, create_device, device_updated, init_device, ws_context, ) from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions from aioshelly.const import BLOCK_GENERATIONS, DEVICES, WS_API_URL from aioshelly.rpc_device import RpcDevice async def connect_and_save( options: ConnectionOptions, init: bool, gen: int | None ) -> None: """Save fixture single device.""" async with ClientSession() as aiohttp_session: device = await create_device(aiohttp_session, options, gen) if init: if not await init_device(device): return save_endpoints(device) device.subscribe_updates(partial(device_updated, action=save_endpoints)) # This is for diagnostic purposes only. while True: # noqa: ASYNC110 await asyncio.sleep(0.1) def save_endpoints(device: BlockDevice | RpcDevice) -> None: """Save device endpoints.""" data_raw = {"shelly": device.shelly.copy(), "status": device.status.copy()} if device.gen in BLOCK_GENERATIONS: data_raw.update({"settings": device.settings.copy()}) data_normalized = _redact_block_data(data_raw) else: data_raw.update({"config": device.config.copy()}) data_normalized = _redact_rpc_data(data_raw) gen = device.gen model = device.model if shelly_device := DEVICES.get(model): name = shelly_device.name else: name = f"Unknown ({model})" version = device.firmware_version.replace("/", "-") current_path = Path(__file__) fixture_path = ( current_path.parent.parent.joinpath("fixtures") / f"gen{gen}_{name}_{model}_{version}.json" ) print(f"Saving fixture to {fixture_path}") with Path.open(fixture_path, "wb") as file: file.write( orjson.dumps( data_normalized, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS, ) ) file.write(b"\n") close_connections() REDACTED_VALUES = { "wifi": "Wifi-Network-Name", "wifi_mac": "11:22:33:44:55:66", "device_mac": "AABBCCDDEEFF", "device_mac_lower": "aabbccddeeff", "device_short_mac": "DDEEFF", "device_short_mac_lower": "ddeeff", "device_name": "Test Name", "switch_name": "Switch Test Name", "input_name": "Input Test Name", "script_name": "Script Test Name", "em_name": "Energy Monitor Test Name", "mqtt_server": "mqtt.test.server", "sntp_server": "sntp.test.server", "coiot_peer": "home-assistant.server:8123", } def _redact_block_data(data: dict[str, Any]) -> dict[str, Any]: """Redact data for BLOCK devices.""" status: dict[str, Any] = data["status"] shelly: dict[str, Any] = data["shelly"] settings: dict[str, dict[str, dict[str, Any] | str]] = data["settings"] real_mac: str = status["mac"] short_mac: str = status["mac"][6:12] # Shelly endpoint shelly["name"] = REDACTED_VALUES["device_name"] shelly["mac"] = REDACTED_VALUES["device_mac"] # Status endpoint status["mac"] = REDACTED_VALUES["device_mac"] if "ssid" in status["wifi_sta"]: status["wifi_sta"]["ssid"] = REDACTED_VALUES["wifi"] # Config endpoint if "peer" in settings["coiot"]: settings["coiot"]["peer"] = REDACTED_VALUES["coiot_peer"] # Some devices use short MAC (uppercase/lowercase) device = settings["device"] device["hostname"] = ( device["hostname"] .replace(real_mac, REDACTED_VALUES["device_mac"]) .replace(short_mac, REDACTED_VALUES["device_short_mac"]) ) device["mac"] = REDACTED_VALUES["device_mac"] # Some devices use MAC and short MAC (uppercase/lowercase) mqtt = settings["mqtt"] mqtt["id"] = ( mqtt["id"] .replace(real_mac, REDACTED_VALUES["device_mac"]) .replace(real_mac.lower(), REDACTED_VALUES["device_mac_lower"]) .replace(short_mac, REDACTED_VALUES["device_short_mac"]) .replace(short_mac.lower(), REDACTED_VALUES["device_short_mac"].lower()) ) settings["name"] = REDACTED_VALUES["device_name"] # Some devices use MAC and short MAC (uppercase/lowercase) settings["wifi_ap"]["ssid"] = ( settings["wifi_ap"]["ssid"] .replace(real_mac, REDACTED_VALUES["device_mac"]) .replace(short_mac, REDACTED_VALUES["device_short_mac"]) ) settings["wifi_sta"]["ssid"] = REDACTED_VALUES["wifi"] return data def _redact_rpc_data(data: dict[str, Any]) -> dict[str, Any]: """Redact data for RPC devices.""" config: dict[str, dict[str, Any] | str] = data["config"] status: dict[str, dict[str, dict[str, Any] | str] | str] = data["status"] shelly: dict[str, dict[str, Any] | str] = data["shelly"] real_mac: str = status["sys"]["mac"] device = config["sys"]["device"] # Config endpoint device["name"] = REDACTED_VALUES["device_name"] device["mac"] = REDACTED_VALUES["device_mac"] # Some devices use MAC uppercase, others lowercase mqtt = config["mqtt"] mqtt["client_id"] = ( mqtt["client_id"] .replace(real_mac, REDACTED_VALUES["device_mac"]) .replace(real_mac.lower(), REDACTED_VALUES["device_mac_lower"]) ) mqtt["server"] = REDACTED_VALUES["mqtt_server"] if mqtt.get("topic_prefix"): # Some devices use MAC uppercase, others lowercase mqtt["topic_prefix"] = ( mqtt["topic_prefix"] .replace(real_mac, REDACTED_VALUES["device_mac"]) .replace(real_mac.lower(), REDACTED_VALUES["device_mac_lower"]) ) if sntp := config["sys"].get("sntp"): sntp["server"] = REDACTED_VALUES["sntp_server"] config_prefixes = ("switch:", "input:", "em:", "script:") for key, value in config.items(): if key.startswith(config_prefixes): key_name, id_ = key.split(":") value["name"] = f"{key_name} {id_}" for id_ in range(5): if thermostat := config.get(f"thermostat:{id_}"): thermostat["sensor"] = thermostat["sensor"].replace( real_mac.lower(), REDACTED_VALUES["device_mac_lower"] ) thermostat["actuator"] = thermostat["actuator"].replace( real_mac.lower(), REDACTED_VALUES["device_mac_lower"] ) config["wifi"]["ap"]["ssid"] = config["wifi"]["ap"]["ssid"].replace( real_mac, REDACTED_VALUES["device_mac"] ) if config["wifi"]["sta"]["ssid"]: config["wifi"]["sta"]["ssid"] = REDACTED_VALUES["wifi"] if config["wifi"]["sta1"]["ssid"]: config["wifi"]["sta1"]["ssid"] = REDACTED_VALUES["wifi"] # Shelly endpoint shelly["name"] = REDACTED_VALUES["device_name"] # Some devices use MAC uppercase, others lowercase shelly["id"] = ( shelly["id"] .replace(real_mac, REDACTED_VALUES["device_mac"]) .replace(real_mac.lower(), REDACTED_VALUES["device_mac_lower"]) ) shelly["mac"] = REDACTED_VALUES["device_mac"] if auth_domain := shelly.get("auth_domain"): shelly["auth_domain"] = auth_domain.replace( real_mac, REDACTED_VALUES["device_mac"] ).replace(real_mac.lower(), REDACTED_VALUES["device_mac_lower"]) # Status endpoint status["sys"]["mac"] = REDACTED_VALUES["device_mac"] if status["wifi"].get("ssid"): status["wifi"]["ssid"] = REDACTED_VALUES["wifi"] if status["wifi"].get("mac"): status["wifi"]["mac"] = REDACTED_VALUES["wifi_mac"] if id_ := status["sys"].get("id"): # Some devices use MAC uppercase, others lowercase status["sys"]["id"] = id_.replace( real_mac, REDACTED_VALUES["device_mac"] ).replace(real_mac.lower(), REDACTED_VALUES["device_mac_lower"]) return data def get_arguments() -> tuple[argparse.ArgumentParser, argparse.Namespace]: """Get parsed passed in arguments.""" parser = argparse.ArgumentParser(description="aioshelly example") parser.add_argument( "--ip_address", "-ip", type=str, help="Test single device by IP address" ) parser.add_argument( "--coap_port", "-cp", type=int, default=5683, help="Specify CoAP UDP port (default=5683)", ) parser.add_argument( "--ws_port", "-wp", type=int, default=8123, help="Specify WebSocket TCP port (default=8123)", ) parser.add_argument( "--ws_api_url", "-au", type=str, default=WS_API_URL, help=f"Specify WebSocket API URL (default={WS_API_URL})", ) parser.add_argument( "--init", "-i", action="store_true", help="Init device(s) at startup" ) parser.add_argument("--username", "-u", type=str, help="Set device username") parser.add_argument("--password", "-p", type=str, help="Set device password") gen = parser.add_mutually_exclusive_group() gen.add_argument( "--gen1", "-g1", action="store_const", const=1, dest="gen", help="Force Gen1 (CoAP) device", ) gen.add_argument( "--gen2", "-g2", action="store_const", const=2, dest="gen", help="Force Gen 2 (RPC) device", ) gen.add_argument( "--gen3", "-g3", action="store_const", const=3, dest="gen", help="Force Gen 3 (RPC) device", ) gen.add_argument( "--gen4", "-g4", action="store_const", const=4, dest="gen", help="Force Gen 4 (RPC) device", ) parser.add_argument( "--debug", "-deb", action="store_true", help="Enable debug level for logging" ) parser.add_argument( "--mac", "-m", type=str, help="Optional device MAC to subscribe for updates" ) arguments = parser.parse_args() return parser, arguments async def main() -> None: """Run main.""" parser, args = get_arguments() await coap_context.initialize(args.coap_port) await ws_context.initialize(args.ws_port, args.ws_api_url) if not args.init and not args.gen: parser.error("specify gen if no device init at startup") if args.debug: logging.basicConfig(level="DEBUG", force=True) def handle_sigint(_exit_code: int, _frame: FrameType) -> None: """Handle Keyboard signal interrupt (ctrl-c).""" coap_context.close() ws_context.close() sys.exit() signal.signal(signal.SIGINT, handle_sigint) if args.ip_address: if args.username and args.password is None: parser.error("--username and --password must be used together") options = ConnectionOptions( args.ip_address, args.username, args.password, device_mac=args.mac ) await connect_and_save(options, args.init, args.gen) else: parser.error("--ip_address or --devices must be specified") if __name__ == "__main__": asyncio.run(main()) python-aioshelly-13.14.0/tools/verify.py000066400000000000000000000076121507326321100202140ustar00rootroot00000000000000"""Download and verify all Coiot examples from the Shelly website.""" import json import logging import re import urllib.parse from dataclasses import dataclass, field from typing import Any from unittest.mock import Mock import requests import urllib3 from aiohttp.helpers import reify from aioshelly.block_device import BLOCK_VALUE_UNIT, BlockDevice from aioshelly.block_device.coap import CoapType from aioshelly.common import ConnectionOptions from aioshelly.const import DEVICE_IO_TIMEOUT urllib3.disable_warnings() BASE_URL = "https://shelly-api-docs.shelly.cloud/docs/coiot/v2/examples/" _LOGGER = logging.getLogger(__name__) @dataclass class CoiotExample: """CoiotExample class.""" filename: str _cache: dict = field(default_factory=dict) @reify def name(self) -> str: """Get filename.""" return urllib.parse.unquote(self.filename) @reify def url(self) -> str: """Get file URL.""" return BASE_URL + self.filename @reify def content(self) -> str: """Get file content.""" return requests.get(self.url, timeout=DEVICE_IO_TIMEOUT, verify=False).text # noqa: S501 @reify def content_parsed(self) -> list[dict[str, Any]]: """Parse file.""" lines = self.content.split("\n") parsed = [] start = None for i, line in enumerate(lines): if line.rstrip() == "{": start = i elif line.rstrip() == "}": parsed.append(lines[start : i + 1]) if len(parsed) != 2: # noqa: PLR2004 raise ValueError("Uuh, not length 2") processed = [] for value in parsed: text = "\n".join(value).strip() try: processed.append(json.loads(text)) except ValueError: _LOGGER.error("Error parsing %s", self.url) _LOGGER.exception(text) raise return processed @reify def cit_s(self) -> dict[str, Any]: """Return parsed cit/s.""" return self.content_parsed[0] @reify def cit_d(self) -> dict[str, Any]: """Return parsed cit/d.""" return self.content_parsed[1] @reify def device(self) -> BlockDevice: """Create mocked device.""" device = BlockDevice(Mock(), None, ConnectionOptions("mock-ip")) device._update_d(self.cit_d) # noqa: SLF001 device._update_s(self.cit_s, CoapType.REPLY) # noqa: SLF001 return device def coiot_examples() -> list[CoiotExample]: """Get coiot examples.""" index = requests.get( BASE_URL, # Not sure, local machine barfs on their cert timeout=DEVICE_IO_TIMEOUT, verify=False, # noqa: S501 ).text return [ CoiotExample(match) for match in re.findall(r'href="(.+?)"', index) if match.startswith("Shelly") ] def print_example(example: CoiotExample) -> None: """Print example.""" print(example.name) print() for block in example.device.blocks: print(block) for attr, value in block.current_values().items(): info = block.info(attr) _value = value if value is not None else "None" unit = " " + info[BLOCK_VALUE_UNIT] if BLOCK_VALUE_UNIT in info else "" print(f"{attr.ljust(16)}{_value}{unit}") print() print("-" * 32) print() def run() -> None: """Run coiot_examples and print errors.""" errors = [] for example in coiot_examples(): try: print_example(example) except Exception as err: # noqa: BLE001 errors.append((example, err)) break for example, err in errors: print("Error fetching", example.name) print(example.url) print() _LOGGER.error("", exc_info=err) print() print("-" * 32) print() if __name__ == "__main__": run()