pax_global_header00006660000000000000000000000064147524352070014523gustar00rootroot0000000000000052 comment=6aa7e3c401ab03b93c083acdf430afb708e20e9b pybalboa-1.1.3/000077500000000000000000000000001475243520700133165ustar00rootroot00000000000000pybalboa-1.1.3/.coveragerc000066400000000000000000000003321475243520700154350ustar00rootroot00000000000000[run] source = pybalboa omit = pybalboa/__main__.py [report] exclude_lines = # Re-enable the standard pragma pragma: no cover # TYPE_CHECKING is never executed during pytest run if TYPE_CHECKING: pybalboa-1.1.3/.github/000077500000000000000000000000001475243520700146565ustar00rootroot00000000000000pybalboa-1.1.3/.github/dependabot.yml000066400000000000000000000011021475243520700175000ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "monthly" groups: dev-dependencies: dependency-type: "development" pybalboa-1.1.3/.github/workflows/000077500000000000000000000000001475243520700167135ustar00rootroot00000000000000pybalboa-1.1.3/.github/workflows/ci.yml000066400000000000000000000021511475243520700200300ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: CI on: push: branches: ["master"] pull_request: branches: ["master"] jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Install poetry run: pipx install poetry - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: "poetry" - name: Install dependencies run: poetry install - name: Run linting run: | poetry run ruff check . poetry run ruff format . --check - name: Run mypy run: poetry run mypy pybalboa tests - name: Test with pytest run: poetry run pytest --timeout=10 --cov=pybalboa --asyncio-mode=auto pybalboa-1.1.3/.github/workflows/python-publish.yaml000066400000000000000000000011471475243520700225670ustar00rootroot00000000000000# An action to build and publish python package to https://pypi.org/ using poetry https://github.com/sdispater/poetry # For more information see: https://github.com/marketplace/actions/publish-python-poetry-package name: Publish python package to PyPi on: push: tags: - "v?*.*.*" permissions: contents: read jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build and publish to pypi uses: JRubics/poetry-publish@v1.16 with: pypi_token: ${{ secrets.PYPI_TOKEN }} plugins: "poetry-dynamic-versioning[plugin]" pybalboa-1.1.3/.github/workflows/stale.yaml000066400000000000000000000015471475243520700207160ustar00rootroot00000000000000# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. # # You can adjust the behavior by modifying this file. # For more information, see: # https://github.com/actions/stale name: Close stale issues and PRs on: schedule: - cron: "30 19 * * *" workflow_dispatch: jobs: stale: runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: actions/stale@v9 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: "This issue has now been marked as stale and will be closed if no further activity occurs." stale-pr-message: "This PR has now been marked as stale and will be closed if no further activity occurs." exempt-issue-labels: "enhancement,help wanted" days-before-stale: 30 pybalboa-1.1.3/.gitignore000066400000000000000000000044371475243520700153160ustar00rootroot00000000000000# -*- mode: gitignore; -*- *~ \#*\# /.emacs.desktop /.emacs.desktop.lock *.elc auto-save-list tramp .\#* # Org-mode .org-id-locations *_archive # flymake-mode *_flymake.* # eshell files /eshell/history /eshell/lastdir # elpa packages /elpa/ # reftex files *.rel # AUCTeX auto folder /auto/ # cask packages .cask/ dist/ # Flycheck flycheck_*.el # server auth directory /server/ # projectiles files .projectile # directory configuration .dir-locals.el # network security /network-security.data # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # Visual Studio Code .vscode/ pybalboa-1.1.3/LICENSE000066400000000000000000000261351475243520700143320ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. pybalboa-1.1.3/MANIFEST.rst000066400000000000000000000000231475243520700152510ustar00rootroot00000000000000include README.rst pybalboa-1.1.3/README.rst000066400000000000000000000010011475243520700147750ustar00rootroot00000000000000pybalboa -------- Python Module to interface with a balboa spa Requires Python 3 with asyncio. To Install:: pip install pybalboa To test:: python3 pybalboa To Use `````` See ``__main__.py`` for usage examples. Minimal example:: import asyncio import pybalboa async with pybalboa.SpaClient(spa_host) as spa: # read/run spa commands return Related ``````` - https://github.com/ccutrer/balboa_worldwide_app/wiki - invaluable wiki for Balboa module protocol pybalboa-1.1.3/poetry.lock000066400000000000000000001024011475243520700155100ustar00rootroot00000000000000# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "cachetools" version = "5.5.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" files = [ {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, ] [[package]] name = "chardet" version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" files = [ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, ] [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "coverage" version = "7.6.11" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" files = [ {file = "coverage-7.6.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eafea49da254a8289bed3fab960f808b322eda5577cb17a3733014928bbfbebd"}, {file = "coverage-7.6.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a3f7cbbcb4ad95067a6525f83a6fc78d9cbc1e70f8abaeeaeaa72ef34f48fc3"}, {file = "coverage-7.6.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de6b079b39246a7da9a40cfa62d5766bd52b4b7a88cf5a82ec4c45bf6e152306"}, {file = "coverage-7.6.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60d4ad09dfc8c36c4910685faafcb8044c84e4dae302e86c585b3e2e7778726c"}, {file = "coverage-7.6.11-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e433b6e3a834a43dae2889adc125f3fa4c66668df420d8e49bc4ee817dd7a70"}, {file = "coverage-7.6.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac5d92e2cc121a13270697e4cb37e1eb4511ac01d23fe1b6c097facc3b46489e"}, {file = "coverage-7.6.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5128f3ba694c0a1bde55fc480090392c336236c3e1a10dad40dc1ab17c7675ff"}, {file = "coverage-7.6.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:397489c611b76302dfa1d9ea079e138dddc4af80fc6819d5f5119ec8ca6c0e47"}, {file = "coverage-7.6.11-cp310-cp310-win32.whl", hash = "sha256:c7719a5e1dc93883a6b319bc0374ecd46fb6091ed659f3fbe281ab991634b9b0"}, {file = "coverage-7.6.11-cp310-cp310-win_amd64.whl", hash = "sha256:c27df03730059118b8a923cfc8b84b7e9976742560af528242f201880879c1da"}, {file = "coverage-7.6.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:532fe139691af134aa8b54ed60dd3c806aa81312d93693bd2883c7b61592c840"}, {file = "coverage-7.6.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0b0f272901a5172090c0802053fbc503cdc3fa2612720d2669a98a7384a7bec"}, {file = "coverage-7.6.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4bda710139ea646890d1c000feb533caff86904a0e0638f85e967c28cb8eec50"}, {file = "coverage-7.6.11-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a165b09e7d5f685bf659063334a9a7b1a2d57b531753d3e04bd442b3cfe5845b"}, {file = "coverage-7.6.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ff136607689c1c87f43d24203b6d2055b42030f352d5176f9c8b204d4235ef27"}, {file = "coverage-7.6.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:050172741de03525290e67f0161ae5f7f387c88fca50d47fceb4724ceaa591d2"}, {file = "coverage-7.6.11-cp311-cp311-win32.whl", hash = "sha256:27700d859be68e4fb2e7bf774cf49933dcac6f81a9bc4c13bd41735b8d26a53b"}, {file = "coverage-7.6.11-cp311-cp311-win_amd64.whl", hash = "sha256:cd4839813b09ab1dd1be1bbc74f9a7787615f931f83952b6a9af1b2d3f708bf7"}, {file = "coverage-7.6.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbb1a822fd858d9853333a7c95d4e70dde9a79e65893138ce32c2ec6457d7a36"}, {file = "coverage-7.6.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61c834cbb80946d6ebfddd9b393a4c46bec92fcc0fa069321fcb8049117f76ea"}, {file = "coverage-7.6.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a46d56e99a31d858d6912d31ffa4ede6a325c86af13139539beefca10a1234ce"}, {file = "coverage-7.6.11-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b48db06f53d1864fea6dbd855e6d51d41c0f06c212c3004511c0bdc6847b297"}, {file = "coverage-7.6.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6ff5be3b1853e0862da9d349fe87f869f68e63a25f7c37ce1130b321140f963"}, {file = "coverage-7.6.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be05bde21d5e6eefbc3a6de6b9bee2b47894b8945342e8663192809c4d1f08ce"}, {file = "coverage-7.6.11-cp312-cp312-win32.whl", hash = "sha256:e3b746fa0ffc5b6b8856529de487da8b9aeb4fb394bb58de6502ef45f3434f12"}, {file = "coverage-7.6.11-cp312-cp312-win_amd64.whl", hash = "sha256:ac476e6d0128fb7919b3fae726de72b28b5c9644cb4b579e4a523d693187c551"}, {file = "coverage-7.6.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c86f4c7a6d1a54a24d804d9684d96e36a62d3ef7c0d7745ae2ea39e3e0293251"}, {file = "coverage-7.6.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7eb0504bb307401fd08bc5163a351df301438b3beb88a4fa044681295bbefc67"}, {file = "coverage-7.6.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca95d40900cf614e07f00cee8c2fad0371df03ca4d7a80161d84be2ec132b7a4"}, {file = "coverage-7.6.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db4b1a69976b1b02acda15937538a1d3fe10b185f9d99920b17a740a0a102e06"}, {file = "coverage-7.6.11-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf96beb05d004e4c51cd846fcdf9eee9eb2681518524b66b2e7610507944c2f"}, {file = "coverage-7.6.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:08e5fb93576a6b054d3d326242af5ef93daaac9bb52bc25f12ccbc3fa94227cd"}, {file = "coverage-7.6.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25575cd5a7d2acc46b42711e8aff826027c0e4f80fb38028a74f31ac22aae69d"}, {file = "coverage-7.6.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8fa4fffd90ee92f62ff7404b4801b59e8ea8502e19c9bf2d3241ce745b52926c"}, {file = "coverage-7.6.11-cp313-cp313-win32.whl", hash = "sha256:0d03c9452d9d1ccfe5d3a5df0427705022a49b356ac212d529762eaea5ef97b4"}, {file = "coverage-7.6.11-cp313-cp313-win_amd64.whl", hash = "sha256:fd2fffc8ce8692ce540103dff26279d2af22d424516ddebe2d7e4d6dbb3816b2"}, {file = "coverage-7.6.11-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5e7ac966ab110bd94ee844f2643f196d78fde1cd2450399116d3efdd706e19f5"}, {file = "coverage-7.6.11-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ba27a0375c5ef4d2a7712f829265102decd5ff78b96d342ac2fa555742c4f4f"}, {file = "coverage-7.6.11-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2778be4f574b39ec9dcd9e5e13644f770351ee0990a0ecd27e364aba95af89b"}, {file = "coverage-7.6.11-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5edc16712187139ab635a2e644cc41fc239bc6d245b16124045743130455c652"}, {file = "coverage-7.6.11-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df6ff122a0a10a30121d9f0cb3fbd03a6fe05861e4ec47adb9f25e9245aabc19"}, {file = "coverage-7.6.11-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff562952f15eff27247a4c4b03e45ce8a82e3fb197de6a7c54080f9d4ba07845"}, {file = "coverage-7.6.11-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4f21e3617f48d683f30cf2a6c8b739c838e600cb1454fe6b2eb486ac2bce8fbd"}, {file = "coverage-7.6.11-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6d60577673ba48d8ae8e362e61fd4ad1a640293ffe8991d11c86f195479100b7"}, {file = "coverage-7.6.11-cp313-cp313t-win32.whl", hash = "sha256:13100f98497086b359bf56fc035a762c674de8ef526daa389ac8932cb9bff1e0"}, {file = "coverage-7.6.11-cp313-cp313t-win_amd64.whl", hash = "sha256:2c81e53782043b323bd34c7de711ed9b4673414eb517eaf35af92185b873839c"}, {file = "coverage-7.6.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ff52b4e2ac0080c96e506819586c4b16cdbf46724bda90d308a7330a73cc8521"}, {file = "coverage-7.6.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f4679fcc9eb9004fdd1b00231ef1ec7167168071bebc4d66327e28c1979b4449"}, {file = "coverage-7.6.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90de4e9ca4489e823138bd13098af9ac8028cc029f33f60098b5c08c675c7bda"}, {file = "coverage-7.6.11-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c96a142057d83ee993eaf71629ca3fb952cda8afa9a70af4132950c2bd3deb9"}, {file = "coverage-7.6.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:476f29a258b9cd153f2be5bf5f119d670d2806363595263917bddc167d6e5cce"}, {file = "coverage-7.6.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:09d03f48d9025b8a6a116cddcb6c7b8ce80e4fb4c31dd2e124a7c377036ad58e"}, {file = "coverage-7.6.11-cp39-cp39-win32.whl", hash = "sha256:bb35ae9f134fbd9cf7302a9654d5a1e597c974202678082dcc569eb39a8cde03"}, {file = "coverage-7.6.11-cp39-cp39-win_amd64.whl", hash = "sha256:f382004fa4c93c01016d9226b9d696a08c53f6818b7ad59b4e96cb67e863353a"}, {file = "coverage-7.6.11-pp39.pp310-none-any.whl", hash = "sha256:adc2d941c0381edfcf3897f94b9f41b1e504902fab78a04b1677f2f72afead4b"}, {file = "coverage-7.6.11-py3-none-any.whl", hash = "sha256:f0f334ae844675420164175bf32b04e18a81fe57ad8eb7e0cfd4689d681ffed7"}, {file = "coverage-7.6.11.tar.gz", hash = "sha256:e642e6a46a04e992ebfdabed79e46f478ec60e2c528e1e1a074d63800eda4286"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] toml = ["tomli"] [[package]] name = "distlib" version = "0.3.8" description = "Distribution utilities" optional = false python-versions = "*" files = [ {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] [[package]] name = "exceptiongroup" version = "1.2.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, ] [package.extras] test = ["pytest (>=6)"] [[package]] name = "filelock" version = "3.16.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, ] [package.extras] docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] typing = ["typing-extensions (>=4.12.2)"] [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] [[package]] name = "mypy" version = "1.15.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" files = [ {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, ] [package.dependencies] mypy_extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing_extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] [[package]] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] [[package]] name = "packaging" version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] name = "platformdirs" version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, ] [package.extras] docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.11.2)"] [[package]] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] [[package]] name = "pyproject-api" version = "1.8.0" description = "API to interact with the python pyproject.toml based projects" optional = false python-versions = ">=3.8" files = [ {file = "pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228"}, {file = "pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496"}, ] [package.dependencies] packaging = ">=24.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=2.4.1)"] testing = ["covdefaults (>=2.3)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] [[package]] name = "pytest" version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" version = "0.25.3" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" files = [ {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, ] [package.dependencies] pytest = ">=8.2,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" version = "6.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" files = [ {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, ] [package.dependencies] coverage = {version = ">=7.5", extras = ["toml"]} pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-timeout" version = "2.3.1" description = "pytest plugin to abort hanging tests" optional = false python-versions = ">=3.7" files = [ {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, ] [package.dependencies] pytest = ">=7.0.0" [[package]] name = "ruff" version = "0.9.5" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ {file = "ruff-0.9.5-py3-none-linux_armv6l.whl", hash = "sha256:d466d2abc05f39018d53f681fa1c0ffe9570e6d73cde1b65d23bb557c846f442"}, {file = "ruff-0.9.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38840dbcef63948657fa7605ca363194d2fe8c26ce8f9ae12eee7f098c85ac8a"}, {file = "ruff-0.9.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d56ba06da53536b575fbd2b56517f6f95774ff7be0f62c80b9e67430391eeb36"}, {file = "ruff-0.9.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7cb2a01da08244c50b20ccfaeb5972e4228c3c3a1989d3ece2bc4b1f996001"}, {file = "ruff-0.9.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:96d5c76358419bc63a671caac70c18732d4fd0341646ecd01641ddda5c39ca0b"}, {file = "ruff-0.9.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:deb8304636ed394211f3a6d46c0e7d9535b016f53adaa8340139859b2359a070"}, {file = "ruff-0.9.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df455000bf59e62b3e8c7ba5ed88a4a2bc64896f900f311dc23ff2dc38156440"}, {file = "ruff-0.9.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de92170dfa50c32a2b8206a647949590e752aca8100a0f6b8cefa02ae29dce80"}, {file = "ruff-0.9.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d28532d73b1f3f627ba88e1456f50748b37f3a345d2be76e4c653bec6c3e393"}, {file = "ruff-0.9.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c746d7d1df64f31d90503ece5cc34d7007c06751a7a3bbeee10e5f2463d52d2"}, {file = "ruff-0.9.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:11417521d6f2d121fda376f0d2169fb529976c544d653d1d6044f4c5562516ee"}, {file = "ruff-0.9.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b9d71c3879eb32de700f2f6fac3d46566f644a91d3130119a6378f9312a38e1"}, {file = "ruff-0.9.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2e36c61145e70febcb78483903c43444c6b9d40f6d2f800b5552fec6e4a7bb9a"}, {file = "ruff-0.9.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2f71d09aeba026c922aa7aa19a08d7bd27c867aedb2f74285a2639644c1c12f5"}, {file = "ruff-0.9.5-py3-none-win32.whl", hash = "sha256:134f958d52aa6fdec3b294b8ebe2320a950d10c041473c4316d2e7d7c2544723"}, {file = "ruff-0.9.5-py3-none-win_amd64.whl", hash = "sha256:78cc6067f6d80b6745b67498fb84e87d32c6fc34992b52bffefbdae3442967d6"}, {file = "ruff-0.9.5-py3-none-win_arm64.whl", hash = "sha256:18a29f1a005bddb229e580795627d297dfa99f16b30c7039e73278cf6b5f9fa9"}, {file = "ruff-0.9.5.tar.gz", hash = "sha256:11aecd7a633932875ab3cb05a484c99970b9d52606ce9ea912b690b02653d56c"}, ] [[package]] name = "tomli" version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] name = "tox" version = "4.24.1" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ {file = "tox-4.24.1-py3-none-any.whl", hash = "sha256:57ba7df7d199002c6df8c2db9e6484f3de6ca8f42013c083ea2d4d1e5c6bdc75"}, {file = "tox-4.24.1.tar.gz", hash = "sha256:083a720adbc6166fff0b7d1df9d154f9d00bfccb9403b8abf6bc0ee435d6a62e"}, ] [package.dependencies] cachetools = ">=5.5" chardet = ">=5.2" colorama = ">=0.4.6" filelock = ">=3.16.1" packaging = ">=24.2" platformdirs = ">=4.3.6" pluggy = ">=1.5" pyproject-api = ">=1.8" tomli = {version = ">=2.1", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""} virtualenv = ">=20.27.1" [package.extras] test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"] [[package]] name = "typing-extensions" version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] name = "virtualenv" version = "20.29.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" files = [ {file = "virtualenv-20.29.1-py3-none-any.whl", hash = "sha256:4e4cb403c0b0da39e13b46b1b2476e505cb0046b25f242bee80f62bf990b2779"}, {file = "virtualenv-20.29.1.tar.gz", hash = "sha256:b8b8970138d32fb606192cb97f6cd4bb644fa486be9308fb9b63f81091b5dc35"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = ">=3.12.2,<4" platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [metadata] lock-version = "2.0" python-versions = "^3.9" content-hash = "d0667aad4eb6e8cea128fd46e345418c8656268d40702404b37d4f9bc49ed4e0" pybalboa-1.1.3/pybalboa/000077500000000000000000000000001475243520700151075ustar00rootroot00000000000000pybalboa-1.1.3/pybalboa/__init__.py000066400000000000000000000003661475243520700172250ustar00rootroot00000000000000"""Balboa spa module.""" __version__ = "0.0.0" from .client import SpaClient from .control import EVENT_UPDATE, SpaControl from .exceptions import SpaConnectionError __all__ = ["SpaClient", "SpaControl", "SpaConnectionError", "EVENT_UPDATE"] pybalboa-1.1.3/pybalboa/__main__.py000066400000000000000000000174611475243520700172120ustar00rootroot00000000000000"""Main entry.""" import argparse import asyncio import logging import sys from enum import IntEnum from typing import Union try: from . import SpaClient, SpaConnectionError, SpaControl, __version__ from .enums import SpaState except ImportError: from pybalboa import SpaClient, SpaConnectionError, SpaControl, __version__ from pybalboa.enums import SpaState async def run_discovery(first_spa: bool = True) -> None: """Attempt to discover a spa and try some commands.""" spas = await SpaClient.discover(first_spa) for spa in spas: await connect_and_listen(spa=spa) async def connect_and_listen( host: Union[str, None] = None, spa: Union[SpaClient, None] = None ) -> None: """Connect to the spa and try some commands.""" print("******** Testing spa connection and configuration **********") try: if host: spa = SpaClient(host) if not spa: print("No spa provided") return async with spa: if not await spa.async_configuration_loaded(): if spa.state == SpaState.TEST_MODE: print("Config not loaded, spa is in test mode!") else: print("Config not loaded, something is wrong!") return print() print("Module identification") print("---------------------") print(f"MAC address: {spa.mac_address}") print(f"iDigi Device Id: {spa.idigi_device_id}") print() print("Device configuration") print("--------------------") print(spa.circulation_pump) print(f"Pumps: {[pump.name for pump in spa.pumps]}") print(f"Lights: {[light.name for light in spa.lights]}") print(f"Aux: {[aux.name for aux in spa.aux]}") print(f"Blower: {[blower.name for blower in spa.blowers]}") print(f"Mister: {[mister.name for mister in spa.misters]}") print() print("System information") print("------------------") print(f"Model: {spa.model}") print(f"Software version: {spa.software_version}") print(f"Configuration signature: {spa.configuration_signature}") print(f"Current setup: {spa.current_setup}") print(f"Voltage: {spa.voltage}") print(f"Heater type: {spa.heater_type}") print(f"DIP switch: {spa.dip_switch}") print() print("Setup parameters") print("----------------") print(f"Min temps: {spa._low_range}") # pylint: disable=protected-access print(f"Max temps: {spa._high_range}") # pylint: disable=protected-access print(f"Pump count: {spa.pump_count}") print() print("Filter cycle") print("------------") print(f"Filter cycle 1 start: {spa.filter_cycle_1_start}") print(f"Filter cycle 1 duration: {spa.filter_cycle_1_duration}") print( f"Filter cycle 2 start: {spa.filter_cycle_2_start} ({'en' if spa.filter_cycle_2_enabled else 'dis'}abled)" ) print(f"Filter cycle 2 duration: {spa.filter_cycle_2_duration}") print() print("Status update") print("-------------") print(f"Temperature unit: {spa.temperature_unit.name}") print(f"Temperature: {spa.temperature}") print(f"Target temperature: {spa.target_temperature}") print(f"Temperature range: {spa.temperature_range.state.name}") print(f"Heat mode: {spa.heat_mode.state.name}") print(f"Heat state: {spa.heat_state.name}") print(f"Pump status: {spa.pumps}") print(spa.circulation_pump) print(f"Light status: {spa.lights}") print(f"Mister status: {spa.misters}") print(f"Aux status: {spa.aux}") print(f"Blower status: {spa.blowers}") print( f"Spa time: {spa.time_hour:02d}:{spa.time_minute:02d} {'24hr' if spa.is_24_hour else '12hr'}" ) print(f"Filter cycle 1 running: {spa.filter_cycle_1_running}") print(f"Filter cycle 2 running: {spa.filter_cycle_2_running}") print() await test_controls(spa) except SpaConnectionError: print(f"Failed to connect to spa at {host}") else: print() print( "If something is not working as expected, please create an issue and add the above output at:" ) print("https://github.com/garbled1/pybalboa/issues/") print() async def test_controls(spa: SpaClient) -> None: """Test spa controls.""" print("******** Testing spa controls **********") print() print("Temperature control") print("-------------------") assert spa.target_temperature is not None assert spa.temperature_maximum is not None assert spa.temperature_minimum is not None target_temperature = spa.target_temperature await adjust_temperature( spa, spa.temperature_maximum if spa.target_temperature != spa.temperature_maximum else spa.temperature_minimum, ) await adjust_temperature(spa, target_temperature) print() for control in spa.controls: print(f"{control.name} control") print("-" * (len(control.name) + 8)) state = control.state for option in control.options: if option not in (state, control.state): await adjust_control(control, option) if control.state != state: await adjust_control(control, state) print() async def adjust_temperature(spa: SpaClient, temperature: float) -> None: """Adjust target temperature settings.""" print(f"Current target temperature: {spa.target_temperature}") print(f" Set to {temperature}") await spa.set_temperature(temperature) async def _temperature_check() -> None: while spa.target_temperature != temperature: await asyncio.sleep(0.1) wait = 10 try: await asyncio.wait_for(_temperature_check(), wait) print(f" Set temperature is now {spa.target_temperature}") except asyncio.TimeoutError: print( f" Set temperature was not changed after {wait} seconds; is {spa.target_temperature}" ) async def adjust_control(control: SpaControl, state: IntEnum) -> None: """Adjust control state.""" print(f"Current state: {control.state.name}") print(f" Set to {state.name}") if not await control.set_state(state): return async def _state_check() -> None: while control.state != state: await asyncio.sleep(0.1) wait = 10 try: await asyncio.wait_for(_state_check(), wait) print(f" State is now {control.state.name}") except asyncio.TimeoutError: print(f" State was not changed after {wait} seconds; is {control.state.name}") if __name__ == "__main__": parser = argparse.ArgumentParser( description="Connect to a spa and listen for updates.", usage=f"{sys.argv[0]} [host] [-d | --debug] [--all]", ) parser.add_argument("host", nargs="?", help="Spa IP address or hostname (optional)") parser.add_argument( "-d", "--debug", action="store_true", help="Enable debug logging" ) parser.add_argument( "--all", action="store_true", help="Discover all available spas instead of just the first one.", ) args = parser.parse_args() if args.debug: logging.basicConfig(level=logging.DEBUG) print(f"pybalboa version: {__version__}") if args.host: asyncio.run(connect_and_listen(args.host)) else: print("No host provided. Running in discovery mode...") asyncio.run(run_discovery(first_spa=not args.all)) sys.exit(0) pybalboa-1.1.3/pybalboa/client.py000066400000000000000000001157411475243520700167500ustar00rootroot00000000000000"""Balboa spa client.""" from __future__ import annotations import asyncio import logging from datetime import datetime, time, timedelta from random import uniform from typing import Any, Callable, TypeVar, cast from .control import EVENT_UPDATE, EventMixin, FaultLog, HeatModeSpaControl, SpaControl from .discovery import async_discover from .enums import ( AccessibilityType, ControlType, HeatState, LowHighRange, MessageType, SettingsCode, SpaState, TemperatureUnit, ToggleItemCode, WiFiState, ) from .exceptions import ( SpaConfigurationNotLoadedError, SpaConnectionError, SpaMessageError, ) from .utils import ( byte_parser, calculate_checksum, calculate_time, calculate_time_difference, cancel_task, default, read_one_message, to_celsius, utcnow, ) _LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") DEFAULT_PORT = 4257 MESSAGE_DELIMETER_BYTE = b"~" MESSAGE_DELIMETER = MESSAGE_DELIMETER_BYTE[0] MESSAGE_SEND = [0x0A, 0xBF] ACCESSIBILITY_TYPE_MAP = { 16: AccessibilityType.PUMP_LIGHT, 32: AccessibilityType.NONE, 48: AccessibilityType.NONE, } class SpaClient(EventMixin): """Spa client.""" def __init__( self, host: str, port: int = DEFAULT_PORT, *, mac_address: str | None = None ) -> None: """Initialize a spa client.""" self._host = host self._port = port self._device_configuration_loaded = False self._filter_cycle_loaded = False self._module_identification_loaded = False self._setup_parameters_loaded = False self._system_information_loaded = False self._configuration_loaded: asyncio.Event = asyncio.Event() self._last_log_mesage: bytes | None = None self._previous_status: bytes | None = None self._last_message_received: datetime | None = None self._last_message_sent: datetime | None = None self._disconnect = False self._reader: asyncio.StreamReader | None = None self._writer: asyncio.StreamWriter | None = None self._connection_monitor: asyncio.Task | None = None self._listener: asyncio.Task | None = None self._controls: list[SpaControl] = [ HeatModeSpaControl(self), SpaControl(self, ControlType.TEMPERATURE_RANGE, list(LowHighRange)), ] # module identification self._idigi_device_id: str | None = None self._mac_address: str | None = mac_address # system information self._dip_switch: str | None = None self._configuration_signature: str | None = None self._current_setup: int | None = None self._heater_type: str | None = None self._model: str | None = None self._software_version: str | None = None self._voltage: int | None | None = None # setup parameters self._low_range: tuple[tuple[int, int], tuple[float, float]] = ( (50, 99), (10.0, 37.0), ) self._high_range: tuple[tuple[int, int], tuple[float, float]] = ( (80, 104), (26.5, 40.0), ) self._pump_count: int = 0 # filter cycle self._filter_cycle_1_start: time | None = None self._filter_cycle_1_duration: timedelta = timedelta() self._filter_cycle_1_end: time | None = None self._filter_cycle_2_enabled: bool = False self._filter_cycle_2_start: time | None = None self._filter_cycle_2_duration: timedelta = timedelta() self._filter_cycle_2_end: time | None = None # status update self._accessibility_type: AccessibilityType = AccessibilityType.NONE self._filter_cycle_1_running: bool = False self._filter_cycle_2_running: bool = False self._heat_state: HeatState = HeatState.OFF self._is_24_hour: bool = False self._state: SpaState = SpaState.UNKNOWN self._time_hour: int = 0 self._time_minute: int = 0 self._time_offset: timedelta = timedelta(0) self._temperature_unit: TemperatureUnit = TemperatureUnit.FAHRENHEIT self._temperature: float | None = None self._target_temperature: float | None = None self._temperature_range: int = 0 self._wifi_state: WiFiState | None = None # fault log self._fault: FaultLog | None = None def _require_configured(self, value: _T | None) -> _T: """Ensure the given value is set before returning it, otherwise raise an error.""" if value is None: raise SpaConfigurationNotLoadedError return value @property def host(self) -> str: """Return the host address.""" return self._host @property def available(self) -> bool: """Return True if the client is connected and available.""" if self.connected and self.last_message_received is not None: return self.last_message_received >= utcnow() - timedelta(seconds=15) return False @property def connected(self) -> bool: """Return `True` if the client is connected.""" if self._writer is None: return False return self._writer.transport.is_reading() # type: ignore @property def last_message_received(self) -> datetime | None: """Return the last message received datetime.""" return self._last_message_received @property def configuration_signature(self) -> str | None: """Return the configuration signature.""" return self._configuration_signature @property def controls(self) -> list[SpaControl]: """Return the controls available.""" return self._controls @property def current_setup(self) -> int | None: """Return the current setup.""" return self._current_setup @property def dip_switch(self) -> str | None: """Return the dip switch settings.""" return self._dip_switch @property def fault(self) -> FaultLog | None: """Return the last received fault.""" return self._fault @property def filter_cycle_1_start(self) -> time | None: """Return filter cycle 1 start time.""" return self._filter_cycle_1_start @property def filter_cycle_1_duration(self) -> timedelta: """Return filter cycle 1 duration.""" return self._filter_cycle_1_duration @property def filter_cycle_1_end(self) -> time | None: """Return filter cycle 1 end time.""" return self._filter_cycle_1_end @property def filter_cycle_1_running(self) -> bool: """Return `True` if filter cycle 1 is running.""" return self._filter_cycle_1_running @property def filter_cycle_2_enabled(self) -> bool: """Return `True` if filter cycle 2 is enabled.""" return self._filter_cycle_2_enabled @property def filter_cycle_2_start(self) -> time | None: """Return filter cycle 2 start time.""" return self._filter_cycle_2_start @property def filter_cycle_2_duration(self) -> timedelta: """Return filter cycle 2 duration.""" return self._filter_cycle_2_duration @property def filter_cycle_2_end(self) -> time | None: """Return filter cycle 2 end time.""" return self._filter_cycle_2_end @property def filter_cycle_2_running(self) -> bool: """Return `True` if filter cycle 2 is running.""" return self._filter_cycle_2_running @property def heat_state(self) -> HeatState: """Return the heat state.""" return self._heat_state @property def heater_type(self) -> str | None: """Return the heater type.""" return self._heater_type @property def idigi_device_id(self) -> str | None: """Return the iDigi Device Id.""" return self._idigi_device_id @property def mac_address(self) -> str: """Return the MAC address.""" return self._require_configured(self._mac_address) @property def model(self) -> str: """Return the model.""" return self._require_configured(self._model) @property def pump_count(self) -> int: """Return the number of pumps.""" return self._pump_count @property def software_version(self) -> str | None: """Return the software version.""" return self._software_version @property def state(self) -> SpaState: """Return the spa state.""" return self._state @property def temperature_unit(self) -> TemperatureUnit: """Return the temperatre unit.""" return self._temperature_unit @property def temperature(self) -> float | None: """Return the temperature.""" return self._temperature @property def target_temperature(self) -> float: """Return the target temperature.""" return self._require_configured(self._target_temperature) @property def temperature_minimum(self) -> float: """Return the temperature minimum.""" valid_temps = (self._low_range, self._high_range)[self._temperature_range] return valid_temps[self._temperature_unit][0] @property def temperature_maximum(self) -> float: """Return the temperature maximum.""" valid_temps = (self._low_range, self._high_range)[self._temperature_range] return valid_temps[self._temperature_unit][1] @property def time_hour(self) -> int: """Return the hour.""" return self._time_hour @property def time_minute(self) -> int: """Return the minute.""" return self._time_minute @property def is_24_hour(self) -> bool: """Return `True` if 24-hour time..""" return self._is_24_hour @property def voltage(self) -> int | None: """Return the voltage.""" return self._voltage @property def aux(self) -> list[SpaControl]: """Return the aux controls.""" return self.get_controls(ControlType.AUX) @property def blowers(self) -> list[SpaControl]: """Return the blower controls.""" return self.get_controls(ControlType.BLOWER) @property def circulation_pump(self) -> SpaControl | None: """Return the circulation pump control.""" return next(iter(self.get_controls(ControlType.CIRCULATION_PUMP)), None) @property def heat_mode(self) -> SpaControl: """Return the heat mode control.""" return self.get_controls(ControlType.HEAT_MODE)[0] @property def lights(self) -> list[SpaControl]: """Return the light controls.""" return self.get_controls(ControlType.LIGHT) @property def misters(self) -> list[SpaControl]: """Return the mister controls.""" return self.get_controls(ControlType.MISTER) @property def pumps(self) -> list[SpaControl]: """Return the pump controls.""" return self.get_controls(ControlType.PUMP) @property def temperature_range(self) -> SpaControl: """Return the temperature range controls.""" return self.get_controls(ControlType.TEMPERATURE_RANGE)[0] def get_controls(self, control_type: ControlType) -> list[SpaControl]: """Get controls based on control type.""" return [ control for control in self.controls if control.control_type == control_type ] @property def configuration_loaded(self) -> bool: """Return `True` if the configuration is loaded.""" return self._configuration_loaded.is_set() def get_current_time(self) -> datetime: """Return the current time.""" return datetime.now() + self._time_offset async def async_configuration_loaded(self, timeout: float = 15) -> bool: """Wait for configuration to complete.""" if self.configuration_loaded: return True try: return await asyncio.wait_for(self._configuration_loaded.wait(), timeout) except asyncio.TimeoutError: return False def _check_configuration_loaded(self) -> None: """Return `True` if the spa is fully configured.""" if all( ( self._device_configuration_loaded, self._filter_cycle_loaded, self._module_identification_loaded, self._setup_parameters_loaded, self._system_information_loaded, self._previous_status, ) ): assert self._previous_status self._parse_status_update(self._previous_status, True) self._configuration_loaded.set() async def connect(self) -> bool: """Connect to the spa.""" self._disconnect = False return await self._connect() async def _connect(self) -> bool: """Connect to the spa.""" if self.connected: _LOGGER.debug("%s -- already connected", self._host) return True if self._disconnect: _LOGGER.debug( "%s -- connect skipped due to previous disconnect request", self._host ) return False _LOGGER.debug("%s -- establishing connection", self._host) try: self._reader, self._writer = await asyncio.wait_for( asyncio.open_connection(self._host, self._port), 10 ) except ( asyncio.TimeoutError, ConnectionRefusedError, TimeoutError, OSError, ) as err: msg = "Timed out" if isinstance(err, asyncio.TimeoutError) else err _LOGGER.error("%s ## cannot connect: %s", self._host, msg) except Exception as ex: # pylint: disable=broad-except _LOGGER.error("%s ## error connecting: %s", self._host, ex) else: _LOGGER.debug("%s -- connected", self._host) self._listener = asyncio.ensure_future(self._start_listener()) asyncio.ensure_future(self.request_all_configuration(True)) await cancel_task(self._connection_monitor) async def _monitor() -> None: attempt = 0 while not self._disconnect: while self.connected: await asyncio.sleep(1) if not await self._connect(): await asyncio.sleep(min(1 * 2**attempt + uniform(0, 1), 60)) attempt += 1 self._connection_monitor = asyncio.ensure_future(_monitor()) return self.connected async def disconnect(self) -> None: """Disconnect from the spa.""" _LOGGER.debug("%s -- disconnect requested", self._host) self._disconnect = True await cancel_task(self._connection_monitor) if self._writer is not None: self._writer.close() try: await self._writer.wait_closed() except Exception: # pylint: disable=broad-except pass await cancel_task(self._listener) self._reader = self._writer = None _LOGGER.debug("%s -- disconnected", self._host) async def _start_listener(self) -> None: """Start the listener.""" timeout = 15 wait_time = timedelta(seconds=timeout) assert self._reader while self.connected: try: data = await read_one_message(self._reader, timeout) except SpaMessageError as err: _LOGGER.debug("%s ## %s", self._host, err) continue except (asyncio.TimeoutError, asyncio.IncompleteReadError): if not (sent := self._last_message_sent) or sent + wait_time < utcnow(): self.emit(EVENT_UPDATE) await self.send_device_present() continue except Exception as ex: # pylint: disable=broad-except _LOGGER.error("%s ## %s", self._host, ex) continue self._process_message(data) self.emit(EVENT_UPDATE) _LOGGER.debug("%s -- stopped listening", self._host) def _process_message(self, data: bytes) -> None: """Process a message.""" self._last_message_received = utcnow() message_type = self._log_message(data) data = data[4:-1] if message_type == MessageType.STATUS_UPDATE: self._parse_status_update(data) elif message_type == MessageType.MODULE_IDENTIFICATION: self._parse_module_identification(data) elif message_type == MessageType.FILTER_CYCLE: self._parse_filter_cycle(data) elif message_type == MessageType.FAULT_LOG: self._parse_fault_log(data) elif message_type == MessageType.DEVICE_CONFIGURATION: self._parse_device_configuration(data) elif message_type == MessageType.SETUP_PARAMETERS: self._parse_setup_parameters(data) elif message_type == MessageType.SYSTEM_INFORMATION: self._parse_system_information(data) def _parse_device_configuration(self, data: bytes) -> None: """Parse a device configuration message. Device configuration messages have a length of 6 bytes with the following information: Byte | Data --------------------------- 00 | P4P3P2P1 - Pumps 1-4 01 | P6P7P8P5 - Pumps 5-8 02 | L4L3L2L1 - Lights 1-4 03 | CxxxB2B1 - circulation pump, blowers 1-2 04 | xMMMAAAA - mister 1-3, aux 1-4 05 | ? """ if not self._device_configuration_loaded: def _add_controls(control_type: ControlType, on_states: list[int]) -> None: self._controls.extend( SpaControl( self, control_type, state + 1, index if len(on_states) > 1 else None, ) for index, state in enumerate(on_states) if state > 0 ) pumps = [ *byte_parser(data[0], count=4, bits=2), # pumps 1-4 data[1] & 0x03, # pump 5 data[1] >> 6 & 0x03, # pump 6 data[1] >> 4 & 0x03, # pump 7 data[1] >> 2 & 0x03, # pump 8 ] lights = byte_parser(data[2], count=4, bits=2) circulation_pump = data[3] >> 7 # only one blowers = byte_parser(data[3], count=2, bits=2) auxs = byte_parser(data[4], count=4) misters = byte_parser(data[4], offset=4, count=3) _add_controls(ControlType.PUMP, pumps) _add_controls(ControlType.LIGHT, lights) _add_controls(ControlType.CIRCULATION_PUMP, [circulation_pump]) _add_controls(ControlType.BLOWER, blowers) _add_controls(ControlType.AUX, auxs) _add_controls(ControlType.MISTER, misters) self._device_configuration_loaded = True self._check_configuration_loaded() def _parse_fault_log(self, data: bytes) -> None: """Parse a fault log message. Fault log messages have a length of 10 bytes with the following information: Byte | Data --------------------------- 00 | fault count 01 | entry number 02 | message code 03 | days ago 04 | time hour 05 | time minute 06 | flags 07 | target temperature 08 | sensor A temperature 09 | sensor B temperature """ self._fault = FaultLog(*(*data, self.get_current_time())) def _parse_filter_cycle(self, data: bytes) -> None: """Parse a filter cycle message. Filter cycle messages have a length of 8 bytes with the following information: Byte | Data --------------------------- 00 | filter cycle 1 start hour 01 | filter cycle 1 start minute 02 | filter cycle 1 duration hours 03 | filter cycle 1 duration minutes 04 | filter cycle 2 enabled and start hour 05 | filter cycle 2 start minute 06 | filter cycle 2 duration hours 07 | filter cycle 2 duration minutes """ self._filter_cycle_1_start = time(data[0], data[1]) self._filter_cycle_1_duration = timedelta(hours=data[2], minutes=data[3]) self._filter_cycle_1_end = calculate_time( self._filter_cycle_1_start, self._filter_cycle_1_duration ) self._filter_cycle_2_enabled = bool(data[4] >> 7) self._filter_cycle_2_start = time(data[4] & 0x7F, data[5]) self._filter_cycle_2_duration = timedelta(hours=data[6], minutes=data[7]) self._filter_cycle_2_end = calculate_time( self._filter_cycle_2_start, self._filter_cycle_2_duration ) self._filter_cycle_loaded = True self._check_configuration_loaded() def _parse_module_identification(self, data: bytes) -> None: """Parse a module identification message. Module identification messages have a length of 25 bytes with the following information: Byte | Data --------------------------- 00-02 | ? ? ? 03-08 | mac address 09-24 | iDigi device id (used to communicate with Balboa cloud API) """ self._mac_address = ":".join(f"{x:02x}" for x in data[3:9]) idigi_device_id = "-".join(data[i : i + 4].hex() for i in range(9, 25, 4)) self._idigi_device_id = idigi_device_id.upper() self._module_identification_loaded = True self._check_configuration_loaded() def _parse_setup_parameters(self, data: bytes) -> None: """Parse a setup parameters message. Setup parameters messages have a length of 9 bytes with the following information: Byte | Data --------------------------- 00-01 | ? ? 02 | low range minimum temperature in °F 03 | low range maximum temperature in °F 04 | high range minimum temperature in °F 05 | high range maximum temperature in °F 06 | ? 07 | pump counter (add the number of "1"s from bit) 08 | ? """ if not self._setup_parameters_loaded: low, high = data[2], data[3] self._low_range = ((low, high), (to_celsius(low), to_celsius(high))) low, high = data[4], data[5] self._high_range = ((low, high), (to_celsius(low), to_celsius(high))) self._pump_count = sum(byte_parser(data[7])) self._setup_parameters_loaded = True self._check_configuration_loaded() def _parse_status_update(self, data: bytes, reprocess: bool = False) -> None: """Parse a status update message. Status update messages have a length of 24 bytes with the following information: Byte | Data --------------------------- 00 | spa state ? 01 | initialization mode ? 02 | current temperature 03 | current hour 04 | current minute 05 | heat mode 06-08 | ? ? ? 09 | temperature scale, time format, filter mode, accessibility type 10 | temperature range, heating 11 | pumps 1-4 12 | pumps 5-8 13 | circulation pump, blower state 14 | lights 1-4 15 | mister 1-3, aux 1-4 16-19 | ? ? ? ? 20 | target temperature 21 | ? 22 | wifi 23 | ? """ if data == self._previous_status and not reprocess: # No new information, so ignore it return self._previous_status = data self._state = SpaState(data[0]) self._time_hour = data[3] self._time_minute = data[4] if not reprocess: now = datetime.now() device_time = now.replace(hour=self._time_hour, minute=self._time_minute) self._time_offset = device_time - now self._is_24_hour = (flag := data[9]) & 0x02 != 0 if flag & 0x01 == 0: self._temperature_unit = TemperatureUnit.FAHRENHEIT divisor = 1 else: self._temperature_unit = TemperatureUnit.CELSIUS divisor = 2 temperature = None if (temperature := data[2]) == 255 else temperature / divisor self._temperature = temperature self._target_temperature = data[20] / divisor self._filter_cycle_1_running = flag & 0x04 != 0 self._filter_cycle_2_running = flag & 0x08 != 0 self._accessibility_type = ACCESSIBILITY_TYPE_MAP.get( flag & 0x48, AccessibilityType.ALL ) self._temperature_range = ((flag := data[10]) >> 2) & 0x01 self._update_control_states( ControlType.TEMPERATURE_RANGE, [self._temperature_range] ) self._heat_state = HeatState(flag >> 4 & 0x03) light_states = byte_parser(data[14], count=4, bits=2, fn=lambda _: _ >> 1) self._update_control_states(ControlType.LIGHT, light_states) heat_mode = data[5] & 0x03 self._update_control_states(ControlType.HEAT_MODE, [heat_mode]) pump_states = byte_parser(data[11], count=4, bits=2) pump_states.extend(byte_parser(data[12], count=4, bits=2)) self._update_control_states(ControlType.PUMP, pump_states) circulation_pump = (data[13] & 0x03) >> 1 self._update_control_states(ControlType.CIRCULATION_PUMP, [circulation_pump]) blower_states = byte_parser(data[13], 1, 2, 2) self._update_control_states(ControlType.BLOWER, blower_states) mister_states = byte_parser(data[15], count=3) self._update_control_states(ControlType.MISTER, mister_states) aux_states = byte_parser(data[15], offset=3, count=4) self._update_control_states(ControlType.AUX, aux_states) self._wifi_state = WiFiState(int((data[22] & 0xF0) / 16)) if not self.configuration_loaded and not reprocess: self._check_configuration_loaded() self.emit(EVENT_UPDATE) def _update_control_states( self, control_type: ControlType, states: list[int] ) -> None: """Update the control states.""" for index, state in enumerate(states): if control := next( ( control for control in self.controls if control.control_type == control_type and (control.index == index or control.index is None) ), None, ): control.update(state) def _parse_system_information(self, data: bytes) -> None: """Parse a system information message. System information messages have a length of 21 bytes with the following information: Byte | Data --------------------------- 00-03 | software id (ssid) and version 04-11 | model name 12 | current setup 13-16 | configuration signature 17 | voltage 18 | heater type 19-20 | dip switch """ self._software_version = f"M{data[0]}_{data[1]} V{data[2]}.{data[3]}" self._model = "".join(map(chr, data[4:12])).strip() self._current_setup = data[12] self._configuration_signature = data[13:17].hex() self._voltage = 240 if data[17] == 0x01 else None self._heater_type = "standard" if data[18] == 0x0A else "unknown" self._dip_switch = f"{data[19]:08b}{data[20]:08b}" self._system_information_loaded = True self._check_configuration_loaded() def _log_message(self, data: bytes) -> MessageType: """Log message and return message type.""" message_type = MessageType(data[3]) if self._last_log_mesage != data: self._last_log_mesage = data _LOGGER.debug("%s -> %s: %s", self._host, message_type.name, data.hex()) return message_type async def request_all_configuration(self, wait: bool = False) -> None: """Request the full spa configuration.""" if not self._module_identification_loaded or not wait: await self.request_module_identification() if not self._system_information_loaded or not wait: await self.request_system_information() if not self._setup_parameters_loaded or not wait: await self.request_setup_parameters() if not self._device_configuration_loaded or not wait: await self.request_device_configuration() if not self._filter_cycle_loaded or not wait: await self.request_filter_cycle() if wait and not await self.async_configuration_loaded(3): if self.connected: await self.request_all_configuration(wait) async def request_device_configuration(self) -> None: """Request the device configuration.""" await self.send_message( MessageType.REQUEST, SettingsCode.DEVICE_CONFIGURATION, 0x00, 0x01 ) async def request_fault_log(self, entry: int = 0xFF) -> None: """Request a fault log entry. entry: The fault log to retrieve, 0..23 or 0xFF (255) for the last fault """ if not 0 <= entry < 24 and entry != 0xFF: raise ValueError( f"Invalid fault log entry: {entry} (expected 0–23 or 0xFF for the last fault)" ) await self.send_message( MessageType.REQUEST, SettingsCode.FAULT_LOG, entry % 256, 0x00 ) async def request_filter_cycle(self) -> None: """Request the filter cycle.""" await self.send_message( MessageType.REQUEST, SettingsCode.FILTER_CYCLE, 0x00, 0x00 ) async def request_module_identification(self) -> None: """Request the module identification.""" await self.send_device_present() async def request_setup_parameters(self) -> None: """Request the system information.""" await self.send_message( MessageType.REQUEST, SettingsCode.SETUP_PARAMETERS, 0x00, 0x00 ) async def request_system_information(self) -> None: """Request the system information.""" await self.send_message( MessageType.REQUEST, SettingsCode.SYSTEM_INFORMATION, 0x00, 0x00 ) async def send_device_present(self) -> None: """Send a device present message.""" await self.send_message(MessageType.DEVICE_PRESENT) async def send_message( self, message_type: MessageType | None, *message: int ) -> None: """Send a message to the spa with variable length.""" if not self.connected: return if not message_type: message_type = MessageType.UNKNOWN prefix = [*MESSAGE_SEND, message_type.value] if message_type else [] message_data = [*prefix, *message] message_length = len(message_data) + 2 data = bytearray(message_length + 2) data[0] = MESSAGE_DELIMETER data[1] = message_length data[2:message_length] = message_data data[-2] = calculate_checksum(data[1:message_length]) data[-1] = MESSAGE_DELIMETER _LOGGER.debug( "%s <- %s%s: %s", self._host, message_type.name, f"_{SettingsCode(data[5]).name}" if message_type == MessageType.REQUEST else "", data[1:-1].hex(), ) try: assert self._writer self._writer.write(data) await self._writer.drain() self._last_message_sent = utcnow() except Exception as ex: # pylint: disable=broad-except _LOGGER.error("%s ## error sending message: %s", self._host, ex) async def __aenter__(self) -> SpaClient: """Connect and start listening for messages.""" if not await self._connect(): raise SpaConnectionError() return self async def __aexit__(self, *exctype: Any) -> None: """Disconnect.""" await self.disconnect() async def configure_filter_cycle( self, filter_cycle: int, *, start: time | None = None, end: time | None = None, duration: timedelta | None = None, enabled: bool | None = None, ) -> None: """Configure a filter cycle.""" if filter_cycle not in (1, 2): raise ValueError(f"Invalid filter cycle: {filter_cycle} (expected 1 or 2)") if all(value is None for value in (start, end, duration, enabled)): raise ValueError( "At least one of start, end, duration, or enabled must be provided" ) if duration is not None and end is not None: raise ValueError("Only one of end or duration should be provided") if filter_cycle == 1: if all(value is None for value in (start, end, duration)): raise ValueError( "Filter cycle 1 requires at least one of start, end, or duration" ) if enabled is False: raise ValueError("Filter cycle 1 cannot be disabled") if duration is not None and not timedelta(minutes=15) <= duration <= timedelta( hours=24 ): raise ValueError( f"Invalid duration: {duration} (must be between 15 minutes and 24 hours)" ) start = start or getattr(self, f"filter_cycle_{filter_cycle}_start") if start is None: raise ValueError("A valid start time could not be determined") if end is None: if duration is None: end = getattr(self, f"filter_cycle_{filter_cycle}_end") else: end = calculate_time(start, duration) if end is None: raise ValueError("A valid end time/duration could not be determined") # 1440 = 24 hours minutes = 1440 if start == end else calculate_time_difference(start, end) if not 15 <= minutes <= 1440: # one more sanity check raise ValueError( f"Invalid duration: {timedelta(minutes=minutes)} (must be between 15 minutes and 24 hours)" ) duration_hours, duration_minutes = divmod(minutes, 60) params: dict[str, Any] = { f"filter_cycle_{filter_cycle}_hour": start.hour, f"filter_cycle_{filter_cycle}_minute": start.minute, f"filter_cycle_{filter_cycle}_duration_hours": duration_hours, f"filter_cycle_{filter_cycle}_duration_minutes": duration_minutes, } if filter_cycle == 2 and enabled is not None: params["filter_cycle_2_enabled"] = enabled await self.set_filter_cycle(**params) async def set_filter_cycle( self, filter_cycle_1_hour: int | None = None, filter_cycle_1_minute: int | None = None, filter_cycle_1_duration_hours: int | None = None, filter_cycle_1_duration_minutes: int | None = None, filter_cycle_2_enabled: bool | None = None, filter_cycle_2_hour: int | None = None, filter_cycle_2_minute: int | None = None, filter_cycle_2_duration_hours: int | None = None, filter_cycle_2_duration_minutes: int | None = None, ) -> None: """Set the filter cycle.""" values = ( filter_cycle_1_hour, filter_cycle_1_minute, filter_cycle_1_duration_hours, filter_cycle_1_duration_minutes, filter_cycle_2_enabled, filter_cycle_2_hour, filter_cycle_2_minute, filter_cycle_2_duration_hours, filter_cycle_2_duration_minutes, ) if all(value is None for value in values): return old_cycle_1_duration = divmod(self.filter_cycle_1_duration.seconds // 60, 60) old_cycle_2_duration = divmod(self.filter_cycle_2_duration.seconds // 60, 60) enabled = default(filter_cycle_2_enabled, self.filter_cycle_2_enabled) << 7 def _time_attr(_prop: str, _attr: str) -> int | Callable[[], int]: def getter() -> int: if (_time := getattr(self, _prop, None)) is None: raise ValueError( f"Unable to get {_attr} from {_prop} because it is {_time}" ) return cast(int, getattr(_time, _attr)) return getter # fmt: off message = [ default(filter_cycle_1_hour, _time_attr("filter_cycle_1_start", "hour")), default(filter_cycle_1_minute, _time_attr("filter_cycle_1_start", "minute")), default(filter_cycle_1_duration_hours, old_cycle_1_duration[0]), default(filter_cycle_1_duration_minutes, old_cycle_1_duration[1]), enabled | default(filter_cycle_2_hour, _time_attr("filter_cycle_2_start", "hour")), default(filter_cycle_2_minute, _time_attr("filter_cycle_2_start", "minute")), default(filter_cycle_2_duration_hours, old_cycle_2_duration[0]), default(filter_cycle_2_duration_minutes, old_cycle_2_duration[1]), ] # fmt: on await self.send_message(MessageType.FILTER_CYCLE, *message) await self.request_filter_cycle() async def set_temperature(self, temperature: float) -> None: """Set the target temperature.""" valid_temps = (self._low_range, self._high_range)[self._temperature_range] low, high = valid_temps[self._temperature_unit] if not low <= temperature <= high: raise ValueError( f"Invalid temperature: {temperature} (expected {low}..{high})" ) if self._temperature_unit == TemperatureUnit.CELSIUS: temperature *= 2 await self.send_message(MessageType.SET_TEMPERATURE, int(temperature)) async def set_temperature_range(self, temperature_range: LowHighRange) -> None: """Set the temperature range.""" if self._temperature_range == temperature_range: return await self.send_message( MessageType.TOGGLE_STATE, ToggleItemCode.TEMPERATURE_RANGE ) async def set_temperature_unit(self, unit: TemperatureUnit) -> None: """Set the temperature unit.""" await self.send_message(MessageType.SET_TEMPERATURE_UNIT, 0x01, unit.value) async def set_time( self, hour: int, minute: int, is_24_hour: bool | None = None ) -> None: """Set the time.""" try: time(hour, minute) except ValueError as err: raise ValueError(f"Invalid time format: {hour}:{minute}") from err if is_24_hour is None: is_24_hour = self._is_24_hour await self.send_message(MessageType.SET_TIME, (is_24_hour << 7) | hour, minute) async def set_24_hour_time(self, is_24_hour: bool) -> None: """Set the 24-hour time.""" await self.set_time(self._time_hour, self._time_minute, is_24_hour) @classmethod async def discover( cls, return_once_found: bool = False, *, timeout: int = 10 ) -> list[SpaClient]: """Discover spas on the network within a specified timeout. If return_once_found is True, the first spa found will stop the scan. """ spas = await async_discover(return_once_found, timeout=timeout) return [cls(spa.address, mac_address=spa.mac_address) for spa in spas] pybalboa-1.1.3/pybalboa/control.py000066400000000000000000000170211475243520700171420ustar00rootroot00000000000000"""Balboa spa control.""" from __future__ import annotations import logging from collections.abc import Callable from dataclasses import InitVar, dataclass, field from datetime import datetime, time, timedelta from enum import IntEnum from typing import TYPE_CHECKING, Any, Final from .enums import ( ControlType, HeatMode, MessageType, OffLowHighState, OffLowMediumHighState, OffOnState, ToggleItemCode, UnknownState, ) if TYPE_CHECKING: from .client import SpaClient _LOGGER = logging.getLogger(__name__) CONTROL_TYPE_MAP = { ControlType.PUMP: ToggleItemCode.PUMP_1, ControlType.BLOWER: ToggleItemCode.BLOWER, ControlType.MISTER: ToggleItemCode.MISTER, ControlType.LIGHT: ToggleItemCode.LIGHT_1, ControlType.AUX: ToggleItemCode.AUX_1, ControlType.CIRCULATION_PUMP: ToggleItemCode.CIRCULATION_PUMP, ControlType.TEMPERATURE_RANGE: ToggleItemCode.TEMPERATURE_RANGE, ControlType.HEAT_MODE: ToggleItemCode.HEAT_MODE, } STATE_OPTIONS_MAP: dict[int, list[IntEnum]] = { 2: list(OffOnState), 3: list(OffLowHighState), 4: list(OffLowMediumHighState), } EVENT_UPDATE = "update" FAULT_LOG_ERROR_CODES: Final[dict[int, str]] = { 15: "Sensors are out of sync", 16: "The water flow is low", 17: "The water flow has failed", 18: "The settings have been reset", 19: "Priming Mode", 20: "The clock has failed", 21: "The settings have been reset", 22: "Program memory failure", 26: "Sensors are out of sync -- Call for service", 27: "The heater is dry", 28: "The heater may be dry", 29: "The water is too hot", 30: "The heater is too hot", 31: "Sensor A Fault", 32: "Sensor B Fault", 34: "A pump may be stuck on", 35: "Hot fault", 36: "The GFCI test failed", 37: "Standby Mode (Hold Mode)", } class EventMixin: """Event mixin.""" _listeners: dict[str, list[Callable]] = {} def on( # pylint: disable=invalid-name self, event_name: str, callback: Callable ) -> Callable: """Register an event callback.""" listeners: list = self._listeners.setdefault(event_name, []) listeners.append(callback) def unsubscribe() -> None: """Unsubscribe listeners.""" if callback in listeners: listeners.remove(callback) return unsubscribe def emit(self, event_name: str, *args: Any, **kwargs: dict[str, Any]) -> None: """Run all callbacks for an event.""" for listener in self._listeners.get(event_name, []): listener(*args, **kwargs) class SpaControl(EventMixin): """Spa control.""" _options: list[IntEnum] def __init__( self, client: SpaClient, control_type: ControlType, states: int | list[IntEnum] = 1, index: int | None = None, custom_options: list[IntEnum] | None = None, ) -> None: """Initialize a spa control.""" self._client = client self._control_type = control_type self._index = index self._name = f"{control_type.value}{'' if index is None else f' {index + 1}'}" self._code = CONTROL_TYPE_MAP[control_type] self._state_value = UnknownState.UNKNOWN.value self._state: IntEnum = UnknownState.UNKNOWN if isinstance(states, int): self._states = states self._options = STATE_OPTIONS_MAP[states] else: self._states = len(states) self._options = states self._custom_options = custom_options def __repr__(self) -> str: """Return repr(self).""" return f"{self.name}: {self.state.name}" @property def client(self) -> SpaClient: """Return the client.""" return self._client @property def control_type(self) -> ControlType: """Return the control type.""" return self._control_type @property def index(self) -> int | None: """Return the index.""" return self._index @property def name(self) -> str: """Return the control name.""" return self._name @property def options(self) -> list[IntEnum]: """Return the available control options.""" return self._custom_options or [*self._options] @property def state(self) -> IntEnum: """Get the control's current state.""" return self._state def update(self, state: int) -> None: """Update the control's current state.""" if self._state_value != state: self._state_value = state self._state = next( (option for option in self._options if option == state), self._options[-1] if self.control_type == ControlType.PUMP and state >= self._states else UnknownState.UNKNOWN, ) _LOGGER.debug( "%s -- %s is now %s (%s)", self._client.host, self.name, self.state.name, state, ) self.emit(EVENT_UPDATE) async def set_state(self, state: int | IntEnum) -> bool: """Set control to state.""" if state not in self.options: _LOGGER.error("Cannot set state to %s", state) return False if self._state == state: return True min_toggle = 1 if self._state != UnknownState.UNKNOWN: min_toggle = max((state - self._state) % self._states, 1) for _ in range(min_toggle): await self._client.send_message( MessageType.TOGGLE_STATE, self._code + (self._index or 0) ) return True class HeatModeSpaControl(SpaControl): """Heat mode spa control.""" def __init__(self, client: SpaClient) -> None: """Initialize a heat mode spa control.""" super().__init__( client, ControlType.HEAT_MODE, list(HeatMode), custom_options=[*HeatMode][:2], ) async def set_state(self, state: int | HeatMode) -> bool: """Set control to state.""" if state not in self.options: _LOGGER.error("Cannot set state to %s", state) return False if self._state == state: return True i = 2 if self.state == HeatMode.READY_IN_REST and state == HeatMode.READY else 1 for _ in range(i): await self._client.send_message(MessageType.TOGGLE_STATE, self._code) return True @dataclass class FaultLog: """Fault log.""" count: int entry_number: int message_code: int days_ago: int time_hour: int time_minute: int flags: int target_temperature: int sensor_a_temperature: int sensor_b_temperature: int current_time: InitVar[datetime | None] = None fault_datetime: datetime = field(init=False) def __post_init__(self, current_time: datetime | None) -> None: """Compute the fault datetime on initialization.""" self.fault_datetime = datetime.combine( (current_time or datetime.now()) - timedelta(days=self.days_ago), time(self.time_hour, self.time_minute), ) def __str__(self) -> str: """Return str(self).""" return ( f"Fault log {self.entry_number + 1}/{self.count}: {self.message} " f"occurred on {self.fault_datetime:%Y-%m-%d at %H:%M}" ) @property def message(self) -> str: """Return the message for the error code.""" return FAULT_LOG_ERROR_CODES.get( self.message_code, f"Unknown ({self.message_code})" ) pybalboa-1.1.3/pybalboa/discovery.py000066400000000000000000000067741475243520700175060ustar00rootroot00000000000000"""Balboa spa discovery.""" from __future__ import annotations import asyncio import logging from dataclasses import dataclass from socket import AF_INET, IPPROTO_UDP from typing import Any _LOGGER = logging.getLogger(__name__) BROADCAST_ADDRESS = ("255.255.255.255", 30303) BROADCAST_MESSAGE = b"Discovery" BROADCAST_INTERVAL = 3 async def async_discover( return_once_found: bool = False, *, timeout: int = 10 ) -> list[DiscoveredSpa]: """Discover spas on the network within a specified timeout.""" loop = asyncio.get_running_loop() transport, protocol = await loop.create_datagram_endpoint( lambda: SpaDiscoveryProtocol(return_once_found), # local_addr=("0.0.0.0", 0), family=AF_INET, proto=IPPROTO_UDP, # reuse_port=True, allow_broadcast=True, ) try: await asyncio.wait_for(protocol.discovery_complete.wait(), timeout=timeout) except asyncio.TimeoutError: if not protocol.spas: _LOGGER.debug("Discovery timed out") finally: transport.close() return protocol.spas @dataclass class DiscoveredSpa: """Discovered spa.""" address: str port: int mac_address: str hostname: str class SpaDiscoveryProtocol(asyncio.DatagramProtocol): """Spa discovery protocol.""" def __init__(self, return_once_found: bool = False) -> None: """Initialize a spa discovery protocol.""" self.transport: asyncio.DatagramTransport | None = None self.broadcast_handle: asyncio.TimerHandle | None = None self.spas: list[DiscoveredSpa] = [] self.discovery_complete = asyncio.Event() self.return_once_found = return_once_found def broadcast(self) -> None: """Send a broadcast message.""" if self.return_once_found and self.spas: # stop broadcasting if a spa is found self.discovery_complete.set() return if not (transport := self.transport) or transport.is_closing(): return # if the transport is closed, don't broadcast self.transport.sendto(BROADCAST_MESSAGE, BROADCAST_ADDRESS) _LOGGER.debug("UDP discovery broadcast sent") # Re-broadcast at BROADCAST_INTERVAL self.broadcast_handle = asyncio.get_running_loop().call_later( BROADCAST_INTERVAL, self.broadcast ) def connection_lost(self, exc: Exception | None) -> None: """Called when the connection is lost or closed.""" if self.broadcast_handle: self.broadcast_handle.cancel() def connection_made(self, transport: asyncio.DatagramTransport) -> None: # type: ignore[override] """Called when a connection is made.""" self.transport = transport self.broadcast() def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None: """Called when some datagram is received.""" _LOGGER.debug("Received response from %s: %s", addr[0], data) if b"BWGS" not in data.upper(): return # Unexpected response, ignore try: hostname, mac = map(str.strip, data.decode().splitlines()[:2]) if (spa := DiscoveredSpa(*addr, mac, hostname)) not in self.spas: self.spas.append(spa) if self.return_once_found: self.discovery_complete.set() except Exception as ex: _LOGGER.error(ex) def error_received(self, exc: Exception) -> None: """Called when a send or receive operation raises an OSError.""" _LOGGER.error(exc) pybalboa-1.1.3/pybalboa/enums.py000066400000000000000000000063511475243520700166150ustar00rootroot00000000000000"""Enums module.""" from __future__ import annotations import logging from enum import Enum, IntEnum _LOGGER = logging.getLogger(__name__) class MessageType(IntEnum): """Message type.""" DEVICE_PRESENT = 0x04 TOGGLE_STATE = 0x11 STATUS_UPDATE = 0x13 SET_TEMPERATURE = 0x20 SET_TIME = 0x21 REQUEST = 0x22 FILTER_CYCLE = 0x23 SYSTEM_INFORMATION = 0x24 SETUP_PARAMETERS = 0x25 PREFERENCES = 0x26 SET_TEMPERATURE_UNIT = 0x27 FAULT_LOG = 0x28 DEVICE_CONFIGURATION = 0x2E SET_WIFI = 0x92 MODULE_IDENTIFICATION = 0x94 UNKNOWN = -1 @classmethod def _missing_(cls, _: object) -> MessageType: """Return default if not found.""" return cls.UNKNOWN class SettingsCode(IntEnum): """Settings code.""" DEVICE_CONFIGURATION = 0x00 FILTER_CYCLE = 0x01 SYSTEM_INFORMATION = 0x02 SETUP_PARAMETERS = 0x04 FAULT_LOG = 0x20 UNKNOWN = -1 @classmethod def _missing_(cls, _: object) -> SettingsCode: """Return default if not found.""" return cls.UNKNOWN class ControlType(Enum): """Control type.""" AUX = "Aux" BLOWER = "Blower" CIRCULATION_PUMP = "Circulation pump" HEAT_MODE = "Heat mode" LIGHT = "Light" MISTER = "Mister" PUMP = "Pump" TEMPERATURE_RANGE = "Temperature range" class AccessibilityType(IntEnum): """Accessibility type.""" PUMP_LIGHT = 0 NONE = 1 ALL = 2 class HeatState(IntEnum): """Heat state.""" OFF = 0 HEATING = 1 HEAT_WAITING = 2 class SpaState(IntEnum): """Spa state.""" RUNNING = 0x00 INITIALIZING = 0x01 HOLD_MODE = 0x05 AB_TEMPS_ON = 0x14 TEST_MODE = 0x17 UNKNOWN = -1 @classmethod def _missing_(cls, value: object) -> SpaState: """Handle unknown values by returning UNKNOWN instead of raising an error.""" _LOGGER.warning("Received unknown value %s for %s", value, cls.__name__) return cls.UNKNOWN class TemperatureUnit(IntEnum): """Tempeature unit.""" FAHRENHEIT = 0 CELSIUS = 1 class ToggleItemCode(IntEnum): """Toggle item code.""" NORMAL_OPERATION = 0x01 CLEAR_NOTIFICATION = 0x03 PUMP_1 = 0x04 PUMP_2 = 0x05 PUMP_3 = 0x06 PUMP_4 = 0x07 PUMP_5 = 0x08 PUMP_6 = 0x09 BLOWER = 0x0C MISTER = 0x0E LIGHT_1 = 0x11 LIGHT_2 = 0x12 LIGHT_3 = 0x13 LIGHT_4 = 0x14 AUX_1 = 0x16 AUX_2 = 0x17 SOAK_MODE = 0x1D HOLD_MODE = 0x3C CIRCULATION_PUMP = 0x3D TEMPERATURE_RANGE = 0x50 HEAT_MODE = 0x51 class WiFiState(IntEnum): """Wi-Fi state.""" OK = 0 SPA_NOT_COMMUNICATING = 1 STARTUP = 2 PRIME = 3 HOLD = 4 PANEL = 5 class HeatMode(IntEnum): """Heat modes.""" READY = 0 REST = 1 READY_IN_REST = 2 class LowHighRange(IntEnum): """Low/high range.""" LOW = 0 HIGH = 1 class OffOnState(IntEnum): """On/off state.""" OFF = 0 ON = 1 class OffLowHighState(IntEnum): """Off/low/high state.""" OFF = 0 LOW = 1 HIGH = 2 class OffLowMediumHighState(IntEnum): """Off/low/medium/high state.""" OFF = 0 LOW = 1 MEDIUM = 2 HIGH = 3 class UnknownState(IntEnum): """Unknown state.""" UNKNOWN = -1 pybalboa-1.1.3/pybalboa/exceptions.py000066400000000000000000000011061475243520700176400ustar00rootroot00000000000000"""pybalboa exceptions.""" class SpaConnectionError(ConnectionError): """Spa connection could not be established.""" class SpaConfigurationNotLoadedError(Exception): """Raised when an operation requires a loaded spa configuration, but it has not been loaded.""" def __init__( self, message: str = ( "Spa configuration not loaded. " "Wait for async_configuration_loaded() to complete before proceeding." ), ): super().__init__(message) class SpaMessageError(Exception): """Spa message is invalid.""" pybalboa-1.1.3/pybalboa/py.typed000066400000000000000000000000001475243520700165740ustar00rootroot00000000000000pybalboa-1.1.3/pybalboa/utils.py000066400000000000000000000062051475243520700166240ustar00rootroot00000000000000"""Utilities module.""" from __future__ import annotations import asyncio from collections.abc import Callable from datetime import datetime, time, timedelta, timezone from typing import Any from .exceptions import SpaMessageError MESSAGE_DELIMETER_BYTE = b"~" MESSAGE_DELIMETER = MESSAGE_DELIMETER_BYTE[0] def byte_parser( value: int, offset: int = 0, count: int = 8, bits: int = 1, fn: Callable[[int], int] = lambda _: _, # pylint: disable=invalid-name ) -> list[int]: """Parse a byte.""" return [ fn(value >> i * bits + offset & int("0b" + "1" * bits, 2)) for i in range(count) ] def calculate_checksum(data: bytes) -> int: """Calculate the checksum byte for a message.""" crc = 0xB5 for _, cur in enumerate(data): for i in range(8): bit = crc & 0x80 crc = ((crc << 1) & 0xFF) | ((cur >> (7 - i)) & 0x01) if bit: crc = crc ^ 0x07 crc &= 0xFF for i in range(8): bit = crc & 0x80 crc = (crc << 1) & 0xFF if bit: crc ^= 0x07 return crc ^ 0x02 def calculate_time(base_time: time | None, duration: timedelta | None) -> time | None: """Calculate the time after adding a duration to a base time.""" if base_time is None: return None duration = duration or timedelta() return (datetime.combine(datetime.now(), base_time) + duration).time() def calculate_time_difference(start: time, end: time) -> int: """Calculate the difference (in minutes) between a start and end time.""" return ((end.hour - start.hour) * 60 + (end.minute - start.minute)) % (24 * 60) async def cancel_task(task: asyncio.Task | None) -> None: """Cancel a task.""" if task is not None and not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass def default(value: Any, default_value: Any | Callable[[], Any]) -> Any: """Return value if not None, else default.""" if value is not None: return value return default_value() if callable(default_value) else default_value async def read_one_message(reader: asyncio.StreamReader, timeout: int = 15) -> bytes: """Read one message.""" data = await asyncio.wait_for(reader.readexactly(2), timeout) if data[0] != MESSAGE_DELIMETER or data[1] == 0: # something went wrong reading a message, so # read to the next delimeter and discard data += await asyncio.wait_for( reader.readuntil(MESSAGE_DELIMETER_BYTE), timeout ) raise SpaMessageError(f"Invalid message: {data.hex()}") data = data[1:] + (await reader.readexactly(data[1]))[:-1] if data[0] != len(data): raise SpaMessageError(f"Incomplete message: {data.hex()}") if calculate_checksum(data[:-1]) != data[-1]: raise SpaMessageError(f"Invalid checksum: {data.hex()}") return data def to_celsius(fahrenheit: float) -> float: """Convert a Fahrenheit temperature to Celsius.""" return 0.5 * round(((fahrenheit - 32) / 1.8) / 0.5) def utcnow() -> datetime: """Get now in UTC time.""" return datetime.now(timezone.utc) pybalboa-1.1.3/pylintrc000066400000000000000000000007241475243520700151100ustar00rootroot00000000000000[MASTER] #ignore=tests # Use a conservative default here; 2 should speed up most setups and not hurt # any too bad. Override on command line as appropriate. jobs=2 persistent=no [MESSAGES CONTROL] # Reasons disabled: # line-too-long is not enforced since it is handled via black # too-many-* - are not enforced for the sake of readability disable= line-too-long, too-many-arguments, too-many-instance-attributes, too-many-public-methods, too-many-statementspybalboa-1.1.3/pyproject.toml000066400000000000000000000017451475243520700162410ustar00rootroot00000000000000[tool.poetry] name = "pybalboa" version = "0.0.0" description = "Module to communicate with a Balboa spa wifi adapter." authors = ["Nathan Spencer ","Tim Rightnour "] readme = "README.rst" homepage = "https://github.com/garbled1/pybalboa" repository = "https://github.com/garbled1/pybalboa" keywords = ["Balboa", "spa", "hot tub", "asynchronous"] include = ["pybalboa/py.typed"] classifiers = [ "License :: OSI Approved :: Apache Software License", ] [tool.poetry.dependencies] python = "^3.9" [tool.poetry.group.dev.dependencies] pytest = ">=7.2.2,<9.0.0" pytest-asyncio = ">=0.20.3,<0.26.0" pytest-cov = ">=4,<7" pytest-timeout = "^2.1.0" mypy = "^1.3" tox = ">=3.26,<5.0" ruff = ">=0.5.0,<0.10" [tool.poetry-dynamic-versioning] enable = true vcs = "git" style = "semver" pattern = "default-unprefixed" [build-system] requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] build-backend = "poetry_dynamic_versioning.backend" pybalboa-1.1.3/setup.cfg000066400000000000000000000013731475243520700151430ustar00rootroot00000000000000[flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build # To work with Black max-line-length = 88 ignore = # E203: whitespace before ':' E203, # E501: line too long E501, # W503: line break before binary operator W503 [isort] multi_line_output = 3 include_trailing_comma = True force_grid_wrap = 0 use_parentheses = True line_length = 88 [mypy] python_version = 3.9 follow_imports = skip ignore_missing_imports = true check_untyped_defs = true disallow_incomplete_defs = true disallow_untyped_calls = true disallow_untyped_defs = true warn_return_any = true warn_unreachable = true warn_unused_ignores = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true [mypy-test.*,] ignore_errors = true pybalboa-1.1.3/tests/000077500000000000000000000000001475243520700144605ustar00rootroot00000000000000pybalboa-1.1.3/tests/__init__.py000066400000000000000000000000241475243520700165650ustar00rootroot00000000000000"""Tests module.""" pybalboa-1.1.3/tests/conftest.py000066400000000000000000000104371475243520700166640ustar00rootroot00000000000000"""Conftest.""" from __future__ import annotations import asyncio import json from collections.abc import Generator from typing import Any import pytest from pybalboa.client import MESSAGE_DELIMETER_BYTE from pybalboa.enums import MessageType, SettingsCode from pybalboa.utils import read_one_message HOST = "localhost" MODULE_IDENTIFICATION = "TEST" def load_spa_from_json(name: str) -> Any: """Load spa from json file.""" with open(f"tests/fixtures/{name}.json", encoding="utf-8") as file: return json.load(file) @pytest.fixture() def bfbp20s( event_loop: asyncio.BaseEventLoop, unused_tcp_port: int ) -> Generator[SpaServer, None, None]: """Mock a BFBP20S spa.""" yield from spa_server(event_loop, unused_tcp_port, "bfbp20s") @pytest.fixture() def bp501g1( event_loop: asyncio.BaseEventLoop, unused_tcp_port: int ) -> Generator[SpaServer, None, None]: """Mock a BP501G1 spa.""" yield from spa_server(event_loop, unused_tcp_port, "bp501g1") @pytest.fixture() def lpi501st( event_loop: asyncio.BaseEventLoop, unused_tcp_port: int ) -> Generator[SpaServer, None, None]: """Mock a LPI501ST spa.""" yield from spa_server(event_loop, unused_tcp_port, "lpi501st") @pytest.fixture() def mxbp20( event_loop: asyncio.BaseEventLoop, unused_tcp_port: int ) -> Generator[SpaServer, None, None]: """Mock a MXBP20 spa.""" yield from spa_server(event_loop, unused_tcp_port, "mxbp20") @pytest.fixture() def bp6013g1( event_loop: asyncio.BaseEventLoop, unused_tcp_port: int ) -> Generator[SpaServer, None, None]: """Mock a BP6013G1 spa.""" yield from spa_server(event_loop, unused_tcp_port, "bp6013g1") def spa_server( event_loop: asyncio.BaseEventLoop, unused_tcp_port: int, filename: str ) -> Generator[SpaServer, None, None]: """Generate a server with an unused tcp port.""" messages = load_spa_from_json(filename) spa = SpaServer(unused_tcp_port, messages) task = asyncio.ensure_future(spa.start_server(), loop=event_loop) event_loop.run_until_complete(asyncio.sleep(0.01)) try: yield spa finally: task.cancel() class SpaServer: """Spa server.""" def __init__(self, port: int, messages: dict[str, str]) -> None: """Initialize a spa server.""" self.port = port self.messages = messages self.received_messages: list[bytes] = [] async def start_server(self) -> None: """Start the server.""" server = await asyncio.start_server(self.handle_message, HOST, self.port) addr = server.sockets[0].getsockname() print(f"SERVER: Serving on {addr[0:2]}") async with server: await server.serve_forever() async def handle_message( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: """Handle a message.""" timeout = 1 while True: try: data = await read_one_message(reader, timeout) self.received_messages.append(data) message_type = MessageType(data[3]) except asyncio.TimeoutError: message_type = MessageType.STATUS_UPDATE message = None if message_type == MessageType.STATUS_UPDATE: message = self.messages["status_update"] elif message_type == MessageType.DEVICE_PRESENT: message = self.messages["module_identification"] elif message_type == MessageType.REQUEST: settings_code = SettingsCode(data[4]) if settings_code == SettingsCode.SYSTEM_INFORMATION: message = self.messages["system_information"] elif settings_code == SettingsCode.SETUP_PARAMETERS: message = self.messages["setup_parameters"] elif settings_code == SettingsCode.DEVICE_CONFIGURATION: message = self.messages["device_configuration"] elif settings_code == SettingsCode.FILTER_CYCLE: message = self.messages["filter_cycle"] if message: print(message) writer.write( MESSAGE_DELIMETER_BYTE + bytes.fromhex(message) + MESSAGE_DELIMETER_BYTE ) await writer.drain() pybalboa-1.1.3/tests/fixtures/000077500000000000000000000000001475243520700163315ustar00rootroot00000000000000pybalboa-1.1.3/tests/fixtures/bfbp20s.json000066400000000000000000000006311475243520700204620ustar00rootroot00000000000000{ "module_identification": "1e0abf9402148000152771f19a0000000000000000001527ffff71f19a0a", "system_information": "1a0abf2464dc24004246425032305320035cd4ccd7010a0000de", "setup_parameters": "0e0abf25040332635068e901454f", "device_configuration": "0b0abf2e020005d00068bc", "filter_cycle": "0d0abf23130002008700010556", "status_update": "1dffaf130003640a3700040100021c00000203000000012068000452f8" } pybalboa-1.1.3/tests/fixtures/bp501g1.json000066400000000000000000000006311475243520700203030ustar00rootroot00000000000000{ "module_identification": "1e0abf94021480001527735be20000000000000000001527ffff735be26f", "system_information": "1a0abf2464c91400425035303147312001129058ff01060400e7", "setup_parameters": "0e0abf2506013263506809034163", "device_configuration": "0b0abf2e060001100000d2", "filter_cycle": "0d0abf23170002000800000fda", "status_update": "1dffaf13000066130600005a00000c0900000000000000006600000005" } pybalboa-1.1.3/tests/fixtures/bp6013g1.json000066400000000000000000000006411475243520700203700ustar00rootroot00000000000000{ "module_identification": "1e0abf94021480001527e4009d0000000000000000001527ffffe4009d8d", "system_information": "1a0abf2464e22b004250363031334731041b456746030a020003", "setup_parameters": "0f0abf2509043263506841014102f8", "device_configuration": "0b0abf2e0100019100006d", "filter_cycle": "0d0abf23030002008e000200e3", "status_update": "20ffaf130003490d23002803060304000000030000000202490000001e000058" } pybalboa-1.1.3/tests/fixtures/lpi501st.json000066400000000000000000000006311475243520700206050ustar00rootroot00000000000000{ "module_identification": "1e0abf9402148000152773d1470000000000000000001527ffff73d147a5", "system_information": "1a0abf2464c924004c504935303153540277c79c4d01060400ef", "setup_parameters": "0e0abf250e023263506809034197", "device_configuration": "0b0abf2e0600051000008a", "filter_cycle": "0d0abf230d00020094000200ea", "status_update": "1dffaf1300006811180000010000040000000000000000006800000064" } pybalboa-1.1.3/tests/fixtures/mxbp20.json000066400000000000000000000006311475243520700203340ustar00rootroot00000000000000{ "module_identification": "1e0abf9402138000152761a1710000000000000000001527ffff61a171ab", "system_information": "1a0abf2464dc24004d584250323020200476ecca96010a04007a", "setup_parameters": "0e0abf2513043263506869034dc2", "device_configuration": "0b0abf2e0a000190000032", "filter_cycle": "0d0abf230800000f14000100d9", "status_update": "1dffaf130000630e3300000100000c0000020000000000006300001079" } pybalboa-1.1.3/tests/test_client.py000066400000000000000000000260311475243520700173510ustar00rootroot00000000000000"""Tests module.""" from __future__ import annotations from datetime import time, timedelta from unittest.mock import patch import pytest from pybalboa import SpaClient from pybalboa.enums import ( HeatMode, LowHighRange, MessageType, OffLowHighState, OffOnState, SettingsCode, TemperatureUnit, ) from .conftest import SpaServer HOST = "localhost" @pytest.mark.asyncio async def test_bfbp20s(bfbp20s: SpaServer) -> None: """Test the spa client.""" async with SpaClient(HOST, bfbp20s.port) as spa: assert spa.connected assert await spa.async_configuration_loaded() assert spa.configuration_loaded assert spa.model == "BFBP20S" assert spa.mac_address == "00:15:27:71:f1:9a" assert spa.software_version == "M100_220 V36.0" assert spa.pump_count == 1 control = spa.pumps[0] assert control.name == "Pump 1" assert isinstance(control.state, OffLowHighState) assert control.state == OffLowHighState.OFF assert control.options == list(OffLowHighState) control = spa.lights[0] assert control.name == "Light 1" assert isinstance(control.state, OffOnState) assert control.state == OffOnState.ON assert control.options == list(OffOnState) await control.set_state(OffOnState.OFF) assert bfbp20s.received_messages[-1] control = spa.lights[1] assert control.name == "Light 2" assert isinstance(control.state, OffOnState) assert control.state == OffOnState.OFF assert control.options == list(OffOnState) assert spa.circulation_pump control = spa.circulation_pump assert control.name == "Circulation pump" assert isinstance(control.state, OffOnState) assert control.state == OffOnState.ON assert control.options == list(OffOnState) control = spa.temperature_range assert control.name == "Temperature range" assert isinstance(control.state, LowHighRange) assert control.state == LowHighRange.HIGH assert control.options == list(LowHighRange) control = spa.heat_mode assert control.name == "Heat mode" assert isinstance(control.state, HeatMode) assert control.state == HeatMode.READY assert control.options == list(HeatMode)[:2] with patch("pybalboa.client.SpaClient.send_message") as send_message: await spa.configure_filter_cycle( 1, start=time(1, 30), duration=timedelta(hours=3, minutes=15) ) # validate a configure filter cycle message was awaited expected_message = [MessageType.FILTER_CYCLE, 1, 30, 3, 15, 135, 0, 1, 5] send_message.assert_any_await(*expected_message) # validate a request filter cycle message was awaited send_message.assert_any_await( MessageType.REQUEST, SettingsCode.FILTER_CYCLE, 0x00, 0x00 ) send_message.reset_mock() await spa.configure_filter_cycle( 2, start=time(13, 15), duration=timedelta(hours=3, minutes=45), enabled=False, ) # validate a configure filter cycle message was awaited expected_message = [MessageType.FILTER_CYCLE, 19, 0, 2, 0, 13, 15, 3, 45] send_message.assert_any_await(*expected_message) @pytest.mark.asyncio async def test_lpi501st(lpi501st: SpaServer) -> None: """Test the spa client.""" async with SpaClient(HOST, lpi501st.port) as spa: assert spa.connected assert await spa.async_configuration_loaded() assert spa.configuration_loaded assert spa.model == "LPI501ST" assert spa.mac_address == "00:15:27:73:d1:47" assert spa.software_version == "M100_201 V36.0" assert spa.pump_count == 2 control = spa.pumps[0] assert control.name == "Pump 1" assert isinstance(control.state, OffLowHighState) assert control.state == OffLowHighState.OFF assert control.options == list(OffLowHighState) control = spa.pumps[1] assert control.name == "Pump 2" assert isinstance(control.state, OffOnState) assert control.state == OffOnState.OFF assert control.options == list(OffOnState) @pytest.mark.asyncio async def test_mxbp20(mxbp20: SpaServer) -> None: """Test the spa client.""" async with SpaClient(HOST, mxbp20.port) as spa: assert spa.connected assert await spa.async_configuration_loaded() assert spa.configuration_loaded assert spa.model == "MXBP20" assert spa.pump_count == 2 control = spa.pumps[0] assert control.name == "Pump 1" assert isinstance(control.state, OffLowHighState) assert control.state == OffLowHighState.OFF assert control.options == list(OffLowHighState) control = spa.pumps[1] assert control.name == "Pump 2" assert isinstance(control.state, OffLowHighState) assert control.state == OffLowHighState.OFF assert control.options == list(OffLowHighState) @pytest.mark.asyncio async def test_bp501g1(bp501g1: SpaServer) -> None: """Test the spa client.""" async with SpaClient(HOST, bp501g1.port) as spa: assert spa.connected assert await spa.async_configuration_loaded() assert spa.configuration_loaded assert spa.model == "BP501G1" assert spa.mac_address == "00:15:27:73:5b:e2" assert spa.software_version == "M100_201 V20.0" assert spa.pump_count == 2 assert len(spa.aux) == 0 assert len(spa.blowers) == 0 assert len(spa.lights) == 1 assert len(spa.pumps) == 2 assert spa.circulation_pump is None control = spa.pumps[0] assert control.name == "Pump 1" assert isinstance(control.state, OffLowHighState) assert control.state == OffLowHighState.LOW assert control.options == list(OffLowHighState) control = spa.pumps[1] assert control.name == "Pump 2" assert isinstance(control.state, OffOnState) assert control.state == OffOnState.ON assert control.options == list(OffOnState) control = spa.lights[0] assert control.name == "Light 1" assert isinstance(control.state, OffOnState) assert control.state == OffOnState.OFF assert control.options == list(OffOnState) await control.set_state(OffOnState.ON) assert bp501g1.received_messages[-1] control = spa.temperature_range assert control.name == "Temperature range" assert isinstance(control.state, LowHighRange) assert control.state == LowHighRange.HIGH assert control.options == list(LowHighRange) control = spa.heat_mode assert control.name == "Heat mode" assert isinstance(control.state, HeatMode) assert control.state == HeatMode.READY assert control.options == list(HeatMode)[:2] @pytest.mark.asyncio async def test_bp6013g1(bp6013g1: SpaServer) -> None: """Test the spa client.""" async with SpaClient(HOST, bp6013g1.port) as spa: assert spa.connected assert await spa.async_configuration_loaded() assert spa.configuration_loaded assert spa.model == "BP6013G1" assert spa.mac_address == "00:15:27:e4:00:9d" assert spa.software_version == "M100_226 V43.0" assert spa.pump_count == 1 assert len(spa.aux) == 0 assert len(spa.blowers) == 1 assert len(spa.lights) == 1 assert len(spa.pumps) == 1 assert spa.temperature_unit == TemperatureUnit.CELSIUS assert spa.circulation_pump control = spa.pumps[0] assert control.name == "Pump 1" assert isinstance(control.state, OffOnState) assert control.state == OffOnState.OFF assert control.options == list(OffOnState) control = spa.lights[0] assert control.name == "Light 1" assert isinstance(control.state, OffOnState) assert control.state == OffOnState.ON assert control.options == list(OffOnState) await control.set_state(OffOnState.OFF) assert bp6013g1.received_messages[-1] control = spa.blowers[0] assert control.name == "Blower 1" assert isinstance(control.state, OffOnState) assert control.state == OffOnState.OFF assert control.options == list(OffOnState) await control.set_state(OffOnState.ON) assert bp6013g1.received_messages[-1] control = spa.temperature_range assert control.name == "Temperature range" assert isinstance(control.state, LowHighRange) assert control.state == LowHighRange.HIGH assert control.options == list(LowHighRange) control = spa.heat_mode assert control.name == "Heat mode" assert isinstance(control.state, HeatMode) assert control.state == HeatMode.READY assert control.options == list(HeatMode)[:2] @pytest.mark.asyncio @pytest.mark.parametrize( ("error", "error_message", "method", "params"), [ ( ValueError, "Invalid filter cycle", "configure_filter_cycle", {"filter_cycle": None}, ), ( ValueError, "Invalid filter cycle", "configure_filter_cycle", {"filter_cycle": 3}, ), (ValueError, "At least one of", "configure_filter_cycle", {"filter_cycle": 1}), ( ValueError, "Only one of", "configure_filter_cycle", {"filter_cycle": 1, "end": time(), "duration": timedelta(hours=1)}, ), ( ValueError, "Filter cycle 1 cannot be disabled", "configure_filter_cycle", {"filter_cycle": 1, "start": time(), "enabled": False}, ), ( ValueError, "Filter cycle 1 requires", "configure_filter_cycle", {"filter_cycle": 1, "enabled": True}, ), ( ValueError, "Invalid duration", "configure_filter_cycle", {"filter_cycle": 1, "duration": timedelta()}, ), ( ValueError, "Invalid duration", "configure_filter_cycle", {"filter_cycle": 1, "duration": timedelta(minutes=5)}, ), (ValueError, "Invalid fault log entry", "request_fault_log", {"entry": -1}), (ValueError, "Invalid fault log entry", "request_fault_log", {"entry": 25}), (ValueError, "Invalid temperature", "set_temperature", {"temperature": 0}), (ValueError, "Invalid time", "set_time", {"hour": 45, "minute": 0}), ], ) async def test_client_errors( bfbp20s: SpaServer, error: Exception, error_message: str, method: str, params: dict | None, ) -> None: """Test the spa client.""" async with SpaClient(HOST, bfbp20s.port) as spa: with pytest.raises(error, match=error_message): await getattr(spa, method)(**(params or {})) pybalboa-1.1.3/tests/test_enums.py000066400000000000000000000005401475243520700172170ustar00rootroot00000000000000"""Tests module.""" from pybalboa.enums import MessageType, SettingsCode def test_enum_parsing() -> None: """Test enum parsing.""" assert MessageType(0x22) == MessageType.REQUEST assert MessageType(0x99) == MessageType.UNKNOWN assert SettingsCode(0x20) == SettingsCode.FAULT_LOG assert SettingsCode(0x99) == SettingsCode.UNKNOWN pybalboa-1.1.3/tests/test_init.py000066400000000000000000000002221475243520700170300ustar00rootroot00000000000000"""Tests module.""" from pybalboa import __version__ def test_version() -> None: """Test the version.""" assert __version__ == "0.0.0" pybalboa-1.1.3/tests/test_utils.py000066400000000000000000000025741475243520700172410ustar00rootroot00000000000000"""Tests module.""" import asyncio from pybalboa.utils import ( byte_parser, calculate_checksum, cancel_task, default, to_celsius, ) def test_byte_parser() -> None: """Test byte_parser.""" byte = int("0b01010101", 2) assert byte_parser(byte) == [1, 0] * 4 assert byte_parser(byte, offset=1, count=3) == [0, 1, 0] assert byte_parser(byte, count=2, bits=2) == [1, 1] assert byte_parser(byte, offset=1, count=2, bits=2) == [2, 2] assert byte_parser(byte, count=3, bits=3) == [5, 2, 1] def test_calculate_checksum() -> None: """Test calculate_checksum.""" value = bytes.fromhex("1DFFAF13000064082D0000010000040000000000000000006400000006") assert calculate_checksum(value[:-1]) == value[-1] value = bytes.fromhex("050ABF0477") assert calculate_checksum(value[:-1]) == value[-1] async def test_cancel_task() -> None: """Test cancel_task.""" async def _long_wait() -> None: await asyncio.sleep(1000) task = asyncio.ensure_future(test_cancel_task()) assert not task.done() await cancel_task(task) assert task.cancelled() def test_default() -> None: """Test default.""" assert default(12, 24) == 12 assert default(None, 24) == 24 def test_to_celsius() -> None: """Test to_celsius.""" assert to_celsius(32) == 0 assert to_celsius(104) == 40 assert to_celsius(80) == 26.5 pybalboa-1.1.3/tox.ini000066400000000000000000000007401475243520700146320ustar00rootroot00000000000000[tox] isolated_build = True envlist = lint, mypy, py39, py310, py311, py312 skip_missing_interpreters = True [tox:.package] basepython = python3 [testenv] whitelist_externals = poetry commands = poetry run pytest --timeout=10 --cov=pybalboa --cov-report=term-missing --asyncio-mode=auto [testenv:lint] ignore_errors = True commands = poetry run ruff check . poetry run ruff format . --check [testenv:mypy] ignore_errors = True commands = poetry run mypy pybalboa tests