├── src ├── __init__.py ├── conftest.py └── iaqualink │ ├── __init__.py │ ├── typing.py │ ├── systems │ ├── exo │ │ ├── __init__.py │ │ ├── system.py │ │ └── device.py │ ├── iaqua │ │ ├── __init__.py │ │ ├── system.py │ │ └── device.py │ └── __init__.py │ ├── const.py │ ├── exception.py │ ├── system.py │ ├── device.py │ └── client.py ├── tests ├── __init__.py ├── systems │ ├── __init__.py │ ├── exo │ │ ├── __init__.py │ │ ├── test_system.py │ │ └── test_device.py │ └── iaqua │ │ ├── __init__.py │ │ └── test_system.py ├── common.py ├── base.py ├── test_system.py ├── base_test_system.py ├── test_client.py ├── test_device.py └── base_test_device.py ├── MANIFEST.in ├── .github ├── workflows │ ├── commitlint.yaml │ ├── release.yaml │ ├── docs.yml │ └── ci.yaml └── dependabot.yml ├── .readthedocs.yml ├── .pre-commit-config.yaml ├── test.py ├── docs ├── getting-started │ ├── installation.md │ ├── authentication.md │ └── quickstart.md ├── api │ ├── client.md │ ├── system.md │ ├── iaqua.md │ ├── device.md │ ├── exo.md │ └── exceptions.md ├── index.md ├── guide │ ├── systems.md │ ├── devices.md │ └── examples.md └── development │ ├── contributing.md │ └── architecture.md ├── LICENSE ├── .gitignore ├── pyproject.toml ├── mkdocs.yml ├── .claude └── commands │ └── update-python-version.md ├── DOCUMENTATION.md ├── CLAUDE.md └── README.md /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/conftest.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/iaqualink/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/systems/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /tests/systems/exo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/systems/iaqua/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/iaqualink/typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | DeviceData = dict[str, str] 4 | Payload = dict[str, str] 5 | -------------------------------------------------------------------------------- /src/iaqualink/systems/exo/__init__.py: -------------------------------------------------------------------------------- 1 | from iaqualink.systems.exo import device, system 2 | 3 | __all__ = ["device", "system"] 4 | -------------------------------------------------------------------------------- /src/iaqualink/systems/iaqua/__init__.py: -------------------------------------------------------------------------------- 1 | from iaqualink.systems.iaqua import device, system 2 | 3 | __all__ = ["device", "system"] 4 | -------------------------------------------------------------------------------- /src/iaqualink/systems/__init__.py: -------------------------------------------------------------------------------- 1 | from os import listdir 2 | from os.path import basename, dirname 3 | 4 | __all__ = [ 5 | basename(f) for f in listdir(dirname(__file__)) if not f.startswith("__") 6 | ] 7 | -------------------------------------------------------------------------------- /src/iaqualink/const.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | AQUALINK_API_KEY = "EOOEMOW4YR6QNB07" 4 | 5 | AQUALINK_LOGIN_URL = "https://prod.zodiac-io.com/users/v1/login" 6 | AQUALINK_DEVICES_URL = "https://r-api.iaqualink.net/devices.json" 7 | 8 | KEEPALIVE_EXPIRY = 30 9 | MIN_SECS_TO_REFRESH = 5 10 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint Commit Messages 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | pull-requests: read 7 | 8 | jobs: 9 | commitlint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: wagoid/commitlint-github-action@v6 14 | -------------------------------------------------------------------------------- /tests/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from unittest.mock import AsyncMock 5 | 6 | async_noop = AsyncMock(return_value=None) 7 | 8 | 9 | def async_returns(x: Any) -> AsyncMock: 10 | return AsyncMock(return_value=x) 11 | 12 | 13 | def async_raises(x: Any) -> AsyncMock: 14 | return AsyncMock(side_effect=x) 15 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.13" 10 | jobs: 11 | pre_create_environment: 12 | - asdf plugin add uv 13 | - asdf install uv latest 14 | - asdf global uv latest 15 | create_environment: 16 | - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" 17 | install: 18 | - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group docs 19 | 20 | # Tell ReadTheDocs to use MkDocs 21 | mkdocs: 22 | configuration: mkdocs.yml 23 | fail_on_warning: false 24 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import httpx 4 | from respx.patterns import M 5 | 6 | from iaqualink.client import AqualinkClient 7 | 8 | dotstar = M(host__regex=".*") 9 | resp_200 = httpx.Response(status_code=200, json={}) 10 | 11 | 12 | class TestBase(unittest.IsolatedAsyncioTestCase): 13 | __test__ = False 14 | 15 | def __init_subclass__(cls) -> None: 16 | if cls.__name__.startswith("TestBase"): 17 | cls.__test__ = False 18 | else: 19 | cls.__test__ = True 20 | return super().__init_subclass__() 21 | 22 | def setUp(self) -> None: 23 | super().setUp() 24 | 25 | self.client = AqualinkClient("foo", "bar") 26 | self.addAsyncCleanup(self.client.close) 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | enable-beta-ecosystems: true 8 | updates: 9 | - package-ecosystem: "uv" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | commit-message: 14 | prefix: build 15 | include: scope 16 | - package-ecosystem: github-actions 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | commit-message: 21 | prefix: build 22 | include: scope 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.9.10 14 | hooks: 15 | - id: ruff 16 | args: [--fix] 17 | - id: ruff-format 18 | 19 | - repo: https://github.com/pre-commit/mirrors-mypy 20 | rev: v1.15.0 21 | hooks: 22 | - id: mypy 23 | additional_dependencies: 24 | - types-PyYAML==6.0.12 25 | exclude: ^(tests/.*) 26 | 27 | - repo: https://github.com/astral-sh/uv-pre-commit 28 | rev: 0.6.5 29 | hooks: 30 | - id: uv-lock 31 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import os 4 | 5 | import anyio 6 | import yaml 7 | 8 | from iaqualink.client import AqualinkClient 9 | from iaqualink.exception import AqualinkException 10 | 11 | 12 | async def main(): 13 | async with await anyio.open_file( 14 | os.path.expanduser("~/.config/iaqualink.yaml") 15 | ) as f: 16 | config = yaml.safe_load(await f.read()) 17 | 18 | data = {} 19 | 20 | async with AqualinkClient( 21 | username=config["username"], password=config["password"] 22 | ) as client: 23 | systems = await client.get_systems() 24 | for system, system_obj in systems.items(): 25 | data[system] = system_obj.data 26 | try: 27 | devices = await system_obj.get_devices() 28 | except AqualinkException: 29 | pass 30 | else: 31 | data[system]["devices"] = devices 32 | 33 | print(yaml.dump(data, default_flow_style=False)) # noqa: T201 34 | 35 | 36 | if __name__ == "__main__": 37 | import asyncio 38 | 39 | asyncio.run(main()) 40 | -------------------------------------------------------------------------------- /docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Using pip 4 | 5 | The easiest way to install iaqualink-py is using pip: 6 | 7 | ```bash 8 | pip install iaqualink 9 | ``` 10 | 11 | ## Using uv 12 | 13 | If you're using [uv](https://github.com/astral-sh/uv), you can install it with: 14 | 15 | ```bash 16 | uv add iaqualink 17 | ``` 18 | 19 | ## From Source 20 | 21 | To install from source for development: 22 | 23 | ```bash 24 | # Clone the repository 25 | git clone https://github.com/flz/iaqualink-py.git 26 | cd iaqualink-py 27 | 28 | # Install with all dependencies 29 | uv sync --all-extras --dev 30 | ``` 31 | 32 | ## Requirements 33 | 34 | - Python 3.13 or higher 35 | - httpx with HTTP/2 support 36 | 37 | All required dependencies will be installed automatically. 38 | 39 | ## Verifying Installation 40 | 41 | You can verify the installation by running: 42 | 43 | ```python 44 | import iaqualink 45 | print(iaqualink.__version__) 46 | ``` 47 | 48 | ## Next Steps 49 | 50 | - [Quick Start](quickstart.md) - Get started with basic usage 51 | - [Authentication](authentication.md) - Learn about authentication 52 | -------------------------------------------------------------------------------- /src/iaqualink/exception.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class AqualinkException(Exception): # noqa: N818 5 | """Base exception for iAqualink library.""" 6 | 7 | 8 | class AqualinkInvalidParameterException(AqualinkException): 9 | """Exception raised when an invalid parameter is passed.""" 10 | 11 | 12 | class AqualinkServiceException(AqualinkException): 13 | """Exception raised when an error is raised by the iaqualink service.""" 14 | 15 | 16 | class AqualinkServiceUnauthorizedException(AqualinkServiceException): 17 | """Exception raised when service access is unauthorized.""" 18 | 19 | 20 | class AqualinkSystemOfflineException(AqualinkServiceException): 21 | """Exception raised when a system is offline.""" 22 | 23 | 24 | class AqualinkSystemUnsupportedException(AqualinkServiceException): 25 | """Exception raised when a system isn't supported by the library.""" 26 | 27 | 28 | class AqualinkOperationNotSupportedException(AqualinkException): 29 | """Exception raised when trying to issue an unsupported operation.""" 30 | 31 | 32 | class AqualinkDeviceNotSupported(AqualinkException): 33 | """Exception raised when a device isn't known-unsupported.""" 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | github: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v6 18 | 19 | - name: Create GitHub Release 20 | uses: softprops/action-gh-release@v2 21 | with: 22 | generate_release_notes: true 23 | 24 | pypi: 25 | runs-on: ubuntu-latest 26 | needs: github 27 | 28 | environment: release 29 | permissions: 30 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 31 | 32 | steps: 33 | - uses: actions/checkout@v6 34 | with: 35 | fetch-depth: "0" # Versioning needs this. 36 | 37 | - name: Install uv 38 | uses: astral-sh/setup-uv@v7 39 | 40 | - name: Set up Python 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version-file: "pyproject.toml" 44 | 45 | - name: Install the project 46 | run: uv sync --group test 47 | 48 | - name: Build package 49 | run: uv build 50 | 51 | - name: Test package 52 | run: uv run pytest 53 | 54 | - name: Publish package to PyPI 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | branches: 10 | - master 11 | - main 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: false 22 | 23 | jobs: 24 | build: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v6 29 | with: 30 | fetch-depth: 0 31 | 32 | - name: Install uv 33 | uses: astral-sh/setup-uv@v7 34 | with: 35 | enable-cache: true 36 | 37 | - name: Install dependencies 38 | run: | 39 | uv sync --group docs 40 | 41 | - name: Build documentation 42 | run: | 43 | uv run mkdocs build --strict 44 | 45 | - name: Upload artifact 46 | uses: actions/upload-pages-artifact@v4 47 | with: 48 | path: ./site 49 | 50 | deploy: 51 | if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') 52 | environment: 53 | name: github-pages 54 | url: ${{ steps.deployment.outputs.page_url }} 55 | runs-on: ubuntu-latest 56 | needs: build 57 | steps: 58 | - name: Deploy to GitHub Pages 59 | id: deployment 60 | uses: actions/deploy-pages@v4 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Florent Thoumie 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | run: 13 | runs-on: ${{ matrix.os }} 14 | 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | python-version: ["3.13", "3.14"] 19 | 20 | steps: 21 | - uses: actions/checkout@v6 22 | 23 | - name: Install uv and set the python version 24 | uses: astral-sh/setup-uv@v7 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install the project 29 | run: uv sync --group dev --group test 30 | 31 | - name: Run linters (pre-commit) 32 | run: uv run pre-commit run --show-diff-on-failure --color=always --all-files 33 | 34 | - name: Run unit tests 35 | run: uv run pytest 36 | 37 | coverage: 38 | runs-on: ${{ matrix.os }} 39 | needs: [run] 40 | 41 | strategy: 42 | matrix: 43 | os: [ubuntu-latest] 44 | 45 | steps: 46 | - uses: actions/checkout@v6 47 | 48 | - name: Install uv 49 | uses: astral-sh/setup-uv@v7 50 | 51 | - name: Set up Python 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version-file: pyproject.toml 55 | 56 | - name: Install the project 57 | run: uv sync --group dev --group test 58 | 59 | - name: Generate coverage report 60 | run: uv run pytest --cov-report=xml --cov=iaqualink 61 | 62 | - name: Upload coverage to Codecov 63 | uses: codecov/codecov-action@v5 64 | with: 65 | flags: full-suite 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Editors 107 | .vscode/ 108 | 109 | # Version file is automatically generated 110 | src/iaqualink/version.py 111 | -------------------------------------------------------------------------------- /docs/api/client.md: -------------------------------------------------------------------------------- 1 | # Client API 2 | 3 | The `AqualinkClient` class is the main entry point for interacting with the iAqualink API. 4 | 5 | ## AqualinkClient 6 | 7 | ::: iaqualink.client.AqualinkClient 8 | 9 | ## Usage 10 | 11 | ### Basic Usage 12 | 13 | ```python 14 | from iaqualink import AqualinkClient 15 | 16 | async with AqualinkClient('user@example.com', 'password') as client: 17 | systems = await client.get_systems() 18 | ``` 19 | 20 | ### Manual Session Management 21 | 22 | ```python 23 | client = AqualinkClient('user@example.com', 'password') 24 | try: 25 | await client.login() 26 | systems = await client.get_systems() 27 | finally: 28 | await client.close() 29 | ``` 30 | 31 | ## Methods 32 | 33 | ### login() 34 | 35 | Authenticate with the iAqualink service. 36 | 37 | **Returns:** `None` 38 | 39 | **Raises:** 40 | - `AqualinkLoginException` - Authentication failed 41 | 42 | ### get_systems() 43 | 44 | Discover and retrieve all pool systems associated with the account. 45 | 46 | **Returns:** `dict[str, AqualinkSystem]` - Dictionary mapping serial numbers to system objects 47 | 48 | **Raises:** 49 | - `AqualinkServiceException` - Service error occurred 50 | 51 | ### close() 52 | 53 | Close the HTTP client session. 54 | 55 | **Returns:** `None` 56 | 57 | ## Properties 58 | 59 | ### username 60 | 61 | The username used for authentication. 62 | 63 | **Type:** `str` 64 | 65 | ### password 66 | 67 | The password used for authentication. 68 | 69 | **Type:** `str` 70 | 71 | ## Context Manager 72 | 73 | The client supports the async context manager protocol: 74 | 75 | ```python 76 | async with AqualinkClient(username, password) as client: 77 | # Client is authenticated and ready to use 78 | systems = await client.get_systems() 79 | # Client is automatically closed 80 | ``` 81 | 82 | ## HTTP Client 83 | 84 | The client uses `httpx.AsyncClient` with HTTP/2 support for efficient API communication. 85 | 86 | ## See Also 87 | 88 | - [System API](system.md) - System object reference 89 | - [Quick Start](../getting-started/quickstart.md) - Getting started guide 90 | -------------------------------------------------------------------------------- /src/iaqualink/system.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, ClassVar 5 | 6 | from iaqualink.exception import AqualinkSystemUnsupportedException 7 | 8 | if TYPE_CHECKING: 9 | from iaqualink.client import AqualinkClient 10 | from iaqualink.device import AqualinkDevice 11 | from iaqualink.typing import Payload 12 | 13 | 14 | LOGGER = logging.getLogger("iaqualink") 15 | 16 | 17 | class AqualinkSystem: 18 | subclasses: ClassVar[dict[str, type[AqualinkSystem]]] = {} 19 | 20 | def __init__(self, aqualink: AqualinkClient, data: Payload): 21 | self.aqualink = aqualink 22 | self.data = data 23 | self.devices: dict[str, AqualinkDevice] = {} 24 | self.last_refresh: int 25 | 26 | # Semantics here are somewhat odd. 27 | # True/False are obvious, None means "unknown". 28 | self.online: bool | None = None 29 | 30 | @classmethod 31 | def __init_subclass__(cls) -> None: 32 | super().__init_subclass__() 33 | if hasattr(cls, "NAME"): 34 | cls.subclasses[cls.NAME] = cls 35 | 36 | def __repr__(self) -> str: 37 | attrs = ["name", "serial", "data"] 38 | attrs = [f"{i}={getattr(self, i)!r}" for i in attrs] 39 | return f"{self.__class__.__name__}({', '.join(attrs)})" 40 | 41 | @property 42 | def name(self) -> str: 43 | return self.data["name"] 44 | 45 | @property 46 | def serial(self) -> str: 47 | return self.data["serial_number"] 48 | 49 | @classmethod 50 | def from_data( 51 | cls, aqualink: AqualinkClient, data: Payload 52 | ) -> AqualinkSystem: 53 | if data["device_type"] not in cls.subclasses: 54 | m = f"{data['device_type']} is not a supported system type." 55 | LOGGER.warning(m) 56 | raise AqualinkSystemUnsupportedException(m) 57 | 58 | return cls.subclasses[data["device_type"]](aqualink, data) 59 | 60 | async def get_devices(self) -> dict[str, AqualinkDevice]: 61 | if not self.devices: 62 | await self.update() 63 | return self.devices 64 | 65 | async def update(self) -> None: 66 | raise NotImplementedError 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "hatchling>=1.3.1", 4 | "hatch-vcs", 5 | ] 6 | build-backend = "hatchling.build" 7 | 8 | [project] 9 | name = "iaqualink" 10 | description = "Asynchronous library for Jandy iAqualink" 11 | readme = "README.md" 12 | license = "BSD-3-Clause" 13 | requires-python = ">=3.13" 14 | authors = [ 15 | { name = "Florent Thoumie", email = "florent@thoumie.net" }, 16 | ] 17 | keywords = [ 18 | "iaqualink", 19 | ] 20 | classifiers = [ 21 | "Development Status :: 2 - Pre-Alpha", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: BSD License", 24 | "Natural Language :: English", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3.14", 27 | ] 28 | dependencies = [ 29 | "httpx[http2]>=0.27.0", 30 | ] 31 | dynamic = [ 32 | "version", 33 | ] 34 | 35 | [dependency-groups] 36 | dev = [ 37 | "pre-commit>=4.2.0", 38 | "mypy>=1.15.0", 39 | "PyYAML>=6.0.2", 40 | "ruff>=0.11.2", 41 | ] 42 | test = [ 43 | "coverage[toml]>=7.7.1", 44 | "pytest>=8.3.5", 45 | "pytest-cov>=6.0.0", 46 | "pytest-icdiff>=0.9", 47 | "pytest-sugar>=1.0.0", 48 | "respx>=0.22.0", 49 | ] 50 | docs = [ 51 | "mkdocs>=1.6.0", 52 | "mkdocs-material>=9.5.0", 53 | "mkdocstrings[python]>=0.26.0", 54 | ] 55 | 56 | [project.urls] 57 | Homepage = "https://github.com/flz/iaqualink-py" 58 | 59 | [tool.hatch.version] 60 | source = "vcs" 61 | 62 | [tool.hatch.build.hooks.vcs] 63 | version-file = "src/iaqualink/version.py" 64 | 65 | [tool.hatch.build.targets.sdist] 66 | 67 | [tool.hatch.build.targets.wheel] 68 | packages = ["src/iaqualink"] 69 | 70 | [tool.ruff] 71 | line-length = 80 72 | 73 | [tool.ruff.lint] 74 | ignore = [ 75 | "SLF001", # Some tests currently use private members 76 | "G004", # Will fix all f-string logging calls later 77 | ] 78 | 79 | [tool.coverage.run] 80 | omit = [ 81 | ".venv/*", 82 | ] 83 | 84 | [tool.coverage.report] 85 | exclude_lines = [ 86 | "pragma: no cover", 87 | "if TYPE_CHECKING:", 88 | ] 89 | 90 | [tool.mypy] 91 | ignore_missing_imports = true 92 | 93 | [tool.pytest.ini_options] 94 | filterwarnings = [ 95 | "error", 96 | "ignore::DeprecationWarning", 97 | ] 98 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # iaqualink-py 2 | 3 | > Asynchronous Python library for Jandy iAqualink pool control systems 4 | 5 | [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) 6 | [![Python](https://img.shields.io/badge/python-3.13%2B-blue.svg)](https://www.python.org/downloads/) 7 | 8 | ## Overview 9 | 10 | **iaqualink-py** is a modern, fully asynchronous Python library for interacting with Jandy iAqualink pool and spa control systems. It provides a clean, Pythonic interface to monitor and control your pool equipment from your Python applications. 11 | 12 | ## Features 13 | 14 | - **Fully Asynchronous** - Built with `asyncio` and `httpx` for efficient, non-blocking I/O 15 | - **Multi-System Support** 16 | - **iAqua** systems (iaqualink.net API) 17 | - **eXO** systems (zodiac-io.com API) 18 | - **Comprehensive Device Support** 19 | - Temperature sensors (pool, spa, air) 20 | - Thermostats with adjustable set points 21 | - Pumps and heaters 22 | - Lights with toggle control 23 | - Auxiliary switches 24 | - Water chemistry sensors (pH, ORP, salinity) 25 | - Freeze protection monitoring 26 | - **Context Manager Support** - Automatic resource cleanup 27 | - **Type Safe** - Full type hints for modern Python development 28 | - **Rate Limiting** - Built-in throttling to respect API limits 29 | 30 | ## Quick Example 31 | 32 | ```python 33 | from iaqualink import AqualinkClient 34 | 35 | async with AqualinkClient('user@example.com', 'password') as client: 36 | # Discover your pool systems 37 | systems = await client.get_systems() 38 | 39 | # Get the first system 40 | system = list(systems.values())[0] 41 | print(f"Found system: {system.name}") 42 | 43 | # Get all devices 44 | devices = await system.get_devices() 45 | 46 | # Access specific devices 47 | pool_temp = devices.get('pool_temp') 48 | if pool_temp: 49 | print(f"Pool temperature: {pool_temp.state}°F") 50 | 51 | # Control devices 52 | pool_pump = devices.get('pool_pump') 53 | if pool_pump: 54 | await pool_pump.turn_on() 55 | ``` 56 | 57 | ## Requirements 58 | 59 | - Python 3.13 or higher 60 | - httpx with HTTP/2 support 61 | 62 | ## Next Steps 63 | 64 | - [Installation](getting-started/installation.md) - Install the library 65 | - [Quick Start](getting-started/quickstart.md) - Get started quickly 66 | - [API Reference](api/client.md) - Detailed API documentation 67 | 68 | ## Disclaimer 69 | 70 | This is an unofficial library and is not affiliated with or endorsed by Jandy, Zodiac Pool Systems, or Fluidra. Use at your own risk. 71 | -------------------------------------------------------------------------------- /tests/test_system.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import unittest 4 | from unittest.mock import MagicMock, patch 5 | 6 | import pytest 7 | 8 | from iaqualink.client import AqualinkClient 9 | from iaqualink.exception import AqualinkSystemUnsupportedException 10 | from iaqualink.system import AqualinkSystem 11 | 12 | 13 | class TestAqualinkSystem(unittest.IsolatedAsyncioTestCase): 14 | def setUp(self) -> None: 15 | pass 16 | 17 | def test_repr(self) -> None: 18 | aqualink = MagicMock() 19 | data = { 20 | "id": 1, 21 | "serial_number": "ABCDEFG", 22 | "device_type": "iaqua", 23 | "name": "foo", 24 | } 25 | system = AqualinkSystem(aqualink, data) 26 | assert ( 27 | repr(system) 28 | == f"AqualinkSystem(name='foo', serial='ABCDEFG', data={data})" 29 | ) 30 | 31 | def test_from_data_iaqua(self) -> None: 32 | aqualink = MagicMock() 33 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "iaqua"} 34 | r = AqualinkSystem.from_data(aqualink, data) 35 | assert r is not None 36 | 37 | def test_from_data_unsupported(self) -> None: 38 | aqualink = MagicMock() 39 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "foo"} 40 | with pytest.raises(AqualinkSystemUnsupportedException): 41 | AqualinkSystem.from_data(aqualink, data) 42 | 43 | async def test_get_devices_needs_update(self) -> None: 44 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "fake"} 45 | aqualink = AqualinkClient("user", "pass") 46 | system = AqualinkSystem(aqualink, data) 47 | system.devices = None 48 | 49 | with patch.object(system, "update") as mock_update: 50 | await system.get_devices() 51 | mock_update.assert_called_once() 52 | 53 | async def test_get_devices(self) -> None: 54 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "fake"} 55 | aqualink = AqualinkClient("user", "pass") 56 | system = AqualinkSystem(aqualink, data) 57 | system.devices = {"foo": "bar"} 58 | 59 | with patch.object(system, "update") as mock_update: 60 | await system.get_devices() 61 | mock_update.assert_not_called() 62 | 63 | async def test_update_not_implemented(self) -> None: 64 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "fake"} 65 | aqualink = AqualinkClient("user", "pass") 66 | system = AqualinkSystem(aqualink, data) 67 | 68 | with pytest.raises(NotImplementedError): 69 | await system.update() 70 | -------------------------------------------------------------------------------- /docs/getting-started/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | iaqualink-py supports both iAqua and eXO system authentication methods. 4 | 5 | ## Basic Authentication 6 | 7 | The simplest way to authenticate is using your email and password: 8 | 9 | ```python 10 | from iaqualink import AqualinkClient 11 | 12 | async with AqualinkClient('user@example.com', 'password') as client: 13 | systems = await client.get_systems() 14 | ``` 15 | 16 | ## Session Management 17 | 18 | The library uses context managers to handle session cleanup automatically: 19 | 20 | ```python 21 | # Recommended: Use context manager for automatic cleanup 22 | async with AqualinkClient(username, password) as client: 23 | # Your code here 24 | pass 25 | # Session is automatically closed 26 | 27 | # Alternative: Manual session management 28 | client = AqualinkClient(username, password) 29 | try: 30 | systems = await client.get_systems() 31 | finally: 32 | await client.close() 33 | ``` 34 | 35 | ## System Type Detection 36 | 37 | The library automatically detects whether you have an iAqua or eXO system and uses the appropriate authentication method: 38 | 39 | ### iAqua Systems 40 | 41 | - Uses iaqualink.net API 42 | - Authentication returns `session_id` and `authentication_token` 43 | - Credentials passed as query parameters 44 | 45 | ### eXO Systems 46 | 47 | - Uses zodiac-io.com API 48 | - Authentication returns JWT `IdToken` 49 | - Token used in Authorization header 50 | - Automatic token refresh on expiration 51 | 52 | ## Authentication Errors 53 | 54 | Handle authentication errors appropriately: 55 | 56 | ```python 57 | from iaqualink import AqualinkClient, AqualinkLoginException 58 | 59 | try: 60 | async with AqualinkClient('user@example.com', 'password') as client: 61 | systems = await client.get_systems() 62 | except AqualinkLoginException as e: 63 | print(f"Login failed: {e}") 64 | ``` 65 | 66 | ## Security Best Practices 67 | 68 | !!! warning "Credential Security" 69 | Never hardcode credentials in your code. Use environment variables or secure configuration files. 70 | 71 | ```python 72 | import os 73 | from iaqualink import AqualinkClient 74 | 75 | username = os.getenv('IAQUALINK_USERNAME') 76 | password = os.getenv('IAQUALINK_PASSWORD') 77 | 78 | async with AqualinkClient(username, password) as client: 79 | systems = await client.get_systems() 80 | ``` 81 | 82 | ## HTTP/2 Support 83 | 84 | The library uses httpx with HTTP/2 support for improved performance: 85 | 86 | ```python 87 | # HTTP/2 is enabled by default 88 | # No additional configuration needed 89 | ``` 90 | 91 | ## Next Steps 92 | 93 | - [Quick Start](quickstart.md) - Start using the library 94 | - [Systems Guide](../guide/systems.md) - Learn about system types 95 | -------------------------------------------------------------------------------- /tests/base_test_system.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import httpx 4 | import pytest 5 | import respx.router 6 | 7 | from iaqualink.exception import ( 8 | AqualinkServiceException, 9 | AqualinkServiceUnauthorizedException, 10 | ) 11 | 12 | from .base import TestBase, dotstar, resp_200 13 | 14 | 15 | class TestBaseSystem(TestBase): 16 | def test_propery_name(self) -> None: 17 | assert isinstance(self.sut.name, str) 18 | 19 | def test_property_serial(self) -> None: 20 | assert isinstance(self.sut.name, str) 21 | 22 | def test_from_data(self) -> None: 23 | if sut_class := getattr(self, "sut_class", None): 24 | assert isinstance(self.sut, sut_class) 25 | 26 | @respx.mock 27 | async def test_update_success( 28 | self, respx_mock: respx.router.MockRouter 29 | ) -> None: 30 | respx_mock.route(dotstar).mock(resp_200) 31 | await self.sut.update() 32 | assert len(respx_mock.calls) > 0 33 | self.respx_calls = copy.copy(respx_mock.calls) 34 | 35 | @respx.mock 36 | async def test_update_consecutive( 37 | self, respx_mock: respx.router.MockRouter 38 | ) -> None: 39 | respx_mock.route(dotstar).mock(resp_200) 40 | await self.sut.update() 41 | respx_mock.reset() 42 | await self.sut.update() 43 | assert len(respx_mock.calls) == 0 44 | 45 | @respx.mock 46 | async def test_update_service_exception( 47 | self, respx_mock: respx.router.MockRouter 48 | ) -> None: 49 | resp_500 = httpx.Response(status_code=500) 50 | respx_mock.route(dotstar).mock(resp_500) 51 | with pytest.raises(AqualinkServiceException): 52 | await self.sut.update() 53 | self.respx_calls = copy.copy(respx_mock.calls) 54 | 55 | @respx.mock 56 | async def test_update_request_unauthorized( 57 | self, respx_mock: respx.router.MockRouter 58 | ) -> None: 59 | resp_401 = httpx.Response(status_code=401) 60 | respx_mock.route(dotstar).mock(resp_401) 61 | with pytest.raises(AqualinkServiceUnauthorizedException): 62 | await self.sut.update() 63 | assert len(respx_mock.calls) > 0 64 | self.respx_calls = copy.copy(respx_mock.calls) 65 | 66 | @respx.mock 67 | async def test_get_devices( 68 | self, respx_mock: respx.router.MockRouter 69 | ) -> None: 70 | respx_mock.route(dotstar).mock(resp_200) 71 | self.sut.devices = {"foo": {}} 72 | await self.sut.get_devices() 73 | assert len(respx_mock.calls) == 0 74 | 75 | @respx.mock 76 | async def test_get_devices_needs_update( 77 | self, respx_mock: respx.router.MockRouter 78 | ) -> None: 79 | respx_mock.route(dotstar).mock(resp_200) 80 | await self.sut.get_devices() 81 | assert len(respx_mock.calls) > 0 82 | self.respx_calls = copy.copy(respx_mock.calls) 83 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: iaqualink-py 2 | site_description: Asynchronous Python library for Jandy iAqualink pool control systems 3 | site_author: Florent Thoumie 4 | site_url: https://flz.github.io/iaqualink-py/ 5 | repo_name: flz/iaqualink-py 6 | repo_url: https://github.com/flz/iaqualink-py 7 | edit_uri: edit/master/docs/ 8 | 9 | theme: 10 | name: material 11 | palette: 12 | # Palette toggle for light mode 13 | - media: "(prefers-color-scheme: light)" 14 | scheme: default 15 | primary: blue 16 | accent: light blue 17 | toggle: 18 | icon: material/brightness-7 19 | name: Switch to dark mode 20 | # Palette toggle for dark mode 21 | - media: "(prefers-color-scheme: dark)" 22 | scheme: slate 23 | primary: blue 24 | accent: light blue 25 | toggle: 26 | icon: material/brightness-4 27 | name: Switch to light mode 28 | features: 29 | - navigation.tabs 30 | - navigation.sections 31 | - navigation.top 32 | - navigation.tracking 33 | - search.highlight 34 | - search.suggest 35 | - content.code.copy 36 | - content.code.annotate 37 | icon: 38 | repo: fontawesome/brands/github 39 | 40 | nav: 41 | - Home: index.md 42 | - Getting Started: 43 | - Installation: getting-started/installation.md 44 | - Quick Start: getting-started/quickstart.md 45 | - Authentication: getting-started/authentication.md 46 | - User Guide: 47 | - Systems: guide/systems.md 48 | - Devices: guide/devices.md 49 | - Examples: guide/examples.md 50 | - API Reference: 51 | - Client: api/client.md 52 | - System: api/system.md 53 | - Device: api/device.md 54 | - iAqua Systems: api/iaqua.md 55 | - eXO Systems: api/exo.md 56 | - Exceptions: api/exceptions.md 57 | - Development: 58 | - Contributing: development/contributing.md 59 | - Architecture: development/architecture.md 60 | 61 | plugins: 62 | - search 63 | - mkdocstrings: 64 | handlers: 65 | python: 66 | options: 67 | docstring_style: google 68 | show_source: true 69 | show_root_heading: true 70 | show_root_full_path: false 71 | show_symbol_type_heading: true 72 | show_symbol_type_toc: true 73 | members_order: source 74 | group_by_category: true 75 | show_category_heading: true 76 | show_if_no_docstring: false 77 | inherited_members: false 78 | filters: 79 | - "!^_" # Exclude private members 80 | signature_crossrefs: true 81 | 82 | markdown_extensions: 83 | - admonition 84 | - pymdownx.details 85 | - pymdownx.superfences 86 | - pymdownx.highlight: 87 | anchor_linenums: true 88 | line_spans: __span 89 | pygments_lang_class: true 90 | - pymdownx.inlinehilite 91 | - pymdownx.snippets 92 | - pymdownx.tabbed: 93 | alternate_style: true 94 | - tables 95 | - toc: 96 | permalink: true 97 | - attr_list 98 | - md_in_html 99 | 100 | extra: 101 | social: 102 | - icon: fontawesome/brands/github 103 | link: https://github.com/flz/iaqualink-py 104 | -------------------------------------------------------------------------------- /docs/api/system.md: -------------------------------------------------------------------------------- 1 | # System API 2 | 3 | The `AqualinkSystem` class represents a pool/spa control system. 4 | 5 | ## AqualinkSystem 6 | 7 | ::: iaqualink.system.AqualinkSystem 8 | 9 | ## Usage 10 | 11 | ### Getting Systems 12 | 13 | ```python 14 | async with AqualinkClient(username, password) as client: 15 | systems = await client.get_systems() 16 | system = list(systems.values())[0] 17 | ``` 18 | 19 | ### Updating State 20 | 21 | ```python 22 | # Update system state 23 | await system.update() 24 | 25 | # Check if online 26 | if system.online: 27 | print(f"System {system.name} is online") 28 | ``` 29 | 30 | ### Getting Devices 31 | 32 | ```python 33 | # Get all devices 34 | devices = await system.get_devices() 35 | 36 | # Access specific device 37 | pool_pump = devices.get('pool_pump') 38 | ``` 39 | 40 | ## Properties 41 | 42 | ### name 43 | 44 | User-friendly name of the system. 45 | 46 | **Type:** `str` 47 | 48 | ### serial 49 | 50 | Unique serial number identifying the system. 51 | 52 | **Type:** `str` 53 | 54 | ### online 55 | 56 | Whether the system is currently online. 57 | 58 | **Type:** `bool` 59 | 60 | ### data 61 | 62 | Raw system data from the API. 63 | 64 | **Type:** `dict[str, Any]` 65 | 66 | ### last_run_success 67 | 68 | Timestamp of the last successful update. 69 | 70 | **Type:** `float | None` 71 | 72 | ## Methods 73 | 74 | ### update() 75 | 76 | Fetch the latest system state from the API. 77 | 78 | Updates are rate-limited to once every 5 seconds. Calls within this window return cached data. 79 | 80 | **Returns:** `None` 81 | 82 | **Raises:** 83 | - `AqualinkSystemOfflineException` - System is offline 84 | - `AqualinkServiceException` - Service error occurred 85 | 86 | ### get_devices() 87 | 88 | Get all devices associated with this system. 89 | 90 | **Returns:** `dict[str, AqualinkDevice]` - Dictionary mapping device names to device objects 91 | 92 | **Raises:** 93 | - `AqualinkServiceException` - Service error occurred 94 | 95 | ## System Types 96 | 97 | The library includes two system implementations: 98 | 99 | ### IaquaSystem 100 | 101 | For iAqua systems using iaqualink.net API. 102 | 103 | **Device Type:** `"iaqua"` 104 | 105 | ### ExoSystem 106 | 107 | For eXO systems using zodiac-io.com API. 108 | 109 | **Device Type:** `"exo"` 110 | 111 | ## Factory Method 112 | 113 | ### from_data() 114 | 115 | Create a system instance from API data. 116 | 117 | ```python 118 | system = AqualinkSystem.from_data(client, system_data) 119 | ``` 120 | 121 | **Parameters:** 122 | - `aqualink` (`AqualinkClient`) - The client instance 123 | - `data` (`dict[str, Any]`) - System data from API 124 | 125 | **Returns:** `AqualinkSystem` - Appropriate system subclass instance 126 | 127 | ## Rate Limiting 128 | 129 | Systems implement automatic rate limiting with a minimum interval of 5 seconds between API calls: 130 | 131 | ```python 132 | # First call - fetches from API 133 | await system.update() 134 | 135 | # Immediate call - returns cached data 136 | await system.update() 137 | 138 | # After 5+ seconds - fetches fresh data 139 | await asyncio.sleep(5) 140 | await system.update() 141 | ``` 142 | 143 | ## See Also 144 | 145 | - [Client API](client.md) - Client reference 146 | - [Device API](device.md) - Device reference 147 | - [iAqua Systems](iaqua.md) - iAqua-specific details 148 | - [eXO Systems](exo.md) - eXO-specific details 149 | -------------------------------------------------------------------------------- /docs/api/iaqua.md: -------------------------------------------------------------------------------- 1 | # iAqua Systems API 2 | 3 | iAqua systems use the iaqualink.net API. 4 | 5 | ## IaquaSystem 6 | 7 | ::: iaqualink.systems.iaqua.system.IaquaSystem 8 | 9 | ## IaquaDevice 10 | 11 | ::: iaqualink.systems.iaqua.device.IaquaDevice 12 | 13 | ## Characteristics 14 | 15 | ### API Endpoint 16 | 17 | - **Base URL:** `https://support.iaqualink.com` 18 | - **API Version:** v1 19 | 20 | ### Authentication 21 | 22 | ```python 23 | # Authentication returns session tokens 24 | { 25 | "session_id": "...", 26 | "authentication_token": "..." 27 | } 28 | ``` 29 | 30 | Credentials are passed as query parameters in API requests. 31 | 32 | ### Device Refresh 33 | 34 | iAqua systems use a two-step refresh process: 35 | 36 | 1. **Home data** - Basic system information 37 | 2. **Device data** - Detailed device states 38 | 39 | ```python 40 | # Implemented internally by IaquaSystem.update() 41 | await system.update() 42 | ``` 43 | 44 | ### Command Format 45 | 46 | Commands are sent as session requests with specific command names: 47 | 48 | ```python 49 | # Example command structure 50 | { 51 | "command": "set_aux", 52 | "aux": "1", 53 | "state": "1" 54 | } 55 | ``` 56 | 57 | ## Device Types 58 | 59 | ### Temperature Sensors 60 | 61 | - `pool_temp` - Pool temperature 62 | - `spa_temp` - Spa temperature 63 | - `air_temp` - Air temperature 64 | 65 | ### Pumps 66 | 67 | - `pool_pump` - Main pool pump 68 | - `spa_pump` - Spa pump 69 | - `pool_filter_pump` - Filter pump 70 | 71 | ### Heaters 72 | 73 | - `pool_heater` - Pool heater 74 | - `spa_heater` - Spa heater 75 | - `solar_heater` - Solar heater 76 | 77 | ### Thermostats 78 | 79 | - `pool_set_point` - Pool temperature setpoint 80 | - `spa_set_point` - Spa temperature setpoint 81 | 82 | Temperature ranges: 83 | - **Fahrenheit:** 32°F - 104°F 84 | - **Celsius:** 0°C - 40°C 85 | 86 | ### Lights 87 | 88 | - `pool_light` - Pool light 89 | - `spa_light` - Spa light 90 | 91 | ### Auxiliary Devices 92 | 93 | - `aux_1` through `aux_7` - Configurable auxiliary switches 94 | 95 | ### Chemistry Sensors 96 | 97 | - `ph` - pH level (0-14) 98 | - `orp` - Oxidation-reduction potential (mV) 99 | - `salt` - Salt level (ppm) 100 | 101 | ### Status Sensors 102 | 103 | - `freeze_protection` - Freeze protection status 104 | 105 | ## Usage Example 106 | 107 | ```python 108 | from iaqualink import AqualinkClient 109 | 110 | async with AqualinkClient(username, password) as client: 111 | systems = await client.get_systems() 112 | 113 | # Find iAqua system 114 | for system in systems.values(): 115 | if system.data.get('device_type') == 'iaqua': 116 | print(f"Found iAqua system: {system.name}") 117 | 118 | devices = await system.get_devices() 119 | 120 | # Control pool pump 121 | pool_pump = devices.get('pool_pump') 122 | await pool_pump.turn_on() 123 | 124 | # Set spa temperature 125 | spa_thermostat = devices.get('spa_set_point') 126 | await spa_thermostat.set_temperature(102) 127 | ``` 128 | 129 | ## API Details 130 | 131 | ### Rate Limiting 132 | 133 | 5-second minimum interval between updates (enforced by base class). 134 | 135 | ### Data Format 136 | 137 | Device data includes: 138 | ```python 139 | { 140 | "name": "device_name", 141 | "label": "User Label", 142 | "state": "1", # String representation 143 | "type": "device_type", 144 | # ... additional fields 145 | } 146 | ``` 147 | 148 | ## See Also 149 | 150 | - [System API](system.md) - Base system reference 151 | - [Device API](device.md) - Base device reference 152 | - [eXO Systems](exo.md) - Compare with eXO systems 153 | -------------------------------------------------------------------------------- /.claude/commands/update-python-version.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Update the minimum Python version requirement across the entire codebase 3 | --- 4 | 5 | # Update Python Version Command 6 | 7 | You are tasked with updating the minimum Python version requirement across the entire codebase. 8 | 9 | ## Input 10 | 11 | The user will provide the new minimum Python version in the format "3.X" (e.g., "3.14", "3.15"). 12 | 13 | ## Steps to Execute 14 | 15 | ### 1. Validate Input 16 | - Ensure the provided version is in the correct format (e.g., "3.14") 17 | - Extract the old version from `pyproject.toml` by reading the `requires-python` field 18 | 19 | ### 2. Search for All References 20 | Search for all occurrences of the old Python version across the codebase. You must check at the very least: 21 | - `pyproject.toml` - `requires-python` field 22 | - `.readthedocs.yml` - Python version specification 23 | - `.github/workflows/*.yaml` - Python version matrices and specifications 24 | - `README.md` - Badge and requirements sections 25 | - `docs/index.md` - Badge and requirements sections 26 | - `docs/getting-started/installation.md` - Requirements section 27 | - `docs/development/contributing.md` - Prerequisites section 28 | - `docs/development/architecture.md` - Type hints references 29 | - `CLAUDE.md` - Any version references 30 | 31 | Use Grep to search for patterns like: 32 | - The old version number (e.g., "3.13") 33 | - Badge URLs containing the version (e.g., "python-3.13%2B") 34 | - Version specifications in YAML files 35 | 36 | ### 3. Update All Files 37 | Update ALL occurrences of the old version to the new version: 38 | 39 | #### pyproject.toml 40 | - Update `requires-python = ">=X.Y"` to the new version 41 | - Update classifier `"Programming Language :: Python :: X.Y"` if present 42 | - Note: This may trigger `uv.lock` regeneration 43 | 44 | #### .readthedocs.yml 45 | - Update `python: "X.Y"` to the new version 46 | 47 | #### .github/workflows/*.yaml 48 | - Update Python version matrices (e.g., `python-version: ["X.Y", ...]`) 49 | - Add new version, potentially remove old unsupported versions 50 | 51 | #### Documentation Files 52 | - Update badges from `python-X.Y%2B` to `python-X.Z%2B` 53 | - Update requirements text from "Python X.Y or higher" to "Python X.Z or higher" 54 | - Update any other references (e.g., "(3.Y+)" to "(3.Z+)") 55 | 56 | ### 4. Regenerate Lock File 57 | After updating `pyproject.toml`, regenerate the lock file: 58 | ```bash 59 | uv sync --all-extras --group dev --group test --group docs 60 | ``` 61 | 62 | ### 5. Run Quality Checks 63 | Execute all quality checks to ensure nothing broke: 64 | 65 | ```bash 66 | # Run linting and formatting 67 | uv run pre-commit run --all-files 68 | 69 | # Run type checking 70 | uv run mypy src/ 71 | 72 | # Run tests 73 | uv run pytest 74 | ``` 75 | 76 | ### 6. Verify No Remaining References 77 | After all updates, search again for the old version to ensure no references were missed: 78 | ```bash 79 | grep -r "X.Y" . --exclude-dir=.git --exclude-dir=.venv --exclude="*.lock" 80 | ``` 81 | 82 | Review any remaining occurrences to determine if they need updating or are false positives. 83 | 84 | ### 7. Summary Report 85 | Provide a summary of: 86 | - All files that were updated 87 | - The specific changes made in each file 88 | - Test results and whether all checks passed 89 | - Any remaining references to the old version (with explanation if they're false positives) 90 | 91 | ## Error Handling 92 | 93 | If any step fails: 94 | 1. Report the failure clearly 95 | 2. Do not proceed to subsequent steps 96 | 3. Provide guidance on how to fix the issue 97 | 98 | ## Notes 99 | 100 | - Always use exact string matching when replacing versions to avoid false positives 101 | - Be careful with version strings that appear in git history or lock files 102 | - Ensure all badge URLs are properly URL-encoded (use %2B for +) 103 | - The command should be idempotent - running it multiple times should be safe 104 | -------------------------------------------------------------------------------- /src/iaqualink/device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from iaqualink.exception import AqualinkOperationNotSupportedException 7 | 8 | if TYPE_CHECKING: 9 | from iaqualink.typing import DeviceData 10 | 11 | LOGGER = logging.getLogger("iaqualink") 12 | 13 | 14 | class AqualinkDevice: 15 | def __init__( 16 | self, 17 | system: Any, # Should be AqualinkSystem but causes mypy errors. 18 | data: DeviceData, 19 | ): 20 | self.system = system 21 | self.data = data 22 | 23 | def __repr__(self) -> str: 24 | attrs = ["data"] 25 | attrs = [f"{i}={getattr(self, i)!r}" for i in attrs] 26 | return f"{self.__class__.__name__}({', '.join(attrs)})" 27 | 28 | def __eq__(self, other: object) -> bool: 29 | if not isinstance(other, AqualinkDevice): 30 | return NotImplemented 31 | 32 | if ( 33 | self.system.serial == other.system.serial 34 | and self.data == other.data 35 | ): 36 | return True 37 | return False 38 | 39 | @property 40 | def label(self) -> str: 41 | raise NotImplementedError 42 | 43 | @property 44 | def state(self) -> str: 45 | raise NotImplementedError 46 | 47 | @property 48 | def name(self) -> str: 49 | raise NotImplementedError 50 | 51 | @property 52 | def manufacturer(self) -> str: 53 | raise NotImplementedError 54 | 55 | @property 56 | def model(self) -> str: 57 | raise NotImplementedError 58 | 59 | 60 | class AqualinkSensor(AqualinkDevice): 61 | pass 62 | 63 | 64 | class AqualinkBinarySensor(AqualinkSensor): 65 | """These are non-actionable sensors, essentially read-only on/off.""" 66 | 67 | @property 68 | def is_on(self) -> bool: 69 | raise NotImplementedError 70 | 71 | 72 | class AqualinkSwitch(AqualinkBinarySensor, AqualinkDevice): 73 | async def turn_on(self) -> None: 74 | raise NotImplementedError 75 | 76 | async def turn_off(self) -> None: 77 | raise NotImplementedError 78 | 79 | 80 | class AqualinkLight(AqualinkSwitch, AqualinkDevice): 81 | @property 82 | def brightness(self) -> int | None: 83 | return None 84 | 85 | @property 86 | def supports_brightness(self) -> bool: 87 | return self.brightness is not None 88 | 89 | async def set_brightness(self, _: int) -> None: 90 | if self.supports_brightness is True: 91 | raise NotImplementedError 92 | raise AqualinkOperationNotSupportedException 93 | 94 | @property 95 | def effect(self) -> str | None: 96 | return None 97 | 98 | @property 99 | def supports_effect(self) -> bool: 100 | return self.effect is not None 101 | 102 | async def set_effect_by_name(self, _: str) -> None: 103 | if self.supports_effect is True: 104 | raise NotImplementedError 105 | raise AqualinkOperationNotSupportedException 106 | 107 | async def set_effect_by_id(self, _: int) -> None: 108 | if self.supports_effect is True: 109 | raise NotImplementedError 110 | raise AqualinkOperationNotSupportedException 111 | 112 | 113 | class AqualinkThermostat(AqualinkSwitch, AqualinkDevice): 114 | @property 115 | def unit(self) -> str: 116 | raise NotImplementedError 117 | 118 | @property 119 | def current_temperature(self) -> str: 120 | raise NotImplementedError 121 | 122 | @property 123 | def target_temperature(self) -> str: 124 | raise NotImplementedError 125 | 126 | @property 127 | def max_temperature(self) -> int: 128 | raise NotImplementedError 129 | 130 | @property 131 | def min_temperature(self) -> int: 132 | raise NotImplementedError 133 | 134 | async def set_temperature(self, _: int) -> None: 135 | raise NotImplementedError 136 | -------------------------------------------------------------------------------- /docs/guide/systems.md: -------------------------------------------------------------------------------- 1 | # Systems 2 | 3 | iaqualink-py supports multiple Jandy pool system types through a unified interface. 4 | 5 | ## System Types 6 | 7 | ### iAqua Systems 8 | 9 | iAqua systems use the iaqualink.net API and are the original Jandy iAqualink systems. 10 | 11 | **Characteristics:** 12 | - API endpoint: iaqualink.net 13 | - Authentication via session tokens 14 | - Two-step device refresh (home + devices) 15 | - Commands sent as session requests 16 | 17 | ### eXO Systems 18 | 19 | eXO systems are newer Zodiac systems using the zodiac-io.com API. 20 | 21 | **Characteristics:** 22 | - API endpoint: zodiac-io.com 23 | - JWT token authentication 24 | - AWS IoT-style shadow state 25 | - Desired/reported state pattern 26 | 27 | ## Discovering Systems 28 | 29 | ```python 30 | from iaqualink import AqualinkClient 31 | 32 | async with AqualinkClient('user@example.com', 'password') as client: 33 | # Returns dict mapping serial numbers to system objects 34 | systems = await client.get_systems() 35 | 36 | for serial, system in systems.items(): 37 | print(f"Serial: {serial}") 38 | print(f"Name: {system.name}") 39 | print(f"Type: {system.data.get('device_type')}") 40 | ``` 41 | 42 | ## System Properties 43 | 44 | All systems have these common properties: 45 | 46 | ```python 47 | # System identification 48 | system.name # User-friendly name 49 | system.serial # Unique serial number 50 | 51 | # System status 52 | system.online # Boolean indicating if system is online 53 | system.data # Raw system data from API 54 | 55 | # Last update time 56 | system.last_run_success # Timestamp of last successful update 57 | ``` 58 | 59 | ## Updating System State 60 | 61 | Systems cache their state and rate-limit API calls to 5-second intervals: 62 | 63 | ```python 64 | # First update - fetches from API 65 | await system.update() 66 | 67 | # Immediate subsequent call - returns cached data 68 | await system.update() 69 | 70 | # After 5+ seconds - fetches fresh data 71 | import asyncio 72 | await asyncio.sleep(5) 73 | await system.update() 74 | ``` 75 | 76 | ## Getting Devices 77 | 78 | Each system manages a collection of devices: 79 | 80 | ```python 81 | # Get all devices as a dictionary 82 | devices = await system.get_devices() 83 | 84 | # Access specific device by name 85 | pool_pump = devices.get('pool_pump') 86 | 87 | # Iterate over all devices 88 | for name, device in devices.items(): 89 | print(f"{name}: {device.label}") 90 | ``` 91 | 92 | ## System Offline Handling 93 | 94 | Handle offline systems gracefully: 95 | 96 | ```python 97 | from iaqualink import AqualinkSystemOfflineException 98 | 99 | try: 100 | await system.update() 101 | except AqualinkSystemOfflineException: 102 | print(f"System {system.name} is offline") 103 | ``` 104 | 105 | ## System Type Detection 106 | 107 | The library automatically selects the correct system implementation: 108 | 109 | ```python 110 | # System type is detected automatically 111 | systems = await client.get_systems() 112 | 113 | # Check system type 114 | for system in systems.values(): 115 | if system.data.get('device_type') == 'iaqua': 116 | print("This is an iAqua system") 117 | elif system.data.get('device_type') == 'exo': 118 | print("This is an eXO system") 119 | ``` 120 | 121 | ## Advanced Usage 122 | 123 | ### Manual System Creation 124 | 125 | Typically you don't need to create systems manually, but it's possible: 126 | 127 | ```python 128 | from iaqualink.systems.iaqua import IaquaSystem 129 | 130 | # Create system from data (rarely needed) 131 | system = IaquaSystem.from_data(client, system_data) 132 | ``` 133 | 134 | ### Accessing Raw API Data 135 | 136 | ```python 137 | # Access raw system data 138 | print(system.data) 139 | 140 | # After update, check what changed 141 | await system.update() 142 | print(system.data) 143 | ``` 144 | 145 | ## Next Steps 146 | 147 | - [Devices Guide](devices.md) - Learn about device types 148 | - [Examples](examples.md) - See complete examples 149 | - [API Reference](../api/system.md) - Detailed API documentation 150 | -------------------------------------------------------------------------------- /docs/getting-started/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | This guide will help you get started with iaqualink-py quickly. 4 | 5 | ## Basic Usage 6 | 7 | ### Connecting to Your System 8 | 9 | ```python 10 | from iaqualink import AqualinkClient 11 | 12 | async with AqualinkClient('user@example.com', 'password') as client: 13 | # Discover your pool systems 14 | systems = await client.get_systems() 15 | 16 | # Get the first system 17 | system = list(systems.values())[0] 18 | print(f"Found system: {system.name}") 19 | ``` 20 | 21 | ### Getting Devices 22 | 23 | ```python 24 | # Get all devices for a system 25 | devices = await system.get_devices() 26 | 27 | # Access specific devices 28 | pool_temp = devices.get('pool_temp') 29 | if pool_temp: 30 | print(f"Pool temperature: {pool_temp.state}°F") 31 | 32 | spa_heater = devices.get('spa_heater') 33 | if spa_heater: 34 | print(f"Spa heater: {'ON' if spa_heater.is_on else 'OFF'}") 35 | ``` 36 | 37 | ### Controlling Devices 38 | 39 | #### Switches and Pumps 40 | 41 | ```python 42 | # Turn on pool pump 43 | pool_pump = devices.get('pool_pump') 44 | if pool_pump: 45 | await pool_pump.turn_on() 46 | 47 | # Turn off spa heater 48 | spa_heater = devices.get('spa_heater') 49 | if spa_heater: 50 | await spa_heater.turn_off() 51 | ``` 52 | 53 | #### Lights 54 | 55 | ```python 56 | # Toggle pool light 57 | pool_light = devices.get('aux_3') 58 | if pool_light: 59 | await pool_light.toggle() 60 | ``` 61 | 62 | #### Thermostats 63 | 64 | ```python 65 | # Set spa temperature 66 | spa_thermostat = devices.get('spa_set_point') 67 | if spa_thermostat: 68 | await spa_thermostat.set_temperature(102) 69 | 70 | # Set pool temperature 71 | pool_thermostat = devices.get('pool_set_point') 72 | if pool_thermostat: 73 | await pool_thermostat.set_temperature(82) 74 | ``` 75 | 76 | ### Monitoring System Status 77 | 78 | ```python 79 | # Update system state 80 | await system.update() 81 | 82 | # Check if system is online 83 | if system.online: 84 | print(f"System {system.name} is online") 85 | 86 | # Get all temperature readings 87 | for device_name, device in devices.items(): 88 | if 'temp' in device_name and device.state: 89 | print(f"{device.label}: {device.state}°") 90 | ``` 91 | 92 | ## Working with Multiple Systems 93 | 94 | If you have multiple pool systems: 95 | 96 | ```python 97 | async with AqualinkClient('user@example.com', 'password') as client: 98 | systems = await client.get_systems() 99 | 100 | for serial, system in systems.items(): 101 | print(f"System: {system.name} ({serial})") 102 | print(f"Type: {system.data.get('device_type')}") 103 | 104 | devices = await system.get_devices() 105 | print(f"Devices: {len(devices)}") 106 | ``` 107 | 108 | ## Rate Limiting 109 | 110 | The library automatically rate-limits updates to once every 5 seconds per system to respect API limits. Subsequent calls within this window return cached data. 111 | 112 | ```python 113 | # First call - fetches from API 114 | await system.update() 115 | 116 | # Immediate second call - returns cached data 117 | await system.update() 118 | 119 | # After 5+ seconds - fetches fresh data 120 | await asyncio.sleep(5) 121 | await system.update() 122 | ``` 123 | 124 | ## Error Handling 125 | 126 | ```python 127 | from iaqualink import ( 128 | AqualinkClient, 129 | AqualinkServiceException, 130 | AqualinkServiceUnauthorizedException, 131 | ) 132 | 133 | try: 134 | async with AqualinkClient('user@example.com', 'password') as client: 135 | systems = await client.get_systems() 136 | except AqualinkServiceUnauthorizedException: 137 | print("Invalid credentials") 138 | except AqualinkServiceException as e: 139 | print(f"Service error: {e}") 140 | ``` 141 | 142 | ## Next Steps 143 | 144 | - [Authentication](authentication.md) - Learn about authentication details 145 | - [Systems Guide](../guide/systems.md) - Deep dive into systems 146 | - [Devices Guide](../guide/devices.md) - Learn about device types 147 | - [Examples](../guide/examples.md) - See more complete examples 148 | -------------------------------------------------------------------------------- /DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # Documentation Setup 2 | 3 | This project uses MkDocs with the Material theme for documentation. 4 | 5 | ## Setup 6 | 7 | Install documentation dependencies: 8 | 9 | ```bash 10 | uv sync --group docs 11 | ``` 12 | 13 | ## Local Development 14 | 15 | Serve documentation locally with live reload: 16 | 17 | ```bash 18 | uv run mkdocs serve 19 | ``` 20 | 21 | Then visit http://127.0.0.1:8000 22 | 23 | ## Building 24 | 25 | Build the documentation: 26 | 27 | ```bash 28 | uv run mkdocs build 29 | ``` 30 | 31 | Build with strict mode (fail on warnings): 32 | 33 | ```bash 34 | uv run mkdocs build --strict 35 | ``` 36 | 37 | ## Deployment 38 | 39 | ### GitHub Pages 40 | 41 | Documentation is automatically deployed to GitHub Pages when changes are pushed to the `master` or `main` branch. 42 | 43 | The workflow is defined in `.github/workflows/docs.yml`. 44 | 45 | **Setup GitHub Pages:** 46 | 47 | 1. Go to your repository settings 48 | 2. Navigate to Pages 49 | 3. Set Source to "GitHub Actions" 50 | 4. The documentation will be available at: https://flz.github.io/iaqualink-py/ 51 | 52 | ### ReadTheDocs 53 | 54 | Documentation can also be hosted on ReadTheDocs. 55 | 56 | **Setup ReadTheDocs:** 57 | 58 | 1. Go to https://readthedocs.org/ 59 | 2. Import your project 60 | 3. The configuration is in `.readthedocs.yml` 61 | 4. Documentation will be available at: https://iaqualink-py.readthedocs.io/ 62 | 63 | ## Documentation Structure 64 | 65 | ``` 66 | docs/ 67 | ├── index.md # Home page 68 | ├── getting-started/ 69 | │ ├── installation.md # Installation guide 70 | │ ├── quickstart.md # Quick start guide 71 | │ └── authentication.md # Authentication details 72 | ├── guide/ 73 | │ ├── systems.md # Systems guide 74 | │ ├── devices.md # Devices guide 75 | │ └── examples.md # Code examples 76 | ├── api/ 77 | │ ├── client.md # Client API reference 78 | │ ├── system.md # System API reference 79 | │ ├── device.md # Device API reference 80 | │ ├── iaqua.md # iAqua systems 81 | │ ├── exo.md # eXO systems 82 | │ └── exceptions.md # Exceptions reference 83 | └── development/ 84 | ├── contributing.md # Contributing guide 85 | └── architecture.md # Architecture documentation 86 | ``` 87 | 88 | ## Features 89 | 90 | - **Material Theme** - Modern, responsive design 91 | - **Code Highlighting** - Syntax highlighting for Python code 92 | - **Search** - Full-text search functionality 93 | - **API Documentation** - Auto-generated from docstrings using mkdocstrings 94 | - **Dark Mode** - Automatic light/dark theme switching 95 | - **Mobile Friendly** - Responsive design for mobile devices 96 | 97 | ## Configuration 98 | 99 | The documentation is configured in `mkdocs.yml`: 100 | 101 | - Site metadata (name, description, URLs) 102 | - Theme settings (Material theme with dark mode) 103 | - Navigation structure 104 | - Plugins (search, mkdocstrings) 105 | - Markdown extensions 106 | 107 | ## Writing Documentation 108 | 109 | ### Markdown Files 110 | 111 | Documentation pages are written in Markdown with support for: 112 | 113 | - Standard Markdown syntax 114 | - Code blocks with syntax highlighting 115 | - Admonitions (notes, warnings, etc.) 116 | - Tables 117 | - Task lists 118 | 119 | ### API Documentation 120 | 121 | API documentation is automatically generated from docstrings using mkdocstrings: 122 | 123 | ```markdown 124 | ::: iaqualink.client.AqualinkClient 125 | ``` 126 | 127 | This will include the class documentation, methods, and properties. 128 | 129 | ### Code Examples 130 | 131 | Use fenced code blocks with language specification: 132 | 133 | ```markdown 134 | \`\`\`python 135 | from iaqualink import AqualinkClient 136 | 137 | async with AqualinkClient(username, password) as client: 138 | systems = await client.get_systems() 139 | \`\`\` 140 | ``` 141 | 142 | ### Admonitions 143 | 144 | Use admonitions for notes, warnings, and tips: 145 | 146 | ```markdown 147 | !!! note "Optional Title" 148 | This is a note. 149 | 150 | !!! warning 151 | This is a warning. 152 | 153 | !!! tip 154 | This is a tip. 155 | ``` 156 | 157 | ## Dependencies 158 | 159 | Documentation dependencies are in `pyproject.toml` under `[dependency-groups.docs]`: 160 | 161 | - `mkdocs` - Static site generator 162 | - `mkdocs-material` - Material theme 163 | - `mkdocstrings[python]` - API documentation from docstrings 164 | -------------------------------------------------------------------------------- /docs/api/device.md: -------------------------------------------------------------------------------- 1 | # Device API 2 | 3 | Device classes represent individual pool equipment and sensors. 4 | 5 | ## Base Classes 6 | 7 | ### AqualinkDevice 8 | 9 | ::: iaqualink.device.AqualinkDevice 10 | 11 | ### AqualinkSensor 12 | 13 | ::: iaqualink.device.AqualinkSensor 14 | 15 | ### AqualinkBinarySensor 16 | 17 | ::: iaqualink.device.AqualinkBinarySensor 18 | 19 | ### AqualinkSwitch 20 | 21 | ::: iaqualink.device.AqualinkSwitch 22 | 23 | ### AqualinkLight 24 | 25 | ::: iaqualink.device.AqualinkLight 26 | 27 | ### AqualinkThermostat 28 | 29 | ::: iaqualink.device.AqualinkThermostat 30 | 31 | ## Device Hierarchy 32 | 33 | ``` 34 | AqualinkDevice 35 | ├── AqualinkSensor (read-only state) 36 | │ └── AqualinkBinarySensor (on/off state) 37 | │ └── AqualinkSwitch (controllable on/off) 38 | │ ├── AqualinkLight (toggle support) 39 | │ └── AqualinkThermostat (temperature control) 40 | ``` 41 | 42 | ## Common Properties 43 | 44 | All devices inherit these properties: 45 | 46 | ### name 47 | 48 | Internal device identifier. 49 | 50 | **Type:** `str` 51 | 52 | ### label 53 | 54 | User-friendly device label. 55 | 56 | **Type:** `str` 57 | 58 | ### state 59 | 60 | Current device state (type varies by device). 61 | 62 | **Type:** `Any` 63 | 64 | ### system 65 | 66 | Reference to parent system. 67 | 68 | **Type:** `AqualinkSystem` 69 | 70 | ### data 71 | 72 | Raw device data from API. 73 | 74 | **Type:** `dict[str, Any]` 75 | 76 | ## Sensor Properties 77 | 78 | ### unit 79 | 80 | Measurement unit for sensor readings. 81 | 82 | **Type:** `str` 83 | 84 | ## Binary Sensor Properties 85 | 86 | ### is_on 87 | 88 | Boolean indicating if device is on. 89 | 90 | **Type:** `bool` 91 | 92 | ## Switch Methods 93 | 94 | ### turn_on() 95 | 96 | Turn the switch on. 97 | 98 | **Returns:** `None` 99 | 100 | **Raises:** 101 | - `AqualinkServiceException` - Command failed 102 | 103 | ### turn_off() 104 | 105 | Turn the switch off. 106 | 107 | **Returns:** `None` 108 | 109 | **Raises:** 110 | - `AqualinkServiceException` - Command failed 111 | 112 | ## Light Methods 113 | 114 | ### toggle() 115 | 116 | Toggle the light state. 117 | 118 | **Returns:** `None` 119 | 120 | **Raises:** 121 | - `AqualinkServiceException` - Command failed 122 | 123 | ## Thermostat Properties 124 | 125 | ### min_temperature 126 | 127 | Minimum allowed temperature. 128 | 129 | **Type:** `int` 130 | 131 | ### max_temperature 132 | 133 | Maximum allowed temperature. 134 | 135 | **Type:** `int` 136 | 137 | ## Thermostat Methods 138 | 139 | ### set_temperature() 140 | 141 | Set target temperature. 142 | 143 | **Parameters:** 144 | - `temperature` (`int`) - Target temperature in device's unit 145 | 146 | **Returns:** `None` 147 | 148 | **Raises:** 149 | - `AqualinkServiceException` - Command failed 150 | 151 | ## Usage Examples 152 | 153 | ### Sensors 154 | 155 | ```python 156 | # Temperature sensor 157 | pool_temp = devices.get('pool_temp') 158 | print(f"Pool: {pool_temp.state}°{pool_temp.unit}") 159 | 160 | # Chemistry sensor 161 | ph_sensor = devices.get('ph') 162 | if ph_sensor: 163 | print(f"pH: {ph_sensor.state}") 164 | ``` 165 | 166 | ### Binary Sensors 167 | 168 | ```python 169 | # Check status 170 | freeze = devices.get('freeze_protection') 171 | if freeze.is_on: 172 | print("Freeze protection active") 173 | ``` 174 | 175 | ### Switches 176 | 177 | ```python 178 | # Control pump 179 | pool_pump = devices.get('pool_pump') 180 | await pool_pump.turn_on() 181 | await pool_pump.turn_off() 182 | 183 | # Check state 184 | if pool_pump.is_on: 185 | print("Pump is running") 186 | ``` 187 | 188 | ### Lights 189 | 190 | ```python 191 | # Control light 192 | pool_light = devices.get('pool_light') 193 | await pool_light.turn_on() 194 | await pool_light.toggle() # Turn off 195 | await pool_light.toggle() # Turn on 196 | ``` 197 | 198 | ### Thermostats 199 | 200 | ```python 201 | # Set temperature 202 | spa_thermostat = devices.get('spa_set_point') 203 | print(f"Range: {spa_thermostat.min_temperature}-{spa_thermostat.max_temperature}") 204 | await spa_thermostat.set_temperature(102) 205 | ``` 206 | 207 | ## Device Discovery 208 | 209 | Devices are accessed through the system: 210 | 211 | ```python 212 | # Get all devices 213 | devices = await system.get_devices() 214 | 215 | # Access by name 216 | device = devices.get('pool_pump') 217 | 218 | # Iterate 219 | for name, device in devices.items(): 220 | print(f"{name}: {device.label}") 221 | ``` 222 | 223 | ## State Updates 224 | 225 | Device state is updated when the parent system updates: 226 | 227 | ```python 228 | # Update system 229 | await system.update() 230 | 231 | # Device state is now current 232 | print(pool_pump.is_on) 233 | ``` 234 | 235 | ## See Also 236 | 237 | - [System API](system.md) - System reference 238 | - [iAqua Devices](iaqua.md) - iAqua-specific devices 239 | - [eXO Devices](exo.md) - eXO-specific devices 240 | - [Devices Guide](../guide/devices.md) - Device usage guide 241 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | import httpx 6 | import pytest 7 | 8 | from iaqualink.client import AqualinkClient 9 | from iaqualink.exception import ( 10 | AqualinkServiceException, 11 | AqualinkServiceUnauthorizedException, 12 | ) 13 | 14 | from .base import TestBase 15 | from .common import async_noop, async_raises 16 | 17 | LOGIN_DATA = { 18 | "id": "id", 19 | "authentication_token": "token", 20 | "session_id": "session_id", 21 | "userPoolOAuth": {"IdToken": "userPoolOAuth:IdToken"}, 22 | } 23 | 24 | 25 | class TestAqualinkClient(TestBase): 26 | def setUp(self) -> None: 27 | super().setUp() 28 | 29 | @patch.object(AqualinkClient, "login") 30 | async def test_context_manager(self, mock_login) -> None: 31 | mock_login.return_value = async_noop 32 | 33 | async with self.client: 34 | pass 35 | 36 | @patch.object(AqualinkClient, "login") 37 | async def test_context_manager_login_exception(self, mock_login) -> None: 38 | mock_login.side_effect = async_raises(AqualinkServiceException) 39 | 40 | with pytest.raises(AqualinkServiceException): 41 | async with self.client: 42 | pass 43 | 44 | @patch("iaqualink.client.AqualinkClient.login", async_noop) 45 | async def test_context_manager_with_client(self) -> None: 46 | client = httpx.AsyncClient() 47 | async with AqualinkClient("user", "pass", httpx_client=client): 48 | pass 49 | 50 | # Clean up. 51 | await client.aclose() 52 | 53 | @patch("httpx.AsyncClient.request") 54 | async def test_login_success(self, mock_request) -> None: 55 | mock_request.return_value.status_code = 200 56 | mock_request.return_value.json = MagicMock(return_value=LOGIN_DATA) 57 | 58 | assert self.client.logged is False 59 | 60 | await self.client.login() 61 | 62 | assert self.client.logged is True 63 | 64 | @patch("httpx.AsyncClient.request") 65 | async def test_login_failed(self, mock_request) -> None: 66 | mock_request.return_value.status_code = 401 67 | 68 | assert self.client.logged is False 69 | 70 | with pytest.raises(AqualinkServiceException): 71 | await self.client.login() 72 | 73 | assert self.client.logged is False 74 | 75 | @patch("httpx.AsyncClient.request") 76 | async def test_login_exception(self, mock_request) -> None: 77 | mock_request.return_value.status_code = 500 78 | 79 | assert self.client.logged is False 80 | 81 | with pytest.raises(AqualinkServiceException): 82 | await self.client.login() 83 | 84 | assert self.client.logged is False 85 | 86 | @patch("httpx.AsyncClient.request") 87 | async def test_unexpectedly_logged_out(self, mock_request) -> None: 88 | mock_request.return_value.status_code = 200 89 | mock_request.return_value.json = MagicMock(return_value=LOGIN_DATA) 90 | 91 | await self.client.login() 92 | 93 | assert self.client.logged is True 94 | 95 | mock_request.return_value.status_code = 401 96 | mock_request.return_value.json = MagicMock(return_value={}) 97 | 98 | with pytest.raises(AqualinkServiceUnauthorizedException): 99 | await self.client.get_systems() 100 | 101 | assert self.client.logged is False 102 | 103 | @patch("httpx.AsyncClient.request") 104 | async def test_systems_request_system_unsupported( 105 | self, mock_request 106 | ) -> None: 107 | mock_request.return_value.status_code = 200 108 | mock_request.return_value.json = MagicMock(return_value=LOGIN_DATA) 109 | 110 | await self.client.login() 111 | 112 | mock_request.return_value.status_code = 200 113 | mock_request.return_value.json.return_value = [ 114 | { 115 | "device_type": "foo", 116 | "serial_number": "SN123456", 117 | } 118 | ] 119 | 120 | systems = await self.client.get_systems() 121 | assert len(systems) == 0 122 | 123 | @patch("httpx.AsyncClient.request") 124 | async def test_systems_request(self, mock_request) -> None: 125 | mock_request.return_value.status_code = 200 126 | mock_request.return_value.json = MagicMock(return_value=LOGIN_DATA) 127 | 128 | await self.client.login() 129 | 130 | mock_request.return_value.status_code = 200 131 | mock_request.return_value.json.return_value = [ 132 | { 133 | "device_type": "iaqua", 134 | "serial_number": "SN123456", 135 | } 136 | ] 137 | 138 | systems = await self.client.get_systems() 139 | assert len(systems) == 1 140 | 141 | @patch("httpx.AsyncClient.request") 142 | async def test_systems_request_unauthorized(self, mock_request) -> None: 143 | mock_request.return_value.status_code = 404 144 | 145 | with pytest.raises(AqualinkServiceUnauthorizedException): 146 | await self.client.get_systems() 147 | -------------------------------------------------------------------------------- /docs/api/exo.md: -------------------------------------------------------------------------------- 1 | # eXO Systems API 2 | 3 | eXO systems use the zodiac-io.com API with AWS IoT-style shadow state. 4 | 5 | ## ExoSystem 6 | 7 | ::: iaqualink.systems.exo.system.ExoSystem 8 | 9 | ## ExoDevice 10 | 11 | ::: iaqualink.systems.exo.device.ExoDevice 12 | 13 | ## Characteristics 14 | 15 | ### API Endpoint 16 | 17 | - **Base URL:** `https://r-api.zodiac-io.com` 18 | - **API Type:** AWS IoT Device Shadow 19 | 20 | ### Authentication 21 | 22 | ```python 23 | # Authentication returns JWT token 24 | { 25 | "userPoolOAuth": { 26 | "IdToken": "eyJ..." 27 | } 28 | } 29 | ``` 30 | 31 | Token is used in Authorization header: 32 | ``` 33 | Authorization: IdToken 34 | ``` 35 | 36 | ### Token Refresh 37 | 38 | Tokens are automatically refreshed on 401 responses. 39 | 40 | ### State Management 41 | 42 | eXO systems use AWS IoT shadow state pattern: 43 | 44 | ```python 45 | { 46 | "state": { 47 | "reported": { 48 | # Current device states 49 | }, 50 | "desired": { 51 | # Desired device states (for commands) 52 | } 53 | } 54 | } 55 | ``` 56 | 57 | ### Command Format 58 | 59 | Commands update the desired state: 60 | 61 | ```python 62 | # Example command 63 | { 64 | "state": { 65 | "desired": { 66 | "equipment": { 67 | "0": { 68 | "desiredState": 1 69 | } 70 | } 71 | } 72 | } 73 | } 74 | ``` 75 | 76 | ## Device Types 77 | 78 | ### Temperature Sensors 79 | 80 | - `pool_temp` - Pool temperature 81 | - `spa_temp` - Spa temperature 82 | - `air_temp` - Air temperature 83 | 84 | ### Equipment 85 | 86 | Equipment is identified by numeric indices (0, 1, 2, etc.) with types: 87 | 88 | - **Pumps** (`type: 1`) 89 | - Pool pump 90 | - Spa pump 91 | - Other circulation pumps 92 | 93 | - **Heaters** (`type: 2`) 94 | - Pool heater 95 | - Spa heater 96 | 97 | - **Lights** (`type: 3`) 98 | - Pool light 99 | - Spa light 100 | 101 | - **Auxiliary** (`type: 4`) 102 | - Generic on/off switches 103 | 104 | ### Thermostats 105 | 106 | - `pool_set_point` - Pool temperature setpoint 107 | - `spa_set_point` - Spa temperature setpoint 108 | 109 | Temperature ranges: 110 | - **Fahrenheit:** 40°F - 104°F 111 | - **Celsius:** 4°C - 40°C 112 | 113 | ### Chemistry Sensors 114 | 115 | - `ph` - pH level 116 | - `orp` - Oxidation-reduction potential (mV) 117 | 118 | ### Status 119 | 120 | - `freeze_protection` - Freeze protection status 121 | - System online status 122 | 123 | ## Usage Example 124 | 125 | ```python 126 | from iaqualink import AqualinkClient 127 | 128 | async with AqualinkClient(username, password) as client: 129 | systems = await client.get_systems() 130 | 131 | # Find eXO system 132 | for system in systems.values(): 133 | if system.data.get('device_type') == 'exo': 134 | print(f"Found eXO system: {system.name}") 135 | 136 | devices = await system.get_devices() 137 | 138 | # Control equipment 139 | pool_pump = devices.get('pool_pump') 140 | if pool_pump: 141 | await pool_pump.turn_on() 142 | 143 | # Set temperature 144 | spa_thermostat = devices.get('spa_set_point') 145 | if spa_thermostat: 146 | await spa_thermostat.set_temperature(102) 147 | ``` 148 | 149 | ## API Details 150 | 151 | ### Rate Limiting 152 | 153 | 5-second minimum interval between updates (enforced by base class). 154 | 155 | ### Shadow State Structure 156 | 157 | ```python 158 | { 159 | "state": { 160 | "reported": { 161 | "equipment": { 162 | "0": { 163 | "name": "Pool Pump", 164 | "state": 1, 165 | "type": 1 166 | } 167 | }, 168 | "temperatures": { 169 | "0": { 170 | "name": "Pool", 171 | "current": 78, 172 | "target": 82 173 | } 174 | } 175 | } 176 | } 177 | } 178 | ``` 179 | 180 | ### Equipment Types 181 | 182 | | Type | Description | 183 | |------|-------------| 184 | | 1 | Pump | 185 | | 2 | Heater | 186 | | 3 | Light | 187 | | 4 | Auxiliary | 188 | 189 | ### Device Naming 190 | 191 | Devices are automatically named based on their equipment type and position: 192 | 193 | ```python 194 | # Examples 195 | "pool_pump" # First pump 196 | "spa_pump" # Second pump (if spa-related) 197 | "pool_heater" # First heater 198 | "pool_light" # First light 199 | "aux_1" # First auxiliary 200 | ``` 201 | 202 | ## Differences from iAqua 203 | 204 | | Feature | iAqua | eXO | 205 | |---------|-------|-----| 206 | | Authentication | Session tokens | JWT tokens | 207 | | State updates | Two API calls | Single shadow state | 208 | | Commands | Session requests | Desired state | 209 | | Device IDs | Named | Numeric indices | 210 | | Token refresh | Manual | Automatic | 211 | 212 | ## See Also 213 | 214 | - [System API](system.md) - Base system reference 215 | - [Device API](device.md) - Base device reference 216 | - [iAqua Systems](iaqua.md) - Compare with iAqua systems 217 | -------------------------------------------------------------------------------- /tests/systems/iaqua/test_system.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | import pytest 6 | 7 | from iaqualink.exception import ( 8 | AqualinkServiceUnauthorizedException, 9 | AqualinkSystemOfflineException, 10 | ) 11 | from iaqualink.system import AqualinkSystem 12 | from iaqualink.systems.iaqua.device import IaquaAuxSwitch 13 | from iaqualink.systems.iaqua.system import IaquaSystem 14 | 15 | from ...base_test_system import TestBaseSystem 16 | 17 | 18 | class TestIaquaSystem(TestBaseSystem): 19 | def setUp(self) -> None: 20 | super().setUp() 21 | 22 | data = { 23 | "id": 123456, 24 | "serial_number": "SN123456", 25 | "created_at": "2017-09-23T01:00:08.000Z", 26 | "updated_at": "2017-09-23T01:00:08.000Z", 27 | "name": "Pool", 28 | "device_type": "iaqua", 29 | "owner_id": None, 30 | "updating": False, 31 | "firmware_version": None, 32 | "target_firmware_version": None, 33 | "update_firmware_start_at": None, 34 | "last_activity_at": None, 35 | } 36 | self.sut = AqualinkSystem.from_data(self.client, data=data) 37 | self.sut_class = IaquaSystem 38 | 39 | async def test_update_success(self) -> None: 40 | with ( 41 | patch.object(self.sut, "_parse_home_response"), 42 | patch.object(self.sut, "_parse_devices_response"), 43 | ): 44 | await super().test_update_success() 45 | 46 | async def test_update_offline(self) -> None: 47 | with patch.object(self.sut, "_parse_home_response") as mock_parse: 48 | mock_parse.side_effect = AqualinkSystemOfflineException 49 | with pytest.raises(AqualinkSystemOfflineException): 50 | await super().test_update_success() 51 | assert self.sut.online is False 52 | 53 | async def test_update_consecutive(self) -> None: 54 | with ( 55 | patch.object(self.sut, "_parse_home_response"), 56 | patch.object(self.sut, "_parse_devices_response"), 57 | ): 58 | await super().test_update_consecutive() 59 | 60 | async def test_get_devices_needs_update(self) -> None: 61 | with ( 62 | patch.object(self.sut, "_parse_home_response"), 63 | patch.object(self.sut, "_parse_devices_response"), 64 | ): 65 | await super().test_get_devices_needs_update() 66 | 67 | async def test_parse_devices_offline(self) -> None: 68 | message = {"message": "", "devices_screen": [{"status": "Offline"}]} 69 | response = MagicMock() 70 | response.json.return_value = message 71 | 72 | with pytest.raises(AqualinkSystemOfflineException): 73 | self.sut._parse_devices_response(response) 74 | assert self.sut.devices == {} 75 | 76 | async def test_parse_devices_good(self) -> None: 77 | message = { 78 | "message": "", 79 | "devices_screen": [ 80 | {"status": "Online"}, 81 | {"response": ""}, 82 | {"group": "1"}, 83 | { 84 | "aux_B1": [ 85 | {"state": "0"}, 86 | {"label": "Label B1"}, 87 | {"icon": "aux_1_0.png"}, 88 | {"type": "0"}, 89 | {"subtype": "0"}, 90 | ] 91 | }, 92 | ], 93 | } 94 | response = MagicMock() 95 | response.json.return_value = message 96 | 97 | expected = { 98 | "aux_B1": IaquaAuxSwitch( 99 | system=self.sut, 100 | data={ 101 | "aux": "B1", 102 | "name": "aux_B1", 103 | "state": "0", 104 | "label": "Label B1", 105 | "icon": "aux_1_0.png", 106 | "type": "0", 107 | "subtype": "0", 108 | }, 109 | ) 110 | } 111 | self.sut._parse_devices_response(response) 112 | assert self.sut.devices == expected 113 | 114 | @patch("httpx.AsyncClient.request") 115 | async def test_home_request(self, mock_request) -> None: 116 | mock_request.return_value.status_code = 200 117 | 118 | await self.sut._send_home_screen_request() 119 | 120 | @patch("httpx.AsyncClient.request") 121 | async def test_home_request_unauthorized(self, mock_request) -> None: 122 | mock_request.return_value.status_code = 401 123 | 124 | with pytest.raises(AqualinkServiceUnauthorizedException): 125 | await self.sut._send_home_screen_request() 126 | 127 | @patch("httpx.AsyncClient.request") 128 | async def test_devices_request(self, mock_request) -> None: 129 | mock_request.return_value.status_code = 200 130 | 131 | await self.sut._send_devices_screen_request() 132 | 133 | @patch("httpx.AsyncClient.request") 134 | async def test_devices_request_unauthorized(self, mock_request) -> None: 135 | mock_request.return_value.status_code = 401 136 | 137 | with pytest.raises(AqualinkServiceUnauthorizedException): 138 | await self.sut._send_devices_screen_request() 139 | -------------------------------------------------------------------------------- /src/iaqualink/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import logging 5 | from typing import TYPE_CHECKING, Any, Self 6 | 7 | import httpx 8 | 9 | from iaqualink.const import ( 10 | AQUALINK_API_KEY, 11 | AQUALINK_DEVICES_URL, 12 | AQUALINK_LOGIN_URL, 13 | KEEPALIVE_EXPIRY, 14 | ) 15 | from iaqualink.exception import ( 16 | AqualinkServiceException, 17 | AqualinkServiceUnauthorizedException, 18 | AqualinkSystemUnsupportedException, 19 | ) 20 | from iaqualink.system import AqualinkSystem 21 | from iaqualink.systems import * # noqa: F403 22 | 23 | if TYPE_CHECKING: 24 | from types import TracebackType 25 | 26 | AQUALINK_HTTP_HEADERS = { 27 | "user-agent": "okhttp/3.14.7", 28 | "content-type": "application/json", 29 | } 30 | 31 | LOGGER = logging.getLogger("iaqualink") 32 | 33 | 34 | class AqualinkClient: 35 | def __init__( 36 | self, 37 | username: str, 38 | password: str, 39 | httpx_client: httpx.AsyncClient | None = None, 40 | ): 41 | self._username = username 42 | self._password = password 43 | self._logged = False 44 | 45 | self._client: httpx.AsyncClient | None = None 46 | 47 | if httpx_client is None: 48 | self._client = None 49 | self._must_close_client = True 50 | else: 51 | self._client = httpx_client 52 | self._must_close_client = False 53 | 54 | self.client_id = "" 55 | self._token = "" 56 | self._user_id = "" 57 | self.id_token = "" 58 | 59 | self._last_refresh = 0 60 | 61 | @property 62 | def logged(self) -> bool: 63 | return self._logged 64 | 65 | async def close(self) -> None: 66 | if self._must_close_client is False: 67 | return 68 | 69 | # There shouldn't be a case where this is None but this quietens mypy. 70 | if self._client is not None: 71 | await self._client.aclose() 72 | self._client = None 73 | 74 | async def __aenter__(self) -> Self: 75 | try: 76 | await self.login() 77 | except AqualinkServiceException: 78 | await self.close() 79 | raise 80 | 81 | return self 82 | 83 | async def __aexit__( 84 | self, 85 | exc_type: type[BaseException] | None, 86 | exc: BaseException | None, 87 | tb: TracebackType | None, 88 | ) -> bool | None: 89 | # All Exceptions get re-raised. 90 | await self.close() 91 | return exc is None 92 | 93 | async def send_request( 94 | self, 95 | url: str, 96 | method: str = "get", 97 | **kwargs: Any, 98 | ) -> httpx.Response: 99 | if self._client is None: 100 | self._client = httpx.AsyncClient( 101 | http2=True, 102 | limits=httpx.Limits(keepalive_expiry=KEEPALIVE_EXPIRY), 103 | ) 104 | 105 | headers = AQUALINK_HTTP_HEADERS 106 | headers.update(kwargs.pop("headers", {})) 107 | 108 | LOGGER.debug(f"-> {method.upper()} {url} {kwargs}") 109 | r = await self._client.request(method, url, headers=headers, **kwargs) 110 | 111 | LOGGER.debug(f"<- {r.status_code} {r.reason_phrase} - {url}") 112 | 113 | if r.status_code == httpx.codes.UNAUTHORIZED: 114 | m = "Unauthorized Access, check your credentials and try again" 115 | self._logged = False 116 | raise AqualinkServiceUnauthorizedException 117 | 118 | if r.status_code != httpx.codes.OK: 119 | m = f"Unexpected response: {r.status_code} {r.reason_phrase}" 120 | raise AqualinkServiceException(m) 121 | 122 | return r 123 | 124 | async def _send_login_request(self) -> httpx.Response: 125 | data = { 126 | "api_key": AQUALINK_API_KEY, 127 | "email": self._username, 128 | "password": self._password, 129 | } 130 | return await self.send_request( 131 | AQUALINK_LOGIN_URL, method="post", json=data 132 | ) 133 | 134 | async def login(self) -> None: 135 | r = await self._send_login_request() 136 | 137 | data = r.json() 138 | self.client_id = data["session_id"] 139 | self._token = data["authentication_token"] 140 | self._user_id = data["id"] 141 | self.id_token = data["userPoolOAuth"]["IdToken"] 142 | self._logged = True 143 | 144 | async def _send_systems_request(self) -> httpx.Response: 145 | params = { 146 | "api_key": AQUALINK_API_KEY, 147 | "authentication_token": self._token, 148 | "user_id": self._user_id, 149 | } 150 | params_str = "&".join(f"{k}={v}" for k, v in params.items()) 151 | url = f"{AQUALINK_DEVICES_URL}?{params_str}" 152 | return await self.send_request(url) 153 | 154 | async def get_systems(self) -> dict[str, AqualinkSystem]: 155 | try: 156 | r = await self._send_systems_request() 157 | except AqualinkServiceException as e: 158 | if "404" in str(e): 159 | raise AqualinkServiceUnauthorizedException from e 160 | raise 161 | 162 | data = r.json() 163 | 164 | systems = [] 165 | for x in data: 166 | with contextlib.suppress(AqualinkSystemUnsupportedException): 167 | systems += [AqualinkSystem.from_data(self, x)] 168 | 169 | return {x.serial: x for x in systems if x is not None} 170 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is an asynchronous Python library for interacting with Jandy iAqualink pool control systems. The library supports two system types: 8 | - **iAqua** systems - Uses the iaqualink.net API 9 | - **eXO** systems - Uses the zodiac-io.com API (added in recent version) 10 | 11 | ## Development Commands 12 | 13 | ### Setup 14 | ```bash 15 | # Install dependencies with uv 16 | uv sync --all-extras --dev 17 | ``` 18 | 19 | ### Testing 20 | ```bash 21 | # Run all tests 22 | uv run pytest 23 | 24 | # Run tests with coverage 25 | uv run pytest --cov-report=xml --cov=iaqualink 26 | 27 | # Run a single test file 28 | uv run pytest tests/test_client.py 29 | 30 | # Run a specific test 31 | uv run pytest tests/test_client.py::TestClassName::test_method_name 32 | ``` 33 | 34 | ### Linting and Type Checking 35 | ```bash 36 | # Run all pre-commit hooks (ruff, ruff-format, mypy) 37 | uv run pre-commit run --all-files 38 | 39 | # Run with diff on failure 40 | uv run pre-commit run --show-diff-on-failure --color=always --all-files 41 | 42 | # Ruff linting with auto-fix 43 | uv run ruff check --fix . 44 | 45 | # Ruff formatting 46 | uv run ruff format . 47 | 48 | # Type checking with mypy (excludes tests/) 49 | uv run mypy src/ 50 | ``` 51 | 52 | ### Documentation 53 | ```bash 54 | # Install documentation dependencies 55 | uv sync --group docs 56 | 57 | # Serve documentation locally (live reload) 58 | uv run mkdocs serve 59 | 60 | # Build documentation 61 | uv run mkdocs build 62 | 63 | # Build with strict mode (fail on warnings) 64 | uv run mkdocs build --strict 65 | ``` 66 | 67 | ## Architecture 68 | 69 | ### Core Class Hierarchy 70 | 71 | The library follows a plugin-style architecture with base classes and system-specific implementations: 72 | 73 | 1. **AqualinkClient** ([client.py](src/iaqualink/client.py)) - Entry point for authentication and system discovery 74 | - Handles login/authentication for both API types 75 | - Uses httpx with HTTP/2 support 76 | - Manages session tokens and credentials 77 | - Factory method `get_systems()` returns appropriate system subclasses 78 | 79 | 2. **AqualinkSystem** ([system.py](src/iaqualink/system.py)) - Base class for pool systems 80 | - Subclass registry pattern using `__init_subclass__` and `NAME` class attribute 81 | - `from_data()` factory method dispatches to correct subclass based on `device_type` 82 | - Two concrete implementations: 83 | - **IaquaSystem** ([systems/iaqua/system.py](src/iaqualink/systems/iaqua/system.py)) - For "iaqua" device_type 84 | - **ExoSystem** ([systems/exo/system.py](src/iaqualink/systems/exo/system.py)) - For "exo" device_type 85 | - Implements polling with rate limiting (MIN_SECS_TO_REFRESH = 5 seconds) 86 | - Tracks online/offline status 87 | 88 | 3. **AqualinkDevice** ([device.py](src/iaqualink/device.py)) - Base class for devices 89 | - Device hierarchy: Sensor → BinarySensor → Switch → Light/Thermostat 90 | - System-specific implementations: 91 | - **IaquaDevice** ([systems/iaqua/device.py](src/iaqualink/systems/iaqua/device.py)) 92 | - **ExoDevice** ([systems/exo/device.py](src/iaqualink/systems/exo/device.py)) 93 | - Device types include: sensors, pumps, heaters, lights, thermostats, aux toggles 94 | 95 | ### API Differences 96 | 97 | **iAqua Systems:** 98 | - Authentication returns `session_id` and `authentication_token` 99 | - Uses query parameters for authentication 100 | - Two API calls for updates: "get_home" and "get_devices" 101 | - Commands sent as session requests with specific command names 102 | 103 | **eXO Systems:** 104 | - Authentication returns JWT `IdToken` in `userPoolOAuth` field 105 | - Uses Authorization header with IdToken 106 | - Single shadow state API (AWS IoT-style) 107 | - State updates via desired/reported state pattern 108 | - Token refresh handled automatically on 401 responses 109 | 110 | ### Test Structure 111 | 112 | Tests use `unittest.IsolatedAsyncioTestCase` with a custom base class: 113 | - **TestBase** ([tests/base.py](tests/base.py)) - Base test class with AqualinkClient setup 114 | - Uses `respx` library for HTTP mocking 115 | - System-specific tests under `tests/systems/iaqua/` and `tests/systems/exo/` 116 | - Abstract base tests in `base_test_system.py` and `base_test_device.py` 117 | 118 | ### Key Constants 119 | 120 | - **Rate limiting:** System updates throttled to 5 second intervals ([const.py](src/iaqualink/const.py)) 121 | - **API key:** Hardcoded AQUALINK_API_KEY for iAqua systems 122 | - **Temperature ranges:** Different for Celsius/Fahrenheit, defined in device files 123 | 124 | ## Adding Support for New System Types 125 | 126 | To add a new system type: 127 | 1. Create `systems/newsystem/` directory with `__init__.py`, `system.py`, `device.py` 128 | 2. Implement `NewSystem(AqualinkSystem)` with `NAME` class attribute 129 | 3. Implement device parsing in `_parse_*_response()` methods 130 | 4. Create corresponding device classes extending base device types 131 | 5. Add tests following existing patterns in `tests/systems/newsystem/` 132 | 133 | ## Notes 134 | 135 | - All API calls are asynchronous using httpx 136 | - Client supports context manager protocol for automatic cleanup 137 | - Exception hierarchy in [exception.py](src/iaqualink/exception.py) covers service errors, auth failures, offline systems 138 | - Python 3.13+ required (uses modern type hints like `Self`, `type[T]`) 139 | - Tests exclude private member access (SLF001) and f-string logging (G004) from ruff 140 | -------------------------------------------------------------------------------- /src/iaqualink/systems/exo/system.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import time 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from iaqualink.const import MIN_SECS_TO_REFRESH 8 | from iaqualink.exception import ( 9 | AqualinkServiceException, 10 | AqualinkServiceUnauthorizedException, 11 | AqualinkSystemOfflineException, 12 | ) 13 | from iaqualink.system import AqualinkSystem 14 | from iaqualink.systems.exo.device import ExoDevice 15 | 16 | if TYPE_CHECKING: 17 | import httpx 18 | 19 | from iaqualink.client import AqualinkClient 20 | from iaqualink.typing import Payload 21 | 22 | EXO_DEVICES_URL = "https://prod.zodiac-io.com/devices/v1" 23 | 24 | 25 | LOGGER = logging.getLogger("iaqualink") 26 | 27 | 28 | class ExoSystem(AqualinkSystem): 29 | NAME = "exo" 30 | 31 | def __init__(self, aqualink: AqualinkClient, data: Payload): 32 | super().__init__(aqualink, data) 33 | # This lives in the parent class but mypy complains. 34 | self.last_refresh: int = 0 35 | self.temp_unit = "C" # TODO: check if unit can be changed on panel? 36 | 37 | def __repr__(self) -> str: 38 | attrs = ["name", "serial", "data"] 39 | attrs = [f"{i}={getattr(self, i)!r}" for i in attrs] 40 | return f"{self.__class__.__name__}({' '.join(attrs)})" 41 | 42 | async def send_devices_request(self, **kwargs: Any) -> httpx.Response: 43 | url = f"{EXO_DEVICES_URL}/{self.serial}/shadow" 44 | headers = {"Authorization": self.aqualink.id_token} 45 | 46 | try: 47 | r = await self.aqualink.send_request(url, headers=headers, **kwargs) 48 | except AqualinkServiceUnauthorizedException: 49 | # token expired so refresh the token and try again 50 | await self.aqualink.login() 51 | headers = {"Authorization": self.aqualink.id_token} 52 | r = await self.aqualink.send_request(url, headers=headers, **kwargs) 53 | 54 | return r 55 | 56 | async def send_reported_state_request(self) -> httpx.Response: 57 | return await self.send_devices_request() 58 | 59 | async def send_desired_state_request( 60 | self, state: dict[str, Any] 61 | ) -> httpx.Response: 62 | return await self.send_devices_request( 63 | method="post", json={"state": {"desired": state}} 64 | ) 65 | 66 | async def update(self) -> None: 67 | # Be nice to Aqualink servers since we rely on polling. 68 | now = int(time.time()) 69 | delta = now - self.last_refresh 70 | if delta < MIN_SECS_TO_REFRESH: 71 | LOGGER.debug(f"Only {delta}s since last refresh.") 72 | return 73 | 74 | try: 75 | r = await self.send_reported_state_request() 76 | except AqualinkServiceException: 77 | self.online = None 78 | raise 79 | 80 | try: 81 | self._parse_shadow_response(r) 82 | except AqualinkSystemOfflineException: 83 | self.online = False 84 | raise 85 | 86 | self.online = True 87 | self.last_refresh = int(time.time()) 88 | 89 | def _parse_shadow_response(self, response: httpx.Response) -> None: 90 | data = response.json() 91 | 92 | LOGGER.debug(f"Shadow response: {data}") 93 | 94 | devices = {} 95 | 96 | # Process the chlorinator attributes[equipmen] 97 | # Make the data a bit flatter. 98 | root = data["state"]["reported"]["equipment"]["swc_0"] 99 | for name, state in root.items(): 100 | attrs = {"name": name} 101 | if isinstance(state, dict): 102 | attrs.update(state) 103 | else: 104 | attrs.update({"state": state}) 105 | devices.update({name: attrs}) 106 | 107 | # Remove those values, they're not handled properly. 108 | devices.pop("boost_time", None) 109 | devices.pop("vsp_speed", None) 110 | devices.pop("sn", None) 111 | devices.pop("vr", None) 112 | devices.pop("version", None) 113 | 114 | # Process the heating control attributes 115 | if "heating" in data["state"]["reported"]: 116 | name = "heating" 117 | attrs = {"name": name} 118 | attrs.update(data["state"]["reported"]["heating"]) 119 | devices.update({name: attrs}) 120 | # extract heater state into seperate device to maintain homeassistant API 121 | name = "heater" 122 | attrs = {"name": name} 123 | attrs.update( 124 | {"state": data["state"]["reported"]["heating"]["state"]} 125 | ) 126 | devices.update({name: attrs}) 127 | 128 | LOGGER.debug(f"devices: {devices}") 129 | 130 | for k, v in devices.items(): 131 | if k in self.devices: 132 | for dk, dv in v.items(): 133 | self.devices[k].data[dk] = dv 134 | else: 135 | self.devices[k] = ExoDevice.from_data(self, v) 136 | 137 | async def set_heating(self, name: str, state: int) -> None: 138 | r = await self.send_desired_state_request({"heating": {name: state}}) 139 | r.raise_for_status() 140 | 141 | async def set_aux(self, aux: str, state: int) -> None: 142 | r = await self.send_desired_state_request( 143 | {"equipment": {"swc_0": {aux: {"state": state}}}} 144 | ) 145 | r.raise_for_status() 146 | 147 | async def set_toggle(self, name: str, state: int) -> None: 148 | r = await self.send_desired_state_request( 149 | {"equipment": {"swc_0": {name: state}}} 150 | ) 151 | r.raise_for_status() 152 | -------------------------------------------------------------------------------- /docs/api/exceptions.md: -------------------------------------------------------------------------------- 1 | # Exceptions API 2 | 3 | Exception classes for error handling. 4 | 5 | ## Exception Hierarchy 6 | 7 | ``` 8 | AqualinkException (base) 9 | ├── AqualinkInvalidParameterException 10 | ├── AqualinkServiceException 11 | │ ├── AqualinkServiceUnauthorizedException 12 | │ ├── AqualinkSystemOfflineException 13 | │ └── AqualinkSystemUnsupportedException 14 | ├── AqualinkOperationNotSupportedException 15 | └── AqualinkDeviceNotSupported 16 | ``` 17 | 18 | ## AqualinkException 19 | 20 | ::: iaqualink.exception.AqualinkException 21 | 22 | ## AqualinkInvalidParameterException 23 | 24 | ::: iaqualink.exception.AqualinkInvalidParameterException 25 | 26 | ## AqualinkServiceException 27 | 28 | ::: iaqualink.exception.AqualinkServiceException 29 | 30 | ## AqualinkServiceUnauthorizedException 31 | 32 | ::: iaqualink.exception.AqualinkServiceUnauthorizedException 33 | 34 | ## AqualinkSystemOfflineException 35 | 36 | ::: iaqualink.exception.AqualinkSystemOfflineException 37 | 38 | ## AqualinkSystemUnsupportedException 39 | 40 | ::: iaqualink.exception.AqualinkSystemUnsupportedException 41 | 42 | ## AqualinkOperationNotSupportedException 43 | 44 | ::: iaqualink.exception.AqualinkOperationNotSupportedException 45 | 46 | ## AqualinkDeviceNotSupported 47 | 48 | ::: iaqualink.exception.AqualinkDeviceNotSupported 49 | 50 | ## Usage Examples 51 | 52 | ### Basic Error Handling 53 | 54 | ```python 55 | from iaqualink import ( 56 | AqualinkClient, 57 | AqualinkException, 58 | ) 59 | 60 | try: 61 | async with AqualinkClient(username, password) as client: 62 | systems = await client.get_systems() 63 | except AqualinkException as e: 64 | print(f"Error: {e}") 65 | ``` 66 | 67 | ### Specific Exception Handling 68 | 69 | ```python 70 | from iaqualink import ( 71 | AqualinkClient, 72 | AqualinkServiceUnauthorizedException, 73 | AqualinkServiceException, 74 | AqualinkSystemOfflineException, 75 | ) 76 | 77 | try: 78 | async with AqualinkClient(username, password) as client: 79 | systems = await client.get_systems() 80 | system = list(systems.values())[0] 81 | 82 | try: 83 | await system.update() 84 | except AqualinkSystemOfflineException: 85 | print("System is offline") 86 | 87 | except AqualinkServiceUnauthorizedException: 88 | print("Authentication failed - check credentials") 89 | except AqualinkServiceException as e: 90 | print(f"Service error: {e}") 91 | ``` 92 | 93 | ### Retry Logic 94 | 95 | ```python 96 | import asyncio 97 | from iaqualink import AqualinkServiceException 98 | 99 | async def update_with_retry(system, max_retries=3): 100 | for attempt in range(max_retries): 101 | try: 102 | await system.update() 103 | return 104 | except AqualinkServiceException as e: 105 | if attempt < max_retries - 1: 106 | wait = 2 ** attempt # Exponential backoff 107 | print(f"Retry {attempt + 1}/{max_retries} in {wait}s") 108 | await asyncio.sleep(wait) 109 | else: 110 | raise 111 | ``` 112 | 113 | ### Graceful Degradation 114 | 115 | ```python 116 | from iaqualink import AqualinkSystemOfflineException 117 | 118 | async def get_system_status(system): 119 | try: 120 | await system.update() 121 | return { 122 | "online": True, 123 | "devices": await system.get_devices() 124 | } 125 | except AqualinkSystemOfflineException: 126 | return { 127 | "online": False, 128 | "devices": {} 129 | } 130 | ``` 131 | 132 | ## Exception Properties 133 | 134 | All exceptions include: 135 | 136 | ### message 137 | 138 | Human-readable error message. 139 | 140 | **Type:** `str` 141 | 142 | ### args 143 | 144 | Exception arguments tuple. 145 | 146 | **Type:** `tuple` 147 | 148 | ## When Exceptions Are Raised 149 | 150 | ### AqualinkServiceUnauthorizedException 151 | 152 | - Invalid username or password 153 | - Account locked or suspended 154 | - API authentication endpoint unavailable 155 | - Session token expired 156 | 157 | ### AqualinkInvalidParameterException 158 | 159 | - Invalid temperature value 160 | - Invalid device parameter 161 | - Out of range values 162 | 163 | ### AqualinkServiceException 164 | 165 | - API request failed 166 | - Invalid response format 167 | - Network connectivity issues 168 | - Rate limiting (though built-in rate limiting should prevent this) 169 | 170 | ### AqualinkSystemOfflineException 171 | 172 | - System is not connected to internet 173 | - System is powered off 174 | - System is in maintenance mode 175 | 176 | ## Best Practices 177 | 178 | ### Always Use Context Managers 179 | 180 | ```python 181 | # Good - automatic cleanup 182 | async with AqualinkClient(username, password) as client: 183 | systems = await client.get_systems() 184 | 185 | # Avoid - manual cleanup needed 186 | client = AqualinkClient(username, password) 187 | try: 188 | systems = await client.get_systems() 189 | finally: 190 | await client.close() 191 | ``` 192 | 193 | ### Catch Specific Exceptions 194 | 195 | ```python 196 | # Good - handle specific errors 197 | try: 198 | await system.update() 199 | except AqualinkSystemOfflineException: 200 | print("System offline") 201 | except AqualinkServiceException: 202 | print("Service error") 203 | 204 | # Avoid - too broad 205 | try: 206 | await system.update() 207 | except Exception: 208 | print("Something went wrong") 209 | ``` 210 | 211 | ### Log Errors 212 | 213 | ```python 214 | import logging 215 | 216 | logger = logging.getLogger(__name__) 217 | 218 | try: 219 | await system.update() 220 | except AqualinkServiceException as e: 221 | logger.error(f"Failed to update system: {e}", exc_info=True) 222 | ``` 223 | 224 | ## See Also 225 | 226 | - [Client API](client.md) - Client reference 227 | - [System API](system.md) - System reference 228 | - [Quick Start](../getting-started/quickstart.md) - Getting started 229 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🏊 iaqualink-py 2 | 3 | > Asynchronous Python library for Jandy iAqualink pool control systems 4 | 5 | [![License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) 6 | [![Python](https://img.shields.io/badge/python-3.13%2B-blue.svg)](https://www.python.org/downloads/) 7 | 8 | ## 📖 Overview 9 | 10 | **iaqualink-py** is a modern, fully asynchronous Python library for interacting with Jandy iAqualink pool and spa control systems. It provides a clean, Pythonic interface to monitor and control your pool equipment from your Python applications. 11 | 12 | ### ✨ Features 13 | 14 | - 🔄 **Fully Asynchronous** - Built with `asyncio` and `httpx` for efficient, non-blocking I/O 15 | - 🏗️ **Multi-System Support** 16 | - **iAqua** systems (iaqualink.net API) 17 | - **eXO** systems (zodiac-io.com API) 18 | - 🌡️ **Comprehensive Device Support** 19 | - Temperature sensors (pool, spa, air) 20 | - Thermostats with adjustable set points 21 | - Pumps and heaters 22 | - Lights with toggle control 23 | - Auxiliary switches 24 | - Water chemistry sensors (pH, ORP, salinity) 25 | - Freeze protection monitoring 26 | - 🔌 **Context Manager Support** - Automatic resource cleanup 27 | - 🛡️ **Type Safe** - Full type hints for modern Python development 28 | - ⚡ **Rate Limiting** - Built-in throttling to respect API limits 29 | 30 | ## 📦 Installation 31 | 32 | ```bash 33 | pip install iaqualink 34 | ``` 35 | 36 | Or using [uv](https://github.com/astral-sh/uv): 37 | 38 | ```bash 39 | uv add iaqualink 40 | ``` 41 | 42 | ## 🚀 Quick Start 43 | 44 | ### Basic Usage 45 | 46 | ```python 47 | from iaqualink import AqualinkClient 48 | 49 | async with AqualinkClient('user@example.com', 'password') as client: 50 | # Discover your pool systems 51 | systems = await client.get_systems() 52 | 53 | # Get the first system 54 | system = list(systems.values())[0] 55 | print(f"Found system: {system.name}") 56 | 57 | # Get all devices 58 | devices = await system.get_devices() 59 | 60 | # Access specific devices 61 | pool_temp = devices.get('pool_temp') 62 | if pool_temp: 63 | print(f"Pool temperature: {pool_temp.state}°F") 64 | 65 | spa_heater = devices.get('spa_heater') 66 | if spa_heater: 67 | print(f"Spa heater: {'ON' if spa_heater.is_on else 'OFF'}") 68 | ``` 69 | 70 | ### Controlling Devices 71 | 72 | ```python 73 | # Turn on pool pump 74 | pool_pump = devices.get('pool_pump') 75 | if pool_pump: 76 | await pool_pump.turn_on() 77 | 78 | # Set spa temperature 79 | spa_thermostat = devices.get('spa_set_point') 80 | if spa_thermostat: 81 | await spa_thermostat.set_temperature(102) 82 | 83 | # Toggle pool light 84 | pool_light = devices.get('aux_3') 85 | if pool_light: 86 | await pool_light.toggle() 87 | ``` 88 | 89 | ### Monitoring System Status 90 | 91 | ```python 92 | # Update system state 93 | await system.update() 94 | 95 | # Check if system is online 96 | if system.online: 97 | print(f"System {system.name} is online") 98 | 99 | # Get all temperature readings 100 | for device_name, device in devices.items(): 101 | if 'temp' in device_name and device.state: 102 | print(f"{device.label}: {device.state}°") 103 | ``` 104 | 105 | ## 🔧 Advanced Usage 106 | 107 | ### Working with Multiple Systems 108 | 109 | ```python 110 | async with AqualinkClient('user@example.com', 'password') as client: 111 | systems = await client.get_systems() 112 | 113 | for serial, system in systems.items(): 114 | print(f"System: {system.name} ({serial})") 115 | print(f"Type: {system.data.get('device_type')}") 116 | 117 | devices = await system.get_devices() 118 | print(f"Devices: {len(devices)}") 119 | ``` 120 | 121 | ### Custom Update Intervals 122 | 123 | The library automatically rate-limits updates to once every 5 seconds per system to respect API limits. Subsequent calls within this window return cached data. 124 | 125 | ```python 126 | # First call - fetches from API 127 | await system.update() 128 | 129 | # Immediate second call - returns cached data 130 | await system.update() 131 | 132 | # After 5+ seconds - fetches fresh data 133 | await asyncio.sleep(5) 134 | await system.update() 135 | ``` 136 | 137 | ## 🏗️ Architecture 138 | 139 | The library uses a plugin-style architecture with base classes and system-specific implementations: 140 | 141 | - **AqualinkClient** - Authentication and system discovery 142 | - **AqualinkSystem** - Base class with iAqua and eXO implementations 143 | - **AqualinkDevice** - Device hierarchy with type-specific subclasses 144 | 145 | See [CLAUDE.md](CLAUDE.md) for detailed architecture documentation. 146 | 147 | ## 🧪 Development 148 | 149 | ### Setup Development Environment 150 | 151 | ```bash 152 | # Clone the repository 153 | git clone https://github.com/flz/iaqualink-py.git 154 | cd iaqualink-py 155 | 156 | # Install dependencies 157 | uv sync --group dev --group test 158 | ``` 159 | 160 | ### Running Tests 161 | 162 | ```bash 163 | # Run all tests 164 | uv run pytest 165 | 166 | # Run with coverage 167 | uv run pytest --cov-report=xml --cov=iaqualink 168 | 169 | # Run specific test file 170 | uv run pytest tests/test_client.py 171 | ``` 172 | 173 | ### Code Quality 174 | 175 | ```bash 176 | # Run all pre-commit hooks (ruff, mypy) 177 | uv run pre-commit run --all-files 178 | 179 | # Auto-fix linting issues 180 | uv run ruff check --fix . 181 | 182 | # Format code 183 | uv run ruff format . 184 | 185 | # Type checking 186 | uv run mypy src/ 187 | ``` 188 | 189 | ## 📋 Requirements 190 | 191 | - Python 3.13 or higher 192 | - httpx with HTTP/2 support 193 | 194 | ## 📄 License 195 | 196 | This project is licensed under the BSD 3-Clause License - see the LICENSE file for details. 197 | 198 | ## 🤝 Contributing 199 | 200 | Contributions are welcome! Please feel free to submit a Pull Request. 201 | 202 | ## 🔗 Links 203 | 204 | - **Homepage**: https://github.com/flz/iaqualink-py 205 | - **Issues**: https://github.com/flz/iaqualink-py/issues 206 | 207 | ## ⚠️ Disclaimer 208 | 209 | This is an unofficial library and is not affiliated with or endorsed by Jandy, Zodiac Pool Systems, or Fluidra. Use at your own risk. 210 | 211 | --- 212 | 213 | Made with ❤️ by [Florent Thoumie](https://github.com/flz) 214 | -------------------------------------------------------------------------------- /src/iaqualink/systems/exo/device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from enum import Enum, unique 5 | from typing import TYPE_CHECKING, Any, cast 6 | 7 | from iaqualink.device import ( 8 | AqualinkDevice, 9 | AqualinkSensor, 10 | AqualinkSwitch, 11 | AqualinkThermostat, 12 | ) 13 | from iaqualink.exception import AqualinkInvalidParameterException 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Callable, Coroutine 17 | 18 | from iaqualink.systems.exo.system import ExoSystem 19 | from iaqualink.typing import DeviceData 20 | 21 | EXO_TEMP_CELSIUS_LOW = 1 22 | EXO_TEMP_CELSIUS_HIGH = 40 23 | 24 | LOGGER = logging.getLogger("iaqualink") 25 | 26 | 27 | @unique 28 | class ExoState(Enum): 29 | OFF = 0 30 | ON = 1 31 | 32 | 33 | class ExoDevice(AqualinkDevice): 34 | def __init__(self, system: ExoSystem, data: DeviceData): 35 | super().__init__(system, data) 36 | 37 | # This silences mypy errors due to AqualinkDevice type annotations. 38 | self.system: ExoSystem = system 39 | 40 | @property 41 | def label(self) -> str: 42 | name = self.name 43 | return " ".join([x.capitalize() for x in name.split("_")]) 44 | 45 | @property 46 | def state(self) -> str: 47 | return str(self.data["state"]) 48 | 49 | @property 50 | def name(self) -> str: 51 | return self.data["name"] 52 | 53 | @property 54 | def manufacturer(self) -> str: 55 | return "Zodiac" 56 | 57 | @property 58 | def model(self) -> str: 59 | return self.__class__.__name__.replace("Exo", "") 60 | 61 | @classmethod 62 | def from_data(cls, system: ExoSystem, data: DeviceData) -> ExoDevice: 63 | class_: type[ExoDevice] 64 | 65 | if data["name"].startswith("aux_"): 66 | class_ = ExoAuxSwitch 67 | elif data["name"].startswith("sns_"): 68 | class_ = ExoSensor 69 | elif data["name"] == "heating": 70 | class_ = ExoThermostat 71 | elif data["name"] == "heater": 72 | class_ = ExoHeater 73 | elif data["name"] in ["production", "boost", "low"]: 74 | class_ = ExoAttributeSwitch 75 | else: 76 | class_ = ExoAttributeSensor 77 | 78 | return class_(system, data) 79 | 80 | 81 | class ExoSensor(ExoDevice, AqualinkSensor): 82 | """These sensors are called sns_#.""" 83 | 84 | @property 85 | def is_on(self) -> bool: 86 | return ExoState(self.data["state"]) == ExoState.ON 87 | 88 | @property 89 | def state(self) -> str: 90 | if self.is_on: 91 | return str(self.data["value"]) 92 | return "" 93 | 94 | @property 95 | def label(self) -> str: 96 | return self.data["sensor_type"] 97 | 98 | @property 99 | def name(self) -> str: 100 | # XXX: We're using the label as name rather than "sns_#". 101 | # Might revisit later. 102 | return self.data["sensor_type"].lower().replace(" ", "_") 103 | 104 | 105 | class ExoAttributeSensor(ExoDevice, AqualinkSensor): 106 | """These sensors are a simple key/value in equipment->swc_0.""" 107 | 108 | 109 | # This is an abstract class, not to be instantiated directly. 110 | class ExoSwitch(ExoDevice, AqualinkSwitch): 111 | @property 112 | def label(self) -> str: 113 | return self.name.replace("_", " ").capitalize() 114 | 115 | @property 116 | def is_on(self) -> bool: 117 | return ExoState(self.data["state"]) == ExoState.ON 118 | 119 | @property 120 | def _command(self) -> Callable[[str, int], Coroutine[Any, Any, None]]: 121 | raise NotImplementedError 122 | 123 | async def turn_on(self) -> None: 124 | if not self.is_on: 125 | await self._command(self.name, 1) 126 | 127 | async def turn_off(self) -> None: 128 | if self.is_on: 129 | await self._command(self.name, 0) 130 | 131 | 132 | class ExoAuxSwitch(ExoSwitch): 133 | @property 134 | def _command(self) -> Callable[[str, int], Coroutine[Any, Any, None]]: 135 | return self.system.set_aux 136 | 137 | 138 | class ExoAttributeSwitch(ExoSwitch): 139 | @property 140 | def _command(self) -> Callable[[str, int], Coroutine[Any, Any, None]]: 141 | return self.system.set_toggle 142 | 143 | 144 | class ExoHeater(ExoDevice): 145 | """This device is to seperate the state of the heater from the thermostat to maintain the existing homeassistant API""" 146 | 147 | 148 | class ExoThermostat(ExoSwitch, AqualinkThermostat): 149 | @property 150 | def state(self) -> str: 151 | return str(self.data["sp"]) 152 | 153 | @property 154 | def unit(self) -> str: 155 | return "C" 156 | 157 | @property 158 | def _sensor(self) -> ExoSensor: 159 | return cast(ExoSensor, self.system.devices["sns_3"]) 160 | 161 | @property 162 | def _heater(self) -> ExoHeater: 163 | return cast(ExoSensor, self.system.devices["heater"]) 164 | 165 | @property 166 | def current_temperature(self) -> str: 167 | return self._sensor.state 168 | 169 | @property 170 | def target_temperature(self) -> str: 171 | return str(self.data["sp"]) 172 | 173 | @property 174 | def min_temperature(self) -> int: 175 | return int(self.data["sp_min"]) 176 | 177 | @property 178 | def max_temperature(self) -> int: 179 | return int(self.data["sp_max"]) 180 | 181 | async def set_temperature(self, temperature: int) -> None: 182 | unit = self.unit 183 | low = self.min_temperature 184 | high = self.max_temperature 185 | 186 | if temperature not in range(low, high + 1): 187 | msg = f"{temperature}{unit} isn't a valid temperature" 188 | msg += f" ({low}-{high}{unit})." 189 | raise AqualinkInvalidParameterException(msg) 190 | 191 | await self.system.set_heating("sp", temperature) 192 | 193 | @property 194 | def is_on(self) -> bool: 195 | return ExoState(self.data["enabled"]) == ExoState.ON 196 | 197 | async def turn_on(self) -> None: 198 | if self.is_on is False: 199 | await self.system.set_heating("enabled", 1) 200 | 201 | async def turn_off(self) -> None: 202 | if self.is_on is True: 203 | await self.system.set_heating("enabled", 0) 204 | -------------------------------------------------------------------------------- /docs/guide/devices.md: -------------------------------------------------------------------------------- 1 | # Devices 2 | 3 | iaqualink-py provides a comprehensive device hierarchy for controlling and monitoring pool equipment. 4 | 5 | ## Device Hierarchy 6 | 7 | The library uses inheritance to organize device types: 8 | 9 | ``` 10 | AqualinkDevice (base) 11 | ├── AqualinkSensor 12 | │ ├── AqualinkBinarySensor 13 | │ │ └── AqualinkSwitch 14 | │ │ ├── AqualinkLight 15 | │ │ └── AqualinkThermostat 16 | ``` 17 | 18 | ## Device Types 19 | 20 | ### Sensors 21 | 22 | Read-only devices that report state: 23 | 24 | ```python 25 | # Temperature sensors 26 | pool_temp = devices.get('pool_temp') 27 | print(f"Pool: {pool_temp.state}°{pool_temp.unit}") 28 | 29 | spa_temp = devices.get('spa_temp') 30 | print(f"Spa: {spa_temp.state}°{spa_temp.unit}") 31 | 32 | air_temp = devices.get('air_temp') 33 | print(f"Air: {air_temp.state}°{air_temp.unit}") 34 | 35 | # Chemistry sensors 36 | ph_sensor = devices.get('ph') 37 | if ph_sensor: 38 | print(f"pH: {ph_sensor.state}") 39 | 40 | orp_sensor = devices.get('orp') 41 | if orp_sensor: 42 | print(f"ORP: {orp_sensor.state} mV") 43 | 44 | salt_sensor = devices.get('salt') 45 | if salt_sensor: 46 | print(f"Salt: {salt_sensor.state} ppm") 47 | ``` 48 | 49 | ### Binary Sensors 50 | 51 | Sensors with on/off states: 52 | 53 | ```python 54 | # Freeze protection 55 | freeze = devices.get('freeze_protection') 56 | if freeze: 57 | print(f"Freeze protection: {'Active' if freeze.is_on else 'Inactive'}") 58 | ``` 59 | 60 | ### Switches 61 | 62 | Devices that can be turned on/off: 63 | 64 | ```python 65 | # Pumps 66 | pool_pump = devices.get('pool_pump') 67 | await pool_pump.turn_on() 68 | await pool_pump.turn_off() 69 | print(f"Pump is {'on' if pool_pump.is_on else 'off'}") 70 | 71 | # Heaters 72 | spa_heater = devices.get('spa_heater') 73 | await spa_heater.turn_on() 74 | await spa_heater.turn_off() 75 | 76 | # Auxiliary devices 77 | aux_1 = devices.get('aux_1') 78 | await aux_1.turn_on() 79 | await aux_1.turn_off() 80 | ``` 81 | 82 | ### Lights 83 | 84 | Special switches with toggle support: 85 | 86 | ```python 87 | # Pool lights 88 | pool_light = devices.get('pool_light') 89 | await pool_light.turn_on() 90 | await pool_light.turn_off() 91 | await pool_light.toggle() # Toggles current state 92 | 93 | # Spa lights 94 | spa_light = devices.get('spa_light') 95 | await spa_light.toggle() 96 | ``` 97 | 98 | ### Thermostats 99 | 100 | Temperature controllers with set points: 101 | 102 | ```python 103 | # Pool thermostat 104 | pool_setpoint = devices.get('pool_set_point') 105 | print(f"Current setting: {pool_setpoint.state}°{pool_setpoint.unit}") 106 | await pool_setpoint.set_temperature(82) 107 | 108 | # Spa thermostat 109 | spa_setpoint = devices.get('spa_set_point') 110 | await spa_setpoint.set_temperature(102) 111 | 112 | # Check temperature ranges 113 | print(f"Min: {spa_setpoint.min_temperature}") 114 | print(f"Max: {spa_setpoint.max_temperature}") 115 | ``` 116 | 117 | ## Common Device Properties 118 | 119 | All devices share these properties: 120 | 121 | ```python 122 | device.name # Internal device name 123 | device.label # User-friendly label 124 | device.state # Current state (varies by type) 125 | device.system # Parent system reference 126 | device.data # Raw device data 127 | ``` 128 | 129 | ## Device States 130 | 131 | Different device types have different state representations: 132 | 133 | ```python 134 | # Sensors: numeric or string value 135 | temp_sensor.state # 78.5 136 | 137 | # Binary sensors: boolean 138 | freeze.state # True or False 139 | freeze.is_on # Convenience property 140 | 141 | # Switches: boolean 142 | pump.state # "1" or "0" (string) 143 | pump.is_on # True or False (boolean) 144 | 145 | # Thermostats: current setpoint 146 | thermostat.state # 82 147 | ``` 148 | 149 | ## Finding Devices 150 | 151 | ### By Name 152 | 153 | Device names are standardized: 154 | 155 | ```python 156 | # Common device names 157 | devices.get('pool_temp') # Pool temperature sensor 158 | devices.get('spa_temp') # Spa temperature sensor 159 | devices.get('air_temp') # Air temperature sensor 160 | devices.get('pool_pump') # Pool pump 161 | devices.get('spa_pump') # Spa pump 162 | devices.get('pool_heater') # Pool heater 163 | devices.get('spa_heater') # Spa heater 164 | devices.get('pool_set_point') # Pool thermostat 165 | devices.get('spa_set_point') # Spa thermostat 166 | devices.get('pool_light') # Pool light 167 | devices.get('spa_light') # Spa light 168 | devices.get('aux_1') # Auxiliary 1 169 | devices.get('aux_2') # Auxiliary 2 170 | # ... etc 171 | ``` 172 | 173 | ### By Type 174 | 175 | ```python 176 | # Find all temperature sensors 177 | temps = { 178 | name: device 179 | for name, device in devices.items() 180 | if 'temp' in name 181 | } 182 | 183 | # Find all switches 184 | switches = { 185 | name: device 186 | for name, device in devices.items() 187 | if hasattr(device, 'turn_on') 188 | } 189 | 190 | # Find all thermostats 191 | thermostats = { 192 | name: device 193 | for name, device in devices.items() 194 | if hasattr(device, 'set_temperature') 195 | } 196 | ``` 197 | 198 | ## Device Commands 199 | 200 | ### Synchronous State Updates 201 | 202 | Device state is updated when the parent system updates: 203 | 204 | ```python 205 | # Update system (refreshes all devices) 206 | await system.update() 207 | 208 | # Check device state 209 | print(pool_pump.is_on) 210 | ``` 211 | 212 | ### Asynchronous Commands 213 | 214 | Commands are sent immediately but state may take time to reflect: 215 | 216 | ```python 217 | # Send command 218 | await pool_pump.turn_on() 219 | 220 | # Wait for state to update 221 | await asyncio.sleep(1) 222 | await system.update() 223 | 224 | # Verify state changed 225 | assert pool_pump.is_on 226 | ``` 227 | 228 | ## Error Handling 229 | 230 | Handle device command errors: 231 | 232 | ```python 233 | from iaqualink import AqualinkServiceException 234 | 235 | try: 236 | await pool_pump.turn_on() 237 | except AqualinkServiceException as e: 238 | print(f"Command failed: {e}") 239 | ``` 240 | 241 | ## System-Specific Devices 242 | 243 | ### iAqua Devices 244 | 245 | iAqua systems have these specific characteristics: 246 | - Devices use numeric IDs 247 | - Commands sent via session requests 248 | - Two-step state refresh 249 | 250 | ### eXO Devices 251 | 252 | eXO systems have these specific characteristics: 253 | - Devices use string endpoints 254 | - Commands update desired state 255 | - Single shadow state update 256 | 257 | ## Next Steps 258 | 259 | - [Examples](examples.md) - See complete examples 260 | - [API Reference](../api/device.md) - Detailed device API 261 | - [Systems Guide](systems.md) - Learn about system types 262 | -------------------------------------------------------------------------------- /docs/development/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome! This guide will help you get started. 4 | 5 | ## Development Setup 6 | 7 | ### Prerequisites 8 | 9 | - Python 3.13 or higher 10 | - [uv](https://github.com/astral-sh/uv) (recommended) or pip 11 | - Git 12 | 13 | ### Clone Repository 14 | 15 | ```bash 16 | git clone https://github.com/flz/iaqualink-py.git 17 | cd iaqualink-py 18 | ``` 19 | 20 | ### Install Dependencies 21 | 22 | Using uv (recommended): 23 | 24 | ```bash 25 | # Install all dependencies including dev and test 26 | uv sync --all-extras --dev 27 | ``` 28 | 29 | Using pip: 30 | 31 | ```bash 32 | pip install -e ".[dev,test,docs]" 33 | ``` 34 | 35 | ### Install Pre-commit Hooks 36 | 37 | ```bash 38 | uv run pre-commit install 39 | ``` 40 | 41 | ## Development Workflow 42 | 43 | ### 1. Create a Branch 44 | 45 | ```bash 46 | git checkout -b feature/your-feature-name 47 | ``` 48 | 49 | ### 2. Make Changes 50 | 51 | Edit the code in `src/iaqualink/`. 52 | 53 | ### 3. Write Tests 54 | 55 | Add tests in `tests/` following the existing structure. 56 | 57 | ### 4. Run Tests 58 | 59 | ```bash 60 | # Run all tests 61 | uv run pytest 62 | 63 | # Run with coverage 64 | uv run pytest --cov-report=xml --cov=iaqualink 65 | 66 | # Run specific test 67 | uv run pytest tests/test_client.py::TestClassName::test_method 68 | ``` 69 | 70 | ### 5. Check Code Quality 71 | 72 | ```bash 73 | # Run all pre-commit hooks 74 | uv run pre-commit run --all-files 75 | 76 | # Or run individually: 77 | 78 | # Linting with auto-fix 79 | uv run ruff check --fix . 80 | 81 | # Formatting 82 | uv run ruff format . 83 | 84 | # Type checking 85 | uv run mypy src/ 86 | ``` 87 | 88 | ### 6. Commit Changes 89 | 90 | ```bash 91 | git add . 92 | git commit -m "feat: add new feature" 93 | ``` 94 | 95 | Follow [Conventional Commits](https://www.conventionalcommits.org/): 96 | 97 | - `feat:` - New feature 98 | - `fix:` - Bug fix 99 | - `docs:` - Documentation changes 100 | - `test:` - Test changes 101 | - `refactor:` - Code refactoring 102 | - `chore:` - Maintenance tasks 103 | 104 | ### 7. Push and Create PR 105 | 106 | ```bash 107 | git push origin feature/your-feature-name 108 | ``` 109 | 110 | Then create a pull request on GitHub. 111 | 112 | ## Code Style 113 | 114 | ### Python Style 115 | 116 | The project uses Ruff for linting and formatting: 117 | 118 | - Line length: 80 characters 119 | - Follow PEP 8 120 | - Use type hints 121 | - Write docstrings for public APIs 122 | 123 | ### Type Hints 124 | 125 | All code must include type hints: 126 | 127 | ```python 128 | def example_function(param: str) -> int: 129 | """Example function with type hints.""" 130 | return len(param) 131 | ``` 132 | 133 | ### Docstrings 134 | 135 | Use Google-style docstrings: 136 | 137 | ```python 138 | def example_function(param: str) -> int: 139 | """Brief description. 140 | 141 | Longer description if needed. 142 | 143 | Args: 144 | param: Description of parameter. 145 | 146 | Returns: 147 | Description of return value. 148 | 149 | Raises: 150 | ValueError: When param is invalid. 151 | """ 152 | return len(param) 153 | ``` 154 | 155 | ## Testing 156 | 157 | ### Test Structure 158 | 159 | Tests use `unittest.IsolatedAsyncioTestCase`: 160 | 161 | ```python 162 | import unittest 163 | from tests.base import TestBase 164 | 165 | class TestMyFeature(TestBase): 166 | async def test_something(self): 167 | """Test something.""" 168 | # Your test here 169 | pass 170 | ``` 171 | 172 | ### Mocking HTTP Requests 173 | 174 | Use `respx` for HTTP mocking: 175 | 176 | ```python 177 | import respx 178 | from httpx import Response 179 | 180 | @respx.mock 181 | async def test_api_call(self): 182 | respx.post("https://api.example.com/endpoint").mock( 183 | return_value=Response(200, json={"result": "success"}) 184 | ) 185 | 186 | # Test code that makes HTTP request 187 | result = await client.some_method() 188 | self.assertEqual(result, "success") 189 | ``` 190 | 191 | ### Test Coverage 192 | 193 | Maintain high test coverage: 194 | 195 | ```bash 196 | # Generate coverage report 197 | uv run pytest --cov-report=html --cov=iaqualink 198 | 199 | # View report 200 | open htmlcov/index.html 201 | ``` 202 | 203 | ## Documentation 204 | 205 | ### Building Docs 206 | 207 | ```bash 208 | # Install docs dependencies 209 | uv sync --group docs 210 | 211 | # Serve docs locally 212 | uv run mkdocs serve 213 | 214 | # Build docs 215 | uv run mkdocs build 216 | ``` 217 | 218 | ### Writing Docs 219 | 220 | - Use Markdown 221 | - Include code examples 222 | - Keep it concise 223 | - Test all examples 224 | 225 | ## Project Structure 226 | 227 | ``` 228 | iaqualink-py/ 229 | ├── src/iaqualink/ # Source code 230 | │ ├── client.py # Main client 231 | │ ├── system.py # Base system 232 | │ ├── device.py # Base devices 233 | │ ├── systems/ 234 | │ │ ├── iaqua/ # iAqua implementation 235 | │ │ └── exo/ # eXO implementation 236 | │ └── exception.py # Exceptions 237 | ├── tests/ # Test suite 238 | │ ├── base.py # Test base classes 239 | │ ├── systems/ 240 | │ │ ├── iaqua/ # iAqua tests 241 | │ │ └── exo/ # eXO tests 242 | │ └── test_*.py # Test files 243 | ├── docs/ # Documentation 244 | └── pyproject.toml # Project config 245 | ``` 246 | 247 | ## Adding New System Types 248 | 249 | To add support for a new system type: 250 | 251 | 1. Create `src/iaqualink/systems/newsystem/` 252 | 2. Implement `NewSystem(AqualinkSystem)` 253 | 3. Set `NAME` class attribute 254 | 4. Implement device parsing methods 255 | 5. Create device classes 256 | 6. Add tests 257 | 7. Update documentation 258 | 259 | See [Architecture](architecture.md) for details. 260 | 261 | ## Pull Request Guidelines 262 | 263 | ### Before Submitting 264 | 265 | - [ ] All tests pass 266 | - [ ] Code is formatted (ruff format) 267 | - [ ] Code is linted (ruff check) 268 | - [ ] Type checking passes (mypy) 269 | - [ ] Tests added/updated 270 | - [ ] Documentation updated 271 | - [ ] Commit messages follow conventions 272 | 273 | ### PR Description 274 | 275 | Include: 276 | 277 | - What changes were made 278 | - Why the changes are needed 279 | - Any breaking changes 280 | - How to test the changes 281 | 282 | ### Review Process 283 | 284 | 1. Automated checks must pass 285 | 2. Code review by maintainer 286 | 3. Address feedback 287 | 4. Merge when approved 288 | 289 | ## Getting Help 290 | 291 | - Open an issue for bugs 292 | - Start a discussion for questions 293 | - Check existing issues first 294 | 295 | ## Code of Conduct 296 | 297 | Be respectful and constructive. This is an open source project maintained by volunteers. 298 | 299 | ## License 300 | 301 | By contributing, you agree that your contributions will be licensed under the BSD 3-Clause License. 302 | -------------------------------------------------------------------------------- /src/iaqualink/systems/iaqua/system.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import time 5 | from typing import TYPE_CHECKING 6 | 7 | from iaqualink.const import MIN_SECS_TO_REFRESH 8 | from iaqualink.exception import ( 9 | AqualinkDeviceNotSupported, 10 | AqualinkServiceException, 11 | AqualinkSystemOfflineException, 12 | ) 13 | from iaqualink.system import AqualinkSystem 14 | from iaqualink.systems.iaqua.device import IaquaDevice 15 | 16 | if TYPE_CHECKING: 17 | import httpx 18 | 19 | from iaqualink.client import AqualinkClient 20 | from iaqualink.typing import Payload 21 | 22 | IAQUA_SESSION_URL = "https://p-api.iaqualink.net/v1/mobile/session.json" 23 | 24 | IAQUA_COMMAND_GET_DEVICES = "get_devices" 25 | IAQUA_COMMAND_GET_HOME = "get_home" 26 | IAQUA_COMMAND_GET_ONETOUCH = "get_onetouch" 27 | 28 | IAQUA_COMMAND_SET_AUX = "set_aux" 29 | IAQUA_COMMAND_SET_LIGHT = "set_light" 30 | IAQUA_COMMAND_SET_POOL_HEATER = "set_pool_heater" 31 | IAQUA_COMMAND_SET_POOL_PUMP = "set_pool_pump" 32 | IAQUA_COMMAND_SET_SOLAR_HEATER = "set_solar_heater" 33 | IAQUA_COMMAND_SET_SPA_HEATER = "set_spa_heater" 34 | IAQUA_COMMAND_SET_SPA_PUMP = "set_spa_pump" 35 | IAQUA_COMMAND_SET_TEMPS = "set_temps" 36 | 37 | 38 | LOGGER = logging.getLogger("iaqualink") 39 | 40 | 41 | class IaquaSystem(AqualinkSystem): 42 | NAME = "iaqua" 43 | 44 | def __init__(self, aqualink: AqualinkClient, data: Payload): 45 | super().__init__(aqualink, data) 46 | 47 | self.temp_unit: str = "" 48 | self.last_refresh: int = 0 49 | 50 | def __repr__(self) -> str: 51 | attrs = ["name", "serial", "data"] 52 | attrs = [f"{i}={getattr(self, i)!r}" for i in attrs] 53 | return f"{self.__class__.__name__}({' '.join(attrs)})" 54 | 55 | async def _send_session_request( 56 | self, 57 | command: str, 58 | params: Payload | None = None, 59 | ) -> httpx.Response: 60 | if not params: 61 | params = {} 62 | 63 | params.update( 64 | { 65 | "actionID": "command", 66 | "command": command, 67 | "serial": self.serial, 68 | "sessionID": self.aqualink.client_id, 69 | } 70 | ) 71 | params_str = "&".join(f"{k}={v}" for k, v in params.items()) 72 | url = f"{IAQUA_SESSION_URL}?{params_str}" 73 | return await self.aqualink.send_request(url) 74 | 75 | async def _send_home_screen_request(self) -> httpx.Response: 76 | return await self._send_session_request(IAQUA_COMMAND_GET_HOME) 77 | 78 | async def _send_devices_screen_request(self) -> httpx.Response: 79 | return await self._send_session_request(IAQUA_COMMAND_GET_DEVICES) 80 | 81 | async def update(self) -> None: 82 | # Be nice to Aqualink servers since we rely on polling. 83 | now = int(time.time()) 84 | delta = now - self.last_refresh 85 | if delta < MIN_SECS_TO_REFRESH: 86 | LOGGER.debug(f"Only {delta}s since last refresh.") 87 | return 88 | 89 | try: 90 | r1 = await self._send_home_screen_request() 91 | r2 = await self._send_devices_screen_request() 92 | except AqualinkServiceException: 93 | self.online = None 94 | raise 95 | 96 | try: 97 | self._parse_home_response(r1) 98 | self._parse_devices_response(r2) 99 | except AqualinkSystemOfflineException: 100 | self.online = False 101 | raise 102 | 103 | self.online = True 104 | self.last_refresh = int(time.time()) 105 | 106 | def _parse_home_response(self, response: httpx.Response) -> None: 107 | data = response.json() 108 | 109 | LOGGER.debug(f"Home response: {data}") 110 | 111 | if data["home_screen"][0]["status"] == "Offline": 112 | LOGGER.warning(f"Status for system {self.serial} is Offline.") 113 | raise AqualinkSystemOfflineException 114 | 115 | self.temp_unit = data["home_screen"][3]["temp_scale"] 116 | 117 | # Make the data a bit flatter. 118 | devices = {} 119 | for x in data["home_screen"][4:]: 120 | name = next(iter(x.keys())) 121 | state = next(iter(x.values())) 122 | attrs = {"name": name, "state": state} 123 | devices.update({name: attrs}) 124 | 125 | for k, v in devices.items(): 126 | if k in self.devices: 127 | for dk, dv in v.items(): 128 | self.devices[k].data[dk] = dv 129 | else: 130 | try: 131 | self.devices[k] = IaquaDevice.from_data(self, v) 132 | except AqualinkDeviceNotSupported as e: 133 | LOGGER.debug("Device found was ignored: %s", e) 134 | 135 | def _parse_devices_response(self, response: httpx.Response) -> None: 136 | data = response.json() 137 | 138 | LOGGER.debug(f"Devices response: {data}") 139 | 140 | if data["devices_screen"][0]["status"] == "Offline": 141 | LOGGER.warning(f"Status for system {self.serial} is Offline.") 142 | raise AqualinkSystemOfflineException 143 | 144 | # Make the data a bit flatter. 145 | devices = {} 146 | for x in data["devices_screen"][3:]: 147 | aux = next(iter(x.keys())) 148 | attrs = {"aux": aux.replace("aux_", ""), "name": aux} 149 | for y in next(iter(x.values())): 150 | attrs.update(y) 151 | devices.update({aux: attrs}) 152 | 153 | for k, v in devices.items(): 154 | if k in self.devices: 155 | for dk, dv in v.items(): 156 | self.devices[k].data[dk] = dv 157 | else: 158 | try: 159 | self.devices[k] = IaquaDevice.from_data(self, v) 160 | except AqualinkDeviceNotSupported as e: 161 | LOGGER.info("Device found was ignored: %s", e) 162 | 163 | async def set_switch(self, command: str) -> None: 164 | r = await self._send_session_request(command) 165 | self._parse_home_response(r) 166 | 167 | async def set_temps(self, temps: Payload) -> None: 168 | # I'm not proud of this. If you read this, please submit a PR to make it better. 169 | # We need to pass the temperatures for both pool and spa (if present) in the same request. 170 | # Set args to current target temperatures and override with the request payload. 171 | args = {} 172 | i = 1 173 | if "spa_set_point" in self.devices: 174 | args[f"temp{i}"] = self.devices["spa_set_point"].target_temperature 175 | i += 1 176 | args[f"temp{i}"] = self.devices["pool_set_point"].target_temperature 177 | args.update(temps) 178 | 179 | r = await self._send_session_request(IAQUA_COMMAND_SET_TEMPS, args) 180 | self._parse_home_response(r) 181 | 182 | async def set_aux(self, aux: str) -> None: 183 | aux = IAQUA_COMMAND_SET_AUX + "_" + aux.replace("aux_", "") 184 | r = await self._send_session_request(aux) 185 | self._parse_devices_response(r) 186 | 187 | async def set_light(self, data: Payload) -> None: 188 | r = await self._send_session_request(IAQUA_COMMAND_SET_LIGHT, data) 189 | self._parse_devices_response(r) 190 | -------------------------------------------------------------------------------- /tests/test_device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch 4 | 5 | import pytest 6 | 7 | from iaqualink.device import ( 8 | AqualinkBinarySensor, 9 | AqualinkDevice, 10 | AqualinkLight, 11 | AqualinkSensor, 12 | AqualinkSwitch, 13 | AqualinkThermostat, 14 | ) 15 | 16 | from .base_test_device import ( 17 | TestBaseBinarySensor, 18 | TestBaseDevice, 19 | TestBaseLight, 20 | TestBaseSensor, 21 | TestBaseSwitch, 22 | TestBaseThermostat, 23 | ) 24 | 25 | 26 | class TestAqualinkDevice(TestBaseDevice): 27 | def setUp(self) -> None: 28 | system = MagicMock() 29 | data = {"foo": "bar"} 30 | self.sut = AqualinkDevice(system, data) 31 | 32 | async def test_repr(self) -> None: 33 | assert ( 34 | repr(self.sut) 35 | == f"{self.sut.__class__.__name__}(data={self.sut.data!r})" 36 | ) 37 | 38 | def test_property_name(self) -> None: 39 | with pytest.raises(NotImplementedError): 40 | super().test_property_name() 41 | 42 | def test_property_label(self) -> None: 43 | with pytest.raises(NotImplementedError): 44 | super().test_property_label() 45 | 46 | def test_property_state(self) -> None: 47 | with pytest.raises(NotImplementedError): 48 | super().test_property_state() 49 | 50 | def test_property_manufacturer(self) -> None: 51 | with pytest.raises(NotImplementedError): 52 | super().test_property_manufacturer() 53 | 54 | def test_property_model(self) -> None: 55 | with pytest.raises(NotImplementedError): 56 | super().test_property_model() 57 | 58 | 59 | class TestAqualinkSensor(TestBaseSensor, TestAqualinkDevice): 60 | def setUp(self) -> None: 61 | system = MagicMock() 62 | data: dict[str, str] = {} 63 | self.sut = AqualinkSensor(system, data) 64 | 65 | 66 | class TestAqualinkBinarySensor(TestBaseBinarySensor, TestAqualinkSensor): 67 | def setUp(self) -> None: 68 | system = MagicMock() 69 | data: dict[str, str] = {} 70 | self.sut = AqualinkBinarySensor(system, data) 71 | 72 | def test_property_is_on_true(self) -> None: 73 | with pytest.raises(NotImplementedError): 74 | super().test_property_is_on_true() 75 | 76 | def test_property_is_on_false(self) -> None: 77 | with pytest.raises(NotImplementedError): 78 | super().test_property_is_on_false() 79 | 80 | 81 | class TestAqualinkSwitch(TestBaseSwitch, TestAqualinkDevice): 82 | def setUp(self) -> None: 83 | system = MagicMock() 84 | data: dict[str, str] = {} 85 | self.sut = AqualinkSwitch(system, data) 86 | 87 | def test_property_is_on_true(self) -> None: 88 | with pytest.raises(NotImplementedError): 89 | super().test_property_is_on_true() 90 | 91 | def test_property_is_on_false(self) -> None: 92 | with pytest.raises(NotImplementedError): 93 | super().test_property_is_on_false() 94 | 95 | async def test_turn_on(self) -> None: 96 | with pytest.raises(NotImplementedError): 97 | await super().test_turn_on() 98 | 99 | async def test_turn_on_noop(self) -> None: 100 | with pytest.raises(NotImplementedError): 101 | await super().test_turn_on_noop() 102 | 103 | async def test_turn_off(self) -> None: 104 | with pytest.raises(NotImplementedError): 105 | await super().test_turn_off() 106 | 107 | async def test_turn_off_noop(self) -> None: 108 | with pytest.raises(NotImplementedError): 109 | await super().test_turn_off_noop() 110 | 111 | 112 | class TestAqualinkLight(TestBaseLight, TestAqualinkDevice): 113 | def setUp(self) -> None: 114 | system = MagicMock() 115 | data: dict[str, str] = {} 116 | self.sut = AqualinkLight(system, data) 117 | 118 | def test_property_is_on_true(self) -> None: 119 | with pytest.raises(NotImplementedError): 120 | super().test_property_is_on_true() 121 | 122 | def test_property_is_on_false(self) -> None: 123 | with pytest.raises(NotImplementedError): 124 | super().test_property_is_on_false() 125 | 126 | async def test_turn_off_noop(self) -> None: 127 | with pytest.raises(NotImplementedError): 128 | await super().test_turn_off_noop() 129 | 130 | async def test_turn_off(self) -> None: 131 | with pytest.raises(NotImplementedError): 132 | await super().test_turn_off() 133 | 134 | async def test_turn_on(self) -> None: 135 | with pytest.raises(NotImplementedError): 136 | await super().test_turn_on() 137 | 138 | async def test_turn_on_noop(self) -> None: 139 | with pytest.raises(NotImplementedError): 140 | await super().test_turn_on_noop() 141 | 142 | async def test_set_brightness_75(self) -> None: 143 | with ( 144 | patch.object( 145 | type(self.sut), 146 | "supports_brightness", 147 | new_callable=PropertyMock(return_value=True), 148 | ), 149 | pytest.raises(NotImplementedError), 150 | ): 151 | await super().test_set_brightness_75() 152 | 153 | async def test_set_effect_by_name_off(self) -> None: 154 | with ( 155 | patch.object( 156 | type(self.sut), 157 | "supports_effect", 158 | new_callable=PropertyMock(return_value=True), 159 | ), 160 | pytest.raises(NotImplementedError), 161 | ): 162 | await super().test_set_effect_by_name_off() 163 | 164 | async def test_set_effect_by_id_4(self) -> None: 165 | with ( 166 | patch.object( 167 | type(self.sut), 168 | "supports_effect", 169 | new_callable=PropertyMock(return_value=True), 170 | ), 171 | pytest.raises(NotImplementedError), 172 | ): 173 | await super().test_set_effect_by_id_4() 174 | 175 | 176 | class TestAqualinkThermostat(TestBaseThermostat, TestAqualinkDevice): 177 | def setUp(self) -> None: 178 | system = AsyncMock() 179 | data: dict[str, str] = {} 180 | self.sut = AqualinkThermostat(system, data) 181 | 182 | def test_property_is_on_true(self) -> None: 183 | with pytest.raises(NotImplementedError): 184 | super().test_property_is_on_true() 185 | 186 | def test_property_is_on_false(self) -> None: 187 | with pytest.raises(NotImplementedError): 188 | super().test_property_is_on_false() 189 | 190 | def test_property_unit(self) -> None: 191 | with pytest.raises(NotImplementedError): 192 | super().test_property_unit() 193 | 194 | def test_property_min_temperature_f(self) -> None: 195 | with pytest.raises(NotImplementedError): 196 | super().test_property_min_temperature_f() 197 | 198 | def test_property_min_temperature_c(self) -> None: 199 | with pytest.raises(NotImplementedError): 200 | super().test_property_min_temperature_c() 201 | 202 | def test_property_max_temperature_f(self) -> None: 203 | with pytest.raises(NotImplementedError): 204 | super().test_property_max_temperature_f() 205 | 206 | def test_property_max_temperature_c(self) -> None: 207 | with pytest.raises(NotImplementedError): 208 | super().test_property_max_temperature_c() 209 | 210 | def test_property_current_temperature(self) -> None: 211 | with pytest.raises(NotImplementedError): 212 | super().test_property_current_temperature() 213 | 214 | def test_property_target_temperature(self) -> None: 215 | with pytest.raises(NotImplementedError): 216 | super().test_property_target_temperature() 217 | 218 | async def test_turn_on(self) -> None: 219 | with pytest.raises(NotImplementedError): 220 | await super().test_turn_on() 221 | 222 | async def test_turn_on_noop(self) -> None: 223 | with pytest.raises(NotImplementedError): 224 | await super().test_turn_on_noop() 225 | 226 | async def test_turn_off(self) -> None: 227 | with pytest.raises(NotImplementedError): 228 | await super().test_turn_off() 229 | 230 | async def test_turn_off_noop(self) -> None: 231 | with pytest.raises(NotImplementedError): 232 | await super().test_turn_off_noop() 233 | 234 | async def test_set_temperature_86f(self) -> None: 235 | with pytest.raises(NotImplementedError): 236 | await super().test_set_temperature_86f() 237 | 238 | async def test_set_temperature_30c(self) -> None: 239 | with pytest.raises(NotImplementedError): 240 | await super().test_set_temperature_30c() 241 | 242 | async def test_set_temperature_invalid_400f(self) -> None: 243 | with pytest.raises(NotImplementedError): 244 | await super().test_set_temperature_invalid_400f() 245 | 246 | async def test_set_temperature_invalid_204c(self) -> None: 247 | with pytest.raises(NotImplementedError): 248 | await super().test_set_temperature_invalid_204c() 249 | -------------------------------------------------------------------------------- /docs/development/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | This document describes the internal architecture of iaqualink-py. 4 | 5 | ## Overview 6 | 7 | The library follows a plugin-style architecture with base classes and system-specific implementations. This design allows supporting multiple API types (iAqua and eXO) through a unified interface. 8 | 9 | ## Core Components 10 | 11 | ### 1. AqualinkClient 12 | 13 | **Location:** `src/iaqualink/client.py` 14 | 15 | The entry point for all interactions with the iAqualink API. 16 | 17 | **Responsibilities:** 18 | - Authentication (both iAqua and eXO) 19 | - HTTP client management (httpx with HTTP/2) 20 | - System discovery 21 | - Session management 22 | 23 | **Key Features:** 24 | - Context manager support for automatic cleanup 25 | - Automatic system type detection 26 | - Token/session management 27 | 28 | ### 2. AqualinkSystem 29 | 30 | **Location:** `src/iaqualink/system.py` 31 | 32 | Base class for pool systems using a registry pattern. 33 | 34 | **Responsibilities:** 35 | - Device management 36 | - State polling and caching 37 | - Rate limiting (5-second minimum interval) 38 | - Online/offline status tracking 39 | 40 | **Design Pattern: Subclass Registry** 41 | 42 | ```python 43 | class AqualinkSystem: 44 | _subclasses: dict[str, type[Self]] = {} 45 | 46 | def __init_subclass__(cls, **kwargs): 47 | """Register subclass by NAME attribute.""" 48 | if hasattr(cls, 'NAME'): 49 | cls._subclasses[cls.NAME] = cls 50 | 51 | @classmethod 52 | def from_data(cls, aqualink, data): 53 | """Factory method - dispatches to correct subclass.""" 54 | device_type = data.get('device_type') 55 | subclass = cls._subclasses.get(device_type) 56 | return subclass(aqualink, data) 57 | ``` 58 | 59 | **Implementations:** 60 | - **IaquaSystem** - `NAME = "iaqua"` 61 | - **ExoSystem** - `NAME = "exo"` 62 | 63 | ### 3. AqualinkDevice 64 | 65 | **Location:** `src/iaqualink/device.py` 66 | 67 | Base class for all devices using inheritance hierarchy. 68 | 69 | **Hierarchy:** 70 | 71 | ``` 72 | AqualinkDevice (base) 73 | ├── AqualinkSensor (read-only) 74 | │ └── AqualinkBinarySensor (on/off state) 75 | │ └── AqualinkSwitch (controllable) 76 | │ ├── AqualinkLight (toggle) 77 | │ └── AqualinkThermostat (temperature) 78 | ``` 79 | 80 | **Responsibilities:** 81 | - Device state management 82 | - Command execution 83 | - Type-specific behavior 84 | 85 | ## System Implementations 86 | 87 | ### iAqua Systems 88 | 89 | **Location:** `src/iaqualink/systems/iaqua/` 90 | 91 | **API Characteristics:** 92 | - Endpoint: iaqualink.net 93 | - Auth: Session tokens (session_id, authentication_token) 94 | - Two API calls for updates: 95 | - `get_home` - System info 96 | - `get_devices` - Device states 97 | - Commands: Session requests with command names 98 | 99 | **Key Files:** 100 | - `system.py` - IaquaSystem implementation 101 | - `device.py` - iAqua device classes 102 | 103 | ### eXO Systems 104 | 105 | **Location:** `src/iaqualink/systems/exo/` 106 | 107 | **API Characteristics:** 108 | - Endpoint: zodiac-io.com 109 | - Auth: JWT IdToken 110 | - Single shadow state API (AWS IoT style) 111 | - State: desired/reported pattern 112 | - Automatic token refresh on 401 113 | 114 | **Key Files:** 115 | - `system.py` - ExoSystem implementation 116 | - `device.py` - eXO device classes 117 | 118 | ## Data Flow 119 | 120 | ### Authentication Flow 121 | 122 | ``` 123 | User → AqualinkClient(username, password) 124 | → async with client (calls login()) 125 | → Detect API type from response 126 | → Store credentials (session_id or IdToken) 127 | ``` 128 | 129 | ### System Discovery Flow 130 | 131 | ``` 132 | client.get_systems() 133 | → Fetch systems from API 134 | → For each system: 135 | → Extract device_type 136 | → AqualinkSystem.from_data() 137 | → Registry lookup by device_type 138 | → Instantiate correct subclass 139 | → Return dict[serial, system] 140 | ``` 141 | 142 | ### Device Refresh Flow 143 | 144 | ``` 145 | system.update() 146 | → Check rate limit (MIN_SECS_TO_REFRESH = 5) 147 | → If cached, return immediately 148 | → Fetch from API (system-specific) 149 | → Parse response 150 | → Update device states 151 | → Cache timestamp 152 | ``` 153 | 154 | ### Command Flow 155 | 156 | ``` 157 | device.turn_on() 158 | → Build command (system-specific) 159 | → Send to API via system 160 | → Return (state updates on next poll) 161 | ``` 162 | 163 | ## Rate Limiting 164 | 165 | Systems implement rate limiting to respect API limits: 166 | 167 | ```python 168 | MIN_SECS_TO_REFRESH = 5.0 169 | 170 | async def update(self): 171 | now = time.time() 172 | if self.last_run_success and \ 173 | (now - self.last_run_success) < MIN_SECS_TO_REFRESH: 174 | return # Use cached data 175 | 176 | # Fetch from API 177 | await self._update_impl() 178 | self.last_run_success = now 179 | ``` 180 | 181 | ## Type System 182 | 183 | The library uses modern Python type hints (3.13+): 184 | 185 | ```python 186 | from typing import Self, Any 187 | from collections.abc import Awaitable 188 | 189 | class AqualinkSystem: 190 | def __init__(self, aqualink: AqualinkClient, data: dict[str, Any]): 191 | ... 192 | 193 | @classmethod 194 | def from_data( 195 | cls, 196 | aqualink: AqualinkClient, 197 | data: dict[str, Any] 198 | ) -> Self: 199 | ... 200 | ``` 201 | 202 | ## Testing Architecture 203 | 204 | **Base Class:** `TestBase` in `tests/base.py` 205 | 206 | **Structure:** 207 | ``` 208 | tests/ 209 | ├── base.py # TestBase with common setup 210 | ├── test_client.py # Client tests 211 | ├── test_system.py # System tests 212 | ├── test_device.py # Device tests 213 | └── systems/ 214 | ├── iaqua/ 215 | │ ├── base_test_system.py # Abstract iAqua tests 216 | │ ├── base_test_device.py # Abstract device tests 217 | │ └── test_*.py # Concrete tests 218 | └── exo/ 219 | ├── base_test_system.py 220 | ├── base_test_device.py 221 | └── test_*.py 222 | ``` 223 | 224 | **Mocking:** Uses `respx` for HTTP mocking 225 | 226 | ## Error Handling 227 | 228 | **Exception Hierarchy:** 229 | 230 | ``` 231 | AqualinkException 232 | └── AqualinkServiceException 233 | ├── AqualinkLoginException 234 | └── AqualinkSystemOfflineException 235 | ``` 236 | 237 | **Location:** `src/iaqualink/exception.py` 238 | 239 | ## Adding New System Types 240 | 241 | To support a new system type: 242 | 243 | ### 1. Create Directory Structure 244 | 245 | ```bash 246 | mkdir -p src/iaqualink/systems/newsystem 247 | touch src/iaqualink/systems/newsystem/__init__.py 248 | touch src/iaqualink/systems/newsystem/system.py 249 | touch src/iaqualink/systems/newsystem/device.py 250 | ``` 251 | 252 | ### 2. Implement System Class 253 | 254 | ```python 255 | # src/iaqualink/systems/newsystem/system.py 256 | from iaqualink.system import AqualinkSystem 257 | 258 | class NewSystem(AqualinkSystem): 259 | NAME = "newsystem" # Must match device_type from API 260 | 261 | async def _update_impl(self): 262 | """Fetch and parse system state.""" 263 | # Implementation here 264 | pass 265 | 266 | async def _send_command(self, device, command): 267 | """Send command to device.""" 268 | # Implementation here 269 | pass 270 | ``` 271 | 272 | ### 3. Implement Device Classes 273 | 274 | ```python 275 | # src/iaqualink/systems/newsystem/device.py 276 | from iaqualink.device import ( 277 | AqualinkDevice, 278 | AqualinkSwitch, 279 | AqualinkThermostat, 280 | ) 281 | 282 | class NewDevice(AqualinkDevice): 283 | """Base device for new system.""" 284 | pass 285 | 286 | class NewSwitch(NewDevice, AqualinkSwitch): 287 | """Switch for new system.""" 288 | pass 289 | ``` 290 | 291 | ### 4. Register in __init__.py 292 | 293 | ```python 294 | # src/iaqualink/systems/newsystem/__init__.py 295 | from .system import NewSystem 296 | from .device import NewDevice, NewSwitch 297 | 298 | __all__ = ["NewSystem", "NewDevice", "NewSwitch"] 299 | ``` 300 | 301 | ### 5. Add Tests 302 | 303 | Follow existing test structure in `tests/systems/newsystem/`. 304 | 305 | ### 6. Update Documentation 306 | 307 | Add system-specific documentation in `docs/api/newsystem.md`. 308 | 309 | ## Dependencies 310 | 311 | **Runtime:** 312 | - `httpx[http2]>=0.27.0` - HTTP client with HTTP/2 313 | 314 | **Development:** 315 | - `ruff>=0.11.2` - Linting and formatting 316 | - `mypy>=1.15.0` - Type checking 317 | - `pytest>=8.3.5` - Testing 318 | - `respx>=0.22.0` - HTTP mocking 319 | 320 | **Documentation:** 321 | - `mkdocs>=1.6.0` - Documentation generator 322 | - `mkdocs-material>=9.5.0` - Material theme 323 | - `mkdocstrings[python]>=0.26.0` - API docs 324 | 325 | ## Build System 326 | 327 | Uses `hatchling` with `hatch-vcs` for version management: 328 | 329 | ```toml 330 | [build-system] 331 | requires = ["hatchling>=1.3.1", "hatch-vcs"] 332 | build-backend = "hatchling.build" 333 | 334 | [tool.hatch.version] 335 | source = "vcs" 336 | ``` 337 | 338 | Version is automatically derived from git tags. 339 | 340 | ## See Also 341 | 342 | - [Contributing Guide](contributing.md) - How to contribute 343 | - [API Reference](../api/client.md) - API documentation 344 | -------------------------------------------------------------------------------- /tests/systems/exo/test_system.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import unittest 4 | from unittest.mock import MagicMock, patch 5 | 6 | import pytest 7 | 8 | from iaqualink.client import AqualinkClient 9 | from iaqualink.exception import ( 10 | AqualinkServiceException, 11 | AqualinkServiceUnauthorizedException, 12 | AqualinkSystemOfflineException, 13 | ) 14 | from iaqualink.system import AqualinkSystem 15 | from iaqualink.systems.exo.system import ExoSystem 16 | 17 | from ...common import async_noop, async_raises 18 | 19 | SAMPLE_DATA = { 20 | "state": { 21 | "reported": { 22 | "vr": "V85W4", 23 | "aws": { 24 | "status": "connected", 25 | "timestamp": 123, 26 | "session_id": "xxxx", 27 | }, 28 | "hmi": { 29 | "ff": { 30 | "fn": "/fluidra-ota-prod/exo/V85W4_OTA.bin", 31 | "vr": "V85W4", 32 | "ts": 123, 33 | "pg": {"fs": 507300, "bd": 507300, "ts": 123, "te": 123}, 34 | }, 35 | "fw": { 36 | "fn": "/fluidra-ota-prod/exo/V85W4_OTA.bin", 37 | "vr": "V85W4", 38 | }, 39 | }, 40 | "main": { 41 | "ff": { 42 | "fn": "/fluidra-ota-prod/exo/V85R67_OTA.bin", 43 | "vr": "V85R67", 44 | "ts": 123, 45 | "pg": {"fs": 402328, "bd": 402328, "ts": 123, "te": 123}, 46 | } 47 | }, 48 | "debug": { 49 | "RSSI": -26, 50 | "OTA fail": 1, 51 | "OTA State": 0, 52 | "Last error": 65278, 53 | "Still alive": 2, 54 | "OTA success": 9, 55 | "MQTT connection": 2, 56 | "OTA fail global": 0, 57 | "Version Firmware": "V85W4B0", 58 | "Nb_Success_Pub_MSG": 463, 59 | "Nb_Fail_Publish_MSG": 0, 60 | "Nb_Success_Sub_Receive": 2, 61 | "MQTT disconnection total": 1, 62 | "OTA fail by disconnection": 0, 63 | "Nb reboot du to MQTT issue": 669, 64 | }, 65 | "state": {"reported": {"debug_main": {"tr": 100}}}, 66 | "equipment": { 67 | "swc_0": { 68 | "vr": "V85R67", 69 | "sn": "xxxxx", 70 | "amp": 1, 71 | "vsp": 1, 72 | "low": 0, 73 | "swc": 50, 74 | "temp": 1, 75 | "lang": 2, 76 | "ph_sp": 74, 77 | "sns_1": {"state": 1, "value": 75, "sensor_type": "Ph"}, 78 | "aux_1": { 79 | "type": "none", 80 | "mode": 0, 81 | "color": 0, 82 | "state": 0, 83 | }, 84 | "sns_2": {"state": 1, "value": 780, "sensor_type": "Orp"}, 85 | "sns_3": { 86 | "state": 1, 87 | "value": 29, 88 | "sensor_type": "Water temp", 89 | }, 90 | "aux_2": { 91 | "type": "none", 92 | "mode": 0, 93 | "state": 0, 94 | "color": 0, 95 | }, 96 | "boost": 0, 97 | "orp_sp": 830, 98 | "aux230": 1, 99 | "ph_only": 1, 100 | "swc_low": 0, 101 | "version": "V1", 102 | "exo_state": 1, 103 | "dual_link": 1, 104 | "production": 1, 105 | "error_code": 0, 106 | "boost_time": "24:00", 107 | "filter_pump": {"type": 1, "state": 1}, 108 | "error_state": 0, 109 | } 110 | }, 111 | "schedules": { 112 | "sch9": { 113 | "id": "sch_9", 114 | "name": "Aux 1", 115 | "timer": {"end": "00:00", "start": "00:00"}, 116 | "active": 0, 117 | "enabled": 0, 118 | "endpoint": "aux1", 119 | }, 120 | "sch1": { 121 | "id": "sch_1", 122 | "name": "Salt Water Chlorinator 1", 123 | "timer": {"end": "00:00", "start": "00:00"}, 124 | "active": 0, 125 | "enabled": 0, 126 | "endpoint": "swc_1", 127 | }, 128 | "sch3": { 129 | "id": "sch_3", 130 | "name": "Filter Pump 1", 131 | "timer": {"end": "00:00", "start": "00:00"}, 132 | "active": 0, 133 | "enabled": 0, 134 | "endpoint": "ssp_1", 135 | }, 136 | "sch4": { 137 | "id": "sch_4", 138 | "name": "Filter Pump 2", 139 | "timer": {"end": "00:00", "start": "00:00"}, 140 | "active": 0, 141 | "enabled": 0, 142 | "endpoint": "ssp_2", 143 | }, 144 | "sch2": { 145 | "id": "sch_2", 146 | "name": "Salt Water Chlorinator 2", 147 | "timer": {"end": "00:00", "start": "00:00"}, 148 | "active": 0, 149 | "enabled": 0, 150 | "endpoint": "swc_2", 151 | }, 152 | "sch10": { 153 | "id": "sch_10", 154 | "name": "Aux 2", 155 | "timer": {"end": "00:00", "start": "00:00"}, 156 | "active": 0, 157 | "enabled": 0, 158 | "endpoint": "aux2", 159 | }, 160 | "supported": 6, 161 | "programmed": 0, 162 | }, 163 | } 164 | }, 165 | "deviceId": "123", 166 | "ts": 123, 167 | } 168 | 169 | 170 | class TestExoSystem(unittest.IsolatedAsyncioTestCase): 171 | def setUp(self) -> None: 172 | pass 173 | 174 | def test_from_data_iaqua(self): 175 | aqualink = MagicMock() 176 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "exo"} 177 | r = AqualinkSystem.from_data(aqualink, data) 178 | assert r is not None 179 | assert isinstance(r, ExoSystem) 180 | 181 | async def test_update_success(self): 182 | aqualink = MagicMock() 183 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "exo"} 184 | r = AqualinkSystem.from_data(aqualink, data) 185 | r.send_reported_state_request = async_noop 186 | r._parse_shadow_response = MagicMock() 187 | await r.update() 188 | assert r.online is True 189 | 190 | async def test_update_service_exception(self): 191 | aqualink = MagicMock() 192 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "exo"} 193 | r = AqualinkSystem.from_data(aqualink, data) 194 | r.send_reported_state_request = async_raises(AqualinkServiceException) 195 | with pytest.raises(AqualinkServiceException): 196 | await r.update() 197 | assert r.online is None 198 | 199 | async def test_update_offline(self): 200 | aqualink = MagicMock() 201 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "exo"} 202 | r = AqualinkSystem.from_data(aqualink, data) 203 | r.send_reported_state_request = async_noop 204 | r._send_devices_screen_request = async_noop 205 | r._parse_shadow_response = MagicMock( 206 | side_effect=AqualinkSystemOfflineException 207 | ) 208 | 209 | with pytest.raises(AqualinkSystemOfflineException): 210 | await r.update() 211 | assert r.online is False 212 | 213 | @pytest.mark.xfail 214 | async def test_parse_devices_offline(self): 215 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "exo"} 216 | aqualink = MagicMock() 217 | system = AqualinkSystem.from_data(aqualink, data) 218 | 219 | message = {"message": "", "devices_screen": [{"status": "Offline"}]} 220 | response = MagicMock() 221 | response.json.return_value = message 222 | 223 | with pytest.raises(AqualinkSystemOfflineException): 224 | system._parse_shadow_response(response) 225 | assert system.devices == {} 226 | 227 | @pytest.mark.xfail 228 | async def test_parse_devices_good(self): 229 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "exo"} 230 | aqualink = MagicMock() 231 | system = ExoSystem.from_data(aqualink, data) 232 | 233 | response = MagicMock() 234 | response.json.return_value = SAMPLE_DATA 235 | system._parse_shadow_response(response) 236 | assert system.devices == {} 237 | 238 | @patch("httpx.AsyncClient.request") 239 | async def test_reported_state_request(self, mock_request): 240 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "exo"} 241 | aqualink = AqualinkClient("user", "pass") 242 | system = ExoSystem.from_data(aqualink, data) 243 | 244 | mock_request.return_value.status_code = 200 245 | 246 | await system.send_reported_state_request() 247 | 248 | @patch("httpx.AsyncClient.request") 249 | async def test_reported_state_request_unauthorized(self, mock_request): 250 | data = {"id": 1, "serial_number": "ABCDEFG", "device_type": "exo"} 251 | aqualink = AqualinkClient("user", "pass") 252 | system = ExoSystem.from_data(aqualink, data) 253 | 254 | mock_request.return_value.status_code = 401 255 | 256 | with pytest.raises(AqualinkServiceUnauthorizedException): 257 | await system.send_reported_state_request() 258 | -------------------------------------------------------------------------------- /docs/guide/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Complete examples demonstrating common use cases. 4 | 5 | ## Basic Monitoring 6 | 7 | Monitor pool and spa temperatures: 8 | 9 | ```python 10 | import asyncio 11 | from iaqualink import AqualinkClient 12 | 13 | async def monitor_temperatures(): 14 | async with AqualinkClient('user@example.com', 'password') as client: 15 | systems = await client.get_systems() 16 | system = list(systems.values())[0] 17 | 18 | while True: 19 | devices = await system.get_devices() 20 | 21 | pool_temp = devices.get('pool_temp') 22 | spa_temp = devices.get('spa_temp') 23 | air_temp = devices.get('air_temp') 24 | 25 | print(f"\nTemperatures:") 26 | if pool_temp: 27 | print(f" Pool: {pool_temp.state}°{pool_temp.unit}") 28 | if spa_temp: 29 | print(f" Spa: {spa_temp.state}°{spa_temp.unit}") 30 | if air_temp: 31 | print(f" Air: {air_temp.state}°{air_temp.unit}") 32 | 33 | # Wait 5 seconds before next update 34 | await asyncio.sleep(5) 35 | 36 | asyncio.run(monitor_temperatures()) 37 | ``` 38 | 39 | ## Spa Automation 40 | 41 | Automatically heat spa to desired temperature: 42 | 43 | ```python 44 | import asyncio 45 | from iaqualink import AqualinkClient 46 | 47 | async def heat_spa_to_temperature(target_temp: int = 102): 48 | async with AqualinkClient('user@example.com', 'password') as client: 49 | systems = await client.get_systems() 50 | system = list(systems.values())[0] 51 | devices = await system.get_devices() 52 | 53 | spa_temp = devices.get('spa_temp') 54 | spa_heater = devices.get('spa_heater') 55 | spa_setpoint = devices.get('spa_set_point') 56 | 57 | if not all([spa_temp, spa_heater, spa_setpoint]): 58 | print("Required spa devices not found") 59 | return 60 | 61 | # Set target temperature 62 | print(f"Setting spa temperature to {target_temp}°F") 63 | await spa_setpoint.set_temperature(target_temp) 64 | 65 | # Turn on heater 66 | if not spa_heater.is_on: 67 | print("Turning on spa heater") 68 | await spa_heater.turn_on() 69 | 70 | # Monitor until target reached 71 | while True: 72 | await system.update() 73 | current = spa_temp.state 74 | 75 | print(f"Current: {current}°F, Target: {target_temp}°F") 76 | 77 | if current >= target_temp: 78 | print("Target temperature reached!") 79 | break 80 | 81 | await asyncio.sleep(30) # Check every 30 seconds 82 | 83 | asyncio.run(heat_spa_to_temperature(102)) 84 | ``` 85 | 86 | ## System Status Report 87 | 88 | Generate a comprehensive status report: 89 | 90 | ```python 91 | import asyncio 92 | from iaqualink import AqualinkClient 93 | 94 | async def system_status_report(): 95 | async with AqualinkClient('user@example.com', 'password') as client: 96 | systems = await client.get_systems() 97 | 98 | for serial, system in systems.items(): 99 | print(f"\n{'='*60}") 100 | print(f"System: {system.name}") 101 | print(f"Serial: {serial}") 102 | print(f"Type: {system.data.get('device_type')}") 103 | print(f"Online: {system.online}") 104 | print(f"{'='*60}") 105 | 106 | if not system.online: 107 | print("System is offline") 108 | continue 109 | 110 | devices = await system.get_devices() 111 | 112 | # Temperature sensors 113 | print("\nTemperatures:") 114 | for name, device in devices.items(): 115 | if 'temp' in name and hasattr(device, 'state'): 116 | if device.state: 117 | print(f" {device.label}: {device.state}°{device.unit}") 118 | 119 | # Switches (pumps, heaters, etc) 120 | print("\nSwitches:") 121 | for name, device in devices.items(): 122 | if hasattr(device, 'is_on') and not hasattr(device, 'set_temperature'): 123 | status = "ON" if device.is_on else "OFF" 124 | print(f" {device.label}: {status}") 125 | 126 | # Thermostats 127 | print("\nThermostats:") 128 | for name, device in devices.items(): 129 | if hasattr(device, 'set_temperature'): 130 | print(f" {device.label}: {device.state}°{device.unit}") 131 | 132 | # Chemistry 133 | print("\nChemistry:") 134 | chemistry_sensors = ['ph', 'orp', 'salt'] 135 | for name in chemistry_sensors: 136 | device = devices.get(name) 137 | if device and device.state: 138 | unit = " mV" if name == "orp" else " ppm" if name == "salt" else "" 139 | print(f" {device.label}: {device.state}{unit}") 140 | 141 | asyncio.run(system_status_report()) 142 | ``` 143 | 144 | ## Scheduled Pool Pump 145 | 146 | Turn pool pump on/off at scheduled times: 147 | 148 | ```python 149 | import asyncio 150 | from datetime import datetime 151 | from iaqualink import AqualinkClient 152 | 153 | async def scheduled_pump_control(): 154 | # Configure schedule 155 | START_HOUR = 8 # 8 AM 156 | STOP_HOUR = 18 # 6 PM 157 | 158 | async with AqualinkClient('user@example.com', 'password') as client: 159 | systems = await client.get_systems() 160 | system = list(systems.values())[0] 161 | devices = await system.get_devices() 162 | 163 | pool_pump = devices.get('pool_pump') 164 | if not pool_pump: 165 | print("Pool pump not found") 166 | return 167 | 168 | print(f"Pump schedule: ON at {START_HOUR}:00, OFF at {STOP_HOUR}:00") 169 | 170 | while True: 171 | await system.update() 172 | now = datetime.now() 173 | current_hour = now.hour 174 | 175 | should_be_on = START_HOUR <= current_hour < STOP_HOUR 176 | 177 | if should_be_on and not pool_pump.is_on: 178 | print(f"[{now}] Turning pump ON") 179 | await pool_pump.turn_on() 180 | elif not should_be_on and pool_pump.is_on: 181 | print(f"[{now}] Turning pump OFF") 182 | await pool_pump.turn_off() 183 | 184 | # Check every 5 minutes 185 | await asyncio.sleep(300) 186 | 187 | asyncio.run(scheduled_pump_control()) 188 | ``` 189 | 190 | ## Multi-System Management 191 | 192 | Manage multiple pool systems: 193 | 194 | ```python 195 | import asyncio 196 | from iaqualink import AqualinkClient 197 | 198 | async def manage_multiple_systems(): 199 | async with AqualinkClient('user@example.com', 'password') as client: 200 | systems = await client.get_systems() 201 | 202 | # Update all systems in parallel 203 | await asyncio.gather(*[ 204 | system.update() for system in systems.values() 205 | ]) 206 | 207 | # Process each system 208 | for serial, system in systems.items(): 209 | print(f"\nSystem: {system.name}") 210 | 211 | if not system.online: 212 | print(" Status: OFFLINE") 213 | continue 214 | 215 | devices = await system.get_devices() 216 | 217 | # Get pool temperature 218 | pool_temp = devices.get('pool_temp') 219 | if pool_temp: 220 | print(f" Pool Temp: {pool_temp.state}°F") 221 | 222 | # Get pump status 223 | pool_pump = devices.get('pool_pump') 224 | if pool_pump: 225 | status = "ON" if pool_pump.is_on else "OFF" 226 | print(f" Pool Pump: {status}") 227 | 228 | asyncio.run(manage_multiple_systems()) 229 | ``` 230 | 231 | ## Error Handling 232 | 233 | Robust error handling example: 234 | 235 | ```python 236 | import asyncio 237 | from iaqualink import ( 238 | AqualinkClient, 239 | AqualinkServiceUnauthorizedException, 240 | AqualinkServiceException, 241 | AqualinkSystemOfflineException, 242 | ) 243 | 244 | async def robust_control(): 245 | try: 246 | async with AqualinkClient('user@example.com', 'password') as client: 247 | try: 248 | systems = await client.get_systems() 249 | system = list(systems.values())[0] 250 | 251 | try: 252 | devices = await system.get_devices() 253 | pool_pump = devices.get('pool_pump') 254 | 255 | if pool_pump: 256 | await pool_pump.turn_on() 257 | print("Pool pump turned on successfully") 258 | 259 | except AqualinkSystemOfflineException: 260 | print("System is offline") 261 | 262 | except AqualinkServiceException as e: 263 | print(f"Service error: {e}") 264 | 265 | except AqualinkServiceUnauthorizedException: 266 | print("Login failed - check credentials") 267 | except Exception as e: 268 | print(f"Unexpected error: {e}") 269 | 270 | asyncio.run(robust_control()) 271 | ``` 272 | 273 | ## Configuration from Environment 274 | 275 | Load credentials from environment variables: 276 | 277 | ```python 278 | import asyncio 279 | import os 280 | from iaqualink import AqualinkClient 281 | 282 | async def main(): 283 | # Load from environment 284 | username = os.getenv('IAQUALINK_USERNAME') 285 | password = os.getenv('IAQUALINK_PASSWORD') 286 | 287 | if not username or not password: 288 | print("Set IAQUALINK_USERNAME and IAQUALINK_PASSWORD") 289 | return 290 | 291 | async with AqualinkClient(username, password) as client: 292 | systems = await client.get_systems() 293 | print(f"Found {len(systems)} system(s)") 294 | 295 | for system in systems.values(): 296 | print(f" - {system.name}") 297 | 298 | asyncio.run(main()) 299 | ``` 300 | 301 | Run with: 302 | ```bash 303 | export IAQUALINK_USERNAME="user@example.com" 304 | export IAQUALINK_PASSWORD="your-password" 305 | python script.py 306 | ``` 307 | 308 | ## Next Steps 309 | 310 | - [API Reference](../api/client.md) - Detailed API documentation 311 | - [Systems Guide](systems.md) - Learn about system types 312 | - [Devices Guide](devices.md) - Learn about device types 313 | -------------------------------------------------------------------------------- /tests/systems/exo/test_device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | from typing import cast 5 | 6 | import pytest 7 | 8 | from iaqualink.systems.exo.device import ( 9 | EXO_TEMP_CELSIUS_HIGH, 10 | EXO_TEMP_CELSIUS_LOW, 11 | ExoAttributeSensor, 12 | ExoAttributeSwitch, 13 | ExoAuxSwitch, 14 | ExoDevice, 15 | ExoSensor, 16 | ExoSwitch, 17 | ExoThermostat, 18 | ) 19 | from iaqualink.systems.exo.system import ExoSystem 20 | 21 | from ...base_test_device import ( 22 | TestBaseDevice, 23 | TestBaseSensor, 24 | TestBaseSwitch, 25 | TestBaseThermostat, 26 | ) 27 | 28 | 29 | class TestExoDevice(TestBaseDevice): 30 | def setUp(self) -> None: 31 | super().setUp() 32 | 33 | data = {"serial_number": "SN123456", "device_type": "exo"} 34 | self.system = ExoSystem(self.client, data=data) 35 | 36 | data = {"name": "Test Device", "state": "42"} 37 | self.sut = ExoDevice(self.system, data) 38 | self.sut_class = ExoDevice 39 | 40 | def test_equal(self) -> None: 41 | assert self.sut == self.sut 42 | 43 | def test_not_equal(self) -> None: 44 | obj2 = copy.deepcopy(self.sut) 45 | obj2.data["name"] = "Test Device 2" 46 | assert self.sut != obj2 47 | 48 | def test_property_name(self) -> None: 49 | assert self.sut.name == self.sut.data["name"] 50 | 51 | def test_property_state(self) -> None: 52 | assert self.sut.state == str(self.sut.data["state"]) 53 | 54 | def test_not_equal_different_type(self) -> None: 55 | assert (self.sut == {}) is False 56 | 57 | def test_property_manufacturer(self) -> None: 58 | assert self.sut.manufacturer == "Zodiac" 59 | 60 | def test_property_model(self) -> None: 61 | assert self.sut.model == self.sut_class.__name__.replace("Exo", "") 62 | 63 | 64 | class TestExoSensor(TestExoDevice, TestBaseSensor): 65 | def setUp(self) -> None: 66 | super().setUp() 67 | 68 | data = { 69 | "name": "sns_1", 70 | "sensor_type": "Foo", 71 | "value": 42, 72 | "state": 1, 73 | } 74 | self.sut = ExoDevice.from_data(self.system, data) 75 | self.sut_class = ExoSensor 76 | 77 | def test_property_name(self) -> None: 78 | assert self.sut.name == self.sut.data["sensor_type"].lower().replace( 79 | " ", "_" 80 | ) 81 | 82 | def test_property_state(self) -> None: 83 | assert self.sut.state == str(self.sut.data["value"]) 84 | 85 | 86 | class TestExoAttributeSensor(TestExoDevice): 87 | def setUp(self) -> None: 88 | super().setUp() 89 | 90 | data = { 91 | "name": "foo_bar", 92 | "state": 42, 93 | } 94 | self.sut = ExoDevice.from_data(self.system, data) 95 | self.sut_class = ExoAttributeSensor 96 | 97 | 98 | class ExoSwitchMixin: 99 | def test_property_is_on_false(self) -> None: 100 | self.sut.data["state"] = 0 101 | super().test_property_is_on_false() 102 | assert self.sut.is_on is False 103 | 104 | def test_property_is_on_true(self) -> None: 105 | self.sut.data["state"] = 1 106 | super().test_property_is_on_true() 107 | assert self.sut.is_on is True 108 | 109 | 110 | class TestExoSwitch(TestExoDevice, ExoSwitchMixin, TestBaseSwitch): 111 | def setUp(self) -> None: 112 | super().setUp() 113 | 114 | data = { 115 | "name": "toggle", 116 | "state": 0, 117 | } 118 | 119 | # ExoSwitch is an abstract class, not meant to be instantiated directly. 120 | self.sut = ExoSwitch(self.system, data) 121 | self.sut_class = ExoSwitch 122 | 123 | async def test_turn_on(self) -> None: 124 | self.sut.data["state"] = 0 125 | with pytest.raises(NotImplementedError): 126 | await super().test_turn_on() 127 | 128 | async def test_turn_on_noop(self) -> None: 129 | self.sut.data["state"] = 1 130 | await super().test_turn_on_noop() 131 | 132 | async def test_turn_off(self) -> None: 133 | self.sut.data["state"] = 1 134 | with pytest.raises(NotImplementedError): 135 | await super().test_turn_off() 136 | 137 | async def test_turn_off_noop(self) -> None: 138 | self.sut.data["state"] = 0 139 | await super().test_turn_off_noop() 140 | 141 | 142 | class TestExoAuxSwitch(TestExoDevice, ExoSwitchMixin, TestBaseSwitch): 143 | def setUp(self) -> None: 144 | super().setUp() 145 | 146 | data = { 147 | "name": "aux_1", 148 | "type": "Foo", 149 | "mode": "mode", 150 | "light": 0, 151 | "state": 1, 152 | } 153 | self.sut = ExoDevice.from_data(self.system, data) 154 | self.sut_class = ExoAuxSwitch 155 | 156 | async def test_turn_on(self) -> None: 157 | self.sut.data["state"] = 0 158 | await super().test_turn_on() 159 | 160 | async def test_turn_on_noop(self) -> None: 161 | self.sut.data["state"] = 1 162 | await super().test_turn_on_noop() 163 | 164 | async def test_turn_off(self) -> None: 165 | self.sut.data["state"] = 1 166 | await super().test_turn_off() 167 | 168 | async def test_turn_off_noop(self) -> None: 169 | self.sut.data["state"] = 0 170 | await super().test_turn_off_noop() 171 | 172 | 173 | class TestExoAttributeSwitch(TestExoDevice, ExoSwitchMixin, TestBaseSwitch): 174 | def setUp(self) -> None: 175 | super().setUp() 176 | 177 | data = { 178 | "name": "boost", 179 | "state": 1, 180 | } 181 | self.sut = ExoDevice.from_data(self.system, data) 182 | self.sut_class = ExoAttributeSwitch 183 | 184 | async def test_turn_on(self) -> None: 185 | self.sut.data["state"] = 0 186 | await super().test_turn_on() 187 | 188 | async def test_turn_on_noop(self) -> None: 189 | self.sut.data["state"] = 1 190 | await super().test_turn_on_noop() 191 | 192 | async def test_turn_off(self) -> None: 193 | self.sut.data["state"] = 1 194 | await super().test_turn_off() 195 | 196 | async def test_turn_off_noop(self) -> None: 197 | self.sut.data["state"] = 0 198 | await super().test_turn_off_noop() 199 | 200 | 201 | class TestExoThermostat(TestExoDevice, TestBaseThermostat): 202 | def setUp(self) -> None: 203 | super().setUp() 204 | 205 | pool_set_point = { 206 | "name": "heating", 207 | "enabled": 1, 208 | "sp": 20, 209 | "sp_min": 1, 210 | "sp_max": 40, 211 | } 212 | 213 | self.pool_set_point = cast( 214 | ExoThermostat, ExoDevice.from_data(self.system, pool_set_point) 215 | ) 216 | 217 | water_temp = { 218 | "name": "sns_3", 219 | "sensor_type": "Water Temp", 220 | "state": 1, 221 | "value": 16, 222 | } 223 | self.water_temp = ExoDevice.from_data(self.system, water_temp) 224 | 225 | devices = [ 226 | self.pool_set_point, 227 | self.water_temp, 228 | ] 229 | self.system.devices = {x.data["name"]: x for x in devices} 230 | 231 | self.sut = self.pool_set_point 232 | self.sut_class = ExoThermostat 233 | 234 | def test_property_label(self) -> None: 235 | assert self.sut.label == "Heating" 236 | 237 | def test_property_name(self) -> None: 238 | assert self.sut.name == "heating" 239 | 240 | def test_property_state(self) -> None: 241 | assert self.sut.state == "20" 242 | 243 | def test_property_is_on_true(self) -> None: 244 | self.sut.data["enabled"] = 1 245 | super().test_property_is_on_true() 246 | 247 | def test_property_is_on_false(self) -> None: 248 | self.sut.data["enabled"] = 0 249 | super().test_property_is_on_false() 250 | 251 | def test_property_unit(self) -> None: 252 | assert self.sut.unit == "C" 253 | 254 | @pytest.mark.skip(reason="Exo doesn't support Fahrenheit") 255 | def test_property_min_temperature_f(self) -> None: 256 | pass 257 | 258 | def test_property_min_temperature_c(self) -> None: 259 | self.sut.system.temp_unit = "C" 260 | super().test_property_min_temperature_c() 261 | assert self.sut.min_temperature == EXO_TEMP_CELSIUS_LOW 262 | 263 | @pytest.mark.skip(reason="Exo doesn't support Fahrenheit") 264 | def test_property_max_temperature_f(self) -> None: 265 | pass 266 | 267 | def test_property_max_temperature_c(self) -> None: 268 | self.sut.system.temp_unit = "C" 269 | super().test_property_max_temperature_c() 270 | assert self.sut.max_temperature == EXO_TEMP_CELSIUS_HIGH 271 | 272 | def test_property_current_temperature(self) -> None: 273 | super().test_property_current_temperature() 274 | assert self.sut.current_temperature == "16" 275 | 276 | def test_property_target_temperature(self) -> None: 277 | super().test_property_target_temperature() 278 | assert self.sut.target_temperature == "20" 279 | 280 | async def test_turn_on(self) -> None: 281 | self.sut.data["enabled"] = 0 282 | await super().test_turn_on() 283 | assert len(self.respx_calls) == 1 284 | content = self.respx_calls[0].request.content.decode("utf-8") 285 | assert "heating" in content 286 | 287 | async def test_turn_on_noop(self) -> None: 288 | self.sut.data["enabled"] = 1 289 | await super().test_turn_on_noop() 290 | 291 | async def test_turn_off(self) -> None: 292 | self.sut.data["enabled"] = 1 293 | await super().test_turn_off() 294 | assert len(self.respx_calls) == 1 295 | content = self.respx_calls[0].request.content.decode("utf-8") 296 | assert "heating" in content 297 | 298 | async def test_turn_off_noop(self) -> None: 299 | self.sut.data["enabled"] = 0 300 | await super().test_turn_off_noop() 301 | 302 | @pytest.mark.skip(reason="Exo doesn't support Fahrenheit") 303 | async def test_set_temperature_86f(self) -> None: 304 | pass 305 | 306 | async def test_set_temperature_30c(self) -> None: 307 | await super().test_set_temperature_30c() 308 | assert len(self.respx_calls) == 1 309 | content = self.respx_calls[0].request.content.decode("utf-8") 310 | assert "heating" in content 311 | -------------------------------------------------------------------------------- /tests/base_test_device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | from unittest.mock import PropertyMock, patch 5 | 6 | import pytest 7 | import respx 8 | import respx.router 9 | 10 | from iaqualink.device import ( 11 | AqualinkBinarySensor, 12 | AqualinkLight, 13 | AqualinkSensor, 14 | AqualinkSwitch, 15 | AqualinkThermostat, 16 | ) 17 | from iaqualink.exception import ( 18 | AqualinkInvalidParameterException, 19 | AqualinkOperationNotSupportedException, 20 | ) 21 | 22 | from .base import TestBase, dotstar, resp_200 23 | 24 | 25 | class TestBaseDevice(TestBase): 26 | def test_property_name(self) -> None: 27 | assert isinstance(self.sut.name, str) 28 | 29 | def test_property_label(self) -> None: 30 | assert isinstance(self.sut.label, str) 31 | 32 | def test_property_state(self) -> None: 33 | assert isinstance(self.sut.state, str) 34 | 35 | def test_property_manufacturer(self) -> None: 36 | assert isinstance(self.sut.manufacturer, str) 37 | 38 | def test_property_model(self) -> None: 39 | assert isinstance(self.sut.model, str) 40 | 41 | def test_from_data(self) -> None: 42 | if sut_class := getattr(self, "sut_class", None): 43 | assert isinstance(self.sut, sut_class) 44 | 45 | 46 | class TestBaseSensor(TestBaseDevice): 47 | def test_inheritance(self) -> None: 48 | assert isinstance(self.sut, AqualinkSensor) 49 | 50 | 51 | class TestBaseBinarySensor(TestBaseSensor): 52 | def test_inheritance(self) -> None: 53 | assert isinstance(self.sut, AqualinkBinarySensor) 54 | 55 | def test_property_is_on_true(self) -> None: 56 | assert self.sut.is_on is True 57 | 58 | def test_property_is_on_false(self) -> None: 59 | assert self.sut.is_on is False 60 | 61 | 62 | class TestBaseSwitch(TestBaseBinarySensor): 63 | def test_inheritance(self) -> None: 64 | assert isinstance(self.sut, AqualinkSwitch) 65 | 66 | @respx.mock 67 | async def test_turn_on(self, respx_mock: respx.router.MockRouter) -> None: 68 | respx_mock.route(dotstar).mock(resp_200) 69 | await self.sut.turn_on() 70 | assert len(respx_mock.calls) > 0 71 | self.respx_calls = copy.copy(respx_mock.calls) 72 | 73 | @respx.mock 74 | async def test_turn_on_noop( 75 | self, respx_mock: respx.router.MockRouter 76 | ) -> None: 77 | respx_mock.route(dotstar).mock(resp_200) 78 | await self.sut.turn_on() 79 | assert len(respx_mock.calls) == 0 80 | 81 | @respx.mock 82 | async def test_turn_off(self, respx_mock: respx.router.MockRouter) -> None: 83 | respx_mock.route(dotstar).mock(resp_200) 84 | await self.sut.turn_off() 85 | assert len(respx_mock.calls) > 0 86 | self.respx_calls = copy.copy(respx_mock.calls) 87 | 88 | @respx.mock 89 | async def test_turn_off_noop( 90 | self, respx_mock: respx.router.MockRouter 91 | ) -> None: 92 | respx_mock.route(dotstar).mock(resp_200) 93 | await self.sut.turn_off() 94 | assert len(respx_mock.calls) == 0 95 | 96 | 97 | class TestBaseLight(TestBaseSwitch): 98 | def test_inheritance(self) -> None: 99 | assert isinstance(self.sut, AqualinkLight) 100 | 101 | def test_property_supports_brightness(self) -> None: 102 | assert isinstance(self.sut.supports_brightness, bool) 103 | 104 | def test_property_supports_effect(self) -> None: 105 | assert isinstance(self.sut.supports_effect, bool) 106 | 107 | def test_property_brightness(self) -> None: 108 | if not self.sut.supports_brightness: 109 | pytest.skip("Device doesn't support brightness") 110 | assert isinstance(self.sut.brightness, int) 111 | assert 0 <= self.sut.brightness <= 100 112 | 113 | def test_property_effect(self) -> None: 114 | if not self.sut.supports_effect: 115 | pytest.skip("Device doesn't support effects") 116 | assert isinstance(self.sut.effect, str) 117 | 118 | def test_property_supported_effects(self) -> None: 119 | if not self.sut.supports_effect: 120 | pytest.skip("Device doesn't support effects") 121 | assert isinstance(self.sut.supported_effects, dict) 122 | 123 | @respx.mock 124 | async def test_set_brightness_75( 125 | self, respx_mock: respx.router.MockRouter 126 | ) -> None: 127 | if not self.sut.supports_brightness: 128 | with pytest.raises(AqualinkOperationNotSupportedException): 129 | await self.sut.set_brightness(75) 130 | return 131 | 132 | respx_mock.route(dotstar).mock(resp_200) 133 | await self.sut.set_brightness(75) 134 | assert len(respx_mock.calls) > 0 135 | self.respx_calls = copy.copy(respx_mock.calls) 136 | 137 | @respx.mock 138 | async def test_set_brightness_invalid_89( 139 | self, respx_mock: respx.router.MockRouter 140 | ) -> None: 141 | if not self.sut.supports_brightness: 142 | with pytest.raises(AqualinkOperationNotSupportedException): 143 | await self.sut.set_brightness(89) 144 | return 145 | 146 | respx_mock.route(dotstar).mock(resp_200) 147 | with pytest.raises(AqualinkInvalidParameterException): 148 | await self.sut.set_brightness(89) 149 | assert len(respx_mock.calls) == 0 150 | 151 | @respx.mock 152 | async def test_set_effect_by_id_4( 153 | self, respx_mock: respx.router.MockRouter 154 | ) -> None: 155 | if not self.sut.supports_effect: 156 | with pytest.raises(AqualinkOperationNotSupportedException): 157 | await self.sut.set_effect_by_id(4) 158 | return 159 | 160 | respx_mock.route(dotstar).mock(resp_200) 161 | await self.sut.set_effect_by_id(4) 162 | assert len(respx_mock.calls) > 0 163 | self.respx_calls = copy.copy(respx_mock.calls) 164 | 165 | @respx.mock 166 | async def test_set_effect_by_id_invalid_27( 167 | self, respx_mock: respx.router.MockRouter 168 | ) -> None: 169 | if not self.sut.supports_effect: 170 | with pytest.raises(AqualinkOperationNotSupportedException): 171 | await self.sut.set_effect_by_id(27) 172 | return 173 | 174 | respx_mock.route(dotstar).mock(resp_200) 175 | with pytest.raises(AqualinkInvalidParameterException): 176 | await self.sut.set_effect_by_id(27) 177 | assert len(respx_mock.calls) == 0 178 | 179 | @respx.mock 180 | async def test_set_effect_by_name_off( 181 | self, respx_mock: respx.router.MockRouter 182 | ) -> None: 183 | if not self.sut.supports_effect: 184 | with pytest.raises(AqualinkOperationNotSupportedException): 185 | await self.sut.set_effect_by_name("Off") 186 | return 187 | 188 | respx_mock.route(dotstar).mock(resp_200) 189 | await self.sut.set_effect_by_name("Off") 190 | assert len(respx_mock.calls) > 0 191 | self.respx_calls = copy.copy(respx_mock.calls) 192 | 193 | @respx.mock 194 | async def test_set_effect_by_name_invalid_amaranth( 195 | self, respx_mock: respx.router.MockRouter 196 | ) -> None: 197 | if not self.sut.supports_effect: 198 | with pytest.raises(AqualinkOperationNotSupportedException): 199 | await self.sut.set_effect_by_name("Amaranth") 200 | return 201 | 202 | respx_mock.route(dotstar).mock(resp_200) 203 | with pytest.raises(AqualinkInvalidParameterException): 204 | await self.sut.set_effect_by_name("Amaranth") 205 | assert len(respx_mock.calls) == 0 206 | 207 | 208 | class TestBaseThermostat(TestBaseSwitch): 209 | def test_inheritance(self) -> None: 210 | assert isinstance(self.sut, AqualinkThermostat) 211 | 212 | def test_property_unit(self) -> None: 213 | assert self.sut.unit in ["C", "F"] 214 | 215 | def test_property_min_temperature_f(self) -> None: 216 | with patch.object( 217 | type(self.sut), "unit", new_callable=PropertyMock 218 | ) as mock_unit: 219 | mock_unit.return_value = "F" 220 | assert isinstance(self.sut.min_temperature, int) 221 | 222 | def test_property_min_temperature_c(self) -> None: 223 | with patch.object( 224 | type(self.sut), "unit", new_callable=PropertyMock 225 | ) as mock_unit: 226 | mock_unit.return_value = "C" 227 | assert isinstance(self.sut.min_temperature, int) 228 | 229 | def test_property_max_temperature_f(self) -> None: 230 | with patch.object( 231 | type(self.sut), "unit", new_callable=PropertyMock 232 | ) as mock_unit: 233 | mock_unit.return_value = "F" 234 | assert isinstance(self.sut.max_temperature, int) 235 | 236 | def test_property_max_temperature_c(self) -> None: 237 | with patch.object( 238 | type(self.sut), "unit", new_callable=PropertyMock 239 | ) as mock_unit: 240 | mock_unit.return_value = "C" 241 | assert isinstance(self.sut.max_temperature, int) 242 | 243 | def test_property_current_temperature(self) -> None: 244 | assert isinstance(self.sut.current_temperature, str) 245 | 246 | def test_property_target_temperature(self) -> None: 247 | assert isinstance(self.sut.target_temperature, str) 248 | 249 | @respx.mock 250 | async def test_set_temperature_86f( 251 | self, respx_mock: respx.router.MockRouter 252 | ) -> None: 253 | respx_mock.route(dotstar).mock(resp_200) 254 | with patch.object( 255 | type(self.sut), "unit", new_callable=PropertyMock 256 | ) as mock_unit: 257 | mock_unit.return_value = "F" 258 | await self.sut.set_temperature(86) 259 | assert len(respx_mock.calls) > 0 260 | self.respx_calls = copy.copy(respx_mock.calls) 261 | 262 | @respx.mock 263 | async def test_set_temperature_30c( 264 | self, respx_mock: respx.router.MockRouter 265 | ) -> None: 266 | respx_mock.route(dotstar).mock(resp_200) 267 | with patch.object( 268 | type(self.sut), "unit", new_callable=PropertyMock 269 | ) as mock_unit: 270 | mock_unit.return_value = "C" 271 | await self.sut.set_temperature(30) 272 | assert len(respx_mock.calls) > 0 273 | self.respx_calls = copy.copy(respx_mock.calls) 274 | 275 | @respx.mock 276 | async def test_set_temperature_invalid_400f( 277 | self, respx_mock: respx.router.MockRouter 278 | ) -> None: 279 | respx_mock.route(dotstar).mock(resp_200) 280 | with patch.object( 281 | type(self.sut), "unit", new_callable=PropertyMock 282 | ) as mock_unit: 283 | mock_unit.return_value = "F" 284 | with pytest.raises(AqualinkInvalidParameterException): 285 | await self.sut.set_temperature(400) 286 | assert len(respx_mock.calls) == 0 287 | 288 | @respx.mock 289 | async def test_set_temperature_invalid_204c( 290 | self, respx_mock: respx.router.MockRouter 291 | ) -> None: 292 | respx_mock.route(dotstar).mock(resp_200) 293 | with patch.object( 294 | type(self.sut), "unit", new_callable=PropertyMock 295 | ) as mock_unit: 296 | mock_unit.return_value = "C" 297 | with pytest.raises(AqualinkInvalidParameterException): 298 | await self.sut.set_temperature(204) 299 | assert len(respx_mock.calls) == 0 300 | -------------------------------------------------------------------------------- /src/iaqualink/systems/iaqua/device.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from enum import Enum, unique 5 | from typing import TYPE_CHECKING, cast 6 | 7 | from iaqualink.device import ( 8 | AqualinkBinarySensor, 9 | AqualinkDevice, 10 | AqualinkLight, 11 | AqualinkSensor, 12 | AqualinkSwitch, 13 | AqualinkThermostat, 14 | ) 15 | from iaqualink.exception import ( 16 | AqualinkDeviceNotSupported, 17 | AqualinkInvalidParameterException, 18 | ) 19 | 20 | if TYPE_CHECKING: 21 | from iaqualink.systems.iaqua.system import IaquaSystem 22 | from iaqualink.typing import DeviceData 23 | 24 | IAQUA_TEMP_CELSIUS_LOW = 1 25 | IAQUA_TEMP_CELSIUS_HIGH = 40 26 | IAQUA_TEMP_FAHRENHEIT_LOW = 34 27 | IAQUA_TEMP_FAHRENHEIT_HIGH = 104 28 | 29 | LOGGER = logging.getLogger("iaqualink") 30 | 31 | 32 | @unique 33 | class AqualinkState(Enum): 34 | OFF = "0" 35 | ON = "1" 36 | STANDBY = "2" 37 | ENABLED = "3" 38 | ABSENT = "absent" 39 | PRESENT = "present" 40 | 41 | 42 | class IaquaDevice(AqualinkDevice): 43 | def __init__(self, system: IaquaSystem, data: DeviceData): 44 | super().__init__(system, data) 45 | 46 | # This silences mypy errors due to AqualinkDevice type annotations. 47 | self.system: IaquaSystem = system 48 | 49 | @property 50 | def label(self) -> str: 51 | if "label" in self.data: 52 | label = self.data["label"] 53 | return " ".join([x.capitalize() for x in label.split()]) 54 | 55 | label = self.data["name"] 56 | return " ".join([x.capitalize() for x in label.split("_")]) 57 | 58 | @property 59 | def state(self) -> str: 60 | return self.data["state"] 61 | 62 | @property 63 | def name(self) -> str: 64 | return self.data["name"] 65 | 66 | @property 67 | def manufacturer(self) -> str: 68 | return "Jandy" 69 | 70 | @property 71 | def model(self) -> str: 72 | return self.__class__.__name__.replace("Iaqua", "") 73 | 74 | @classmethod 75 | def from_data(cls, system: IaquaSystem, data: DeviceData) -> IaquaDevice: 76 | class_: type[IaquaDevice] 77 | 78 | # I don't have a system where these fields get populated. 79 | # No idea what they are and what to do with them. 80 | if isinstance(data["state"], dict | list): 81 | raise AqualinkDeviceNotSupported(data) 82 | 83 | if data["name"].endswith("_heater") or data["name"].endswith("_pump"): 84 | class_ = IaquaSwitch 85 | elif data["name"].endswith("_set_point"): 86 | if data["state"] == "": 87 | raise AqualinkDeviceNotSupported(data) 88 | class_ = IaquaThermostat 89 | elif data["name"] == "freeze_protection" or data["name"].endswith( 90 | "_present" 91 | ): 92 | class_ = IaquaBinarySensor 93 | elif data["name"].startswith("aux_"): 94 | if data["type"] == "2": 95 | class_ = light_subtype_to_class[data["subtype"]] 96 | elif data["type"] == "1": 97 | class_ = IaquaDimmableLight 98 | elif "LIGHT" in data["label"]: 99 | class_ = IaquaLightSwitch 100 | else: 101 | class_ = IaquaAuxSwitch 102 | else: 103 | class_ = IaquaSensor 104 | 105 | return class_(system, data) 106 | 107 | 108 | class IaquaSensor(IaquaDevice, AqualinkSensor): 109 | pass 110 | 111 | 112 | class IaquaBinarySensor(IaquaSensor, AqualinkBinarySensor): 113 | """These are non-actionable sensors, essentially read-only on/off.""" 114 | 115 | @property 116 | def is_on(self) -> bool: 117 | return ( 118 | AqualinkState(self.state) 119 | in [AqualinkState.ON, AqualinkState.ENABLED, AqualinkState.PRESENT] 120 | if self.state 121 | else False 122 | ) 123 | 124 | 125 | class IaquaSwitch(IaquaBinarySensor, AqualinkSwitch): 126 | async def _toggle(self) -> None: 127 | await self.system.set_switch(f"set_{self.name}") 128 | 129 | async def turn_on(self) -> None: 130 | if not self.is_on: 131 | await self._toggle() 132 | 133 | async def turn_off(self) -> None: 134 | if self.is_on: 135 | await self._toggle() 136 | 137 | 138 | class IaquaAuxSwitch(IaquaSwitch): 139 | @property 140 | def is_on(self) -> bool: 141 | return ( 142 | AqualinkState(self.state) == AqualinkState.ON 143 | if self.state 144 | else False 145 | ) 146 | 147 | async def _toggle(self) -> None: 148 | await self.system.set_aux(self.data["aux"]) 149 | 150 | 151 | class IaquaLightSwitch(IaquaAuxSwitch, AqualinkLight): 152 | pass 153 | 154 | 155 | class IaquaDimmableLight(IaquaAuxSwitch, AqualinkLight): 156 | async def turn_on(self) -> None: 157 | if not self.is_on: 158 | await self.set_brightness(100) 159 | 160 | async def turn_off(self) -> None: 161 | if self.is_on: 162 | await self.set_brightness(0) 163 | 164 | @property 165 | def brightness(self) -> int | None: 166 | return int(self.data["subtype"]) 167 | 168 | async def set_brightness(self, brightness: int) -> None: 169 | # Brightness only works in 25% increments. 170 | if brightness not in [0, 25, 50, 75, 100]: 171 | msg = f"{brightness}% isn't a valid percentage." 172 | msg += " Only use 25% increments." 173 | raise AqualinkInvalidParameterException(msg) 174 | 175 | data = {"aux": self.data["aux"], "light": f"{brightness}"} 176 | await self.system.set_light(data) 177 | 178 | 179 | class IaquaColorLight(IaquaAuxSwitch, AqualinkLight): 180 | async def turn_on(self) -> None: 181 | if not self.is_on: 182 | await self.set_effect_by_id(1) 183 | 184 | async def turn_off(self) -> None: 185 | if self.is_on: 186 | await self.set_effect_by_id(0) 187 | 188 | @property 189 | def effect(self) -> str | None: 190 | # "state"=0 indicates the light is off. 191 | # "state"=1 indicates the light is on. 192 | # I don't see a way to retrieve the current effect. 193 | # The official iAquaLink app doesn't seem to show the current effect 194 | # choice either, so perhaps it's an unfortunate limitation of the 195 | # current API. 196 | return self.data["state"] 197 | 198 | @property 199 | def supported_effects(self) -> dict[str, int]: 200 | raise NotImplementedError 201 | 202 | async def set_effect_by_name(self, effect: str) -> None: 203 | try: 204 | effect_id = self.supported_effects[effect] 205 | except KeyError as e: 206 | msg = f"{effect!r} isn't a valid effect." 207 | raise AqualinkInvalidParameterException(msg) from e 208 | await self.set_effect_by_id(effect_id) 209 | 210 | async def set_effect_by_id(self, effect_id: int) -> None: 211 | try: 212 | _ = list(self.supported_effects.values()).index(effect_id) 213 | except ValueError as e: 214 | msg = f"{effect_id!r} isn't a valid effect." 215 | raise AqualinkInvalidParameterException(msg) from e 216 | 217 | data = { 218 | "aux": self.data["aux"], 219 | "light": str(effect_id), 220 | "subtype": self.data["subtype"], 221 | } 222 | await self.system.set_light(data) 223 | 224 | 225 | class IaquaColorLightJC(IaquaColorLight): 226 | @property 227 | def manufacturer(self) -> str: 228 | return "Jandy" 229 | 230 | @property 231 | def model(self) -> str: 232 | return "Colors Light" 233 | 234 | @property 235 | def supported_effects(self) -> dict[str, int]: 236 | return { 237 | "Off": 0, 238 | "Alpine White": 1, 239 | "Sky Blue": 2, 240 | "Cobalt Blue": 3, 241 | "Caribbean Blue": 4, 242 | "Spring Green": 5, 243 | "Emerald Green": 6, 244 | "Emerald Rose": 7, 245 | "Magenta": 8, 246 | "Garnet Red": 9, 247 | "Violet": 10, 248 | "Color Splash": 11, 249 | } 250 | 251 | 252 | class IaquaColorLightSL(IaquaColorLight): 253 | @property 254 | def manufacturer(self) -> str: 255 | return "Pentair" 256 | 257 | @property 258 | def model(self) -> str: 259 | return "SAm/SAL Light" 260 | 261 | @property 262 | def supported_effects(self) -> dict[str, int]: 263 | return { 264 | "Off": 0, 265 | "White": 1, 266 | "Light Green": 2, 267 | "Green": 3, 268 | "Cyan": 4, 269 | "Blue": 5, 270 | "Lavender": 6, 271 | "Magenta": 7, 272 | "Light Magenta": 8, 273 | "Color Splash": 9, 274 | } 275 | 276 | 277 | class IaquaColorLightCL(IaquaColorLight): 278 | @property 279 | def manufacturer(self) -> str: 280 | return "Pentair" 281 | 282 | @property 283 | def model(self) -> str: 284 | return "ColorLogic Light" 285 | 286 | @property 287 | def supported_effects(self) -> dict[str, int]: 288 | return { 289 | "Off": 0, 290 | "Voodoo Lounge": 1, 291 | "Deep Blue Sea": 2, 292 | "Afternoon Skies": 3, 293 | "Emerald": 4, 294 | "Sangria": 5, 295 | "Cloud White": 6, 296 | "Twilight": 7, 297 | "Tranquility": 8, 298 | "Gemstone": 9, 299 | "USA!": 10, 300 | "Mardi Gras": 11, 301 | "Cool Cabaret": 12, 302 | } 303 | 304 | 305 | class IaquaColorLightJL(IaquaColorLight): 306 | @property 307 | def manufacturer(self) -> str: 308 | return "Jandy" 309 | 310 | @property 311 | def model(self) -> str: 312 | return "LED WaterColors Light" 313 | 314 | @property 315 | def supported_effects(self) -> dict[str, int]: 316 | return { 317 | "Off": 0, 318 | "Alpine White": 1, 319 | "Sky Blue": 2, 320 | "Cobalt Blue": 3, 321 | "Caribbean Blue": 4, 322 | "Spring Green": 5, 323 | "Emerald Green": 6, 324 | "Emerald Rose": 7, 325 | "Magenta": 8, 326 | "Violet": 9, 327 | "Slow Splash": 10, 328 | "Fast Splash": 11, 329 | "USA!": 12, 330 | "Fat Tuesday": 13, 331 | "Disco Tech": 14, 332 | } 333 | 334 | 335 | class IaquaColorLightIB(IaquaColorLight): 336 | @property 337 | def manufacturer(self) -> str: 338 | return "Pentair" 339 | 340 | @property 341 | def model(self) -> str: 342 | return "Intellibrite Light" 343 | 344 | @property 345 | def supported_effects(self) -> dict[str, int]: 346 | return { 347 | "Off": 0, 348 | "SAm": 1, 349 | "Party": 2, 350 | "Romance": 3, 351 | "Caribbean": 4, 352 | "American": 5, 353 | "California Sunset": 6, 354 | "Royal": 7, 355 | "Blue": 8, 356 | "Green": 9, 357 | "Red": 10, 358 | "White": 11, 359 | "Magenta": 12, 360 | } 361 | 362 | 363 | class IaquaColorLightHU(IaquaColorLight): 364 | @property 365 | def manufacturer(self) -> str: 366 | return "Hayward" 367 | 368 | @property 369 | def model(self) -> str: 370 | return "Universal Light" 371 | 372 | @property 373 | def supported_effects(self) -> dict[str, int]: 374 | return { 375 | "Off": 0, 376 | "Voodoo Lounge": 1, 377 | "Deep Blue Sea": 2, 378 | "Royal Blue": 3, 379 | "Afternoon Skies": 4, 380 | "Aqua Green": 5, 381 | "Emerald": 6, 382 | "Cloud White": 7, 383 | "Warm Red": 8, 384 | "Flamingo": 9, 385 | "Vivid Violet": 10, 386 | "Sangria": 11, 387 | "Twilight": 12, 388 | "Tranquility": 13, 389 | "Gemstone": 14, 390 | "USA!": 15, 391 | "Mardi Gras": 16, 392 | "Cool Cabaret": 17, 393 | } 394 | 395 | 396 | light_subtype_to_class = { 397 | "1": IaquaColorLightJC, 398 | "2": IaquaColorLightSL, 399 | "3": IaquaColorLightCL, 400 | "4": IaquaColorLightJL, 401 | "5": IaquaColorLightIB, 402 | "6": IaquaColorLightHU, 403 | } 404 | 405 | 406 | class IaquaThermostat(IaquaSwitch, AqualinkThermostat): 407 | @property 408 | def _type(self) -> str: 409 | return self.name.split("_")[0] 410 | 411 | @property 412 | def _temperature(self) -> str: 413 | # Spa takes precedence for temp1 if present. 414 | if self._type == "pool" and "spa_set_point" in self.system.devices: 415 | return "temp2" 416 | return "temp1" 417 | 418 | @property 419 | def unit(self) -> str: 420 | return self.system.temp_unit 421 | 422 | @property 423 | def _sensor(self) -> IaquaSensor: 424 | return cast(IaquaSensor, self.system.devices[f"{self._type}_temp"]) 425 | 426 | @property 427 | def current_temperature(self) -> str: 428 | return self._sensor.state 429 | 430 | @property 431 | def target_temperature(self) -> str: 432 | return self.state 433 | 434 | @property 435 | def min_temperature(self) -> int: 436 | if self.unit == "F": 437 | return IAQUA_TEMP_FAHRENHEIT_LOW 438 | return IAQUA_TEMP_CELSIUS_LOW 439 | 440 | @property 441 | def max_temperature(self) -> int: 442 | if self.unit == "F": 443 | return IAQUA_TEMP_FAHRENHEIT_HIGH 444 | return IAQUA_TEMP_CELSIUS_HIGH 445 | 446 | async def set_temperature(self, temperature: int) -> None: 447 | unit = self.unit 448 | low = self.min_temperature 449 | high = self.max_temperature 450 | 451 | if temperature not in range(low, high + 1): 452 | msg = f"{temperature}{unit} isn't a valid temperature" 453 | msg += f" ({low}-{high}{unit})." 454 | raise AqualinkInvalidParameterException(msg) 455 | 456 | data = {self._temperature: str(temperature)} 457 | await self.system.set_temps(data) 458 | 459 | @property 460 | def _heater(self) -> IaquaSwitch: 461 | return cast(IaquaSwitch, self.system.devices[f"{self._type}_heater"]) 462 | 463 | @property 464 | def is_on(self) -> bool: 465 | return self._heater.is_on 466 | 467 | async def turn_on(self) -> None: 468 | if self._heater.is_on is False: 469 | await self._heater.turn_on() 470 | 471 | async def turn_off(self) -> None: 472 | if self._heater.is_on is True: 473 | await self._heater.turn_off() 474 | --------------------------------------------------------------------------------