diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1179c54 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +venv/ +.venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ +.tox/ +.mypy_cache/ +.ruff_cache/ +tests/ + +# Git +.git/ +.gitignore + +# CI/CD +.github/ + +# Documentation +docs/ +*.md +!README.md + +# Development files +dev_files/ +tests/ + +# Misc +.DS_Store +Thumbs.db +dev_files/ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 33fa38d..8de9e16 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -85,3 +85,364 @@ find_tasks(filter_by="project", filter_value="proj-123") - Keep queries SHORT (2-5 keywords) for better search results - Higher `task_order` = higher priority (0-100) - Tasks should be 30 min - 4 hours of work + +--- + +# GitHub Copilot Instructions + +## Priority Guidelines + +When generating code for this repository: + +1. **Version Compatibility**: Always use Python 3.12+ features (requires-python = ">=3.12,<4.0.0") +2. **Type Safety**: This is a strictly typed codebase - use type hints, follow mypy strict mode, and leverage Pydantic for validation +3. **Codebase Patterns**: Scan existing code for established patterns before generating new code +4. **Architectural Consistency**: Maintain the layered architecture with clear separation between API, models, and equipment classes +5. **Code Quality**: Prioritize maintainability, type safety, and testability in all generated code + +## Technology Stack + +### Python Version +- **Required**: Python 3.12+ +- **Type Checking**: mypy with strict mode enabled (python_version = "3.13" in config) +- **Use modern Python features**: Pattern matching, typing improvements, exception groups + +### Core Dependencies +- **pydantic**: v2.x - Use for all data validation and models +- **click**: v8.x - CLI framework (use click decorators and commands) +- **xmltodict**: v1.x - XML parsing (used for OmniLogic protocol) + +### Development Tools +- **pytest**: v8.x - Testing framework with async support (pytest-asyncio) +- **black**: Line length 140 - Code formatting +- **isort**: Profile "black" - Import sorting +- **mypy**: Strict mode - Type checking with Pydantic plugin +- **pylint**: Custom configuration - Code linting + +## Code Quality Standards + +### Type Safety & Type Hints +- **MANDATORY**: All functions/methods MUST have complete type annotations +- Use `from __future__ import annotations` for forward references +- Leverage generics extensively (see `OmniEquipment[MSPConfigT, TelemetryT]`) +- Use `TYPE_CHECKING` imports to avoid circular dependencies +- Apply `@overload` for methods with different return types based on parameters +- Use Pydantic models for all data structures requiring validation +- Prefer `str | None` over `Optional[str]` (Python 3.10+ union syntax) + +Example patterns from codebase: +```python +from typing import TYPE_CHECKING, Generic, TypeVar, cast, overload, Literal + +MSPConfigT = TypeVar("MSPConfigT", bound=MSPEquipmentType) +TelemetryT = TypeVar("TelemetryT", bound=TelemetryType | None) + +class OmniEquipment(Generic[MSPConfigT, TelemetryT]): + """Base class with generic parameters for type safety.""" + + @overload + async def async_send_message(self, need_response: Literal[True]) -> str: ... + @overload + async def async_send_message(self, need_response: Literal[False]) -> None: ... + async def async_send_message(self, need_response: bool = False) -> str | None: + """Method with overloads for precise return type inference.""" +``` + +### Naming Conventions +- **Modules**: `snake_case` (e.g., `heater_equip.py`, `colorlogiclight.py`) +- **Classes**: `PascalCase` (e.g., `OmniLogicAPI`, `HeaterEquipment`, `ColorLogicLight`) +- **Functions/Methods**: `snake_case` with async prefix (e.g., `async_get_telemetry`, `turn_on`) +- **Private attributes**: Single underscore prefix (e.g., `_api`, `_omni`, `_validate_temperature`) +- **Type variables**: Descriptive with `T` suffix (e.g., `MSPConfigT`, `TelemetryT`, `OE`) +- **Constants**: `UPPER_SNAKE_CASE` (e.g., `MAX_TEMPERATURE_F`, `XML_NAMESPACE`) +- **Pydantic field aliases**: Use `alias="System-Id"` for XML field names + +### Documentation Style +- **Docstrings**: Google-style docstrings for all public classes and methods +- Include Args, Returns, Raises, and Example sections where applicable +- Document generic parameters clearly +- Provide usage examples in class docstrings +- Currently missing docstrings are disabled in pylint - aim to add them when code stabilizes + +Example from codebase: +```python +def _validate_temperature(temperature: int, param_name: str = "temperature") -> None: + """Validate temperature is within acceptable range. + + Args: + temperature: Temperature value in Fahrenheit. + param_name: Name of the parameter for error messages. + + Raises: + OmniValidationException: If temperature is out of range. + """ +``` + +### Error Handling +- Use custom exception hierarchy (all inherit from `OmniLogicException` or `OmniLogicLocalError`) +- API exceptions: `OmniProtocolException`, `OmniValidationException`, `OmniCommandException` +- Equipment exceptions: `OmniEquipmentNotReadyError`, `OmniEquipmentNotInitializedError` +- Validate inputs early with dedicated `_validate_*` functions +- Provide clear error messages with parameter names and values + +### Async Patterns + +#### Async Method Naming +- **API layer**: All async methods MUST use `async_` prefix + - Example: `async_send_message`, `async_get_telemetry`, `async_set_equipment`, `async_set_heater` + - Rationale: Clear indication that these are async protocol/network operations +- **Equipment layer**: User-facing control methods do NOT use `async_` prefix + - Example: `turn_on`, `turn_off`, `set_speed`, `set_temperature`, `set_show` + - Rationale: Cleaner, more intuitive API for end users (they already use `await`) +- **Internal utilities**: Use `async_` prefix for non-user-facing async functions + - Example: `async_get_filter_diagnostics` in CLI utils + +#### Async Best Practices +- Use `asyncio.get_running_loop()` for low-level operations +- Properly manage transport lifecycle (create and close in try/finally) +- Equipment control methods are async and use `@control_method` decorator + +## Architectural Patterns + +### Equipment Hierarchy +All equipment classes inherit from `OmniEquipment[MSPConfigT, TelemetryT]`: +- First generic parameter: MSP config type (e.g., `MSPRelay`, `MSPVirtualHeater`) +- Second generic parameter: Telemetry type or `None` (e.g., `TelemetryRelay`, `None`) +- Access parent controller via `self._omni` +- Access API via `self._api` property +- Store child equipment in `self.child_equipment: dict[int, OmniEquipment]` + +### State Management +- Use `@control_method` decorator on all equipment control methods +- Decorator automatically checks `is_ready` before execution +- Raises `OmniEquipmentNotReadyError` with descriptive message if not ready +- Marks telemetry as dirty after successful execution +- Users call `await omni.refresh()` to update state + +Example: +```python +@control_method +async def turn_on(self) -> None: + """Turn on equipment (readiness check and state marking handled by decorator).""" + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot turn on: bow_id or system_id is None") + await self._api.async_set_equipment(...) +``` + +### Pydantic Models +- All configuration models inherit from `OmniBase` (which inherits from `BaseModel`) +- Use `Field(alias="XML-Name")` for XML field mapping +- Implement `_YES_NO_FIELDS` class variable for automatic "yes"/"no" to bool conversion +- Use `@computed_field` for derived properties +- Implement `model_validator(mode="before")` for custom preprocessing +- Use `ConfigDict(from_attributes=True)` for attribute-based initialization + +### Collections +- `EquipmentDict[OE]`: Type-safe dictionary for equipment access by name or system_id +- `EffectsCollection[E]`: Type-safe collection for light effects +- Support both indexing (`dict[key]`) and attribute access (`.key` via `__getattr__`) + +## Testing Approach + +### Test Structure +- Use **table-driven tests** with `pytest-subtests` for validation functions +- Organize tests into clear sections with comment headers +- Use helper functions for XML parsing and assertions (e.g., `_find_elem`, `_find_param`) +- Test both success and failure cases comprehensively + +Example pattern: +```python +def test_validate_temperature(subtests: SubTests) -> None: + """Test temperature validation with various inputs using table-driven approach.""" + test_cases = [ + # (temperature, param_name, should_pass, description) + (MIN_TEMPERATURE_F, "temp", True, "minimum valid temperature"), + (MAX_TEMPERATURE_F + 1, "temp", False, "above maximum temperature"), + ("80", "temp", False, "string instead of int"), + ] + + for temperature, param_name, should_pass, description in test_cases: + with subtests.test(msg=description, temperature=temperature): + if should_pass: + _validate_temperature(temperature, param_name) + else: + with pytest.raises(OmniValidationException): + _validate_temperature(temperature, param_name) +``` + +### Test Coverage +- Unit tests for validation functions +- Integration tests for API message generation +- Mock external dependencies (transport, protocol) +- Test both async and sync code paths +- Aim for 80%+ coverage (pytest-cov configured) + +### Test Naming +- Prefix all test functions with `test_` +- Use descriptive names: `test_async_set_heater_generates_valid_xml` +- For async tests, use `async def test_...` with pytest-asyncio + +## Code Formatting & Linting + +### Line Length & Formatting +- **Maximum line length**: 140 characters (black, pylint, ruff configured) +- Use black for automatic formatting +- Use isort with black profile for import organization +- Prefer explicit over implicit line continuations + +### Import Organization +- Standard library imports first +- Third-party imports second +- Local imports third +- Use `from __future__ import annotations` at top when needed +- Group `from typing import ...` statements +- Use `TYPE_CHECKING` guard for circular dependency imports + +Example: +```python +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +from pydantic import BaseModel, Field + +from pyomnilogic_local.models import MSPConfig +from pyomnilogic_local.omnitypes import HeaterMode + +if TYPE_CHECKING: + from pyomnilogic_local.omnilogic import OmniLogic +``` + +### Pylint Configuration +- Py-version: 3.13 +- Many rules disabled for pragmatic development (see pyproject.toml) +- Focus on: type safety, useless suppressions, symbolic messages +- Docstrings currently disabled until codebase stabilizes + +## Project-Specific Patterns + +### Equipment Properties +All equipment classes expose standard properties: +```python +@property +def bow_id(self) -> int | None: + """The body of water ID this equipment belongs to.""" + return self.mspconfig.bow_id + +@property +def name(self) -> str | None: + """The name of the equipment.""" + return self.mspconfig.name + +@property +def is_ready(self) -> bool: + """Whether equipment can accept commands.""" + return self._omni.backyard.state == BackyardState.READY +``` + +### API Method Patterns +API methods follow consistent patterns: +1. Validate inputs with `_validate_*` helper functions +2. Build XML message using ElementTree +3. Call `async_send_message` with appropriate message type +4. Parse response if needed +5. Return strongly typed result + +### Equipment Control Methods +Equipment control methods: +1. Use `@control_method` decorator (handles readiness check and state dirtying) +2. Check for required attributes (bow_id, system_id) and raise `OmniEquipmentNotInitializedError` if None +3. Validate input parameters (temperature, speed, etc.) +4. Call appropriate API method +5. Return `None` (state updated via refresh) + +The `@control_method` decorator automatically: +- Checks `self.is_ready` before execution +- Raises `OmniEquipmentNotReadyError` if not ready (auto-generated message from method name) +- Marks telemetry as dirty after successful execution + +## Version Control & Semantic Versioning + +- Follow Semantic Versioning (currently v0.19.0) +- Version defined in `pyproject.toml:project.version` +- Use `python-semantic-release` for automated versioning +- Main branch: `main` +- CHANGELOG.md is automatically generated by release pipeline + +## General Best Practices + +1. **Consistency First**: Match existing patterns even if they differ from external best practices +2. **Type Safety**: Never compromise on type annotations +3. **Validation**: Validate all inputs at API boundaries +4. **Async/Await**: Use proper async patterns, don't block the event loop +5. **Error Messages**: Include parameter names and actual values in validation errors +6. **Generics**: Leverage Python's generic types for type-safe collections and base classes +7. **Pydantic**: Use for all data validation, XML parsing, and model definitions +8. **Testing**: Write tests before implementation when possible (especially for validation) +9. **Documentation**: Provide examples in docstrings for complex classes +10. **Separation of Concerns**: Keep API layer separate from equipment models + +## Example: Adding New Equipment Type + +When adding a new equipment type, follow this pattern: + +1. **Create Pydantic model** in `models/mspconfig.py`: +```python +class MSPNewEquipment(OmniBase): + _sub_devices: set[str] | None = None + omni_type: Literal[OmniType.NEW_EQUIPMENT] = OmniType.NEW_EQUIPMENT + # Add fields with XML aliases +``` + +2. **Create telemetry model** in `models/telemetry.py` (if applicable): +```python +class TelemetryNewEquipment(BaseModel): + # Add telemetry fields +``` + +3. **Create equipment class** in `new_equipment.py`: +```python +class NewEquipment(OmniEquipment[MSPNewEquipment, TelemetryNewEquipment]): + """New equipment type.""" + + @property + def some_property(self) -> str | None: + """Equipment-specific property.""" + return self.mspconfig.some_field + + @control_method + async def control_method(self) -> None: + """Control method (readiness and state handled by decorator). + + Raises: + OmniEquipmentNotInitializedError: If required IDs are None. + OmniEquipmentNotReadyError: If equipment is not ready (handled by decorator). + """ + if self.bow_id is None or self.system_id is None: + raise OmniEquipmentNotInitializedError("Cannot control: bow_id or system_id is None") + await self._api.async_some_command(...) +``` + +4. **Add API method** in `api/api.py`: +```python +async def async_some_command(self, param: int) -> None: + """Send command to equipment. + + Args: + param: Description of parameter. + + Raises: + OmniValidationException: If param is invalid. + """ + _validate_id(param, "param") + # Build and send XML message +``` + +5. **Write tests** in `tests/test_new_equipment.py`: +```python +def test_some_command_generates_valid_xml(subtests: SubTests) -> None: + """Test command XML generation.""" + # Table-driven tests +``` diff --git a/.github/workflows/cd-docker.yml b/.github/workflows/cd-docker.yml new file mode 100644 index 0000000..f13c4ab --- /dev/null +++ b/.github/workflows/cd-docker.yml @@ -0,0 +1,280 @@ +# This workflow builds a multi-arch Docker image using GitHub Actions and separated Github Runners with native support for ARM64 and AMD64 architectures, without using QEMU emulation. +# It uses Docker Buildx to build and push the image to GitHub Container Registry (GHCR). +name: CD Build and publish multi arch Docker Image + +on: + workflow_dispatch: + workflow_run: + workflows: + - Continuous Delivery + types: + - completed + branches: + - main + +env: + # The name of the Docker image to be built and pushed to GHCR + # The image name is derived from the GitHub repository name and the GitHub Container Registry (GHCR) URL. + # The image name will be in the format: ghcr.io// + GHCR_IMAGE: ghcr.io/${{ github.repository }} + +permissions: + # Global permissions for the workflow, which can be overridden at the job level + contents: read + +concurrency: + # This concurrency group ensures that only one job in the group runs at a time. + # If a new job is triggered, the previous one will be canceled. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The build job builds the Docker image for each platform specified in the matrix. + build: + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + platform_pair: linux-amd64 + - platform: linux/arm64 + platform_pair: linux-arm64 + # The matrix includes two platforms: linux/amd64 and linux/arm64. + # The build job will run for each platform in the matrix. + + permissions: + # Permissions for the build job, which can be overridden at the step level + # The permissions are set to allow the job to write to the GitHub Container Registry (GHCR) and read from the repository. + attestations: write + actions: read + checks: write + contents: write + deployments: none + id-token: write + issues: read + discussions: read + packages: write + pages: none + pull-requests: read + repository-projects: read + security-events: read + statuses: read + + runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }} + # The job runs on different runners based on the platform. + # For linux/amd64, it runs on the latest Ubuntu runner. + # For linux/arm64, it runs on an Ubuntu 24.04 ARM runner. + # The runner is selected based on the platform specified in the matrix. + + name: Build Docker image for ${{ matrix.platform }} + + steps: + - + name: Prepare environment for current platform + # This step sets up the environment for the current platform being built. + # It replaces the '/' character in the platform name with '-' and sets it as an environment variable. + # This is useful for naming artifacts and other resources that cannot contain '/'. + # The environment variable PLATFORMS_PAIR will be used later in the workflow. + id: prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@v5.0.0 + # This step checks out the code from the repository. + # It uses the actions/checkout action to clone the repository into the runner's workspace. + + - name: Docker meta default + # This step generates metadata for the Docker image. + # It uses the docker/metadata-action to create metadata based on the repository information. + # The metadata includes information such as the image name, tags, and labels. + # The metadata will be used later in the workflow to build and push the Docker image. + id: meta + uses: docker/metadata-action@v5.9.0 + with: + images: ${{ env.GHCR_IMAGE }} + + - name: Set up Docker Context for Buildx + # This step sets up a Docker context for Buildx. + # It creates a new context named "builders" that will be used for building the Docker image. + # The context allows Buildx to use the Docker daemon for building images. + id: buildx-context + run: | + docker context create builders + + - name: Set up Docker Buildx + # This step sets up Docker Buildx, which is a Docker CLI plugin for extended build capabilities with BuildKit. + # It uses the docker/setup-buildx-action to configure Buildx with the specified context and platforms. + # The platforms are specified in the matrix and will be used for building the Docker image. + uses: docker/setup-buildx-action@v3.11.1 + with: + endpoint: builders + platforms: ${{ matrix.platform }} + + - name: Login to GitHub Container Registry + # This step logs in to the GitHub Container Registry (GHCR) using the docker/login-action. + # It uses the GitHub actor's username and the GITHUB_TOKEN secret for authentication. + uses: docker/login-action@v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + + - name: Build and push by digest + # This step builds and pushes the Docker image using Buildx. + # It uses the docker/build-push-action to build the image with the specified context and platforms. + # The image is built with the labels and annotations generated in the previous steps. + # The outputs are configured to push the image by digest, which allows for better caching and versioning. + # The cache-from and cache-to options are used to enable caching for the build process. + # The cache is stored in GitHub Actions cache and is scoped to the repository, branch, and platform. + id: build + uses: docker/build-push-action@v6.18.0 + env: + DOCKER_BUILDKIT: 1 + with: + context: . + build-args: | + VERSION=${{ steps.meta.outputs.version }} + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true + cache-from: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }} + cache-to: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }} + + + - name: Export digest + # This step exports the digest of the built image to a file. + # It creates a directory in /tmp/digests and saves the digest of the image to a file. + # The digest is obtained from the output of the build step. + # The digest is used to uniquely identify the built image and can be used for further processing or verification. + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + # This step uploads the digest file to the GitHub Actions artifact storage. + # It uses the actions/upload-artifact action to upload the file created in the previous step. + # The artifact is named digests-${{ matrix.platform_pair }}, where platform_pair is the platform name with '/' replaced by '-'. + # The artifact is retained for 1 day, and if no files are found, it will throw an error. + uses: actions/upload-artifact@v5.0.0 + with: + name: digests-${{ matrix.platform_pair }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + + merge: + # This job merges the Docker manifests for the different platforms built in the previous job. + name: Merge Docker manifests + runs-on: ubuntu-latest + permissions: + attestations: write + actions: read + checks: read + contents: read + deployments: none + id-token: write + issues: read + discussions: read + packages: write + pages: none + pull-requests: read + repository-projects: read + security-events: read + statuses: read + + needs: + - build + # This job depends on the build job to complete before it starts. + # It ensures that the Docker images for all platforms are built before merging the manifests. + steps: + - name: Download digests + # This step downloads the digest files uploaded in the build job. + # It uses the actions/download-artifact action to download the artifacts with the pattern digests-*. + # The downloaded files are merged into the /tmp/digests directory. + uses: actions/download-artifact@v6.0.0 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + + - name: Docker meta + # This step generates metadata for the Docker image. + # It uses the docker/metadata-action to create metadata based on the repository information. + # The metadata includes information such as the image name, tags, and labels. + id: meta + uses: docker/metadata-action@v5.9.0 + with: + images: ${{ env.GHCR_IMAGE }} + annotations: | + type=org.opencontainers.image.description,value=${{ github.event.repository.description || 'No description provided' }} + tags: | + type=ref,event=tag + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.11.1 + # This step sets up Docker Buildx, which is a Docker CLI plugin for extended build capabilities with BuildKit. + with: + driver-opts: | + network=host + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3.6.0 + # This step logs in to the GitHub Container Registry (GHCR) using the docker/login-action. + # It uses the GitHub actor's username and the GITHUB_TOKEN secret for authentication. + # The login is necessary to push the merged manifest list to GHCR. + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Get execution timestamp with RFC3339 format + # This step gets the current execution timestamp in RFC3339 format. + # It uses the date command to get the current UTC time and formats it as a string. + # The timestamp is used for annotating the Docker manifest list. + id: timestamp + run: | + echo "timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> $GITHUB_OUTPUT + + - name: Create manifest list and pushs + # This step creates a manifest list for the Docker images built for different platforms. + # It uses the docker buildx imagetools create command to create the manifest list. + # The manifest list is annotated with metadata such as description, creation timestamp, and source URL. + # The annotations are obtained from the metadata generated in the previous steps. + # The manifest list is pushed to the GitHub Container Registry (GHCR) with the specified tags. + working-directory: /tmp/digests + id: manifest-annotate + continue-on-error: true + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + --annotation='index:org.opencontainers.image.description=${{ github.event.repository.description }}' \ + --annotation='index:org.opencontainers.image.created=${{ steps.timestamp.outputs.timestamp }}' \ + --annotation='index:org.opencontainers.image.url=${{ github.event.repository.url }}' \ + --annotation='index:org.opencontainers.image.source=${{ github.event.repository.url }}' \ + $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *) + + - name: Create manifest list and push without annotations + # This step creates a manifest list for the Docker images built for different platforms. + # It uses the docker buildx imagetools create command to create the manifest list. + # The manifest list is created without annotations if the previous step fails. + # The manifest list is pushed to the GitHub Container Registry (GHCR) with the specified tags. + if: steps.manifest-annotate.outcome == 'failure' + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *) + + - name: Inspect image + # This step inspects the created manifest list to verify its contents. + # It uses the docker buildx imagetools inspect command to display information about the manifest list. + # The inspection output will show the platforms and tags associated with the manifest list. + id: inspect + run: | + docker buildx imagetools inspect '${{ env.GHCR_IMAGE }}:${{ steps.meta.outputs.version }}' diff --git a/.github/workflows/cd-release.yaml b/.github/workflows/cd-release.yaml new file mode 100644 index 0000000..ec82eda --- /dev/null +++ b/.github/workflows/cd-release.yaml @@ -0,0 +1,140 @@ +name: CD Release and Publish + +on: + workflow_run: + workflows: + - Continuous Integration + types: + - completed + branches: + - main + +# default: least privileged permissions across all jobs +permissions: + contents: read + +jobs: + check_success: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Workflow completed successfully + run: echo "CI workflow passed. Proceeding with CD." + + release: + runs-on: ubuntu-latest + needs: check_success + concurrency: + group: ${{ github.workflow }}-release-${{ github.ref_name }} + cancel-in-progress: false + + permissions: + contents: write + + steps: + # Note: We checkout the repository at the branch that triggered the workflow + # with the entire history to ensure to match PSR's release branch detection + # and history evaluation. + # However, we forcefully reset the branch to the workflow sha because it is + # possible that the branch was updated while the workflow was running. This + # prevents accidentally releasing un-evaluated changes. + - name: Setup | Checkout Repository on Release Branch + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: Setup | Force release branch to be at workflow sha + run: | + git reset --hard ${{ github.sha }} + + - name: Evaluate | Verify upstream has NOT changed + # Last chance to abort before causing an error as another PR/push was applied to + # the upstream branch while this workflow was running. This is important + # because we are committing a version change (--commit). You may omit this step + # if you have 'commit: false' in your configuration. + # + # You may consider moving this to a repo script and call it from this step instead + # of writing it in-line. + shell: bash + run: | + set +o pipefail + + UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | awk -F '\\.\\.\\.' '{print $2}' | cut -d ' ' -f1)" + printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME" + + set -o pipefail + + if [ -z "$UPSTREAM_BRANCH_NAME" ]; then + printf >&2 '%s\n' "::error::Unable to determine upstream branch name!" + exit 1 + fi + + git fetch "${UPSTREAM_BRANCH_NAME%%/*}" + + if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then + printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" + exit 1 + fi + + HEAD_SHA="$(git rev-parse HEAD)" + + if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then + printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" + printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." + exit 1 + fi + + printf '%s\n' "Verified upstream branch has not changed, continuing with release..." + + - name: Action | Semantic Version Release + id: release + # Adjust tag with desired version if applicable. + uses: python-semantic-release/python-semantic-release@v10.4.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + git_committer_name: "github-actions" + git_committer_email: "actions@users.noreply.github.com" + + - name: Publish | Upload to GitHub Release Assets + uses: python-semantic-release/publish-action@v10.4.1 + if: steps.release.outputs.released == 'true' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ steps.release.outputs.tag }} + + - name: Upload | Distribution Artifacts + uses: actions/upload-artifact@v4 + with: + name: distribution-artifacts + path: dist + if-no-files-found: error + + deploy: + # 1. Separate out the deploy step from the publish step to run each step at + # the least amount of token privilege + # 2. Also, deployments can fail, and its better to have a separate job if you need to retry + # and it won't require reversing the release. + runs-on: ubuntu-latest + needs: release + if: ${{ needs.release.outputs.released == 'true' }} + + environment: release + permissions: + contents: read + id-token: write + + steps: + - name: Setup | Download Build Artifacts + uses: actions/download-artifact@v4 + id: artifact-download + with: + name: distribution-artifacts + path: dist + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@v1.13.0 + with: + packages-dir: dist + print-hash: true + verbose: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci-testing.yml similarity index 53% rename from .github/workflows/ci.yml rename to .github/workflows/ci-testing.yml index 27a56a5..7bc46f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci-testing.yml @@ -1,11 +1,13 @@ -name: CI +name: CI Testing Workflow on: + workflow_dispatch: + pull_request: + branches: + - main push: branches: - main - pull_request: - workflow_dispatch: concurrency: group: ${{ github.head_ref || github.run_id }} @@ -29,20 +31,18 @@ jobs: python-version: - "3.12" - "3.13" - poetry-version: - - "2.2.1" steps: - uses: actions/checkout@v3 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Set up Poetry - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: ${{ matrix.poetry-version }} - name: Install Dependencies - run: poetry install + run: uv sync --all-extras shell: bash - uses: pre-commit/action@v3.0.0 @@ -53,8 +53,6 @@ jobs: python-version: - "3.12" - "3.13" - poetry-version: - - "2.2.1" os: - ubuntu-latest - windows-latest @@ -62,47 +60,17 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Set up Poetry - uses: abatilo/actions-poetry@v4.0.0 - with: - poetry-version: ${{ matrix.poetry-version }} - name: Install Dependencies - run: poetry install + run: uv sync --all-extras shell: bash - name: Test with Pytest - run: poetry run pytest + run: uv run pytest shell: bash - release: - runs-on: ubuntu-latest - environment: release - if: github.ref == 'refs/heads/main' - needs: - - test - - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.13" - - # Run semantic release: - # - Update CHANGELOG.md - # - Update version in code - # - Create git tag - # - Create GitHub release - # - Publish to PyPI - - name: Python Semantic Release - uses: relekang/python-semantic-release@v7.34.6 - with: - github_token: ${{ secrets.GH_TOKEN }} - repository_username: __token__ - repository_password: ${{ secrets.REPOSITORY_PASSWORD }} diff --git a/.gitignore b/.gitignore index 1dbefcb..d1f891d 100644 --- a/.gitignore +++ b/.gitignore @@ -94,12 +94,9 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +# uv +# uv.lock should be committed to version control for reproducibility +# uv.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. @@ -143,6 +140,9 @@ venv.bak/ .dmypy.json dmypy.json +# ruff +.ruff_cache/ + # Pyre type checker .pyre/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 682692d..c3642cc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,41 +17,45 @@ repos: - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/python-poetry/poetry - rev: 2.2.1 + - repo: https://github.com/astral-sh/uv-pre-commit + # uv version. + rev: 0.9.8 hooks: - - id: poetry-check - - repo: https://github.com/PyCQA/isort - rev: 7.0.0 - hooks: - - id: isort - - repo: https://github.com/psf/black - rev: 25.9.0 - hooks: - - id: black + - id: uv-lock - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell - exclude: poetry.lock - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.14.1 - hooks: - - id: ruff - args: - - --fix - - repo: local - hooks: - - id: pylint - name: pylint - entry: poetry run pylint - language: system - types: [python] - require_serial: true + exclude: uv.lock + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version + rev: v0.14.4 + hooks: + # Run the linter + - id: ruff-check + args: [ --fix ] + # Run the formatter + - id: ruff-format + # - repo: https://github.com/PyCQA/isort # Replacing isort with ruff + # rev: 7.0.0 + # hooks: + # - id: isort + # - repo: https://github.com/psf/black # Replacing black with ruff + # rev: 25.9.0 + # hooks: + # - id: black + # - repo: local # Replacing pylint with ruff + # hooks: + # - id: pylint + # name: pylint + # entry: uv run pylint + # language: system + # types: [python] + # require_serial: true - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.18.2 hooks: - id: mypy exclude: cli.py additional_dependencies: [ "pydantic>=2.0.0", "pytest>=8.0.0" ] - args: [ "--config-file=./pyproject.toml", "--follow-imports=silent", "--strict", "--ignore-missing-imports", "--disallow-subclassing-any", "--no-warn-return-any" ] + args: [ "--config-file=./pyproject.toml"] diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 599bb10..dcf63f8 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,8 @@ { "recommendations": [ "ms-python.python", - "ms-python.black-formatter", "ms-python.mypy-type-checker", - "njpwerner.autodocstring" + "njpwerner.autodocstring", + "charliermarsh.ruff" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 43d710e..fd02a96 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "files.trimTrailingWhitespace": true, - "editor.rulers": [140], + "editor.rulers": [80, 100, 140], // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings "python.testing.pytestEnabled": true, "python.testing.pytestArgs": [ @@ -11,7 +11,7 @@ "mypy-type-checker.importStrategy": "fromEnvironment", "python.analysis.typeCheckingMode": "basic", "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter", + "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnSave": true } } diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..60f2159 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,235 @@ +# Docker Usage Guide + +This guide explains how to build and use the Docker image for the `omnilogic` CLI tool. + +## Quick Start + +### Build the Image + +```bash +docker build -t omnilogic-cli . +``` + +### Run the CLI + +Replace `192.168.1.100` with your OmniLogic controller's IP address: + +```bash +# Show help +docker run --rm omnilogic-cli --help + +# Get raw MSP configuration +docker run --rm omnilogic-cli --host 192.168.1.100 debug --raw get-mspconfig + +# Get telemetry data parsed with pydantic +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-telemetry + +# List lights +docker run --rm omnilogic-cli --host 192.168.1.100 get lights + +# List pumps +docker run --rm omnilogic-cli --host 192.168.1.100 get pumps + +``` + +## CLI Structure + +The CLI has two main command groups: + +### `get` - Query Equipment Information + +Retrieves information about specific pool equipment. + +```bash +# View available equipment types +docker run --rm omnilogic-cli get --help + +# Examples +docker run --rm omnilogic-cli --host 192.168.1.100 get lights +docker run --rm omnilogic-cli --host 192.168.1.100 get heaters +docker run --rm omnilogic-cli --host 192.168.1.100 get pumps +docker run --rm omnilogic-cli --host 192.168.1.100 get chlorinators +docker run --rm omnilogic-cli --host 192.168.1.100 get schedules +docker run --rm omnilogic-cli --host 192.168.1.100 get sensors +``` + +### `debug` - Low-level Controller Access + +Provides direct access to controller data and debugging utilities. + +```bash +# View debug commands +docker run --rm omnilogic-cli debug --help + +# Get configuration (use --raw for unprocessed XML) +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-mspconfig +docker run --rm omnilogic-cli --host 192.168.1.100 debug --raw get-mspconfig + +# Get telemetry (use --raw for unprocessed XML) +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-telemetry +docker run --rm omnilogic-cli --host 192.168.1.100 debug --raw get-telemetry + +# Get filter diagnostics (requires pool-id and filter-id) +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-filter-diagnostics --pool-id 1 --filter-id 5 + +# Control equipment directly (BOW_ID EQUIP_ID IS_ON) +docker run --rm omnilogic-cli --host 192.168.1.100 debug set-equipment 7 10 true +docker run --rm omnilogic-cli --host 192.168.1.100 debug set-equipment 7 8 50 +``` + +## Network Considerations + +The container needs to reach your OmniLogic controller on UDP port 10444. Ensure: + +1. Your Docker network can reach the controller's IP +2. No firewall is blocking UDP port 10444 +3. The default bridge networking should work fine + +## Advanced Usage + +### Parse PCAP Files + +The CLI includes a PCAP parser for analyzing OmniLogic protocol traffic. Mount the PCAP file into the container: + +```bash +# Capture traffic with tcpdump (on your host or network device) +tcpdump -i eth0 -w pool.pcap udp port 10444 + +# Parse the PCAP file with Docker +docker run --rm -v $(pwd):/data omnilogic-cli debug parse-pcap /data/pool.pcap +``` + +**Note**: The `parse-pcap` command analyzes existing PCAP files; it does NOT capture live traffic. Use tcpdump, Wireshark, or similar tools to create the PCAP file first. + +## Docker Compose + +Create a `docker-compose.yml` file for easier usage: + +```yaml +version: '3.8' + +services: + omnilogic: + build: . + image: omnilogic-cli + volumes: + - ./captures:/data # For PCAP file analysis +``` + +Run commands with: + +```bash +# Query equipment +docker-compose run --rm omnilogic --host 192.168.1.100 get lights + +# Debug commands +docker-compose run --rm omnilogic --host 192.168.1.100 debug get-telemetry + +# Parse PCAP files from ./captures directory +docker-compose run --rm omnilogic debug parse-pcap /data/pool.pcap +``` + +## Building for Multiple Architectures + +Build for both AMD64 and ARM64 (useful for Raspberry Pi): + +```bash +docker buildx build --platform linux/amd64,linux/arm64 -t omnilogic-cli . +``` + +## Image Details + +### Size + +The multi-stage build keeps the image size minimal: +- Builder stage: ~500MB (discarded after build) +- Final runtime image: ~150-200MB + +### Included Dependencies + +- Python 3.12 +- Core dependencies: pydantic, click, xmltodict +- CLI dependencies: scapy (for PCAP parsing) +- Runtime tools: tcpdump (for potential traffic capture outside container) + +## Security Notes + +- The container runs as a non-root user (`omnilogic`, UID 1000) for security +- No sensitive data is stored in the image +- Network access is only required to communicate with your OmniLogic controller on UDP port 10444 +- PCAP parsing does NOT require elevated privileges (only parsing existing files) + +## Troubleshooting + +### Cannot reach controller + +```bash +# Test basic connectivity +docker run --rm omnilogic-cli --host 192.168.1.100 debug get-mspconfig +``` + +If this fails, check: +- Controller IP address is correct and reachable +- Docker container can access your network +- No firewall blocking UDP port 10444 +- Controller is powered on and responsive + +### Connection timeout + +The default timeout is 5 seconds. If your network is slow: +- Check network latency to the controller +- Ensure UDP port 10444 is not being filtered +- Try from host networking mode: `--network host` + +### PCAP file not found + +When parsing PCAP files, ensure the file path is accessible from inside the container: + +```bash +# BAD - file not accessible to container +docker run --rm omnilogic-cli debug parse-pcap /home/user/pool.pcap + +# GOOD - mount the directory containing the PCAP +docker run --rm -v /home/user:/data omnilogic-cli debug parse-pcap /data/pool.pcap +``` + +## Command Reference + +### Equipment Query Commands + +```bash +# Get information about specific equipment types +docker run --rm omnilogic-cli --host get backyard # Backyard info +docker run --rm omnilogic-cli --host get bows # Bodies of water +docker run --rm omnilogic-cli --host get chlorinators # Chlorinators +docker run --rm omnilogic-cli --host get csads # Chemical systems +docker run --rm omnilogic-cli --host get filters # Filters/pumps +docker run --rm omnilogic-cli --host get groups # Equipment groups +docker run --rm omnilogic-cli --host get heaters # Heaters +docker run --rm omnilogic-cli --host get lights # Lights +docker run --rm omnilogic-cli --host get pumps # Pumps +docker run --rm omnilogic-cli --host get relays # Relays +docker run --rm omnilogic-cli --host get schedules # Schedules +docker run --rm omnilogic-cli --host get sensors # Sensors +docker run --rm omnilogic-cli --host get valves # Valves +``` + +### Debug Commands + +```bash +# Configuration and telemetry +docker run --rm omnilogic-cli --host debug get-mspconfig +docker run --rm omnilogic-cli --host debug get-telemetry +docker run --rm omnilogic-cli --host debug --raw get-mspconfig # Raw XML + +# Filter diagnostics (requires IDs from get-mspconfig) +docker run --rm omnilogic-cli --host debug get-filter-diagnostics --pool-id 1 --filter-id 5 + +# Equipment control (BOW_ID EQUIP_ID VALUE) +docker run --rm omnilogic-cli --host debug set-equipment 7 10 true # Turn on +docker run --rm omnilogic-cli --host debug set-equipment 7 10 false # Turn off +docker run --rm omnilogic-cli --host debug set-equipment 7 8 50 # 50% speed + +# PCAP analysis (file must be mounted into container) +docker run --rm -v $(pwd):/data omnilogic-cli debug parse-pcap /data/capture.pcap +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..03b0065 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# Multi-stage build for python-omnilogic-local CLI +# Stage 1: Builder +FROM python:3.12-slim AS builder + +# Set working directory +WORKDIR /build + +# Install build dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy project files +COPY pyproject.toml README.md LICENSE ./ +COPY pyomnilogic_local/ ./pyomnilogic_local/ + +# Install the package with CLI dependencies in a virtual environment +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -e ".[cli]" + +# Stage 2: Runtime +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Install runtime dependencies for scapy (needed for CLI packet capture tools) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + tcpdump \ + && rm -rf /var/lib/apt/lists/* + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv +COPY --from=builder /build /build + +# Set environment variables +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 + +# Create non-root user for security +RUN useradd -m -u 1000 omnilogic && \ + chown -R omnilogic:omnilogic /app + +USER omnilogic + +# Set entrypoint to the omnilogic CLI +ENTRYPOINT ["omnilogic"] + +# Default help command +CMD ["--help"] diff --git a/README.md b/README.md index 4bb6fc3..c292f73 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,354 @@ -# Pyomnilogic Local +
-

- - PyPI Version - - Supported Python versions - License - Buy Me A Coffee -

+# Python OmniLogic Local -A library implementing the UDP XML Local Control api for Hayward OmniLogic and OmniHub pool controllers +[![PyPI Version](https://img.shields.io/pypi/v/python-omnilogic-local.svg?logo=python&logoColor=fff&style=flat-square)](https://pypi.org/project/python-omnilogic-local/) +[![Python Versions](https://img.shields.io/pypi/pyversions/python-omnilogic-local.svg?style=flat-square&logo=python&logoColor=fff)](https://pypi.org/project/python-omnilogic-local/) +[![Build Status](https://img.shields.io/github/actions/workflow/status/cryptk/python-omnilogic-local/build-test.yml?style=flat-square&label=Build)](https://github.com/cryptk/python-omnilogic-local/actions) +[![License](https://img.shields.io/pypi/l/python-omnilogic-local.svg?style=flat-square)](LICENSE) +[![Buy Me A Coffee](https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=000)](https://www.buymeacoffee.com/cryptk) + +**A modern Python library for local control of Hayward OmniLogic and OmniHub pool controllers** + +[Features](#features) • [Installation](#installation) • [Quick Start](#quick-start) • [Documentation](#documentation) • [CLI Tool](#cli-tool) + +
+ +--- + +## Overview + +Python OmniLogic Local provides complete local control over Hayward OmniLogic and OmniHub pool automation systems using their UDP-based XML protocol. Built with modern Python 3.12+, comprehensive type hints, and Pydantic validation, this library offers a async, type-safe interface for pool automation. + +## Features + +### Equipment Control +- **Heaters**: Temperature control, mode selection (heat/auto/off), solar support +- **Pumps & Filters**: Variable speed control, on/off operation, diagnostic information +- **ColorLogic Lights**: Multiple models supported (2.5, 4.0, UCL, SAM), brightness, speed, show selection +- **Relays**: Control auxiliary equipment like fountains, deck jets, blowers +- **Chlorinators**: Timed percent control, enable/disable operation +- **Groups**: Coordinated equipment control (turn multiple devices on/off together) +- **Schedules**: Enable/disable automated schedules + +### Monitoring & State Management +- **Real-time Telemetry**: Water temperature, chemical readings, equipment state +- **Configuration Discovery**: Automatic detection of all equipment and capabilities +- **Sensor Data**: pH, ORP, TDS, salt levels, flow sensors +- **Filter Diagnostics**: Last speed, valve positions, priming states +- **Equipment Hierarchy**: Automatic parent-child relationship tracking + +### Developer-Friendly Design +- **Type Safety**: Comprehensive type hints with strict mypy validation +- **Async/Await**: Non-blocking asyncio-based API +- **Pydantic Models**: Automatic validation and serialization +- **Smart State Management**: Automatic dirty tracking and efficient refreshing +- **Equipment Collections**: Dict-like and attribute access patterns +- **Generic Architecture**: Type-safe equipment hierarchy with generics ## Installation -This package is published to pypi at https://pypi.org/project/python-omnilogic-local/: +**Requirements**: Python 3.12 or higher + +```bash +pip install python-omnilogic-local +``` + +**With CLI tools** (includes packet capture utilities): +```bash +pip install python-omnilogic-local[cli] +``` + +## Quick Start + +### Basic Usage + +```python +import asyncio +from pyomnilogic_local import OmniLogic + +async def main(): + # Connect to your OmniLogic controller + omni = OmniLogic("192.168.1.100") + + # Initial refresh to load configuration and state + await omni.refresh() + + # Access equipment by name + pool = omni.backyard.bow["Pool"] + + # Control heater + heater = pool.heater + print(f"Current temperature: {heater.current_temperature}°F") + print(f"Target temperature: {heater.current_set_point}°F") + + await heater.set_temperature(85) + await heater.turn_on() + + # Refresh to get updated state + await omni.refresh() + + # Control lights + from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicSpeed + + light = pool.lights["Pool Light"] + await light.turn_on() + await light.set_show( + show=light.effects.TWILIGHT, + brightness=ColorLogicBrightness.ONE_HUNDRED_PERCENT, + speed=ColorLogicSpeed.ONE_TIMES + ) + + # Control pump speed + pump = pool.pumps["Pool Pump"] + await pump.set_speed(75) # Set to 75% + +asyncio.run(main()) +``` + +### Monitoring Equipment State + +```python +async def monitor_pool(): + omni = OmniLogic("192.168.1.100") + await omni.refresh() + + pool = omni.backyard.bow["Pool"] + + # Check multiple equipment states + print(f"Water temperature: {pool.heater.current_temperature}°F") + print(f"Heater is {'on' if pool.heater.is_on else 'off'}") + print(f"Pump speed: {pool.pumps['Main Pump'].current_speed}%") + + # Check all lights + for name, light in pool.lights.items(): + if light.is_on: + print(f"{name}: {light.show.name} @ {light.brightness.name}") + else: + print(f"{name}: OFF") + + # Access chemical sensors + if pool.sensors: + for name, sensor in pool.sensors.items(): + print(f"{name}: {sensor.current_reading}") + +asyncio.run(monitor_pool()) +``` + +### Efficient State Updates + +The library includes intelligent state management to minimize unnecessary API calls: + +```python +# Force immediate refresh +await omni.refresh(force=True) + +# Refresh only if data is older than 30 seconds +await omni.refresh(if_older_than=30.0) + +# Refresh only if equipment state changed (default after control commands) +await omni.refresh(if_dirty=True) +``` + +## Documentation + +### Equipment Hierarchy + +``` +OmniLogic +├── Backyard +│ ├── Bodies of Water (BOW) +│ │ ├── Heater (single virtual heater) +│ │ ├── Pumps +│ │ ├── Filters +│ │ ├── Chlorinator +│ │ ├── Lights (ColorLogic) +│ │ ├── Relays +│ │ ├── Sensors +│ │ └── CSAD (Chemical Sensing & Dispensing) +│ ├── Lights (ColorLogic) +│ ├── Relays +│ └── Sensors +├── Groups +└── Schedules +``` + +### Accessing Equipment + +Equipment can be accessed using dictionary-style or attribute-style syntax: -`pip install python-omnilogic-local` +```python +# Dictionary access (by name) +pool = omni.backyard.bow["Pool"] -## Functionality +# Heater is a single object (not a collection) +heater = pool.heater -This library is still under development and is not yet able to control every function of a Hayward pool controller. The implemented functionality is: +# Most equipment are collections +for pump_name, pump in pool.pumps.items(): + print(f"Pump: {pump_name} - Speed: {pump.current_speed}%") -- Pulling the MSP Config -- Polling telemetry -- Polling a list of active alarms -- Polling filter/pump diagnostic information -- Polling the logging configuration -- Setting pool heater temperature -- Turning pool heaters on/off -- Turning other pool equipment on/off, including countdown timers -- Setting filter/pump speed -- Controlling ColorLogic lights including brightness, speed, and selected shows, with support for countdown timers +# Lights, relays, and sensors can be on both BOW and backyard levels +for light_name, light in pool.lights.items(): + print(f"BOW Light: {light_name}") -If your controller has functionality outside of this list, please do not hesitate to [Open an Issue](https://github.com/cryptk/python-omnilogic-local/issues) +for light_name, light in omni.backyard.lights.items(): + print(f"Backyard Light: {light_name}") + +# Groups and schedules are at the OmniLogic level +for group_name, group in omni.groups.items(): + print(f"Group: {group_name}") +``` + +### Equipment Properties + +All equipment exposes standard properties: + +```python +equipment.name # Equipment name +equipment.system_id # Unique system identifier +equipment.bow_id # Body of water ID (if applicable) +equipment.is_ready # Whether equipment can accept commands +equipment.mspconfig # Configuration data +equipment.telemetry # Real-time state data +``` + +### Control Methods + +Control methods are async and automatically handle readiness checks: + +```python +from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicSpeed + +# All control methods are async +await heater.turn_on() +await heater.turn_off() +await heater.set_temperature(85) + +# Light show control - brightness and speed are parameters to set_show() +await light.set_show( + show=light.effects.CARIBBEAN, + brightness=ColorLogicBrightness.EIGHTY_PERCENT, + speed=ColorLogicSpeed.TWO_TIMES +) + +# Pump speed control +await pump.set_speed(75) + +# State is automatically marked dirty after control commands +# Refresh to get updated telemetry +await omni.refresh() +``` + +### Exception Handling + +The library provides specific exception types: + +```python +from pyomnilogic_local import ( + OmniLogicLocalError, # Base exception + OmniEquipmentNotReadyError, # Equipment in transitional state + OmniEquipmentNotInitializedError, # Missing required attributes + OmniConnectionError, # Network/communication errors +) + +try: + await heater.set_temperature(120) # Too high +except OmniValidationException as e: + print(f"Invalid temperature: {e}") + +try: + await light.turn_on() +except OmniEquipmentNotReadyError as e: + print(f"Light not ready: {e}") +``` + +## CLI Tool + +The library includes a command-line tool for monitoring and debugging: + +```bash +# Get telemetry data +omnilogic --host 192.168.1.100 debug get-telemetry + +# List all equipment +omnilogic get lights +omnilogic get pumps +omnilogic get heaters + +# Get raw XML responses +omnilogic debug --raw get-mspconfig + +# View filter diagnostics +omnilogic debug get-filter-diagnostics +``` + +**Installation with CLI tools**: +```bash +pip install python-omnilogic-local[cli] +``` + +## Supported Equipment + +### Fully Supported +- Pool/Spa Heaters (gas, heat pump, solar, hybrid) +- Variable Speed Pumps & Filters +- ColorLogic Lights (2.5, 4.0, UCL, SAM models) +- Relays (water features, auxiliary equipment) +- Chlorinators (timed percent control) +- Sensors (temperature, pH, ORP, TDS, salt, flow) +- Groups (coordinated equipment control) +- Schedules (enable/disable) +- CSAD (Chemical Sensing & Dispensing) - monitoring + +### Partial Support +- CSAD equipment control (monitoring only currently) +- Some advanced heater configurations + +> [!NOTE] +> If your controller has equipment not listed here, please [open an issue](https://github.com/cryptk/python-omnilogic-local/issues) with details about your configuration. + +## Development + +This project uses modern Python tooling: + +- **Python**: 3.12+ with type hints +- **Type Checking**: mypy strict mode +- **Validation**: Pydantic v2 +- **Testing**: pytest with async support +- **Code Quality**: black, isort, pylint, ruff +- **Package Management**: uv (optional) or pip + +### Running Tests + +```bash +# Install development dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Run with coverage +pytest --cov=pyomnilogic_local --cov-report=html + +# Type checking +mypy pyomnilogic_local + +# Linting +pylint pyomnilogic_local +``` ## Credits -The work on this library would not have been possible without the efforts of [djtimca](https://github.com/djtimca/) and [John Sutherland](garionphx@gmail.com) +This library was made possible by the pioneering work of: + +- [djtimca](https://github.com/djtimca/) - Original protocol research and implementation +- [John Sutherland](mailto:garionphx@gmail.com) - Protocol documentation and testing + +## Related Projects + +- [Home Assistant Integration](https://github.com/cryptk/haomnilogic-local) - Use this library with Home Assistant + +## Disclaimer + +This is an unofficial library and is not affiliated with, endorsed by, or connected to Hayward Industries, Inc. Use at your own risk. The developers are not responsible for any damage to equipment or property resulting from the use of this software. diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 6f3a546..0000000 --- a/poetry.lock +++ /dev/null @@ -1,856 +0,0 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "astroid" -version = "4.0.1" -description = "An abstract syntax tree for Python with inference support." -optional = false -python-versions = ">=3.10.0" -groups = ["dev"] -files = [ - {file = "astroid-4.0.1-py3-none-any.whl", hash = "sha256:37ab2f107d14dc173412327febf6c78d39590fdafcb44868f03b6c03452e3db0"}, - {file = "astroid-4.0.1.tar.gz", hash = "sha256:0d778ec0def05b935e198412e62f9bcca8b3b5c39fdbe50b0ba074005e477aab"}, -] - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "click" -version = "8.0.4" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, - {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} - -[[package]] -name = "coverage" -version = "7.11.0" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31"}, - {file = "coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785"}, - {file = "coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866"}, - {file = "coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841"}, - {file = "coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf"}, - {file = "coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969"}, - {file = "coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847"}, - {file = "coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623"}, - {file = "coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601"}, - {file = "coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e"}, - {file = "coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c"}, - {file = "coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9"}, - {file = "coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745"}, - {file = "coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1"}, - {file = "coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115"}, - {file = "coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d"}, - {file = "coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2"}, - {file = "coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5"}, - {file = "coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0"}, - {file = "coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad"}, - {file = "coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1"}, - {file = "coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52"}, - {file = "coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc"}, - {file = "coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48"}, - {file = "coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040"}, - {file = "coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05"}, - {file = "coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a"}, - {file = "coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b"}, - {file = "coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c"}, - {file = "coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0"}, - {file = "coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca"}, - {file = "coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2"}, - {file = "coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268"}, - {file = "coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836"}, - {file = "coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497"}, - {file = "coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd"}, - {file = "coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d"}, - {file = "coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4"}, - {file = "coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721"}, - {file = "coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad"}, - {file = "coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479"}, - {file = "coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f"}, - {file = "coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b"}, - {file = "coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996"}, - {file = "coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11"}, - {file = "coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73"}, - {file = "coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547"}, - {file = "coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3"}, - {file = "coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68"}, - {file = "coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "dill" -version = "0.4.0" -description = "serialize all of Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, - {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, -] - -[package.extras] -graph = ["objgraph (>=1.7.2)"] -profile = ["gprof2dot (>=2022.7.29)"] - -[[package]] -name = "distlib" -version = "0.4.0" -description = "Distribution utilities" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, - {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, -] - -[[package]] -name = "filelock" -version = "3.20.0" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"}, - {file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"}, -] - -[[package]] -name = "identify" -version = "2.6.15" -description = "File identification library for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, - {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "isort" -version = "6.1.0" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.9.0" -groups = ["dev"] -files = [ - {file = "isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784"}, - {file = "isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481"}, -] - -[package.extras] -colors = ["colorama"] -plugins = ["setuptools"] - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mypy" -version = "1.18.2" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"}, - {file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"}, - {file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"}, - {file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"}, - {file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"}, - {file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"}, - {file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"}, - {file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"}, - {file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"}, - {file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"}, - {file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"}, - {file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"}, - {file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"}, - {file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"}, - {file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"}, - {file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"}, - {file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"}, - {file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"}, - {file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"}, - {file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"}, - {file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"}, - {file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"}, - {file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"}, - {file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"}, - {file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"}, - {file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"}, - {file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"}, - {file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"}, - {file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"}, - {file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"}, - {file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"}, - {file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"}, - {file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"}, - {file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"}, - {file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"}, - {file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"}, - {file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"}, - {file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"}, -] - -[package.dependencies] -mypy_extensions = ">=1.0.0" -pathspec = ">=0.9.0" -typing_extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - -[[package]] -name = "packaging" -version = "25.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "platformdirs" -version = "4.5.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, - {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, -] - -[package.extras] -docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] -type = ["mypy (>=1.18.2)"] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "pre-commit" -version = "4.3.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"}, - {file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "pydantic" -version = "2.12.3" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf"}, - {file = "pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.41.4" -typing-extensions = ">=4.14.1" -typing-inspection = ">=0.4.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.41.4" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e"}, - {file = "pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b"}, - {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6"}, - {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9"}, - {file = "pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57"}, - {file = "pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc"}, - {file = "pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80"}, - {file = "pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8"}, - {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a"}, - {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e"}, - {file = "pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db"}, - {file = "pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887"}, - {file = "pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47"}, - {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8"}, - {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff"}, - {file = "pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8"}, - {file = "pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746"}, - {file = "pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84"}, - {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2"}, - {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4"}, - {file = "pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2"}, - {file = "pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89"}, - {file = "pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1"}, - {file = "pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12"}, - {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a"}, - {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894"}, - {file = "pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d"}, - {file = "pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0"}, - {file = "pydantic_core-2.41.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062"}, - {file = "pydantic_core-2.41.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891"}, - {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005"}, - {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8"}, - {file = "pydantic_core-2.41.4-cp39-cp39-win32.whl", hash = "sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb"}, - {file = "pydantic_core-2.41.4-cp39-cp39-win_amd64.whl", hash = "sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee"}, - {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c"}, - {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a"}, - {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308"}, - {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f"}, - {file = "pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.1" - -[[package]] -name = "pygments" -version = "2.19.2" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pylint" -version = "4.0.2" -description = "python code static checker" -optional = false -python-versions = ">=3.10.0" -groups = ["dev"] -files = [ - {file = "pylint-4.0.2-py3-none-any.whl", hash = "sha256:9627ccd129893fb8ee8e8010261cb13485daca83e61a6f854a85528ee579502d"}, - {file = "pylint-4.0.2.tar.gz", hash = "sha256:9c22dfa52781d3b79ce86ab2463940f874921a3e5707bcfc98dd0c019945014e"}, -] - -[package.dependencies] -astroid = ">=4.0.1,<=4.1.dev0" -colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} -isort = ">=5,<5.13 || >5.13,<8" -mccabe = ">=0.6,<0.8" -platformdirs = ">=2.2" -tomlkit = ">=0.10.1" - -[package.extras] -spelling = ["pyenchant (>=3.2,<4.0)"] -testutils = ["gitpython (>3)"] - -[[package]] -name = "pytest" -version = "8.4.2" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, - {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1" -packaging = ">=20" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "1.2.0" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, - {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, -] - -[package.dependencies] -pytest = ">=8.2,<9" -typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, - {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, -] - -[package.dependencies] -coverage = {version = ">=7.10.6", extras = ["toml"]} -pluggy = ">=1.2" -pytest = ">=7" - -[package.extras] -testing = ["process-tests", "pytest-xdist", "virtualenv"] - -[[package]] -name = "pyyaml" -version = "6.0.3" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, - {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, - {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, - {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, - {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, - {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, - {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, - {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, - {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, - {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, -] - -[[package]] -name = "scapy" -version = "2.6.1" -description = "Scapy: interactive packet manipulation tool" -optional = false -python-versions = "<4,>=3.7" -groups = ["cli"] -files = [ - {file = "scapy-2.6.1-py3-none-any.whl", hash = "sha256:88a998572049b511a1f3e44f4aa7c62dd39c6ea2aa1bb58434f503956641789d"}, - {file = "scapy-2.6.1.tar.gz", hash = "sha256:7600d7e2383c853e5c3a6e05d37e17643beebf2b3e10d7914dffcc3bc3c6e6c5"}, -] - -[package.extras] -all = ["cryptography (>=2.0)", "ipython", "matplotlib", "pyx"] -cli = ["ipython"] -doc = ["sphinx (>=7.0.0)", "sphinx-rtd-theme (>=1.3.0)", "tox (>=3.0.0)"] - -[[package]] -name = "tomlkit" -version = "0.13.3" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, - {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "virtualenv" -version = "20.35.3" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a"}, - {file = "virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - -[[package]] -name = "xmltodict" -version = "0.13.0" -description = "Makes working with XML feel like you are working with JSON" -optional = false -python-versions = ">=3.4" -groups = ["main"] -files = [ - {file = "xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852"}, - {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, -] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.12,<4.0.0" -content-hash = "63006bf4923ea08a13f1aa55ef74fce1ada37b297afc0887676af5759892c54e" diff --git a/pyomnilogic_local/__init__.py b/pyomnilogic_local/__init__.py index e69de29..c9f842c 100644 --- a/pyomnilogic_local/__init__.py +++ b/pyomnilogic_local/__init__.py @@ -0,0 +1,22 @@ +"""PyOmniLogic-Local: A Python library for interacting with Hayward OmniLogic Local API.""" + +from __future__ import annotations + +from .collections import EffectsCollection, LightEffectsCollection +from .omnilogic import OmniLogic +from .util import ( + OmniConnectionError, + OmniEquipmentNotInitializedError, + OmniEquipmentNotReadyError, + OmniLogicLocalError, +) + +__all__ = [ + "EffectsCollection", + "LightEffectsCollection", + "OmniConnectionError", + "OmniEquipmentNotInitializedError", + "OmniEquipmentNotReadyError", + "OmniLogic", + "OmniLogicLocalError", +] diff --git a/pyomnilogic_local/_base.py b/pyomnilogic_local/_base.py new file mode 100644 index 0000000..8b7898c --- /dev/null +++ b/pyomnilogic_local/_base.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +from pyomnilogic_local.models import MSPEquipmentType +from pyomnilogic_local.models.telemetry import TelemetryType +from pyomnilogic_local.omnitypes import BackyardState + +if TYPE_CHECKING: + from pyomnilogic_local.api.api import OmniLogicAPI + from pyomnilogic_local.models import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + + +_LOGGER = logging.getLogger(__name__) + + +class OmniEquipment[MSPConfigT: MSPEquipmentType, TelemetryT: TelemetryType | None]: + """Base class for all OmniLogic equipment. + + This is an abstract base class that provides common functionality for all equipment + types in the OmniLogic system. It handles configuration updates, telemetry updates, + and provides access to the API for control operations. + + All equipment classes inherit from this base and are strongly typed using generic + parameters for their specific configuration and telemetry types. + + Generic Parameters: + MSPConfigT: The specific MSP configuration type (e.g., MSPBoW, MSPRelay) + TelemetryT: The specific telemetry type (e.g., TelemetryBoW, TelemetryRelay, or None) + + Attributes: + mspconfig: Configuration data from the MSP XML + telemetry: Live telemetry data (may be None for equipment without telemetry) + child_equipment: Dictionary of child equipment indexed by system_id + + Properties: + bow_id: The body of water ID this equipment belongs to + name: Equipment name from configuration + system_id: Unique system identifier + omni_type: OmniLogic type identifier + is_ready: Whether equipment can accept commands (checks backyard state) + + Example: + Equipment classes should not be instantiated directly. Access them through + the OmniLogic instance: + + >>> omni = OmniLogic("192.168.1.100") + >>> await omni.refresh() + >>> # Access equipment through the backyard + >>> pool = omni.backyard.bow["Pool"] + >>> pump = pool.pumps["Main Pump"] + """ + + mspconfig: MSPConfigT + telemetry: TelemetryT + + # Use a forward reference for the type hint to avoid issues with self-referential generics + child_equipment: dict[int, OmniEquipment[MSPConfigT, TelemetryT]] + + def __init__(self, omni: OmniLogic, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: + """Initialize the equipment with configuration and telemetry data. + + Args: + omni: The OmniLogic instance (parent controller) + mspconfig: The MSP configuration for this specific equipment + telemetry: The full Telemetry object containing all equipment telemetry + """ + self._omni = omni + + self.update(mspconfig, telemetry) + + @property + def _api(self) -> OmniLogicAPI: + """Access the OmniLogic API through the parent controller.""" + return self._omni._api + + @property + def bow_id(self) -> int | None: + """The bow ID of the equipment.""" + return self.mspconfig.bow_id + + @property + def name(self) -> str | None: + """The name of the equipment.""" + return self.mspconfig.name + + @property + def system_id(self) -> int | None: + """The system ID of the equipment.""" + return self.mspconfig.system_id + + @property + def omni_type(self) -> str | None: + """The OmniType of the equipment.""" + return self.mspconfig.omni_type + + @property + def is_ready(self) -> bool: + """Check if the equipment is ready to accept commands. + + Equipment is not ready when the backyard is in service or configuration mode. + This is the base implementation that checks backyard state. + Subclasses should call super().is_ready first and add their own checks. + + Returns: + bool: False if backyard is in SERVICE_MODE, CONFIG_MODE, or TIMED_SERVICE_MODE, + True otherwise (equipment-specific checks in subclasses) + """ + # Check if backyard state allows equipment operations + backyard_state = self._omni.backyard.telemetry.state + return backyard_state not in ( + BackyardState.SERVICE_MODE, + BackyardState.CONFIG_MODE, + BackyardState.TIMED_SERVICE_MODE, + ) + + def update(self, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + self.update_config(mspconfig) + if telemetry is not None: + self.update_telemetry(telemetry) + + self._update_equipment(mspconfig, telemetry) + + def _update_equipment(self, mspconfig: MSPConfigT, telemetry: Telemetry | None) -> None: + """Allow a class to trigger updates of sub-equipment. + + This method can be overridden by subclasses to update any child equipment. + """ + + def update_config(self, mspconfig: MSPConfigT) -> None: + """Update the configuration data for the equipment.""" + try: + # If the Equipment has subdevices, we don't store those as part of this device's config + # They will get parsed and stored as their own equipment instances + self.mspconfig = cast("MSPConfigT", mspconfig.without_subdevices()) + except AttributeError: + self.mspconfig = mspconfig + + def update_telemetry(self, telemetry: Telemetry) -> None: + """Update the telemetry data for the equipment.""" + # Only update telemetry if this equipment type has telemetry + # if hasattr(self, "telemetry"): + # Extract the specific telemetry for this equipment from the full telemetry object + # Note: Some equipment (like sensors) don't have their own telemetry, so this may be None + if (specific_telemetry := telemetry.get_telem_by_systemid(self.mspconfig.system_id)) is not None: + self.telemetry = cast("TelemetryT", specific_telemetry) + else: + self.telemetry = cast("TelemetryT", None) + + def __repr__(self) -> str: + """Return a string representation of the equipment for debugging. + + Returns: + A string showing the class name, system_id, name, and state (if available). + """ + class_name = self.__class__.__name__ + parts = [f"system_id={self.system_id!r}", f"name={self.name!r}"] + + # Include state if the equipment has telemetry with a state attribute + if (hasattr(self, "telemetry") and self.telemetry is not None) and ((state := getattr(self.telemetry, "state", None)) is not None): + parts.append(f"state={state!r}") + + return f"{class_name}({', '.join(parts)})" diff --git a/pyomnilogic_local/api/__init__.py b/pyomnilogic_local/api/__init__.py new file mode 100644 index 0000000..b9f9ff1 --- /dev/null +++ b/pyomnilogic_local/api/__init__.py @@ -0,0 +1,14 @@ +"""API module for interacting with Hayward OmniLogic pool controllers. + +This module provides the OmniLogicAPI class, which allows for local +control and monitoring of Hayward OmniLogic and OmniHub pool controllers +over a local network connection via the UDP XML API. +""" + +from __future__ import annotations + +from .api import OmniLogicAPI + +__all__ = [ + "OmniLogicAPI", +] diff --git a/pyomnilogic_local/api.py b/pyomnilogic_local/api/api.py similarity index 68% rename from pyomnilogic_local/api.py rename to pyomnilogic_local/api/api.py index e63834b..cf986c7 100644 --- a/pyomnilogic_local/api.py +++ b/pyomnilogic_local/api/api.py @@ -1,34 +1,119 @@ -# pylint: disable=too-many-positional-arguments from __future__ import annotations import asyncio import logging import xml.etree.ElementTree as ET -from typing import Literal, overload +from typing import TYPE_CHECKING, Literal, overload -from .models.filter_diagnostics import FilterDiagnostics -from .models.mspconfig import MSPConfig -from .models.telemetry import Telemetry -from .models.util import to_pydantic -from .omnitypes import ( +from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics +from pyomnilogic_local.models.mspconfig import MSPConfig +from pyomnilogic_local.models.telemetry import Telemetry +from pyomnilogic_local.omnitypes import ( ColorLogicBrightness, - ColorLogicShow, ColorLogicSpeed, - HeaterMode, MessageType, ) + +from .constants import ( + DEFAULT_CONTROLLER_PORT, + DEFAULT_RESPONSE_TIMEOUT, + MAX_SPEED_PERCENT, + MAX_TEMPERATURE_F, + MIN_SPEED_PERCENT, + MIN_TEMPERATURE_F, + XML_ENCODING, + XML_NAMESPACE, +) +from .exceptions import OmniValidationError from .protocol import OmniLogicProtocol +if TYPE_CHECKING: + from pyomnilogic_local.omnitypes import HeaterMode, LightShows + _LOGGER = logging.getLogger(__name__) +def _validate_temperature(temperature: int, param_name: str = "temperature") -> None: + """Validate temperature is within acceptable range. + + Args: + temperature: Temperature value in Fahrenheit. + param_name: Name of the parameter for error messages. + + Raises: + OmniValidationException: If temperature is out of range. + """ + if not isinstance(temperature, int): + msg = f"{param_name} must be an integer, got {type(temperature).__name__}" + raise OmniValidationError(msg) + if not MIN_TEMPERATURE_F <= temperature <= MAX_TEMPERATURE_F: + msg = f"{param_name} must be between {MIN_TEMPERATURE_F}°F and {MAX_TEMPERATURE_F}°F, got {temperature}°F" + raise OmniValidationError(msg) + + +def _validate_speed(speed: int, param_name: str = "speed") -> None: + """Validate speed percentage is within acceptable range. + + Args: + speed: Speed percentage (0-100). + param_name: Name of the parameter for error messages. + + Raises: + OmniValidationException: If speed is out of range. + """ + if not isinstance(speed, int): + msg = f"{param_name} must be an integer, got {type(speed).__name__}" + raise OmniValidationError(msg) + if not MIN_SPEED_PERCENT <= speed <= MAX_SPEED_PERCENT: + msg = f"{param_name} must be between {MIN_SPEED_PERCENT} and {MAX_SPEED_PERCENT}, got {speed}" + raise OmniValidationError(msg) + + +def _validate_id(id_value: int, param_name: str) -> None: + """Validate an ID is a positive integer. + + Args: + id_value: The ID value to validate. + param_name: Name of the parameter for error messages. + + Raises: + OmniValidationException: If ID is invalid. + """ + if not isinstance(id_value, int): + msg = f"{param_name} must be an integer, got {type(id_value).__name__}" + raise OmniValidationError(msg) + if id_value < 0: + msg = f"{param_name} must be non-negative, got {id_value}" + raise OmniValidationError(msg) + + class OmniLogicAPI: - def __init__(self, controller_ip: str, controller_port: int, response_timeout: float) -> None: + def __init__( + self, controller_ip: str, controller_port: int = DEFAULT_CONTROLLER_PORT, response_timeout: float = DEFAULT_RESPONSE_TIMEOUT + ) -> None: + """Initialize the OmniLogic API client. + + Args: + controller_ip: IP address of the OmniLogic controller. + controller_port: UDP port of the OmniLogic controller (default: 10444). + response_timeout: Timeout in seconds for receiving responses (default: 5.0). + + Raises: + OmniValidationException: If parameters are invalid. + """ + if not controller_ip: + msg = "controller_ip cannot be empty" + raise OmniValidationError(msg) + if not isinstance(controller_port, int) or controller_port <= 0 or controller_port > 65535: + msg = f"controller_port must be between 1 and 65535, got {controller_port}" + raise OmniValidationError(msg) + if not isinstance(response_timeout, (int, float)) or response_timeout <= 0: + msg = f"response_timeout must be positive, got {response_timeout}" + raise OmniValidationError(msg) + self.controller_ip = controller_ip self.controller_port = controller_port self.response_timeout = response_timeout - self._loop = asyncio.get_running_loop() - self._protocol_factory = OmniLogicProtocol @overload async def async_send_message(self, message_type: MessageType, message: str | None, need_response: Literal[True]) -> str: ... @@ -61,8 +146,13 @@ async def async_send_message(self, message_type: MessageType, message: str | Non return resp - @to_pydantic(pydantic_type=MSPConfig) - async def async_get_config(self) -> str: + @overload + async def async_get_mspconfig(self, raw: Literal[True]) -> str: ... + @overload + async def async_get_mspconfig(self, raw: Literal[False]) -> MSPConfig: ... + @overload + async def async_get_mspconfig(self) -> MSPConfig: ... + async def async_get_mspconfig(self, raw: bool = False) -> MSPConfig | str: """Retrieve the MSPConfig from the Omni, optionally parse it into a pydantic model. Args: @@ -71,31 +161,39 @@ async def async_get_config(self) -> str: Returns: MSPConfig|str: Either a parsed .models.mspconfig.MSPConfig object or a str depending on arg raw """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "RequestConfiguration" - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) - return await self.async_send_message(MessageType.REQUEST_CONFIGURATION, req_body, True) + resp = await self.async_send_message(MessageType.REQUEST_CONFIGURATION, req_body, True) - @to_pydantic(pydantic_type=FilterDiagnostics) - async def async_get_filter_diagnostics( - self, - pool_id: int, - equipment_id: int, - ) -> str: + if raw: + return resp + return MSPConfig.load_xml(resp) + + @overload + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: Literal[True]) -> str: ... + @overload + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: Literal[False]) -> FilterDiagnostics: ... + @overload + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int) -> FilterDiagnostics: ... + @overload + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: bool) -> FilterDiagnostics | str: ... + async def async_get_filter_diagnostics(self, pool_id: int, equipment_id: int, raw: bool = False) -> FilterDiagnostics | str: """Retrieve filter diagnostics from the Omni, optionally parse it into a pydantic model. Args: pool_id (int): The Pool/BodyOfWater ID that you want to address equipment_id (int): Which equipment_id within that Pool to address + raw (bool): Do not parse the response into a Pydantic model, just return the raw XML. Defaults to False. Returns: FilterDiagnostics|str: Either a parsed .models.mspconfig.FilterDiagnostics object or a str depending on arg raw """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "GetUIFilterDiagnosticInfo" @@ -106,45 +204,56 @@ async def async_get_filter_diagnostics( parameter = ET.SubElement(parameters_element, "Parameter", name="equipmentId", dataType="int") parameter.text = str(equipment_id) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) - return await self.async_send_message(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body, True) + resp = await self.async_send_message(MessageType.GET_FILTER_DIAGNOSTIC_INFO, req_body, True) - @to_pydantic(pydantic_type=Telemetry) - async def async_get_telemetry(self) -> str: + if raw: + return resp + return FilterDiagnostics.load_xml(resp) + + @overload + async def async_get_telemetry(self, raw: Literal[True]) -> str: ... + @overload + async def async_get_telemetry(self, raw: Literal[False]) -> Telemetry: ... + @overload + async def async_get_telemetry(self) -> Telemetry: ... + async def async_get_telemetry(self, raw: bool = False) -> Telemetry | str: """Retrieve the current telemetry data from the Omni, optionally parse it into a pydantic model. Returns: Telemetry|str: Either a parsed .models.telemetry.Telemetry object or a str depending on arg raw """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "RequestTelemetryData" - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) - return await self.async_send_message(MessageType.GET_TELEMETRY, req_body, True) + resp = await self.async_send_message(MessageType.GET_TELEMETRY, req_body, True) + + if raw: + return resp + return Telemetry.load_xml(resp) async def async_set_heater( self, pool_id: int, equipment_id: int, temperature: int, - unit: str, ) -> None: - """Set the temperature for a heater on the Omni + """Set the temperature for a heater on the Omni. Args: pool_id (int): The Pool/BodyOfWater ID that you want to address equipment_id (int): Which equipment_id within that Pool to address - temperature (int): What temperature to request - unit (str): The temperature unit to use (either F or C) + temperature (int): What temperature to request (must be in Fahrenheit) Returns: None """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUIHeaterCmd" @@ -154,10 +263,10 @@ async def async_set_heater( parameter.text = str(pool_id) parameter = ET.SubElement(parameters_element, "Parameter", name="HeaterID", dataType="int", alias="EquipmentID") parameter.text = str(equipment_id) - parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit=unit, alias="Data") + parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit="F", alias="Data") parameter.text = str(temperature) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_HEATER_COMMAND, req_body, False) @@ -166,20 +275,18 @@ async def async_set_solar_heater( pool_id: int, equipment_id: int, temperature: int, - unit: str, ) -> None: """Set the solar set point for a heater on the Omni. Args: pool_id (int): The Pool/BodyOfWater ID that you want to address equipment_id (int): Which equipment_id within that Pool to address - temperature (int): What temperature to request - unit (str): The temperature unit to use (either F or C) + temperature (int): What temperature to request (must be in Fahrenheit) Returns: None """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUISolarSetPointCmd" @@ -189,10 +296,10 @@ async def async_set_solar_heater( parameter.text = str(pool_id) parameter = ET.SubElement(parameters_element, "Parameter", name="HeaterID", dataType="int", alias="EquipmentID") parameter.text = str(equipment_id) - parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit=unit, alias="Data") + parameter = ET.SubElement(parameters_element, "Parameter", name="Temp", dataType="int", unit="F", alias="Data") parameter.text = str(temperature) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_SOLAR_SET_POINT_COMMAND, req_body, False) @@ -212,7 +319,7 @@ async def async_set_heater_mode( Returns: None """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUIHeaterModeCmd" @@ -225,7 +332,7 @@ async def async_set_heater_mode( parameter = ET.SubElement(parameters_element, "Parameter", name="Mode", dataType="int", alias="Data") parameter.text = str(mode.value) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_HEATER_MODE_COMMAND, req_body, False) @@ -235,7 +342,7 @@ async def async_set_heater_enable( equipment_id: int, enabled: int | bool, ) -> None: - """async_set_heater_enable handles sending a SetHeaterEnable XML API call to the Hayward Omni pool controller + """Send a SetHeaterEnable XML API call to the Hayward Omni pool controller. Args: pool_id (int): The Pool/BodyOfWater ID that you want to address @@ -245,7 +352,7 @@ async def async_set_heater_enable( Returns: _type_: _description_ """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetHeaterEnable" @@ -258,7 +365,7 @@ async def async_set_heater_enable( parameter = ET.SubElement(parameters_element, "Parameter", name="Enabled", dataType="bool", alias="Data") parameter.text = str(int(enabled)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_HEATER_ENABLED, req_body, False) @@ -284,14 +391,14 @@ async def async_set_equipment( For Variable Speed Pumps, you can optionally provide an int from 0-100 to set the speed percentage with 0 being Off. The interpretation of value depends on the piece of equipment being targeted. is_countdown_timer (bool, optional): For potential future use, included to be "API complete". Defaults to False. - startTimeHours (int, optional): For potential future use, included to be "API complete". Defaults to 0. - startTimeMinutes (int, optional): For potential future use, included to be "API complete". Defaults to 0. - endTimeHours (int, optional): For potential future use, included to be "API complete". Defaults to 0. - endTimeMinutes (int, optional): For potential future use, included to be "API complete". Defaults to 0. - daysActive (int, optional): For potential future use, included to be "API complete". Defaults to 0. + start_time_hours (int, optional): For potential future use, included to be "API complete". Defaults to 0. + start_time_minutes (int, optional): For potential future use, included to be "API complete". Defaults to 0. + end_time_hours (int, optional): For potential future use, included to be "API complete". Defaults to 0. + end_time_minutes (int, optional): For potential future use, included to be "API complete". Defaults to 0. + days_active (int, optional): For potential future use, included to be "API complete". Defaults to 0. recurring (bool, optional): For potential future use, included to be "API complete". Defaults to False. """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUIEquipmentCmd" @@ -318,7 +425,7 @@ async def async_set_equipment( parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") parameter.text = str(int(recurring)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_EQUIPMENT, req_body, False) @@ -330,7 +437,7 @@ async def async_set_filter_speed(self, pool_id: int, equipment_id: int, speed: i equipment_id (int): Which equipment_id within that Pool to address speed (int): Speed value from 0-100 to set the filter to. A value of 0 will turn the filter off. """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUIFilterSpeedCmd" @@ -344,7 +451,7 @@ async def async_set_filter_speed(self, pool_id: int, equipment_id: int, speed: i parameter = ET.SubElement(parameters_element, "Parameter", name="Speed", dataType="int", unit="RPM", alias="Data") parameter.text = str(speed) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_FILTER_SPEED, req_body, False) @@ -352,7 +459,7 @@ async def async_set_light_show( self, pool_id: int, equipment_id: int, - show: ColorLogicShow, + show: LightShows, speed: ColorLogicSpeed = ColorLogicSpeed.ONE_TIMES, brightness: ColorLogicBrightness = ColorLogicBrightness.ONE_HUNDRED_PERCENT, reserved: int = 0, @@ -381,7 +488,7 @@ async def async_set_light_show( days_active (int, optional): For potential future use, included to be "API complete". Defaults to 0. recurring (bool, optional): For potential future use, included to be "API complete". Defaults to False. """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetStandAloneLightShow" @@ -414,11 +521,11 @@ async def async_set_light_show( parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") parameter.text = str(int(recurring)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_STANDALONE_LIGHT_SHOW, req_body, False) async def async_set_chlorinator_enable(self, pool_id: int, enabled: int | bool) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetCHLOREnable" @@ -429,7 +536,7 @@ async def async_set_chlorinator_enable(self, pool_id: int, enabled: int | bool) parameter = ET.SubElement(parameters_element, "Parameter", name="Enabled", dataType="bool", alias="Data") parameter.text = str(int(enabled)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_CHLOR_ENABLED, req_body, False) @@ -445,7 +552,7 @@ async def async_set_chlorinator_params( orp_timeout: int, cfg_state: int = 3, ) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetCHLORParams" @@ -470,7 +577,7 @@ async def async_set_chlorinator_params( parameter = ET.SubElement(parameters_element, "Parameter", name="ORPTimout", dataType="byte", unit="hour", alias="Data7") parameter.text = str(orp_timeout) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_CHLOR_PARAMS, req_body, False) @@ -480,7 +587,7 @@ async def async_set_chlorinator_superchlorinate( equipment_id: int, enabled: int | bool, ) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUISuperCHLORCmd" @@ -493,19 +600,19 @@ async def async_set_chlorinator_superchlorinate( parameter = ET.SubElement(parameters_element, "Parameter", name="IsOn", dataType="byte", alias="Data1") parameter.text = str(int(enabled)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_SUPERCHLORINATE, req_body, False) async def async_restore_idle_state(self) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "RestoreIdleState" ET.SubElement(body_element, "Parameters") - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.RESTORE_IDLE_STATE, req_body, False) @@ -521,7 +628,7 @@ async def async_set_spillover( days_active: int = 0, recurring: bool = False, ) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "SetUISpilloverCmd" @@ -546,7 +653,7 @@ async def async_set_spillover( parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") parameter.text = str(int(recurring)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.SET_SPILLOVER, req_body, False) @@ -562,7 +669,7 @@ async def async_set_group_enable( days_active: int = 0, recurring: bool = False, ) -> None: - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "RunGroupCmd" @@ -587,6 +694,77 @@ async def async_set_group_enable( parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") parameter.text = str(int(recurring)) - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) return await self.async_send_message(MessageType.RUN_GROUP_CMD, req_body, False) + + async def async_edit_schedule( + self, + equipment_id: int, + data: int, + action_id: int, + start_time_hours: int, + start_time_minutes: int, + end_time_hours: int, + end_time_minutes: int, + days_active: int, + is_enabled: bool, + recurring: bool, + ) -> None: + """Edit an existing schedule on the Omni. + + Args: + equipment_id (int): The schedule's system ID (schedule-system-id from MSPConfig), NOT the equipment-id. + data (int): The data value for the schedule action (e.g., 50 for 50% speed, 1 for on, 0 for off). + action_id (int): The action/event ID that will be executed (e.g., 164 for SetUIEquipmentCmd). + Maps to the 'event' field in the schedule. Common values: + - 164: SetUIEquipmentCmd (turn equipment on/off or set speed) + - 308: SetStandAloneLightShow + - 311: SetUISpilloverCmd + start_time_hours (int): Hour to start the schedule (0-23). Maps to 'start-hour'. + start_time_minutes (int): Minute to start the schedule (0-59). Maps to 'start-minute'. + end_time_hours (int): Hour to end the schedule (0-23). Maps to 'end-hour'. + end_time_minutes (int): Minute to end the schedule (0-59). Maps to 'end-minute'. + days_active (int): Bitmask of active days. Maps to 'days-active'. + 1=Monday, 2=Tuesday, 4=Wednesday, 8=Thursday, 16=Friday, 32=Saturday, 64=Sunday + 127=All days (1+2+4+8+16+32+64) + is_enabled (bool): Whether the schedule is enabled. Maps to 'enabled' (0 or 1). + recurring (bool): Whether the schedule repeats. Maps to 'recurring' (0 or 1). + + Returns: + None + + Note: + The schedule's equipment-id (which equipment is controlled) cannot be changed via this call. + Only the schedule parameters (timing, data, enabled state) can be modified. + """ + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) + + name_element = ET.SubElement(body_element, "Name") + name_element.text = "EditUIScheduleCmd" + + parameters_element = ET.SubElement(body_element, "Parameters") + parameter = ET.SubElement(parameters_element, "Parameter", name="EquipmentID", dataType="int") + parameter.text = str(equipment_id) + parameter = ET.SubElement(parameters_element, "Parameter", name="Data", dataType="int") + parameter.text = str(data) + parameter = ET.SubElement(parameters_element, "Parameter", name="ActionID", dataType="int") + parameter.text = str(action_id) + parameter = ET.SubElement(parameters_element, "Parameter", name="StartTimeHours", dataType="int") + parameter.text = str(start_time_hours) + parameter = ET.SubElement(parameters_element, "Parameter", name="StartTimeMinutes", dataType="int") + parameter.text = str(start_time_minutes) + parameter = ET.SubElement(parameters_element, "Parameter", name="EndTimeHours", dataType="int") + parameter.text = str(end_time_hours) + parameter = ET.SubElement(parameters_element, "Parameter", name="EndTimeMinutes", dataType="int") + parameter.text = str(end_time_minutes) + parameter = ET.SubElement(parameters_element, "Parameter", name="DaysActive", dataType="int") + parameter.text = str(days_active) + parameter = ET.SubElement(parameters_element, "Parameter", name="IsEnabled", dataType="bool") + parameter.text = str(int(is_enabled)) + parameter = ET.SubElement(parameters_element, "Parameter", name="Recurring", dataType="bool") + parameter.text = str(int(recurring)) + + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) + + return await self.async_send_message(MessageType.EDIT_SCHEDULE, req_body, False) diff --git a/pyomnilogic_local/api/constants.py b/pyomnilogic_local/api/constants.py new file mode 100644 index 0000000..7d0c76e --- /dev/null +++ b/pyomnilogic_local/api/constants.py @@ -0,0 +1,35 @@ +"""Constants for the OmniLogic API.""" + +from __future__ import annotations + +# Protocol Configuration +PROTOCOL_HEADER_SIZE = 24 # Size of the message header in bytes +PROTOCOL_HEADER_FORMAT = "!LQ4sLBBBB" # struct format for header +PROTOCOL_VERSION = "1.19" # Current protocol version + +# Block Message Constants +BLOCK_MESSAGE_HEADER_OFFSET = 8 # Offset to skip block message header and get to payload + +# Timing Constants (in seconds) +OMNI_RETRANSMIT_TIME = 2.1 # Time Omni waits before retransmitting a packet +OMNI_RETRANSMIT_COUNT = 5 # Number of retransmit attempts (6 total including initial) +ACK_WAIT_TIMEOUT = 0.5 # Timeout waiting for ACK response +DEFAULT_RESPONSE_TIMEOUT = 5.0 # Default timeout for receiving responses + +# Network Constants +DEFAULT_CONTROLLER_PORT = 10444 # Default UDP port for OmniLogic communication + +# Queue Constants +MAX_QUEUE_SIZE = 100 # Maximum number of messages to queue +MAX_FRAGMENT_WAIT_TIME = 30.0 # Maximum time to wait for all fragments (seconds) + +# Validation Constants +MAX_TEMPERATURE_F = 104 # Maximum temperature in Fahrenheit +MIN_TEMPERATURE_F = 65 # Minimum temperature in Fahrenheit +MAX_SPEED_PERCENT = 100 # Maximum speed percentage +MIN_SPEED_PERCENT = 0 # Minimum speed percentage +MAX_MESSAGE_SIZE = 65507 # Maximum UDP payload size (theoretical) + +# XML Constants +XML_NAMESPACE = "http://nextgen.hayward.com/api" # Namespace for XML messages +XML_ENCODING = "unicode" # Encoding for XML output diff --git a/pyomnilogic_local/api/exceptions.py b/pyomnilogic_local/api/exceptions.py new file mode 100644 index 0000000..5b3b026 --- /dev/null +++ b/pyomnilogic_local/api/exceptions.py @@ -0,0 +1,33 @@ +from __future__ import annotations + + +class OmniLogicError(Exception): + """Base exception for all OmniLogic errors.""" + + +class OmniProtocolError(OmniLogicError): + """Protocol-level errors during communication with the OmniLogic controller.""" + + +class OmniTimeoutError(OmniProtocolError): + """Timeout occurred while waiting for a response from the controller.""" + + +class OmniMessageFormatError(OmniProtocolError): + """Received a malformed or invalid message from the controller.""" + + +class OmniFragmentationError(OmniProtocolError): + """Error occurred during message fragmentation or reassembly.""" + + +class OmniConnectionError(OmniLogicError): + """Network connection error occurred.""" + + +class OmniValidationError(OmniLogicError): + """Invalid parameter or configuration value provided.""" + + +class OmniCommandError(OmniLogicError): + """Error occurred while executing a command on the controller.""" diff --git a/pyomnilogic_local/protocol.py b/pyomnilogic_local/api/protocol.py similarity index 61% rename from pyomnilogic_local/protocol.py rename to pyomnilogic_local/api/protocol.py index 158604a..3e383bc 100644 --- a/pyomnilogic_local/protocol.py +++ b/pyomnilogic_local/api/protocol.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import logging import random @@ -5,29 +7,45 @@ import time import xml.etree.ElementTree as ET import zlib -from typing import Any, cast - -from typing_extensions import Self - -from .exceptions import OmniTimeoutException -from .models.leadmessage import LeadMessage -from .omnitypes import ClientType, MessageType +from typing import Any, Self, cast + +from pyomnilogic_local.models.leadmessage import LeadMessage +from pyomnilogic_local.omnitypes import ClientType, MessageType + +from .constants import ( + ACK_WAIT_TIMEOUT, + BLOCK_MESSAGE_HEADER_OFFSET, + MAX_FRAGMENT_WAIT_TIME, + MAX_QUEUE_SIZE, + OMNI_RETRANSMIT_COUNT, + OMNI_RETRANSMIT_TIME, + PROTOCOL_HEADER_FORMAT, + PROTOCOL_HEADER_SIZE, + PROTOCOL_VERSION, + XML_ENCODING, + XML_NAMESPACE, +) +from .exceptions import ( + OmniFragmentationError, + OmniMessageFormatError, + OmniTimeoutError, +) _LOGGER = logging.getLogger(__name__) class OmniLogicMessage: - """ - Represents a protocol message for communication with the OmniLogic controller. + """A protocol message for communication with the OmniLogic controller. + Handles serialization and deserialization of message headers and payloads. """ - header_format = "!LQ4sLBBBB" + header_format = PROTOCOL_HEADER_FORMAT id: int type: MessageType payload: bytes client_type: ClientType = ClientType.SIMPLE - version: str = "1.19" + version: str = PROTOCOL_VERSION timestamp: int | None = int(time.time()) reserved_1: int = 0 compressed: bool = False @@ -38,10 +56,10 @@ def __init__( msg_id: int, msg_type: MessageType, payload: str | None = None, - version: str = "1.19", + version: str = PROTOCOL_VERSION, ) -> None: - """ - Initialize a new OmniLogicMessage. + """Initialize a new OmniLogicMessage. + Args: msg_id: Unique message identifier. msg_type: Type of message being sent. @@ -59,8 +77,8 @@ def __init__( self.version = version def __bytes__(self) -> bytes: - """ - Serialize the message to bytes for UDP transmission. + """Serialize the message to bytes for UDP transmission. + Returns: Byte representation of the message. """ @@ -78,9 +96,7 @@ def __bytes__(self) -> bytes: return header + self.payload def __repr__(self) -> str: - """ - Return a string representation of the message for debugging. - """ + """Return a string representation of the message for debugging.""" if self.compressed or self.type is MessageType.MSP_BLOCKMESSAGE: return f"ID: {self.id}, Type: {self.type.name}, Compressed: {self.compressed}, Client: {self.client_type.name}" return ( @@ -90,21 +106,48 @@ def __repr__(self) -> str: @classmethod def from_bytes(cls, data: bytes) -> Self: - """ - Parse a message from its byte representation. + """Parse a message from its byte representation. + Args: data: Byte data received from the controller. + Returns: OmniLogicMessage instance. + + Raises: + OmniMessageFormatException: If the message format is invalid. """ + if len(data) < PROTOCOL_HEADER_SIZE: + msg = f"Message too short: {len(data)} bytes, expected at least {PROTOCOL_HEADER_SIZE}" + raise OmniMessageFormatError(msg) + # split the header and data - header = data[:24] - rdata: bytes = data[24:] + header = data[:PROTOCOL_HEADER_SIZE] + rdata: bytes = data[PROTOCOL_HEADER_SIZE:] - (msg_id, tstamp, vers, msg_type, client_type, res1, compressed, res2) = struct.unpack(cls.header_format, header) - message = cls(msg_id=msg_id, msg_type=MessageType(msg_type), version=vers.decode("utf-8")) + try: + (msg_id, tstamp, vers, msg_type, client_type, res1, compressed, res2) = struct.unpack(cls.header_format, header) + except struct.error as exc: + msg = f"Failed to unpack message header: {exc}" + raise OmniMessageFormatError(msg) from exc + + # Validate message type + try: + message_type_enum = MessageType(msg_type) + except ValueError as exc: + msg = f"Unknown message type: {msg_type}: {exc}" + raise OmniMessageFormatError(msg) from exc + + # Validate client type + try: + client_type_enum = ClientType(int(client_type)) + except ValueError as exc: + msg = f"Unknown client type: {client_type}: {exc}" + raise OmniMessageFormatError(msg) from exc + + message = cls(msg_id=msg_id, msg_type=message_type_enum, version=vers.decode("utf-8")) message.timestamp = tstamp - message.client_type = ClientType(int(client_type)) + message.client_type = client_type_enum message.reserved_1 = res1 # There are some messages that are ALWAYS compressed although they do not return a 1 in their LeadMessage message.compressed = compressed == 1 or message.type in [MessageType.MSP_TELEMETRY_UPDATE] @@ -115,43 +158,37 @@ def from_bytes(cls, data: bytes) -> Self: class OmniLogicProtocol(asyncio.DatagramProtocol): - """ - Asyncio DatagramProtocol implementation for OmniLogic UDP communication. + """Asyncio DatagramProtocol implementation for OmniLogic UDP communication. + Handles message sending, receiving, retries, and block message reassembly. """ transport: asyncio.DatagramTransport # The omni will re-transmit a packet every 2 seconds if it does not receive an ACK. We pad that just a touch to be safe - _omni_retransmit_time = 2.1 + _omni_retransmit_time = OMNI_RETRANSMIT_TIME # The omni will re-transmit 5 times (a total of 6 attempts including the initial) if it does not receive an ACK - _omni_retransmit_count = 5 + _omni_retransmit_count = OMNI_RETRANSMIT_COUNT data_queue: asyncio.Queue[OmniLogicMessage] error_queue: asyncio.Queue[Exception] def __init__(self) -> None: - """ - Initialize the protocol handler and message queue. - """ - self.data_queue = asyncio.Queue() - self.error_queue = asyncio.Queue() + """Initialize the protocol handler and message queue.""" + self.data_queue = asyncio.Queue(maxsize=MAX_QUEUE_SIZE) + self.error_queue = asyncio.Queue(maxsize=MAX_QUEUE_SIZE) def connection_made(self, transport: asyncio.BaseTransport) -> None: - """ - Called when a UDP connection is made. - """ - self.transport = cast(asyncio.DatagramTransport, transport) + """Called when a UDP connection is made.""" + self.transport = cast("asyncio.DatagramTransport", transport) def connection_lost(self, exc: Exception | None) -> None: - """ - Called when the UDP connection is lost or closed. - """ + """Called when the UDP connection is lost or closed.""" if exc: raise exc def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None: - """ - Called when a datagram is received from the controller. + """Called when a datagram is received from the controller. + Parses the message and puts it on the queue. Handles corrupt or unexpected data gracefully. """ try: @@ -160,21 +197,32 @@ def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None: try: self.data_queue.put_nowait(message) except asyncio.QueueFull: - _LOGGER.error("Data queue is full. Dropping message: %s", str(message)) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.error("Failed to parse incoming datagram from %s: %s", addr, exc, exc_info=True) + _LOGGER.exception("Data queue is full. Dropping message: %s", str(message)) + except OmniMessageFormatError as exc: + _LOGGER.exception("Failed to parse incoming datagram from %s", addr) + self.error_queue.put_nowait(exc) + except Exception as exc: + _LOGGER.exception("Unexpected error processing datagram from %s", addr) + self.error_queue.put_nowait(exc) def error_received(self, exc: Exception) -> None: - """ - Called when a UDP error is received. + """Called when a UDP error is received. + Store the error so it can be handled by awaiting coroutines. """ self.error_queue.put_nowait(exc) async def _wait_for_ack(self, ack_id: int) -> None: - """ - Wait for an ACK message with the given ID. + """Wait for an ACK message with the given ID. + Handles dropped or out-of-order ACKs. + + Args: + ack_id: The message ID to wait for an ACK. + + Raises: + OmniTimeoutException: If no ACK is received. + Exception: If a protocol error occurs. """ # Wait for either an ACK message or an error while True: @@ -186,10 +234,11 @@ async def _wait_for_ack(self, ack_id: int) -> None: exc = error_task.result() if isinstance(exc, Exception): raise exc - _LOGGER.error("Unknown error occurred during communication with Omnilogic: %s", exc) + _LOGGER.error("Unknown error occurred during communication with OmniLogic: %s", exc) if data_task in done: message = data_task.result() if message.id == ack_id: + _LOGGER.debug("Received ACK for message ID %s", ack_id) return _LOGGER.debug("We received a message that is not our ACK, it appears the ACK was dropped") if message.type in {MessageType.MSP_LEADMESSAGE, MessageType.MSP_TELEMETRY_UPDATE}: @@ -202,16 +251,18 @@ async def _ensure_sent( message: OmniLogicMessage, max_attempts: int = 5, ) -> None: - """ - Send a message and ensure it is acknowledged, retrying if necessary. + """Send a message and ensure it is acknowledged, retrying if necessary. + Args: message: The message to send. max_attempts: Maximum number of send attempts. + Raises: OmniTimeoutException: If no ACK is received after retries. """ - for attempt in range(0, max_attempts): + for attempt in range(max_attempts): self.transport.sendto(bytes(message)) + _LOGGER.debug("Sent message ID %s (attempt %d/%d)", message.id, attempt + 1, max_attempts) # If the message that we just sent is an ACK, we do not need to wait to receive an ACK, we are done if message.type in [MessageType.XML_ACK, MessageType.ACK]: @@ -219,8 +270,7 @@ async def _ensure_sent( # Wait for a bit to either receive an ACK for our message, otherwise, we retry delivery try: - await asyncio.wait_for(self._wait_for_ack(message.id), 0.5) - return + await asyncio.wait_for(self._wait_for_ack(message.id), ACK_WAIT_TIMEOUT) except TimeoutError as exc: if attempt < max_attempts - 1: _LOGGER.warning( @@ -231,10 +281,13 @@ async def _ensure_sent( max_attempts, ) else: - _LOGGER.error( + _LOGGER.exception( "Failed to receive ACK for message type %s (ID: %s) after %d attempts.", message.type.name, message.id, max_attempts ) - raise OmniTimeoutException("Failed to receive acknowledgement of command, max retries exceeded") from exc + msg = f"Failed to receive acknowledgement of command, max retries exceeded: {exc}" + raise OmniTimeoutError(msg) from exc + else: + return async def send_and_receive( self, @@ -242,18 +295,18 @@ async def send_and_receive( payload: str | None, msg_id: int | None = None, ) -> str: - """ - Send a message and wait for a response, returning the response payload as a string. + """Send a message and wait for a response, returning the response payload as a string. + Args: msg_type: Type of message to send. payload: Optional payload string. msg_id: Optional message ID. + Returns: Response payload as a string. """ await self.send_message(msg_type, payload, msg_id) - resp = await self._receive_file() - return resp + return await self._receive_file() # Send a message that you do NOT need a response to async def send_message( @@ -262,8 +315,8 @@ async def send_message( payload: str | None, msg_id: int | None = None, ) -> None: - """ - Send a message that does not require a response. + """Send a message that does not require a response. + Args: msg_type: Type of message to send. payload: Optional payload string. @@ -280,24 +333,25 @@ async def send_message( await self._ensure_sent(message) async def _send_ack(self, msg_id: int) -> None: - """ - Send an ACK message for the given message ID. - """ - body_element = ET.Element("Request", {"xmlns": "http://nextgen.hayward.com/api"}) + """Send an ACK message for the given message ID.""" + body_element = ET.Element("Request", {"xmlns": XML_NAMESPACE}) name_element = ET.SubElement(body_element, "Name") name_element.text = "Ack" - req_body = ET.tostring(body_element, xml_declaration=True, encoding="unicode") + req_body = ET.tostring(body_element, xml_declaration=True, encoding=XML_ENCODING) await self.send_message(MessageType.XML_ACK, req_body, msg_id) async def _receive_file(self) -> str: - """ - Wait for and reassemble a full response from the controller. + """Wait for and reassemble a full response from the controller. + Handles single and multi-block (LeadMessage/BlockMessage) responses. + Returns: Response payload as a string. + Raises: OmniTimeoutException: If a block message is not received in time. + OmniFragmentationException: If fragment reassembly fails. """ # wait for the initial packet. message = await self.data_queue.get() @@ -305,50 +359,81 @@ async def _receive_file(self) -> str: # If messages have to be re-transmitted, we can sometimes receive multiple ACKs. The first one would be handled by # self._ensure_sent, but if any subsequent ACKs are sent to us, we need to dump them and wait for a "real" message. while message.type in [MessageType.ACK, MessageType.XML_ACK]: + _LOGGER.debug("Skipping duplicate ACK message") message = await self.data_queue.get() await self._send_ack(message.id) # If the response is too large, the controller will send a LeadMessage indicating how many follow-up messages will be sent if message.type is MessageType.MSP_LEADMESSAGE: - leadmsg = LeadMessage.model_validate(ET.fromstring(message.payload[:-1])) + try: + leadmsg = LeadMessage.model_validate(ET.fromstring(message.payload[:-1])) + except Exception as exc: + msg = f"Failed to parse LeadMessage: {exc}" + raise OmniFragmentationError(msg) from exc - _LOGGER.debug("Will receive %s blockmessages", leadmsg.msg_block_count) + _LOGGER.debug("Will receive %s blockmessages for fragmented response", leadmsg.msg_block_count) # Wait for the block data data retval: bytes = b"" # If we received a LeadMessage, continue to receive messages until we have all of our data # Fragments of data may arrive out of order, so we store them in a buffer as they arrive and sort them after data_fragments: dict[int, bytes] = {} + fragment_start_time = time.time() + while len(data_fragments) < leadmsg.msg_block_count: + # Check if we've been waiting too long for fragments + if time.time() - fragment_start_time > MAX_FRAGMENT_WAIT_TIME: + _LOGGER.error( + "Timeout waiting for fragments: received %d/%d after %ds", + len(data_fragments), + leadmsg.msg_block_count, + MAX_FRAGMENT_WAIT_TIME, + ) + msg = ( + f"Timeout waiting for fragments: received {len(data_fragments)}/{leadmsg.msg_block_count} " + f"after {MAX_FRAGMENT_WAIT_TIME}s" + ) + raise OmniFragmentationError(msg) + # We need to wait long enough for the Omni to get through all of it's retries before we bail out. try: resp = await asyncio.wait_for(self.data_queue.get(), self._omni_retransmit_time * self._omni_retransmit_count) except TimeoutError as exc: - raise OmniTimeoutException from exc + msg = f"Timeout receiving fragment: got {len(data_fragments)}/{leadmsg.msg_block_count} fragments: {exc}" + raise OmniFragmentationError(msg) from exc # We only want to collect blockmessages here if resp.type is not MessageType.MSP_BLOCKMESSAGE: - _LOGGER.debug("Received a message other than a blockmessage: %s", resp.type) + _LOGGER.debug("Received a message other than a blockmessage during fragmentation: %s", resp.type) continue await self._send_ack(resp.id) # remove an 8 byte header to get to the payload data - data_fragments[resp.id] = resp.payload[8:] + data_fragments[resp.id] = resp.payload[BLOCK_MESSAGE_HEADER_OFFSET:] + _LOGGER.debug("Received fragment %d/%d", len(data_fragments), leadmsg.msg_block_count) # Reassemble the fragmets in order for _, data in sorted(data_fragments.items()): retval += data + _LOGGER.debug("Successfully reassembled %d fragments into %d bytes", leadmsg.msg_block_count, len(retval)) + # We did not receive a LeadMessage, so our payload is just this one packet else: retval = message.payload # Decompress the returned data if necessary if message.compressed: - comp_bytes = bytes.fromhex(retval.hex()) - retval = zlib.decompress(comp_bytes) + _LOGGER.debug("Decompressing response payload") + try: + comp_bytes = bytes.fromhex(retval.hex()) + retval = zlib.decompress(comp_bytes) + _LOGGER.debug("Decompressed %d bytes to %d bytes", len(comp_bytes), len(retval)) + except zlib.error as exc: + msg = f"Failed to decompress message: {exc}" + raise OmniMessageFormatError(msg) from exc # For some API calls, the Omni null terminates the response, we are stripping that here to make parsing it later easier return retval.decode("utf-8").strip("\x00") diff --git a/pyomnilogic_local/backyard.py b/pyomnilogic_local/backyard.py new file mode 100644 index 0000000..17d8a10 --- /dev/null +++ b/pyomnilogic_local/backyard.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pyomnilogic_local.collections import EquipmentDict +from pyomnilogic_local.models.mspconfig import MSPBackyard +from pyomnilogic_local.models.telemetry import TelemetryBackyard +from pyomnilogic_local.omnitypes import BackyardState + +from ._base import OmniEquipment +from .bow import Bow +from .colorlogiclight import ColorLogicLight +from .relay import Relay +from .sensor import Sensor + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + +_LOGGER = logging.getLogger(__name__) + + +class Backyard(OmniEquipment[MSPBackyard, TelemetryBackyard]): + """Represents the backyard (top-level equipment container) in the OmniLogic system. + + The Backyard is the root equipment container that holds all pool and spa + equipment. It contains: + - Bodies of Water (BoW): Pools and spas + - Backyard-level lights: Lights not associated with a specific body of water + - Backyard-level relays: Auxiliary equipment, landscape lighting, etc. + - Backyard-level sensors: Air temperature, etc. + + The Backyard also provides system-wide status information including air + temperature, service mode state, and firmware version. + + Attributes: + mspconfig: Configuration data for the backyard + telemetry: Real-time system-wide telemetry + bow: Collection of Bodies of Water (pools/spas) + lights: Collection of backyard-level lights + relays: Collection of backyard-level relays + sensors: Collection of backyard-level sensors + + Properties (Telemetry): + status_version: Telemetry protocol version number + air_temp: Current air temperature (Fahrenheit) + state: System state (ON, OFF, SERVICE_MODE, CONFIG_MODE, etc.) + config_checksum: Configuration checksum (status_version 11+) + msp_version: MSP firmware version string (status_version 11+) + is_service_mode: True if in any service/config mode + + Example: + >>> omni = OmniLogic("192.168.1.100") + >>> await omni.refresh() + >>> + >>> # Access backyard + >>> backyard = omni.backyard + >>> + >>> # Check system status + >>> print(f"Air temp: {backyard.air_temp}°F") + >>> print(f"System state: {backyard.state}") + >>> print(f"Firmware: {backyard.msp_version}") + >>> + >>> # Check service mode + >>> if backyard.is_service_mode: + ... print("System is in service mode - equipment cannot be controlled") + >>> + >>> # Access bodies of water + >>> for bow in backyard.bow: + ... print(f"Body of Water: {bow.name} ({bow.equip_type})") + >>> + >>> # Access backyard equipment + >>> for light in backyard.lights: + ... print(f"Backyard light: {light.name}") + >>> + >>> for relay in backyard.relays: + ... print(f"Backyard relay: {relay.name}") + + Service Mode: + When the backyard is in service mode (SERVICE_MODE, CONFIG_MODE, or + TIMED_SERVICE_MODE), equipment control is disabled. This typically + occurs during: + - System maintenance + - Configuration changes + - Timed service operations + + Always check is_service_mode or is_ready before controlling equipment. + + Note: + - The Backyard is the root of the equipment hierarchy + - All equipment belongs to either the Backyard or a Body of Water + - Service mode blocks all equipment control operations + - Configuration changes require backyard state changes + - Air temperature sensor must be configured for air_temp readings + """ + + mspconfig: MSPBackyard + telemetry: TelemetryBackyard + bow: EquipmentDict[Bow] = EquipmentDict() + lights: EquipmentDict[ColorLogicLight] = EquipmentDict() + relays: EquipmentDict[Relay] = EquipmentDict() + sensors: EquipmentDict[Sensor] = EquipmentDict() + + def __init__(self, omni: OmniLogic, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def status_version(self) -> int: + """Telemetry status version number.""" + return self.telemetry.status_version + + @property + def air_temp(self) -> int | None: + """Current air temperature reading from the backyard sensor. + + Note: Temperature is in Fahrenheit. May be None if sensor is not available. + """ + return self.telemetry.air_temp + + @property + def state(self) -> BackyardState: + """Current backyard state (OFF, ON, SERVICE_MODE, CONFIG_MODE, TIMED_SERVICE_MODE).""" + return self.telemetry.state + + @property + def config_checksum(self) -> int | None: + """Configuration checksum value. + + Note: Only available when status_version >= 11. Returns None otherwise. + """ + return self.telemetry.config_checksum + + @property + def msp_version(self) -> str | None: + """MSP firmware version string. + + Note: Only available when status_version >= 11. Returns None otherwise. + Example: "R0408000" + """ + return self.telemetry.msp_version + + @property + def is_service_mode(self) -> bool: + """Check if the backyard is in any service mode. + + Returns: + True if in SERVICE_MODE, CONFIG_MODE, or TIMED_SERVICE_MODE, False otherwise + """ + return self.state in ( + BackyardState.SERVICE_MODE, + BackyardState.CONFIG_MODE, + BackyardState.TIMED_SERVICE_MODE, + ) + + def _update_equipment(self, mspconfig: MSPBackyard, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + _LOGGER.warning("No telemetry provided to update Backyard equipment.") + return + self._update_bows(mspconfig, telemetry) + self._update_lights(mspconfig, telemetry) + self._update_relays(mspconfig, telemetry) + self._update_sensors(mspconfig, telemetry) + + def _update_bows(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + """Update the bows based on the MSP configuration.""" + if mspconfig.bow is None: + self.bow = EquipmentDict() + return + + self.bow = EquipmentDict([Bow(self._omni, bow, telemetry) for bow in mspconfig.bow]) + + def _update_lights(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + """Update the lights based on the MSP configuration.""" + if mspconfig.colorlogic_light is None: + self.lights = EquipmentDict() + return + + self.lights = EquipmentDict([ColorLogicLight(self._omni, light, telemetry) for light in mspconfig.colorlogic_light]) + + def _update_relays(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + """Update the relays based on the MSP configuration.""" + if mspconfig.relay is None: + self.relays = EquipmentDict() + return + + self.relays = EquipmentDict([Relay(self._omni, relay, telemetry) for relay in mspconfig.relay]) + + def _update_sensors(self, mspconfig: MSPBackyard, telemetry: Telemetry) -> None: + """Update the sensors based on the MSP configuration.""" + if mspconfig.sensor is None: + self.sensors = EquipmentDict() + return + + self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) diff --git a/pyomnilogic_local/bow.py b/pyomnilogic_local/bow.py new file mode 100644 index 0000000..e095f38 --- /dev/null +++ b/pyomnilogic_local/bow.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.chlorinator import Chlorinator +from pyomnilogic_local.collections import EquipmentDict +from pyomnilogic_local.colorlogiclight import ColorLogicLight +from pyomnilogic_local.csad import CSAD +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.filter import Filter +from pyomnilogic_local.heater import Heater +from pyomnilogic_local.models.mspconfig import MSPBoW +from pyomnilogic_local.models.telemetry import TelemetryBoW +from pyomnilogic_local.pump import Pump +from pyomnilogic_local.relay import Relay +from pyomnilogic_local.sensor import Sensor +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import BodyOfWaterType + +_LOGGER = logging.getLogger(__name__) + + +class Bow(OmniEquipment[MSPBoW, TelemetryBoW]): + """Represents a Body of Water (BoW) - pool or spa - in the OmniLogic system. + + A Body of Water (commonly abbreviated as BoW) is a pool or spa, along with + all of its associated equipment. Each BoW contains: + - Filtration pumps + - Heating equipment + - Chlorination/sanitization systems + - Chemistry monitoring (CSAD) + - Lighting + - Auxiliary pumps (water features, etc.) + - Relays (jets, blowers, etc.) + - Sensors (water temperature, flow, etc.) + + The Bow class provides access to all equipment associated with a specific + pool or spa, as well as water temperature monitoring and spillover control + for pool/spa combination systems. + + Attributes: + mspconfig: Configuration data for this body of water + telemetry: Real-time operational data + filters: Collection of filtration pumps + heater: Virtual heater (if configured) + relays: Collection of relays (jets, blowers, aux equipment) + sensors: Collection of sensors (water temp, flow, etc.) + lights: Collection of ColorLogic lights + pumps: Collection of pumps (water features, etc.) + chlorinator: Chlorinator system (if configured) + csads: Collection of CSAD (chemistry) systems + + Properties (Configuration): + equip_type: Body of water type (BOW_POOL or BOW_SPA) + supports_spillover: Whether spillover is available + + Properties (Telemetry): + water_temp: Current water temperature (Fahrenheit) + flow: True if flow is detected, False otherwise + + Control Methods: + set_spillover(speed): Set spillover pump speed (0-100%) + turn_on_spillover(): Turn on spillover at maximum speed + turn_off_spillover(): Turn off spillover + + Example: + >>> omni = OmniLogic("192.168.1.100") + >>> await omni.refresh() + >>> + >>> # Access pool + >>> pool = omni.backyard.bow["Pool"] + >>> print(f"Water temp: {pool.water_temp}°F") + >>> print(f"Flow detected: {pool.flow > 0}") + >>> + >>> # Access pool equipment + >>> if pool.heater: + ... await pool.heater.set_temperature(85) + >>> + >>> if pool.chlorinator: + ... print(f"Salt level: {pool.chlorinator.avg_salt_level} ppm") + >>> + >>> for filter in pool.filters: + ... print(f"Filter: {filter.name}, Speed: {filter.speed}%") + >>> + >>> for light in pool.lights: + ... await light.set_show(ColorLogicShow25.TROPICAL) + >>> + >>> # Spillover control (pool/spa combo systems) + >>> if pool.supports_spillover: + ... await pool.turn_on_spillover() + ... await pool.set_spillover(75) # 75% speed + ... await pool.turn_off_spillover() + + Pool vs Spa: + Bodies of water can be either pools or spas, distinguished by the + equip_type property: + + >>> if pool.equip_type == BodyOfWaterType.POOL: + ... print("This is a pool") + >>> elif pool.equip_type == BodyOfWaterType.SPA: + ... print("This is a spa") + + Spillover Systems: + Some installations have combined pool/spa systems with spillover + capability that allows water to flow from spa to pool or vice versa: + + - supports_spillover indicates if the feature is available + - Spillover is controlled by a dedicated pump + - Speed range is 0-100% (0 turns spillover off) + - Convenience methods simplify on/off operations + + Equipment Collections: + Equipment is stored in EquipmentDict collections which allow access by: + - Name (string): pool.filters["Main Filter"] + - System ID (int): pool.filters[123] + - Index (int): pool.filters[0] + - Iteration: for filter in pool.filters: ... + + Note: + - Water temperature returns -1 if sensor not available + - Flow telemetry typically reads 255 or 1 for flow, 0 for no flow, we simplify to bool + - Not all bodies of water have all equipment types + - Some equipment (heater, chlorinator) may be None if not configured + - Spillover operations raise ValueError if not supported + """ + + mspconfig: MSPBoW + telemetry: TelemetryBoW + filters: EquipmentDict[Filter] = EquipmentDict() + heater: Heater | None = None + relays: EquipmentDict[Relay] = EquipmentDict() + sensors: EquipmentDict[Sensor] = EquipmentDict() + lights: EquipmentDict[ColorLogicLight] = EquipmentDict() + pumps: EquipmentDict[Pump] = EquipmentDict() + chlorinator: Chlorinator | None = None + csads: EquipmentDict[CSAD] = EquipmentDict() + + def __init__(self, omni: OmniLogic, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + def __repr__(self) -> str: + """Return a string representation of the Bow for debugging. + + Returns: + A string showing the class name, system_id, name, type, and equipment counts. + """ + parts = [f"system_id={self.system_id!r}", f"name={self.name!r}", f"type={self.equip_type!r}"] + + # Add equipment counts + parts.append(f"filters={len(self.filters)}") + parts.append(f"pumps={len(self.pumps)}") + parts.append(f"lights={len(self.lights)}") + parts.append(f"relays={len(self.relays)}") + parts.append(f"sensors={len(self.sensors)}") + + # Add heater and chlorinator status (present or not) + if self.heater is not None: + parts.append("heater=True") + if self.chlorinator is not None: + parts.append("chlorinator=True") + if len(self.csads) > 0: + parts.append(f"csads={len(self.csads)}") + + return f"Bow({', '.join(parts)})" + + @property + def equip_type(self) -> BodyOfWaterType | str: + """The equipment type of the bow (POOL or SPA).""" + return self.mspconfig.equip_type + + @property + def supports_spillover(self) -> bool: + """Whether this body of water supports spillover functionality.""" + return self.mspconfig.supports_spillover + + @property + def water_temp(self) -> int: + """Current water temperature reading from the bow sensor. + + Note: Temperature is in Fahrenheit. Returns -1 if sensor is not available. + """ + return self.telemetry.water_temp + + @property + def flow(self) -> bool: + """Current flow sensor reading. + + Returns: + bool: True if flow is present, False otherwise. + """ + # Flow values: + # 255 seems to indicate "assumed flow", for example, because a filter pump is on + # 1 seems to indicate "certain flow", for example, when there is an actual flow sensor + # 0 indicates no flow + return self.telemetry.flow > 0 + + # Control methods + @control_method + async def set_spillover(self, speed: int) -> None: + """Set the spillover speed for this body of water. + + Spillover allows water to flow between pool and spa. This method sets + the speed at which the spillover pump operates. + + Args: + speed: Spillover speed value (0-100 percent). A value of 0 will turn spillover off. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + ValueError: If spillover is not supported by this body of water. + """ + if self.system_id is None: + msg = "Bow system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + if not self.supports_spillover: + msg = f"Spillover is not supported by {self.name}" + raise ValueError(msg) + + await self._api.async_set_spillover( + pool_id=self.system_id, + speed=speed, + ) + + @control_method + async def turn_on_spillover(self) -> None: + """Turn on spillover at maximum speed (100%). + + This is a convenience method that calls set_spillover(100). + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + ValueError: If spillover is not supported by this body of water. + """ + await self.set_spillover(100) + + @control_method + async def turn_off_spillover(self) -> None: + """Turn off spillover. + + This is a convenience method that calls set_spillover(0). + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + ValueError: If spillover is not supported by this body of water. + """ + await self.set_spillover(0) + + def _update_equipment(self, mspconfig: MSPBoW, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + _LOGGER.warning("No telemetry provided to update Bow equipment.") + return + self._update_chlorinators(mspconfig, telemetry) + self._update_csads(mspconfig, telemetry) + self._update_filters(mspconfig, telemetry) + self._update_heater(mspconfig, telemetry) + self._update_lights(mspconfig, telemetry) + self._update_pumps(mspconfig, telemetry) + self._update_relays(mspconfig, telemetry) + self._update_sensors(mspconfig, telemetry) + + def _update_chlorinators(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the chlorinators based on the MSP configuration.""" + if mspconfig.chlorinator is None: + self.chlorinator = None + return + + self.chlorinator = Chlorinator(self._omni, mspconfig.chlorinator, telemetry) + + def _update_csads(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the CSADs based on the MSP configuration.""" + if mspconfig.csad is None: + self.csads = EquipmentDict() + return + + self.csads = EquipmentDict([CSAD(self._omni, csad, telemetry) for csad in mspconfig.csad]) + + def _update_filters(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the filters based on the MSP configuration.""" + if mspconfig.filter is None: + self.filters = EquipmentDict() + return + + self.filters = EquipmentDict([Filter(self._omni, filter_, telemetry) for filter_ in mspconfig.filter]) + + def _update_heater(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the heater based on the MSP configuration.""" + if mspconfig.heater is None: + self.heater = None + return + + self.heater = Heater(self._omni, mspconfig.heater, telemetry) + + def _update_lights(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the lights based on the MSP configuration.""" + if mspconfig.colorlogic_light is None: + self.lights = EquipmentDict() + return + + self.lights = EquipmentDict([ColorLogicLight(self._omni, light, telemetry) for light in mspconfig.colorlogic_light]) + + def _update_pumps(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the pumps based on the MSP configuration.""" + if mspconfig.pump is None: + self.pumps = EquipmentDict() + return + + self.pumps = EquipmentDict([Pump(self._omni, pump, telemetry) for pump in mspconfig.pump]) + + def _update_relays(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the relays based on the MSP configuration.""" + if mspconfig.relay is None: + self.relays = EquipmentDict() + return + + self.relays = EquipmentDict([Relay(self._omni, relay, telemetry) for relay in mspconfig.relay]) + + def _update_sensors(self, mspconfig: MSPBoW, telemetry: Telemetry) -> None: + """Update the sensors based on the MSP configuration.""" + if mspconfig.sensor is None: + self.sensors = EquipmentDict() + return + + self.sensors = EquipmentDict([Sensor(self._omni, sensor, telemetry) for sensor in mspconfig.sensor]) diff --git a/pyomnilogic_local/chlorinator.py b/pyomnilogic_local/chlorinator.py new file mode 100644 index 0000000..83e137c --- /dev/null +++ b/pyomnilogic_local/chlorinator.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.chlorinator_equip import ChlorinatorEquipment +from pyomnilogic_local.collections import EquipmentDict +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.models.mspconfig import MSPChlorinator +from pyomnilogic_local.models.telemetry import TelemetryChlorinator +from pyomnilogic_local.omnitypes import ChlorinatorStatus +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import ChlorinatorCellType, ChlorinatorOperatingMode + + +class Chlorinator(OmniEquipment[MSPChlorinator, TelemetryChlorinator]): + """Represents a chlorinator in the OmniLogic system. + + A chlorinator is responsible for generating chlorine through electrolysis + (for salt-based systems) or dispensing chlorine (for liquid/tablet systems). + It monitors and reports salt levels, chlorine generation status, and various + alerts and errors. + + Attributes: + mspconfig: The MSP configuration for this chlorinator + telemetry: Real-time telemetry data for this chlorinator + chlorinator_equipment: Collection of physical chlorinator equipment units + + Example: + >>> chlorinator = pool.get_chlorinator() + >>> print(f"Salt level: {chlorinator.avg_salt_level} ppm") + >>> print(f"Is generating: {chlorinator.is_generating}") + >>> if chlorinator.has_alert: + ... print(f"Alerts: {chlorinator.alert_messages}") + """ + + mspconfig: MSPChlorinator + telemetry: TelemetryChlorinator + chlorinator_equipment: EquipmentDict[ChlorinatorEquipment] = EquipmentDict() + + def __init__(self, omni: OmniLogic, mspconfig: MSPChlorinator, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + def _update_equipment(self, mspconfig: MSPChlorinator, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + return + self._update_chlorinator_equipment(mspconfig, telemetry) + + def _update_chlorinator_equipment(self, mspconfig: MSPChlorinator, telemetry: Telemetry) -> None: + """Update the chlorinator equipment based on the MSP configuration.""" + if mspconfig.chlorinator_equipment is None: + self.chlorinator_equipment = EquipmentDict() + return + + self.chlorinator_equipment = EquipmentDict( + [ChlorinatorEquipment(self._omni, equip, telemetry) for equip in mspconfig.chlorinator_equipment] + ) + + # Expose MSPConfig attributes + @property + def enabled(self) -> bool: + """Whether the chlorinator is enabled in the system configuration.""" + return self.mspconfig.enabled + + @property + def timed_percent(self) -> int: + """Configured chlorine generation percentage when in timed mode (0-100%).""" + return self.mspconfig.timed_percent + + @property + def superchlor_timeout(self) -> int: + """Timeout duration for super-chlorination mode in minutes.""" + return self.mspconfig.superchlor_timeout + + @property + def orp_timeout(self) -> int: + """Timeout duration for ORP (Oxidation-Reduction Potential) mode in minutes.""" + return self.mspconfig.orp_timeout + + @property + def dispenser_type(self) -> str: + """Type of chlorine dispenser (SALT, LIQUID, or TABLET).""" + return self.mspconfig.dispenser_type + + @property + def cell_type(self) -> ChlorinatorCellType: + """Type of T-Cell installed (e.g., T3, T5, T9, T15).""" + return self.mspconfig.cell_type + + # Expose Telemetry attributes + @property + def operating_state(self) -> int: + """Current operational state of the chlorinator (raw value).""" + return self.telemetry.operating_state + + @property + def operating_mode(self) -> ChlorinatorOperatingMode | int: + """Current operating mode (DISABLED, TIMED, ORP_AUTO, or ORP_TIMED_RW). + + Returns: + ChlorinatorOperatingMode: The operating mode enum value + """ + return self.telemetry.operating_mode + + @property + def timed_percent_telemetry(self) -> int | None: + """Current chlorine generation percentage from telemetry (0-100%). + + This may differ from the configured timed_percent if the system + is in a special mode (e.g., super-chlorination). + + Returns: + Current generation percentage, or None if not available + """ + return self.telemetry.timed_percent + + @property + def sc_mode(self) -> int: + """Super-chlorination mode status (raw value).""" + return self.telemetry.sc_mode + + @property + def avg_salt_level(self) -> int: + """Average salt level reading in parts per million (ppm). + + This is a smoothed reading over time, useful for monitoring + long-term salt levels. + """ + return self.telemetry.avg_salt_level + + @property + def instant_salt_level(self) -> int: + """Instantaneous salt level reading in parts per million (ppm). + + This is the current salt level reading, which may fluctuate + more than the average salt level. + """ + return self.telemetry.instant_salt_level + + # Computed properties for status, alerts, and errors + @property + def status(self) -> list[str]: + """List of active status flags as human-readable strings. + + Decodes the status bitmask into individual flag names. + Possible values include: + - ERROR_PRESENT: An error condition exists (check error_messages) + - ALERT_PRESENT: An alert condition exists (check alert_messages) + - GENERATING: Power is applied to T-Cell, actively chlorinating + - SYSTEM_PAUSED: System processor is pausing chlorination + - LOCAL_PAUSED: Local processor is pausing chlorination + - AUTHENTICATED: T-Cell is authenticated and recognized + - K1_ACTIVE: K1 relay is active + - K2_ACTIVE: K2 relay is active + + Returns: + List of active status flag names + + Example: + >>> chlorinator.status + ['GENERATING', 'AUTHENTICATED', 'K1_ACTIVE'] + """ + return self.telemetry.status + + @property + def alert_messages(self) -> list[str]: + """List of active alert conditions as human-readable strings. + + Decodes the alert bitmask into individual alert names. + Possible values include: + - SALT_LOW: Salt level is low (add salt soon) + - SALT_TOO_LOW: Salt level is too low (add salt now) + - HIGH_CURRENT: High current alert + - LOW_VOLTAGE: Low voltage alert + - CELL_TEMP_LOW: Cell water temperature is low + - CELL_TEMP_SCALEBACK: Cell water temperature scaleback + - CELL_TEMP_HIGH: Cell water temperature is high (bits 4+5 both set) + - BOARD_TEMP_HIGH: Board temperature is high + - BOARD_TEMP_CLEARING: Board temperature is clearing + - CELL_CLEAN: Cell cleaning/runtime alert + + Returns: + List of active alert names + + Example: + >>> chlorinator.alert_messages + ['SALT_LOW', 'CELL_CLEAN'] + """ + return self.telemetry.alerts + + @property + def error_messages(self) -> list[str]: + """List of active error conditions as human-readable strings. + + Decodes the error bitmask into individual error names. + Possible values include: + - CURRENT_SENSOR_SHORT: Current sensor short circuit + - CURRENT_SENSOR_OPEN: Current sensor open circuit + - VOLTAGE_SENSOR_SHORT: Voltage sensor short circuit + - VOLTAGE_SENSOR_OPEN: Voltage sensor open circuit + - CELL_TEMP_SENSOR_SHORT: Cell temperature sensor short + - CELL_TEMP_SENSOR_OPEN: Cell temperature sensor open + - BOARD_TEMP_SENSOR_SHORT: Board temperature sensor short + - BOARD_TEMP_SENSOR_OPEN: Board temperature sensor open + - K1_RELAY_SHORT: K1 relay short circuit + - K1_RELAY_OPEN: K1 relay open circuit + - K2_RELAY_SHORT: K2 relay short circuit + - K2_RELAY_OPEN: K2 relay open circuit + - CELL_ERROR_TYPE: Cell type error + - CELL_ERROR_AUTH: Cell authentication error + - CELL_COMM_LOSS: Cell communication loss (bits 12+13 both set) + - AQUARITE_PCB_ERROR: AquaRite PCB error + + Returns: + List of active error names + + Example: + >>> chlorinator.error_messages + ['CURRENT_SENSOR_SHORT', 'K1_RELAY_OPEN'] + """ + return self.telemetry.errors + + # High-level status properties + @property + def is_on(self) -> bool: + """Check if the chlorinator is currently enabled and operational. + + A chlorinator is considered "on" if it is enabled in the configuration, + regardless of whether it is actively generating chlorine at this moment. + + Returns: + True if the chlorinator is enabled, False otherwise + + See Also: + is_generating: Check if actively producing chlorine right now + """ + return self.enabled and self.telemetry.enable + + @property + def is_generating(self) -> bool: + """Check if the chlorinator is actively generating chlorine. + + This indicates that power is currently applied to the T-Cell and + chlorine is being produced through electrolysis. + + Returns: + True if the GENERATING status flag is set, False otherwise + + Example: + >>> if chlorinator.is_generating: + ... print(f"Generating at {chlorinator.timed_percent_telemetry}%") + """ + return self.telemetry.active + + @property + def is_paused(self) -> bool: + """Check if chlorination is currently paused. + + Chlorination can be paused by either the system processor or the + local processor for various reasons (e.g., low flow, maintenance). + + Returns: + True if either SYSTEM_PAUSED or LOCAL_PAUSED flags are set + + Example: + >>> if chlorinator.is_paused: + ... print("Chlorination is paused") + """ + return bool( + (ChlorinatorStatus.SYSTEM_PAUSED.value & self.telemetry.status_raw) + or (ChlorinatorStatus.LOCAL_PAUSED.value & self.telemetry.status_raw) + ) + + @property + def has_alert(self) -> bool: + """Check if any alert conditions are present. + + Returns: + True if the ALERT_PRESENT status flag is set, False otherwise + + See Also: + alert_messages: Get the list of specific alert conditions + """ + return ChlorinatorStatus.ALERT_PRESENT.value & self.telemetry.status_raw == ChlorinatorStatus.ALERT_PRESENT.value + + @property + def has_error(self) -> bool: + """Check if any error conditions are present. + + Returns: + True if the ERROR_PRESENT status flag is set, False otherwise + + See Also: + error_messages: Get the list of specific error conditions + """ + return ChlorinatorStatus.ERROR_PRESENT.value & self.telemetry.status_raw == ChlorinatorStatus.ERROR_PRESENT.value + + @property + def is_authenticated(self) -> bool: + """Check if the T-Cell is authenticated. + + An authenticated T-Cell is recognized by the system and can generate + chlorine. Unauthenticated cells may be counterfeit or damaged. + + Returns: + True if the AUTHENTICATED status flag is set, False otherwise + """ + return ChlorinatorStatus.AUTHENTICATED.value & self.telemetry.status_raw == ChlorinatorStatus.AUTHENTICATED.value + + @property + def salt_level_status(self) -> str: + """Get a human-readable status of the salt level. + + Returns: + 'OK' if salt level is adequate + 'LOW' if salt is low (add salt soon) + 'TOO_LOW' if salt is too low (add salt now) + + Example: + >>> status = chlorinator.salt_level_status + >>> if status != 'OK': + ... print(f"Salt level is {status}: {chlorinator.avg_salt_level} ppm") + """ + alerts = self.alert_messages + if "SALT_TOO_LOW" in alerts: + return "TOO_LOW" + if "SALT_LOW" in alerts: + return "LOW" + return "OK" + + @property + def is_ready(self) -> bool: + """Check if the chlorinator is ready to accept commands. + + A chlorinator is considered ready if: + - The backyard is not in service/config mode (checked by parent class) + - It is authenticated + - It has no critical errors that would prevent it from operating + + Returns: + True if chlorinator can accept commands, False otherwise + + Example: + >>> if chlorinator.is_ready: + ... await chlorinator.set_chlorine_level(75) + """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check chlorinator-specific readiness + return self.is_authenticated and not self.has_error + + # Control methods + @control_method + async def turn_on(self) -> None: + """Turn the chlorinator on (enable it). + + Raises: + OmniEquipmentNotInitializedError: If bow_id is None. + """ + if self.bow_id is None: + msg = "Cannot turn on chlorinator: bow_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_chlorinator_enable(self.bow_id, True) + + @control_method + async def turn_off(self) -> None: + """Turn the chlorinator off (disable it). + + Raises: + OmniEquipmentNotInitializedError: If bow_id is None. + """ + if self.bow_id is None: + msg = "Cannot turn off chlorinator: bow_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_chlorinator_enable(self.bow_id, False) + + @control_method + async def set_timed_percent(self, percent: int) -> None: + """Set the timed percent for chlorine generation. + + Args: + percent: The chlorine generation percentage (0-100) + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If percent is outside the valid range (0-100). + + Note: + This method uses the async_set_chlorinator_params API which requires + all chlorinator configuration parameters. The current values from + mspconfig are used for unchanged parameters. + """ + if self.bow_id is None or self.system_id is None: + msg = "Cannot set timed percent: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + + if not 0 <= percent <= 100: + msg = f"Timed percent {percent} is outside valid range [0, 100]" + raise ValueError(msg) + + # Get the parent Bow to determine bow_type + # We need to find our bow in the backyard + if (bow := self._omni.backyard.bow.get(self.bow_id)) is None: + msg = f"Cannot find bow with id {self.bow_id}" + raise OmniEquipmentNotInitializedError(msg) + + # Map equipment type to numeric bow_type value + # BOW_POOL = 0, BOW_SPA = 1 (based on typical protocol values) + bow_type = 0 if bow.equip_type == "BOW_POOL" else 1 + + # Get operating mode from telemetry (it's already an int or enum with .value) + op_mode = self.telemetry.operating_mode if isinstance(self.telemetry.operating_mode, int) else self.telemetry.operating_mode.value + + await self._api.async_set_chlorinator_params( + pool_id=self.bow_id, + equipment_id=self.system_id, + timed_percent=percent, + cell_type=self.mspconfig.cell_type.value, # ChlorinatorCellType is now IntEnum, use .value + op_mode=op_mode, + sc_timeout=self.mspconfig.superchlor_timeout, + bow_type=bow_type, + orp_timeout=self.mspconfig.orp_timeout, + ) diff --git a/pyomnilogic_local/chlorinator_equip.py b/pyomnilogic_local/chlorinator_equip.py new file mode 100644 index 0000000..2473dc0 --- /dev/null +++ b/pyomnilogic_local/chlorinator_equip.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPChlorinatorEquip + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import ChlorinatorType + + +class ChlorinatorEquipment(OmniEquipment[MSPChlorinatorEquip, None]): + """Represents physical chlorinator equipment in the OmniLogic system. + + ChlorinatorEquipment represents an individual physical chlorinator device + (salt cell, liquid dispenser, tablet feeder, etc.). It is controlled by a + parent Chlorinator which manages one or more physical chlorinator units. + + The OmniLogic system uses a parent/child chlorinator architecture: + - Chlorinator: User-facing chlorinator control (turn_on, set_timed_percent, etc.) + - ChlorinatorEquipment: Individual physical chlorination devices managed by the parent + + This architecture allows the system to coordinate multiple chlorination sources + under a single chlorinator interface. + + Chlorinator Equipment Types: + - MAIN_PANEL: Main panel chlorinator + - DISPENSER: Chemical dispenser + - AQUA_RITE: AquaRite chlorinator system + + Note: Unlike heater equipment, chlorinator equipment does not have separate + telemetry entries. All telemetry is reported through the parent Chlorinator. + + Attributes: + mspconfig: Configuration data for this physical chlorinator equipment + + Properties (Configuration): + equip_type: Equipment type (always "PET_CHLORINATOR") + chlorinator_type: Type of chlorinator (MAIN_PANEL, DISPENSER, AQUA_RITE) + enabled: Whether this chlorinator equipment is enabled + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> chlorinator = pool.chlorinator + >>> + >>> # Access physical chlorinator equipment + >>> for equip in chlorinator.chlorinator_equipment: + ... print(f"Chlorinator Equipment: {equip.name}") + ... print(f"Type: {equip.chlorinator_type}") + ... print(f"Enabled: {equip.enabled}") + ... print(f"System ID: {equip.system_id}") + + Important Notes: + - ChlorinatorEquipment is read-only (no direct control methods) + - Control chlorinator equipment through the parent Chlorinator instance + - Multiple chlorinator equipment can work together + - Telemetry is accessed through the parent Chlorinator, not individual equipment + - Equipment may be disabled but still configured in the system + """ + + mspconfig: MSPChlorinatorEquip + telemetry: None + + def __init__(self, omni: OmniLogic, mspconfig: MSPChlorinatorEquip, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def equip_type(self) -> Literal["PET_CHLORINATOR"]: + """Returns the equipment type (always 'PET_CHLORINATOR').""" + return self.mspconfig.equip_type + + @property + def chlorinator_type(self) -> ChlorinatorType: + """Returns the type of chlorinator (MAIN_PANEL, DISPENSER, or AQUA_RITE).""" + return self.mspconfig.chlorinator_type + + @property + def enabled(self) -> bool: + """Returns whether the chlorinator equipment is enabled in configuration.""" + return self.mspconfig.enabled diff --git a/pyomnilogic_local/cli/__init__.py b/pyomnilogic_local/cli/__init__.py index e07f453..7831644 100644 --- a/pyomnilogic_local/cli/__init__.py +++ b/pyomnilogic_local/cli/__init__.py @@ -4,6 +4,8 @@ OmniLogic and OmniHub pool controllers. """ +from __future__ import annotations + from pyomnilogic_local.cli.utils import ensure_connection __all__ = ["ensure_connection"] diff --git a/pyomnilogic_local/cli/cli.py b/pyomnilogic_local/cli/cli.py index 164e683..0357d40 100644 --- a/pyomnilogic_local/cli/cli.py +++ b/pyomnilogic_local/cli/cli.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import click from pyomnilogic_local.cli.debug import commands as debug from pyomnilogic_local.cli.get import commands as get -@click.group(invoke_without_command=True) +@click.group() @click.pass_context @click.option("--host", default="127.0.0.1", help="Hostname or IP address of OmniLogic system (default: 127.0.0.1)") def entrypoint(ctx: click.Context, host: str) -> None: diff --git a/pyomnilogic_local/cli/debug/__init__.py b/pyomnilogic_local/cli/debug/__init__.py index e69de29..099dadf 100644 --- a/pyomnilogic_local/cli/debug/__init__.py +++ b/pyomnilogic_local/cli/debug/__init__.py @@ -0,0 +1 @@ +"""CLI commands for debugging the Hayward OmniLogic Local API.""" diff --git a/pyomnilogic_local/cli/debug/commands.py b/pyomnilogic_local/cli/debug/commands.py index 9266c0d..56c65b1 100644 --- a/pyomnilogic_local/cli/debug/commands.py +++ b/pyomnilogic_local/cli/debug/commands.py @@ -1,15 +1,21 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" + +from __future__ import annotations + import asyncio from pathlib import Path +from typing import TYPE_CHECKING import click -from pyomnilogic_local.api import OmniLogicAPI from pyomnilogic_local.cli import ensure_connection from pyomnilogic_local.cli.pcap_utils import parse_pcap_file, process_pcap_messages from pyomnilogic_local.cli.utils import async_get_filter_diagnostics +if TYPE_CHECKING: + from pyomnilogic_local.api.api import OmniLogicAPI + @click.group() @click.option("--raw/--no-raw", default=False, help="Output the raw XML from the OmniLogic, do not parse the response") @@ -36,10 +42,11 @@ def get_mspconfig(ctx: click.Context) -> None: Example: omnilogic debug get-mspconfig omnilogic debug --raw get-mspconfig + """ ensure_connection(ctx) omni: OmniLogicAPI = ctx.obj["OMNI"] - mspconfig = asyncio.run(omni.async_get_config(raw=ctx.obj["RAW"])) + mspconfig = asyncio.run(omni.async_get_mspconfig(raw=ctx.obj["RAW"])) click.echo(mspconfig) @@ -54,6 +61,7 @@ def get_telemetry(ctx: click.Context) -> None: Example: omnilogic debug get-telemetry omnilogic debug --raw get-telemetry + """ ensure_connection(ctx) omni: OmniLogicAPI = ctx.obj["OMNI"] @@ -75,6 +83,7 @@ def get_filter_diagnostics(ctx: click.Context, pool_id: int, filter_id: int) -> Example: omnilogic debug get-filter-diagnostics --pool-id 1 --filter-id 5 + """ ensure_connection(ctx) filter_diags = asyncio.run(async_get_filter_diagnostics(ctx.obj["OMNI"], pool_id, filter_id, ctx.obj["RAW"])) @@ -102,9 +111,8 @@ def get_filter_diagnostics(ctx: click.Context, pool_id: int, filter_id: int) -> @debug.command() @click.argument("pcap_file", type=click.Path(exists=True, path_type=Path)) -@click.pass_context -def parse_pcap(ctx: click.Context, pcap_file: Path) -> None: - """Parse a PCAP file and reconstruct Omnilogic protocol communication. +def parse_pcap(pcap_file: Path) -> None: + """Parse a PCAP file and reconstruct OmniLogic protocol communication. Analyzes network packet captures to decode OmniLogic protocol messages. Automatically reassembles multi-part messages (LeadMessage + BlockMessages) @@ -117,13 +125,14 @@ def parse_pcap(ctx: click.Context, pcap_file: Path) -> None: omnilogic debug parse-pcap /path/to/capture.pcap tcpdump -i eth0 -w pool.pcap udp port 10444 omnilogic debug parse-pcap pool.pcap + """ # Read the PCAP file try: packets = parse_pcap_file(str(pcap_file)) except Exception as e: click.echo(f"Error reading PCAP file: {e}", err=True) - raise click.Abort() + raise click.Abort from e # Process all packets and extract OmniLogic messages results = process_pcap_messages(packets) @@ -136,3 +145,67 @@ def parse_pcap(ctx: click.Context, pcap_file: Path) -> None: click.echo("Decoded message content:") click.echo(decoded_content) click.echo() # Extra newline for readability + + +@debug.command() +@click.argument("bow_id", type=int) +@click.argument("equip_id", type=int) +@click.argument("is_on") +@click.pass_context +def set_equipment(ctx: click.Context, bow_id: int, equip_id: int, is_on: str) -> None: + """Control equipment by turning it on/off or setting a value. + + BOW_ID: The Body of Water (pool/spa) system ID + EQUIP_ID: The equipment system ID to control + IS_ON: Equipment state - can be: + - Boolean: true/false, on/off, 1/0 + - Integer: 0-100 for variable speed equipment (0=off, 1-100=speed percentage) + + For most equipment (relays, lights), use true/false or 1/0. + For variable speed pumps/filters, use 0-100 to set speed percentage. + + Examples: + # Turn on a relay + omnilogic --host 192.168.1.100 debug set-equipment 7 10 true + + # Turn off a light + omnilogic --host 192.168.1.100 debug set-equipment 7 15 false + + # Set pump to 50% speed + omnilogic --host 192.168.1.100 debug set-equipment 7 8 50 + + # Turn off pump (0% speed) + omnilogic --host 192.168.1.100 debug set-equipment 7 8 0 + + """ + ensure_connection(ctx) + omni: OmniLogicAPI = ctx.obj["OMNI"] + + # Parse is_on parameter - can be bool-like string or integer + is_on_lower = is_on.lower() + if is_on_lower in ("true", "on", "yes", "1"): + is_on_value: int | bool = True + elif is_on_lower in ("false", "off", "no", "0"): + is_on_value = False + else: + # Try to parse as integer for variable speed equipment + try: + is_on_value = int(is_on) + if not 0 <= is_on_value <= 100: + click.echo(f"Error: Integer value must be between 0-100, got {is_on_value}", err=True) + raise click.Abort + except ValueError as exc: + click.echo(f"Error: Invalid value '{is_on}'. Use true/false, on/off, or 0-100 for speed.", err=True) + raise click.Abort from exc + + # Execute the command + try: + asyncio.run(omni.async_set_equipment(bow_id, equip_id, is_on_value)) + if isinstance(is_on_value, bool): + state = "ON" if is_on_value else "OFF" + click.echo(f"Successfully set equipment {equip_id} in BOW {bow_id} to {state}") + else: + click.echo(f"Successfully set equipment {equip_id} in BOW {bow_id} to {is_on_value}%") + except Exception as e: + click.echo(f"Error setting equipment: {e}", err=True) + raise click.Abort from e diff --git a/pyomnilogic_local/cli/get/__init__.py b/pyomnilogic_local/cli/get/__init__.py index e69de29..f06b4f3 100644 --- a/pyomnilogic_local/cli/get/__init__.py +++ b/pyomnilogic_local/cli/get/__init__.py @@ -0,0 +1 @@ +"""CLI commands for retrieving data from the Hayward OmniLogic Local API.""" diff --git a/pyomnilogic_local/cli/get/backyard.py b/pyomnilogic_local/cli/get/backyard.py index 44e8e99..55bad33 100644 --- a/pyomnilogic_local/cli/get/backyard.py +++ b/pyomnilogic_local/cli/get/backyard.py @@ -1,21 +1,17 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPBackyard, - MSPConfig, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryType, -) -from pyomnilogic_local.omnitypes import ( - BackyardState, -) +from pyomnilogic_local.omnitypes import BackyardState + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPBackyard, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType @click.command() @@ -40,7 +36,7 @@ def _print_backyard_info(backyardconfig: MSPBackyard, telemetry: TelemetryType | """Format and print backyard information in a nice table format. Args: - backyard: Backyard object from MSPConfig with attributes to display + backyardconfig: Backyard object from MSPConfig with attributes to display telemetry: Telemetry object containing current state information """ click.echo("\n" + "=" * 60) @@ -75,8 +71,7 @@ def _print_backyard_info(backyardconfig: MSPBackyard, telemetry: TelemetryType | if backyardconfig.bow: equipment_counts.append(f"Bodies of Water: {len(backyardconfig.bow)}") - for bow in backyardconfig.bow: - equipment_counts.append(f" - {bow.name} ({bow.type})") + equipment_counts.extend(f" - {bow.name} ({bow.equip_type})" for bow in backyardconfig.bow) if backyardconfig.sensor: equipment_counts.append(f"Backyard Sensors: {len(backyardconfig.sensor)}") diff --git a/pyomnilogic_local/cli/get/bows.py b/pyomnilogic_local/cli/get/bows.py index d048db7..d6538d7 100644 --- a/pyomnilogic_local/cli/get/bows.py +++ b/pyomnilogic_local/cli/get/bows.py @@ -1,21 +1,17 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPBoW, - MSPConfig, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryType, -) -from pyomnilogic_local.omnitypes import ( - BodyOfWaterType, -) +from pyomnilogic_local.omnitypes import BodyOfWaterType + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPBoW, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType @click.command() diff --git a/pyomnilogic_local/cli/get/chlorinators.py b/pyomnilogic_local/cli/get/chlorinators.py new file mode 100644 index 0000000..a0b538c --- /dev/null +++ b/pyomnilogic_local/cli/get/chlorinators.py @@ -0,0 +1,75 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import click + +from pyomnilogic_local.omnitypes import ChlorinatorCellType, ChlorinatorDispenserType, ChlorinatorOperatingMode + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPChlorinator, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryChlorinator + + +@click.command() +@click.pass_context +def chlorinators(ctx: click.Context) -> None: + """List all chlorinators and their current settings. + + Displays information about all chlorinators including their system IDs, names, + salt levels, operational status, alerts, and errors. + + Example: + omnilogic get chlorinators + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + chlorinators_found = False + + # Check for chlorinators in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.chlorinator: + chlorinators_found = True + _print_chlorinator_info( + bow.chlorinator, cast("TelemetryChlorinator", telemetry.get_telem_by_systemid(bow.chlorinator.system_id)) + ) + + if not chlorinators_found: + click.echo("No chlorinators found in the system configuration.") + + +def _print_chlorinator_info(chlorinator: MSPChlorinator, telemetry: TelemetryChlorinator | None) -> None: + """Format and print chlorinator information in a nice table format. + + Args: + chlorinator: Chlorinator object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("CHLORINATOR") + click.echo("=" * 60) + + chlor_data: dict[Any, Any] = {**dict(chlorinator), **dict(telemetry)} if telemetry else dict(chlorinator) + for attr_name, value in chlor_data.items(): + if attr_name == "cell_type": + value = ChlorinatorCellType(value).pretty() + elif attr_name == "dispenser_type": + value = ChlorinatorDispenserType(value).pretty() + elif attr_name == "operating_mode": + value = ChlorinatorOperatingMode(value).pretty() + elif attr_name in ("status", "alerts", "errors") and isinstance(value, list): + # These are computed properties that return lists of flag names + value = ", ".join(value) if value else "None" + elif isinstance(value, list): + # Format other lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/commands.py b/pyomnilogic_local/cli/get/commands.py index 597b162..860ad9d 100644 --- a/pyomnilogic_local/cli/get/commands.py +++ b/pyomnilogic_local/cli/get/commands.py @@ -1,14 +1,23 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" +from __future__ import annotations + import click from pyomnilogic_local.cli import ensure_connection from pyomnilogic_local.cli.get.backyard import backyard from pyomnilogic_local.cli.get.bows import bows +from pyomnilogic_local.cli.get.chlorinators import chlorinators +from pyomnilogic_local.cli.get.csads import csads from pyomnilogic_local.cli.get.filters import filters +from pyomnilogic_local.cli.get.groups import groups from pyomnilogic_local.cli.get.heaters import heaters from pyomnilogic_local.cli.get.lights import lights +from pyomnilogic_local.cli.get.pumps import pumps +from pyomnilogic_local.cli.get.relays import relays +from pyomnilogic_local.cli.get.schedules import schedules +from pyomnilogic_local.cli.get.sensors import sensors from pyomnilogic_local.cli.get.valves import valves @@ -28,7 +37,14 @@ def get(ctx: click.Context) -> None: # Register subcommands get.add_command(backyard) get.add_command(bows) +get.add_command(chlorinators) +get.add_command(csads) get.add_command(filters) +get.add_command(groups) get.add_command(heaters) get.add_command(lights) +get.add_command(pumps) +get.add_command(relays) +get.add_command(schedules) +get.add_command(sensors) get.add_command(valves) diff --git a/pyomnilogic_local/cli/get/csads.py b/pyomnilogic_local/cli/get/csads.py new file mode 100644 index 0000000..645420e --- /dev/null +++ b/pyomnilogic_local/cli/get/csads.py @@ -0,0 +1,69 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import click + +from pyomnilogic_local.omnitypes import CSADMode, CSADType + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPCSAD, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryCSAD + + +@click.command() +@click.pass_context +def csads(ctx: click.Context) -> None: + """List all CSAD (Chemistry Sense and Dispense) systems and their current settings. + + Displays information about all CSAD systems including their system IDs, names, + current pH/ORP readings, mode, and target values. + + Example: + omnilogic get csads + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + csads_found = False + + # Check for CSADs in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.csad: + for csad in bow.csad: + csads_found = True + _print_csad_info(csad, cast("TelemetryCSAD", telemetry.get_telem_by_systemid(csad.system_id))) + + if not csads_found: + click.echo("No CSAD systems found in the system configuration.") + + +def _print_csad_info(csad: MSPCSAD, telemetry: TelemetryCSAD | None) -> None: + """Format and print CSAD information in a nice table format. + + Args: + csad: CSAD object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("CSAD (CHEMISTRY SENSE AND DISPENSE)") + click.echo("=" * 60) + + csad_data: dict[Any, Any] = {**dict(csad), **dict(telemetry)} if telemetry else dict(csad) + for attr_name, value in csad_data.items(): + if attr_name == "equip_type": + value = CSADType(value).pretty() + elif attr_name == "mode": + value = CSADMode(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/filters.py b/pyomnilogic_local/cli/get/filters.py index f519dfe..cd23f83 100644 --- a/pyomnilogic_local/cli/get/filters.py +++ b/pyomnilogic_local/cli/get/filters.py @@ -1,24 +1,17 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPFilter, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryType, -) -from pyomnilogic_local.omnitypes import ( - FilterState, - FilterType, - FilterValvePosition, - FilterWhyOn, -) +from pyomnilogic_local.omnitypes import FilterState, FilterType, FilterValvePosition, FilterWhyOn + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPFilter + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType @click.command() diff --git a/pyomnilogic_local/cli/get/groups.py b/pyomnilogic_local/cli/get/groups.py new file mode 100644 index 0000000..92750f5 --- /dev/null +++ b/pyomnilogic_local/cli/get/groups.py @@ -0,0 +1,68 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import click + +from pyomnilogic_local.omnitypes import GroupState + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPGroup + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryGroup + + +@click.command() +@click.pass_context +def groups(ctx: click.Context) -> None: + """List all groups and their current settings. + + Displays information about all groups including their system IDs, names, + current state, and icon IDs. + + Example: + omnilogic get groups + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + groups_found = False + + # Check for groups at the top level + if mspconfig.groups: + for group in mspconfig.groups: + groups_found = True + _print_group_info(group, cast("TelemetryGroup", telemetry.get_telem_by_systemid(group.system_id))) + + if not groups_found: + click.echo("No groups found in the system configuration.") + + +def _print_group_info(group: MSPGroup, telemetry: TelemetryGroup | None) -> None: + """Format and print group information in a nice table format. + + Args: + group: Group object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("GROUP") + click.echo("=" * 60) + + group_data: dict[Any, Any] = {**dict(group), **dict(telemetry)} if telemetry else dict(group) + for attr_name, value in group_data.items(): + if attr_name == "bow_id": + # Skip bow_id as it's not relevant for groups + continue + if attr_name == "state": + value = GroupState(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/heaters.py b/pyomnilogic_local/cli/get/heaters.py index 83b5731..1e9b6e5 100644 --- a/pyomnilogic_local/cli/get/heaters.py +++ b/pyomnilogic_local/cli/get/heaters.py @@ -1,23 +1,17 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPHeaterEquip, - MSPVirtualHeater, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, -) -from pyomnilogic_local.omnitypes import ( - HeaterMode, - HeaterState, - HeaterType, -) +from pyomnilogic_local.omnitypes import HeaterMode, HeaterState, HeaterType + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPHeaterEquip, MSPVirtualHeater + from pyomnilogic_local.models.telemetry import Telemetry @click.command() diff --git a/pyomnilogic_local/cli/get/lights.py b/pyomnilogic_local/cli/get/lights.py index a7eecd2..b990fe3 100644 --- a/pyomnilogic_local/cli/get/lights.py +++ b/pyomnilogic_local/cli/get/lights.py @@ -1,24 +1,17 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast import click -from pyomnilogic_local.models.mspconfig import ( - MSPColorLogicLight, - MSPConfig, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, - TelemetryType, -) -from pyomnilogic_local.omnitypes import ( - ColorLogicBrightness, - ColorLogicPowerState, - ColorLogicShow, - ColorLogicSpeed, -) +from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicPowerState, ColorLogicSpeed + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPColorLogicLight, MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryColorLogicLight @click.command() @@ -41,7 +34,7 @@ def lights(ctx: click.Context) -> None: if mspconfig.backyard.colorlogic_light: for light in mspconfig.backyard.colorlogic_light: lights_found = True - _print_light_info(light, telemetry.get_telem_by_systemid(light.system_id)) + _print_light_info(light, cast("TelemetryColorLogicLight", telemetry.get_telem_by_systemid(light.system_id))) # Check for lights in Bodies of Water if mspconfig.backyard.bow: @@ -49,13 +42,13 @@ def lights(ctx: click.Context) -> None: if bow.colorlogic_light: for cl_light in bow.colorlogic_light: lights_found = True - _print_light_info(cl_light, telemetry.get_telem_by_systemid(cl_light.system_id)) + _print_light_info(cl_light, cast("TelemetryColorLogicLight", telemetry.get_telem_by_systemid(cl_light.system_id))) if not lights_found: click.echo("No ColorLogic lights found in the system configuration.") -def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryType | None) -> None: +def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryColorLogicLight | None) -> None: """Format and print light information in a nice table format. Args: @@ -67,6 +60,7 @@ def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryType | None click.echo("=" * 60) light_data: dict[Any, Any] = {**dict(light), **dict(telemetry)} if telemetry else dict(light) + for attr_name, value in light_data.items(): if attr_name == "brightness": value = ColorLogicBrightness(value).pretty() @@ -74,7 +68,7 @@ def _print_light_info(light: MSPColorLogicLight, telemetry: TelemetryType | None show_names = [show.pretty() if hasattr(show, "pretty") else str(show) for show in value] value = ", ".join(show_names) if show_names else "None" elif attr_name == "show" and value is not None: - value = ColorLogicShow(value).pretty() + value = telemetry.show_name(light.equip_type, light.v2_active) if telemetry else str(value) elif attr_name == "speed": value = ColorLogicSpeed(value).pretty() elif attr_name == "state": diff --git a/pyomnilogic_local/cli/get/pumps.py b/pyomnilogic_local/cli/get/pumps.py new file mode 100644 index 0000000..7e73d92 --- /dev/null +++ b/pyomnilogic_local/cli/get/pumps.py @@ -0,0 +1,71 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import click + +from pyomnilogic_local.omnitypes import PumpFunction, PumpState, PumpType + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPPump + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType + + +@click.command() +@click.pass_context +def pumps(ctx: click.Context) -> None: + """List all pumps and their current settings. + + Displays information about all pumps including their system IDs, names, + current state, speed settings, and pump type. + + Example: + omnilogic get pumps + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + pumps_found = False + + # Check for pumps in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.pump: + for pump in bow.pump: + pumps_found = True + _print_pump_info(pump, telemetry.get_telem_by_systemid(pump.system_id)) + + if not pumps_found: + click.echo("No pumps found in the system configuration.") + + +def _print_pump_info(pump: MSPPump, telemetry: TelemetryType | None) -> None: + """Format and print pump information in a nice table format. + + Args: + pump: Pump object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("PUMP") + click.echo("=" * 60) + + pump_data: dict[Any, Any] = {**dict(pump), **dict(telemetry)} if telemetry else dict(pump) + for attr_name, value in pump_data.items(): + if attr_name == "state": + value = PumpState(value).pretty() + elif attr_name == "equip_type": + value = PumpType(value).pretty() + elif attr_name == "function": + value = PumpFunction(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/relays.py b/pyomnilogic_local/cli/get/relays.py new file mode 100644 index 0000000..a3237dd --- /dev/null +++ b/pyomnilogic_local/cli/get/relays.py @@ -0,0 +1,79 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import click + +from pyomnilogic_local.omnitypes import RelayFunction, RelayState, RelayType, RelayWhyOn + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPRelay + from pyomnilogic_local.models.telemetry import Telemetry, TelemetryType + + +@click.command() +@click.pass_context +def relays(ctx: click.Context) -> None: + """List all relays and their current settings. + + Displays information about all relays including their system IDs, names, + current state, type, and function. + + Example: + omnilogic get relays + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + telemetry: Telemetry = ctx.obj["TELEMETRY"] + + relays_found = False + + # Check for relays in the backyard + if mspconfig.backyard.relay: + for relay in mspconfig.backyard.relay: + relays_found = True + _print_relay_info(relay, telemetry.get_telem_by_systemid(relay.system_id)) + + # Check for relays in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.relay: + for relay in bow.relay: + relays_found = True + _print_relay_info(relay, telemetry.get_telem_by_systemid(relay.system_id)) + + if not relays_found: + click.echo("No relays found in the system configuration.") + + +def _print_relay_info(relay: MSPRelay, telemetry: TelemetryType | None) -> None: + """Format and print relay information in a nice table format. + + Args: + relay: Relay object from MSPConfig with attributes to display + telemetry: Telemetry object containing current state information + """ + click.echo("\n" + "=" * 60) + click.echo("RELAY") + click.echo("=" * 60) + + relay_data: dict[Any, Any] = {**dict(relay), **dict(telemetry)} if telemetry else dict(relay) + for attr_name, value in relay_data.items(): + if attr_name == "state": + value = RelayState(value).pretty() + elif attr_name == "type": + value = RelayType(value).pretty() + elif attr_name == "function": + value = RelayFunction(value).pretty() + elif attr_name == "why_on": + value = RelayWhyOn(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/schedules.py b/pyomnilogic_local/cli/get/schedules.py new file mode 100644 index 0000000..13d73c0 --- /dev/null +++ b/pyomnilogic_local/cli/get/schedules.py @@ -0,0 +1,65 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import click + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPSchedule + + +@click.command() +@click.pass_context +def schedules(ctx: click.Context) -> None: + """List all schedules and their current settings. + + Displays information about all schedules including their system IDs, names, + current state, and icon IDs. + + Example: + omnilogic get schedules + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + + schedules_found = False + + # Check for schedules at the top level + if mspconfig.schedules: + for schedule in mspconfig.schedules: + schedules_found = True + _print_schedule_info(schedule) + + if not schedules_found: + click.echo("No schedules found in the system configuration.") + + +def _print_schedule_info(schedule: MSPSchedule) -> None: + """Format and print schedule information in a nice table format. + + Args: + schedule: Schedule object from MSPConfig with attributes to display + """ + click.echo("\n" + "=" * 60) + click.echo("SCHEDULE") + click.echo("=" * 60) + + schedule_data: dict[Any, Any] = dict(schedule) + for attr_name, value in schedule_data.items(): + if attr_name == "days_active_raw": + # Skip raw bitmask field + continue + if attr_name == "event": + value = value.pretty() + if isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + # Days Active is a computed property, so it's not in the dict representation + click.echo(f"{'Days Active':20} : {schedule.days_active}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/sensors.py b/pyomnilogic_local/cli/get/sensors.py new file mode 100644 index 0000000..d847c8e --- /dev/null +++ b/pyomnilogic_local/cli/get/sensors.py @@ -0,0 +1,72 @@ +# Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators +# mypy: disable-error-code="misc" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import click + +from pyomnilogic_local.omnitypes import SensorType, SensorUnits + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPSensor + + +@click.command() +@click.pass_context +def sensors(ctx: click.Context) -> None: + """List all sensors and their current settings. + + Displays information about all sensors including their system IDs, names, + sensor type, and units. + + Example: + omnilogic get sensors + """ + mspconfig: MSPConfig = ctx.obj["MSPCONFIG"] + + sensors_found = False + + # Check for sensors in the backyard + if mspconfig.backyard.sensor: + for sensor in mspconfig.backyard.sensor: + sensors_found = True + _print_sensor_info(sensor) + + # Check for sensors in Bodies of Water + if mspconfig.backyard.bow: + for bow in mspconfig.backyard.bow: + if bow.sensor: + for sensor in bow.sensor: + sensors_found = True + _print_sensor_info(sensor) + + if not sensors_found: + click.echo("No sensors found in the system configuration.") + + +def _print_sensor_info(sensor: MSPSensor) -> None: + """Format and print sensor information in a nice table format. + + Args: + sensor: Sensor object from MSPConfig with attributes to display + """ + click.echo("\n" + "=" * 60) + click.echo("SENSOR") + click.echo("=" * 60) + + sensor_data: dict[Any, Any] = dict(sensor) + for attr_name, value in sensor_data.items(): + if attr_name == "equip_type": + value = SensorType(value).pretty() + elif attr_name == "units": + value = SensorUnits(value).pretty() + elif isinstance(value, list): + # Format lists nicely + value = ", ".join(str(v) for v in value) if value else "None" + + # Format the attribute name to be more readable + display_name = attr_name.replace("_", " ").title() + click.echo(f"{display_name:20} : {value}") + click.echo("=" * 60) diff --git a/pyomnilogic_local/cli/get/valves.py b/pyomnilogic_local/cli/get/valves.py index cfce99a..e7ffd3c 100644 --- a/pyomnilogic_local/cli/get/valves.py +++ b/pyomnilogic_local/cli/get/valves.py @@ -1,23 +1,17 @@ # Need to figure out how to resolve the 'Untyped decorator makes function "..." untyped' errors in mypy when using click decorators # mypy: disable-error-code="misc" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any import click -from pyomnilogic_local.models.mspconfig import ( - MSPConfig, - MSPRelay, -) -from pyomnilogic_local.models.telemetry import ( - Telemetry, -) -from pyomnilogic_local.omnitypes import ( - RelayFunction, - RelayType, - RelayWhyOn, - ValveActuatorState, -) +from pyomnilogic_local.omnitypes import RelayFunction, RelayType, RelayWhyOn, ValveActuatorState + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPConfig, MSPRelay + from pyomnilogic_local.models.telemetry import Telemetry @click.command() @@ -31,6 +25,8 @@ def valves(ctx: click.Context) -> None: Valve actuators control physical valves for features like waterfalls, fountains, and other water features. + Valves will also show under the output of `get relays` as they are a type of relay. + Example: omnilogic get valves """ diff --git a/pyomnilogic_local/cli/pcap_utils.py b/pyomnilogic_local/cli/pcap_utils.py index 15696e9..cdfa036 100644 --- a/pyomnilogic_local/cli/pcap_utils.py +++ b/pyomnilogic_local/cli/pcap_utils.py @@ -5,18 +5,22 @@ reassembly and payload decompression. """ +from __future__ import annotations + import xml.etree.ElementTree as ET import zlib from collections import defaultdict -from typing import Any +from typing import TYPE_CHECKING, Any from scapy.layers.inet import UDP -from scapy.packet import Packet from scapy.utils import rdpcap +from pyomnilogic_local.api.protocol import OmniLogicMessage from pyomnilogic_local.models.leadmessage import LeadMessage from pyomnilogic_local.omnitypes import MessageType -from pyomnilogic_local.protocol import OmniLogicMessage + +if TYPE_CHECKING: + from scapy.packet import Packet def parse_pcap_file(pcap_path: str) -> Any: @@ -54,9 +58,10 @@ def extract_omnilogic_message(packet: Packet) -> tuple[OmniLogicMessage, str, st # Not an OmniLogic message try: omni_msg = OmniLogicMessage.from_bytes(bytes(udp.payload)) - return omni_msg, src_ip, dst_ip - except Exception: # pylint: disable=broad-except + except Exception: return None + else: + return omni_msg, src_ip, dst_ip def reassemble_message_blocks(messages: list[OmniLogicMessage]) -> str: @@ -91,9 +96,7 @@ def reassemble_message_blocks(messages: list[OmniLogicMessage]) -> str: reassembled = zlib.decompress(reassembled) # Decode to string - decoded = reassembled.decode("utf-8").strip("\x00") - - return decoded + return reassembled.decode("utf-8").strip("\x00") def process_pcap_messages(packets: Any) -> list[tuple[str, str, OmniLogicMessage, str | None]]: @@ -135,10 +138,8 @@ def process_pcap_messages(packets: Any) -> list[tuple[str, str, OmniLogicMessage # Find the matching LeadMessage sequence matching_seq: tuple[str, str, int] | None = None for seq_key in message_sequences: - if seq_key[0] == src_ip and seq_key[1] == dst_ip: - # Check if this is the right sequence - if not matching_seq or seq_key[2] > matching_seq[2]: # pylint: disable=unsubscriptable-object - matching_seq = seq_key + if (seq_key[0] == src_ip and seq_key[1] == dst_ip) and (not matching_seq or seq_key[2] > matching_seq[2]): + matching_seq = seq_key if matching_seq: message_sequences[matching_seq].append(omni_msg) @@ -154,7 +155,7 @@ def process_pcap_messages(packets: Any) -> list[tuple[str, str, OmniLogicMessage decoded_msg = reassemble_message_blocks(message_sequences[matching_seq]) # Add the reassembled message result results.append((src_ip, dst_ip, lead_msg, decoded_msg)) - except Exception: # pylint: disable=broad-except + except Exception: pass # Clean up this sequence diff --git a/pyomnilogic_local/cli/utils.py b/pyomnilogic_local/cli/utils.py index c8e880a..ce61cb8 100644 --- a/pyomnilogic_local/cli/utils.py +++ b/pyomnilogic_local/cli/utils.py @@ -4,15 +4,19 @@ accessing controller data within the Click context. """ +from __future__ import annotations + import asyncio -from typing import Literal, overload +from typing import TYPE_CHECKING, Literal, overload import click -from pyomnilogic_local.api import OmniLogicAPI -from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics -from pyomnilogic_local.models.mspconfig import MSPConfig -from pyomnilogic_local.models.telemetry import Telemetry +from pyomnilogic_local.api.api import OmniLogicAPI + +if TYPE_CHECKING: + from pyomnilogic_local.models.filter_diagnostics import FilterDiagnostics + from pyomnilogic_local.models.mspconfig import MSPConfig + from pyomnilogic_local.models.telemetry import Telemetry async def get_omni(host: str) -> OmniLogicAPI: @@ -40,10 +44,11 @@ async def fetch_startup_data(omni: OmniLogicAPI) -> tuple[MSPConfig, Telemetry]: RuntimeError: If unable to fetch configuration or telemetry from controller """ try: - mspconfig = await omni.async_get_config() + mspconfig = await omni.async_get_mspconfig() telemetry = await omni.async_get_telemetry() except Exception as exc: - raise RuntimeError(f"[ERROR] Failed to fetch config or telemetry from controller: {exc}") from exc + msg = f"[ERROR] Failed to fetch config or telemetry from controller: {exc}" + raise RuntimeError(msg) from exc return mspconfig, telemetry @@ -69,7 +74,7 @@ def ensure_connection(ctx: click.Context) -> None: try: omni = asyncio.run(get_omni(host)) mspconfig, telemetry = asyncio.run(fetch_startup_data(omni)) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: click.secho(str(exc), fg="red", err=True) ctx.exit(1) @@ -98,5 +103,4 @@ async def async_get_filter_diagnostics(omni: OmniLogicAPI, pool_id: int, filter_ Returns: FilterDiagnostics object or raw XML string depending on raw parameter """ - filter_diags = await omni.async_get_filter_diagnostics(pool_id, filter_id, raw=raw) - return filter_diags + return await omni.async_get_filter_diagnostics(pool_id, filter_id, raw=raw) diff --git a/pyomnilogic_local/collections.py b/pyomnilogic_local/collections.py new file mode 100644 index 0000000..be1f39b --- /dev/null +++ b/pyomnilogic_local/collections.py @@ -0,0 +1,527 @@ +"""Custom collection types for OmniLogic equipment management.""" + +from __future__ import annotations + +import logging +from collections import Counter +from enum import Enum +from typing import TYPE_CHECKING, Any, overload + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.omnitypes import LightShows + +if TYPE_CHECKING: + from collections.abc import Iterator + +_LOGGER = logging.getLogger(__name__) + +# Track which duplicate names we've already warned about to avoid log spam +_WARNED_DUPLICATE_NAMES: set[str] = set() + + +class EquipmentDict[OE: OmniEquipment[Any, Any]]: + """A dictionary-like collection that supports lookup by both name and system_id. + + This collection allows accessing equipment using either their name (str) or + system_id (int), providing flexible and intuitive access patterns. + + Type Safety: + The lookup key type determines the lookup method: + - str keys lookup by equipment name + - int keys lookup by equipment system_id + + Examples: + >>> # Create collection from list of equipment + >>> bows = EquipmentDict([pool_bow, spa_bow]) + >>> + >>> # Access by name (string key) + >>> pool = bows["Pool"] + >>> + >>> # Access by system_id (integer key) + >>> pool = bows[3] + >>> + >>> # Explicit methods for clarity + >>> pool = bows.get_by_name("Pool") + >>> pool = bows.get_by_id(3) + >>> + >>> # Standard dict operations + >>> for bow in bows: + ... print(bow.name) + >>> len(bows) + >>> if "Pool" in bows: + ... print("Pool exists") + + Note: + If an equipment item has a name that looks like a number (e.g., "123"), + you must use an actual int type to lookup by system_id, as string keys + always lookup by name. This type-based differentiation prevents ambiguity. + """ + + def __init__(self, items: list[OE] | None = None) -> None: + """Initialize the equipment collection. + + Args: + items: Optional list of equipment items to populate the collection. + + Raises: + ValueError: If any item has neither a system_id nor a name. + """ + self._items: list[OE] = items if items is not None else [] + self._validate() + + def _validate(self) -> None: + """Validate the equipment collection. + + Checks for: + 1. Items without both system_id and name (raises ValueError) + 2. Duplicate names (logs warning once per unique duplicate) + + Raises: + ValueError: If any item has neither a system_id nor a name. + """ + # Check for items with no system_id AND no name + if invalid_items := [item for item in self._items if item.system_id is None and item.name is None]: + msg = ( + f"Equipment collection contains {len(invalid_items)} item(s) " + "with neither a system_id nor a name. All equipment must have " + "at least one identifier for addressing." + ) + raise ValueError(msg) + + # Find duplicate names that we haven't warned about yet + name_counts = Counter(item.name for item in self._items if item.name is not None) + duplicate_names = {name for name, count in name_counts.items() if count > 1} + unwarned_duplicates = duplicate_names.difference(_WARNED_DUPLICATE_NAMES) + + # Log warnings for new duplicates + for name in unwarned_duplicates: + _LOGGER.warning( + "Equipment collection contains %d items with the same name '%s'. " + "Name-based lookups will return the first match. " + "Consider using system_id-based lookups for reliability " + "or renaming equipment to avoid duplicates.", + name_counts[name], + name, + ) + _WARNED_DUPLICATE_NAMES.add(name) + + @property + def _by_name(self) -> dict[str, OE]: + """Dynamically build name-to-equipment mapping.""" + return {item.name: item for item in self._items if item.name is not None} + + @property + def _by_id(self) -> dict[int, OE]: + """Dynamically build system_id-to-equipment mapping.""" + return {item.system_id: item for item in self._items if item.system_id is not None} + + @overload + def __getitem__(self, key: str) -> OE: ... + + @overload + def __getitem__(self, key: int) -> OE: ... + + def __getitem__(self, key: str | int) -> OE: + """Get equipment by name (str) or system_id (int). + + Args: + key: Equipment name (str) or system_id (int) + + Returns: + The equipment item matching the key + + Raises: + KeyError: If no equipment matches the key + TypeError: If key is not str or int + + Examples: + >>> bows["Pool"] # Lookup by name + >>> bows[3] # Lookup by system_id + """ + if isinstance(key, str): + return self._by_name[key] + if isinstance(key, int): + return self._by_id[key] + + msg = f"Key must be str or int, got {type(key).__name__}" + raise TypeError(msg) + + def __setitem__(self, key: str | int, value: OE) -> None: + """Add or update equipment in the collection. + + The key is only used to determine the operation type (add vs update). + The actual name and system_id are taken from the equipment object itself. + + Args: + key: Equipment name (str) or system_id (int) - must match the equipment's values + value: Equipment item to add or update + + Raises: + TypeError: If key is not str or int + ValueError: If key doesn't match the equipment's name or system_id + + Examples: + >>> # Add by name + >>> bows["Pool"] = new_pool_bow + >>> # Add by system_id + >>> bows[3] = new_pool_bow + """ + if isinstance(key, str): + if value.name != key: + msg = f"Equipment name '{value.name}' does not match key '{key}'" + raise ValueError(msg) + elif isinstance(key, int): + if value.system_id != key: + msg = f"Equipment system_id {value.system_id} does not match key {key}" + raise ValueError(msg) + else: + msg = f"Key must be str or int, got {type(key).__name__}" + raise TypeError(msg) + + # Check if we're updating an existing item (prioritize system_id) + existing_item = None + if value.system_id and value.system_id in self._by_id: + existing_item = self._by_id[value.system_id] + elif value.name and value.name in self._by_name: + existing_item = self._by_name[value.name] + + if existing_item: + # Replace existing item in place + idx = self._items.index(existing_item) + self._items[idx] = value + else: + # Add new item + self._items.append(value) + + # Validate after modification + self._validate() + + def __delitem__(self, key: str | int) -> None: + """Remove equipment from the collection. + + Args: + key: Equipment name (str) or system_id (int) + + Raises: + KeyError: If no equipment matches the key + TypeError: If key is not str or int + + Examples: + >>> del bows["Pool"] # Remove by name + >>> del bows[3] # Remove by system_id + """ + # First, get the item to remove + item = self[key] # This will raise KeyError if not found + + # Remove from the list (indexes rebuild automatically via properties) + self._items.remove(item) + + def __contains__(self, key: str | int) -> bool: + """Check if equipment exists by name (str) or system_id (int). + + Args: + key: Equipment name (str) or system_id (int) + + Returns: + True if equipment exists, False otherwise + + Examples: + >>> if "Pool" in bows: + ... print("Pool exists") + >>> if 3 in bows: + ... print("System ID 3 exists") + """ + if isinstance(key, str): + return key in self._by_name + if isinstance(key, int): + return key in self._by_id + + return False + + def __iter__(self) -> Iterator[OE]: + """Iterate over all equipment items in the collection. + + Returns: + Iterator over equipment items + + Examples: + >>> for bow in bows: + ... print(bow.name) + """ + return iter(self._items) + + def __len__(self) -> int: + """Get the number of equipment items in the collection. + + Returns: + Number of items + + Examples: + >>> len(bows) + 2 + """ + return len(self._items) + + def __repr__(self) -> str: + """Get string representation of the collection. + + Returns: + String representation showing item count and names + """ + names = [f"" for item in self._items] + return f"EquipmentDict({names})" + + def append(self, item: OE) -> None: + """Add or update equipment in the collection (list-like interface). + + If equipment with the same system_id or name already exists, it will be + replaced. System_id is checked first as it's the more reliable unique identifier. + + Args: + item: Equipment item to add or update + + Examples: + >>> # Add new equipment + >>> bows.append(new_pool_bow) + >>> + >>> # Update existing equipment (replaces if system_id or name matches) + >>> bows.append(updated_pool_bow) + """ + # Check if we're updating an existing item (prioritize system_id as it's guaranteed unique) + existing_item = None + if item.system_id and item.system_id in self._by_id: + existing_item = self._by_id[item.system_id] + elif item.name and item.name in self._by_name: + existing_item = self._by_name[item.name] + + if existing_item: + # Replace existing item in place + idx = self._items.index(existing_item) + self._items[idx] = item + else: + # Add new item + self._items.append(item) + + # Validate after modification + self._validate() + + def get_by_name(self, name: str) -> OE | None: + """Get equipment by name with explicit method (returns None if not found). + + Args: + name: Equipment name + + Returns: + Equipment item or None if not found + + Examples: + >>> pool = bows.get_by_name("Pool") + >>> if pool is not None: + ... await pool.filters[0].turn_on() + """ + return self._by_name.get(name) + + def get_by_id(self, system_id: int) -> OE | None: + """Get equipment by system_id with explicit method (returns None if not found). + + Args: + system_id: Equipment system_id + + Returns: + Equipment item or None if not found + + Examples: + >>> pool = bows.get_by_id(3) + >>> if pool is not None: + ... print(pool.name) + """ + return self._by_id.get(system_id) + + def get(self, key: str | int, default: OE | None = None) -> OE | None: + """Get equipment by name or system_id with optional default. + + Args: + key: Equipment name (str) or system_id (int) + default: Default value to return if key not found + + Returns: + Equipment item or default if not found + + Examples: + >>> pool = bows.get("Pool") + >>> pool = bows.get(3) + >>> pool = bows.get("NonExistent", default=None) + """ + try: + return self[key] + except KeyError: + return default + + def keys(self) -> list[str]: + """Get list of all equipment names. + + Returns: + List of equipment names (excluding items without names) + + Examples: + >>> bows.keys() + ['Pool', 'Spa'] + """ + return list(self._by_name.keys()) + + def values(self) -> list[OE]: + """Get list of all equipment items. + + Returns: + List of equipment items + + Examples: + >>> for equipment in bows.values(): + ... print(equipment.name) + """ + return self._items.copy() + + def items(self) -> list[tuple[int | None, str | None, OE]]: + """Get list of (system_id, name, equipment) tuples. + + Returns: + List of (system_id, name, equipment) tuples where both system_id + and name can be None (though at least one must be set per validation). + + Examples: + >>> for system_id, name, bow in bows.items(): + ... print(f"ID: {system_id}, Name: {name}") + """ + return [(item.system_id, item.name, item) for item in self._items] + + +class EffectsCollection[E: Enum]: + """A collection that provides both attribute and dict-like access to light effects. + + This class wraps a list of light shows and exposes them through multiple access patterns: + - Attribute access: `effects.VOODOO_LOUNGE` + - Dict-like access: `effects["VOODOO_LOUNGE"]` + - Iteration: `for effect in effects: ...` + - Length: `len(effects)` + - Membership: `effect in effects` + + The collection is read-only and provides type-safe access to only the shows + supported by a specific light model. + + Example: + >>> light = pool.lights["Pool Light"] + >>> # Attribute access + >>> await light.set_show(light.effects.TROPICAL) + >>> # Dict-like access + >>> await light.set_show(light.effects["TROPICAL"]) + >>> # Check if a show is available + >>> if "VOODOO_LOUNGE" in light.effects: + ... await light.set_show(light.effects.VOODOO_LOUNGE) + >>> # Iterate through available shows + >>> for effect in light.effects: + ... print(f"{effect.name}: {effect.value}") + """ + + def __init__(self, effects: list[E]) -> None: + """Initialize the effects collection. + + Args: + effects: List of light show enums available for this light model. + """ + self._effects = effects + # Create a lookup dict for fast access by name + self._effects_by_name = {effect.name: effect for effect in effects} + + def __getattr__(self, name: str) -> E: + """Enable attribute access to effects by name. + + Args: + name: The name of the light show (e.g., "VOODOO_LOUNGE") + + Returns: + The light show enum value. + + Raises: + AttributeError: If the show name is not available for this light model. + """ + if name.startswith("_"): + # Avoid infinite recursion for internal attributes + msg = f"'{type(self).__name__}' object has no attribute '{name}'" + raise AttributeError(msg) + + try: + return self._effects_by_name[name] + except KeyError as exc: + msg = f"Light effect '{name}' is not available for this light model" + raise AttributeError(msg) from exc + + def __getitem__(self, key: str | int) -> E: + """Enable dict-like and index access to effects. + + Args: + key: Either the effect name (str) or index position (int) + + Returns: + The light show enum value. + + Raises: + KeyError: If the show name is not available for this light model. + IndexError: If the index is out of range. + TypeError: If key is not a string or integer. + """ + if isinstance(key, str): + try: + return self._effects_by_name[key] + except KeyError as exc: + msg = f"Light effect '{key}' is not available for this light model" + raise KeyError(msg) from exc + elif isinstance(key, int): + return self._effects[key] + else: + msg = f"indices must be integers or strings, not {type(key).__name__}" + raise TypeError(msg) + + def __contains__(self, item: str | E) -> bool: + """Check if an effect is available in this collection. + + Args: + item: Either the effect name (str) or the effect enum value + + Returns: + True if the effect is available, False otherwise. + + Note: + When checking enum membership, this uses identity checking (is), + not value equality (==). This ensures that only the exact enum + instance from this collection's type is matched, even if different + enum types share the same value. + """ + if isinstance(item, str): + return item in self._effects_by_name + # Use identity check to ensure exact type match + return any(item is effect for effect in self._effects) + + def __iter__(self) -> Iterator[E]: + """Enable iteration over the effects.""" + return iter(self._effects) + + def __len__(self) -> int: + """Return the number of effects in the collection.""" + return len(self._effects) + + def __repr__(self) -> str: + """Return a string representation of the collection.""" + effect_names = [effect.name for effect in self._effects] + return f"EffectsCollection({effect_names})" + + def to_list(self) -> list[E]: + """Return the underlying list of effects. + + Returns: + A list of all light show enums in this collection. + """ + return self._effects.copy() + + +# Type alias for light effects specifically +LightEffectsCollection = EffectsCollection[LightShows] diff --git a/pyomnilogic_local/colorlogiclight.py b/pyomnilogic_local/colorlogiclight.py new file mode 100644 index 0000000..2e90878 --- /dev/null +++ b/pyomnilogic_local/colorlogiclight.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.collections import LightEffectsCollection +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.models.mspconfig import MSPColorLogicLight +from pyomnilogic_local.models.telemetry import TelemetryColorLogicLight +from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicLightType, ColorLogicPowerState, ColorLogicSpeed +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import LightShows + +_LOGGER = logging.getLogger(__name__) + + +class ColorLogicLight(OmniEquipment[MSPColorLogicLight, TelemetryColorLogicLight]): + """Represents a ColorLogic or compatible LED light in the OmniLogic system. + + ColorLogic lights are intelligent LED pool and spa lights that can display + various color shows, patterns, and effects. The OmniLogic system supports + multiple light models with different capabilities: + + Light Models: + - ColorLogic 2.5 + - ColorLogic 4.0 + - ColorLogic UCL + - ColorLogic SAM + - Pentair and Zodiac compatible lights (limited functionality) + + Each light model supports different shows, speeds, and brightness levels. + The ColorLogicLight class automatically handles these differences. + + Attributes: + mspconfig: Configuration data for this light from MSP XML + telemetry: Real-time operational state and settings + + Properties (Configuration): + model: Light model type (TWO_FIVE, FOUR_ZERO, UCL, SAM, etc.) + v2_active: Whether V2 protocol features are active + effects: Collection of available light shows for this model with + attribute and dict-like access patterns + + Properties (Telemetry): + state: Current power state (ON, OFF, transitional states) + show: Currently selected light show + speed: Show animation speed (1x, 2x, 4x, 8x) + brightness: Light brightness (25%, 50%, 75%, 100%) + special_effect: Special effect setting + + Properties (Computed): + is_on: True if light is currently on + is_ready: True if light can accept commands (not in transitional state) + + Control Methods: + turn_on(): Turn on light (starts at saved show) + turn_off(): Turn off light + toggle(): Toggle light on/off + set_show(show): Set light to specific show + set_speed(speed): Set animation speed (1x-8x) + set_brightness(brightness): Set brightness (25%-100%) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> pool_light = pool.lights["Pool Light"] + >>> + >>> # Check light capabilities + >>> print(f"Light model: {pool_light.model}") + >>> print(f"Available shows: {pool_light.effects}") + >>> + >>> # Check current state + >>> if pool_light.is_on: + ... print(f"Show: {pool_light.show}") + ... print(f"Speed: {pool_light.speed}") + ... print(f"Brightness: {pool_light.brightness}") + >>> + >>> # Control light - use attribute access for available effects! + >>> await pool_light.turn_on() + >>> await pool_light.set_show(pool_light.effects.TROPICAL) + >>> await pool_light.set_speed(ColorLogicSpeed.TWO_TIMES) + >>> await pool_light.set_brightness(ColorLogicBrightness.SEVENTY_FIVE_PERCENT) + >>> await pool_light.turn_off() + >>> + >>> # Or use traditional enum imports + >>> from pyomnilogic_local import ColorLogicShow25 + >>> await pool_light.set_show(ColorLogicShow25.TROPICAL) + + Important - Light State Transitions: + ColorLogic lights go through several transitional states when changing + settings. During these states, the light is NOT ready to accept commands: + + - FIFTEEN_SECONDS_WHITE: 15-second white period after power on + - CHANGING_SHOW: Actively cycling through shows + - POWERING_OFF: In process of turning off + - COOLDOWN: Cooling down after being turned off + + Always check is_ready before sending commands, or wait for state to + stabilize after each command. + + Note: + - Different light models support different show sets + - Non-ColorLogic lights (Pentair, Zodiac) have limited control + - Speed and brightness may not be adjustable on all models + - Some shows may require specific light hardware + - V2 protocol enables enhanced features on compatible lights + """ + + mspconfig: MSPColorLogicLight + telemetry: TelemetryColorLogicLight + + def __init__(self, omni: OmniLogic, mspconfig: MSPColorLogicLight, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def model(self) -> ColorLogicLightType: + """Returns the model of the light.""" + return self.mspconfig.equip_type + + @property + def v2_active(self) -> bool: + """Returns whether the light is v2 active.""" + return self.mspconfig.v2_active + + @property + def effects(self) -> LightEffectsCollection | None: + """Returns the available light effects as a collection with attribute and dict-like access. + + The effects collection provides multiple access patterns: + - Attribute access: `light.effects.VOODOO_LOUNGE` + - Dict-like access: `light.effects["VOODOO_LOUNGE"]` + - Index access: `light.effects[0]` + - Membership testing: `"TROPICAL" in light.effects` + - Iteration: `for effect in light.effects: ...` + + Returns: + LightEffectsCollection containing the available shows for this light model, + or None if effects are not available. + + Example: + >>> # Attribute access - most intuitive + >>> await light.set_show(light.effects.TROPICAL) + >>> + >>> # Dict-like access + >>> await light.set_show(light.effects["TROPICAL"]) + >>> + >>> # Check availability + >>> if "VOODOO_LOUNGE" in light.effects: + ... await light.set_show(light.effects.VOODOO_LOUNGE) + >>> + >>> # List all available effects + >>> for effect in light.effects: + ... print(f"{effect.name}: {effect.value}") + """ + if self.mspconfig.effects is None: + return None + return LightEffectsCollection(self.mspconfig.effects) + + @property + def state(self) -> ColorLogicPowerState: + """Returns the state of the light.""" + return self.telemetry.state + + @property + def show(self) -> LightShows: + """Returns the current light show.""" + return self.telemetry.show + + @property + def speed(self) -> ColorLogicSpeed: + """Returns the current speed.""" + if self.model in [ColorLogicLightType.SAM, ColorLogicLightType.TWO_FIVE, ColorLogicLightType.FOUR_ZERO, ColorLogicLightType.UCL]: + return self.telemetry.speed + # Non color-logic lights only support 1x speed + return ColorLogicSpeed.ONE_TIMES + + @property + def brightness(self) -> ColorLogicBrightness: + """Returns the current brightness.""" + if self.model in [ColorLogicLightType.SAM, ColorLogicLightType.TWO_FIVE, ColorLogicLightType.FOUR_ZERO, ColorLogicLightType.UCL]: + return self.telemetry.brightness + # Non color-logic lights only support 100% brightness + return ColorLogicBrightness.ONE_HUNDRED_PERCENT + + @property + def special_effect(self) -> int: + """Returns the current special effect.""" + return self.telemetry.special_effect + + @property + def is_ready(self) -> bool: + """Return whether the light is ready to accept commands. + + The light is not ready when: + - The backyard is in service/config mode (checked by parent class) + - The light is in a transitional state: + - FIFTEEN_SECONDS_WHITE: Light is in the 15-second white period after power on + - CHANGING_SHOW: Light is actively changing between shows + - POWERING_OFF: Light is in the process of turning off + - COOLDOWN: Light is in cooldown period after being turned off + + Returns: + bool: True if the light can accept commands, False otherwise. + """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check light-specific readiness + return self.state not in [ + ColorLogicPowerState.FIFTEEN_SECONDS_WHITE, + ColorLogicPowerState.CHANGING_SHOW, + ColorLogicPowerState.POWERING_OFF, + ColorLogicPowerState.COOLDOWN, + ] + + @control_method + async def turn_on(self) -> None: + """Turn the light on. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + msg = "Cannot turn on light: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_equipment(self.bow_id, self.system_id, True) + + @control_method + async def turn_off(self) -> None: + """Turn the light off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + msg = "Cannot turn off light: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_equipment(self.bow_id, self.system_id, False) + + @control_method + async def set_show( + self, show: LightShows | None = None, speed: ColorLogicSpeed | None = None, brightness: ColorLogicBrightness | None = None + ) -> None: + """Set the light show, speed, and brightness. + + Args: + show: The light show to set. If None, uses the current show. + speed: The speed to set. If None, uses the current speed. + brightness: The brightness to set. If None, uses the current brightness. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + + Note: + Non color-logic lights do not support speed or brightness control. + If speed or brightness are provided for non color-logic lights, they will be ignored + and a warning will be logged. + """ + # Non color-logic lights do not support speed or brightness control + if self.model not in [ + ColorLogicLightType.SAM, + ColorLogicLightType.TWO_FIVE, + ColorLogicLightType.FOUR_ZERO, + ColorLogicLightType.UCL, + ]: + if speed is not None: + _LOGGER.warning("Non colorlogic lights do not support speed control %s", self.model.name) + speed = ColorLogicSpeed.ONE_TIMES + if brightness is not None: + _LOGGER.warning("Non colorlogic lights do not support brightness control %s", self.model.name) + brightness = ColorLogicBrightness.ONE_HUNDRED_PERCENT + + if self.bow_id is None or self.system_id is None: + msg = "Cannot set light show: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + + await self._api.async_set_light_show( + self.bow_id, + self.system_id, + show or self.show, # use current value if None + speed or self.speed, # use current value if None + brightness or self.brightness, # use current value if None + ) diff --git a/pyomnilogic_local/csad.py b/pyomnilogic_local/csad.py new file mode 100644 index 0000000..3a98ce4 --- /dev/null +++ b/pyomnilogic_local/csad.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.collections import EquipmentDict +from pyomnilogic_local.csad_equip import CSADEquipment +from pyomnilogic_local.models.mspconfig import MSPCSAD +from pyomnilogic_local.models.telemetry import TelemetryCSAD +from pyomnilogic_local.omnitypes import CSADMode, CSADStatus + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import CSADType + + +class CSAD(OmniEquipment[MSPCSAD, TelemetryCSAD]): + """Represents a CSAD (Chemistry Sense and Dispense) system in the OmniLogic system. + + A CSAD system monitors and automatically dispenses chemicals (typically pH reducer + or CO2) to maintain optimal water chemistry. It continuously measures pH levels + and dispenses treatment chemicals as needed to maintain target pH levels. + + The Chemistry Sense Module (CSM) contains both pH and ORP probes. The pH sensor + output is the primary control input for the CSAD function, while the ORP sensor + output is primarily used by the chlorinator function for automatic chlorine + generation control (though ORP readings are included in CSAD telemetry for + monitoring chlorinator effectiveness). + + Attributes: + mspconfig: The MSP configuration for this CSAD + telemetry: Real-time telemetry data for this CSAD + csad_equipment: Collection of physical CSAD equipment devices + + Example: + >>> csad = pool.get_csad() + >>> print(f"Current pH: {csad.current_ph}") + >>> print(f"Target pH: {csad.target_ph}") + >>> if csad.is_dispensing: + ... print("Currently dispensing chemicals") + """ + + mspconfig: MSPCSAD + telemetry: TelemetryCSAD + csad_equipment: EquipmentDict[CSADEquipment] = EquipmentDict() + + def __init__(self, omni: OmniLogic, mspconfig: MSPCSAD, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + def _update_equipment(self, mspconfig: MSPCSAD, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + return + self._update_csad_equipment(mspconfig, telemetry) + + def _update_csad_equipment(self, mspconfig: MSPCSAD, telemetry: Telemetry) -> None: + """Update the CSAD equipment based on the MSP configuration.""" + if mspconfig.csad_equipment is None: + self.csad_equipment = EquipmentDict() + return + + self.csad_equipment = EquipmentDict([CSADEquipment(self._omni, equip, telemetry) for equip in mspconfig.csad_equipment]) + + # Expose MSPConfig attributes + @property + def enabled(self) -> bool: + """Whether the CSAD is enabled in the system configuration.""" + return self.mspconfig.enabled + + @property + def equip_type(self) -> CSADType | str: + """Type of CSAD system (ACID or CO2).""" + return self.mspconfig.equip_type + + @property + def target_ph(self) -> float: + """Target pH level that the CSAD aims to maintain.""" + return self.mspconfig.target_value + + @property + def calibration_value(self) -> float: + """Calibration offset value for pH sensor.""" + return self.mspconfig.calibration_value + + @property + def ph_low_alarm(self) -> float: + """Low pH threshold for triggering an alarm.""" + return self.mspconfig.ph_low_alarm_value + + @property + def ph_high_alarm(self) -> float: + """High pH threshold for triggering an alarm.""" + return self.mspconfig.ph_high_alarm_value + + @property + def orp_target_level(self) -> int: + """Target ORP (Oxidation-Reduction Potential) level in millivolts.""" + return self.mspconfig.orp_target_level + + @property + def orp_runtime_level(self) -> int: + """ORP runtime level threshold in millivolts.""" + return self.mspconfig.orp_runtime_level + + @property + def orp_low_alarm_level(self) -> int: + """ORP level that triggers a low ORP alarm in millivolts.""" + return self.mspconfig.orp_low_alarm_level + + @property + def orp_high_alarm_level(self) -> int: + """ORP level that triggers a high ORP alarm in millivolts.""" + return self.mspconfig.orp_high_alarm_level + + @property + def orp_forced_on_time(self) -> int: + """Duration in minutes for forced ORP dispensing mode.""" + return self.mspconfig.orp_forced_on_time + + @property + def orp_forced_enabled(self) -> bool: + """Whether forced ORP dispensing mode is enabled.""" + return self.mspconfig.orp_forced_enabled + + # Expose Telemetry attributes + @property + def status(self) -> CSADStatus: + """Raw status value from telemetry.""" + return self.telemetry.status + + @property + def current_ph(self) -> float: + """Current pH level reading from the sensor. + + Returns: + Current pH level (typically 0-14, where 7 is neutral) + + Example: + >>> print(f"pH: {csad.current_ph:.2f}") + """ + return self.telemetry.ph + + @property + def current_orp(self) -> int: + """Current ORP (Oxidation-Reduction Potential) reading in millivolts. + + Note: + ORP readings in CSAD telemetry are provided for monitoring purposes to + assess chlorinator effectiveness. The ORP sensor output is primarily + used by the chlorinator function for ORP-based automatic chlorine + generation control. The CSAD system focuses on pH control. + + Returns: + Current ORP level in mV (typically 400-800 mV for pools) + + Example: + >>> print(f"ORP: {csad.current_orp} mV") + """ + return self.telemetry.orp + + @property + def mode(self) -> CSADMode | int: + """Current operating mode of the CSAD. + + Returns: + CSADMode enum value: + - OFF (0): CSAD is off + - AUTO (1): Automatic mode, dispensing as needed + - FORCE_ON (2): Forced dispensing mode + - MONITORING (3): Monitoring only, not dispensing + - DISPENSING_OFF (4): Dispensing is disabled + + Example: + >>> if csad.mode == CSADMode.AUTO: + ... print("CSAD is in automatic mode") + """ + return self.telemetry.mode + + # Computed properties + @property + def state(self) -> CSADStatus: + """Current dispensing state of the CSAD. + + Returns: + CSADStatus.NOT_DISPENSING (0): Not currently dispensing + CSADStatus.DISPENSING (1): Currently dispensing chemicals + + Example: + >>> if csad.state == CSADStatus.DISPENSING: + ... print("Dispensing chemicals") + """ + return self.status + + @property + def is_on(self) -> bool: + """Check if the CSAD is currently enabled and operational. + + A CSAD is considered "on" if it is enabled in configuration and + not in OFF mode. + + Returns: + True if the CSAD is enabled and operational, False otherwise + + Example: + >>> if csad.is_on: + ... print(f"CSAD is monitoring pH: {csad.current_ph:.2f}") + """ + return self.enabled and self.mode != CSADMode.OFF + + @property + def is_dispensing(self) -> bool: + """Check if the CSAD is currently dispensing chemicals. + + Returns: + True if actively dispensing, False otherwise + + Example: + >>> if csad.is_dispensing: + ... print(f"Dispensing to reach target pH: {csad.target_ph:.2f}") + """ + return self.state == CSADStatus.DISPENSING + + @property + def has_alert(self) -> bool: + """Check if there are any pH or ORP alerts. + + Checks if current readings are outside the configured alarm thresholds. + + Returns: + True if pH or ORP is outside alarm levels, False otherwise + + Example: + >>> if csad.has_alert: + ... print(f"Alert! {csad.alert_status}") + """ + ph_alert = self.current_ph < self.ph_low_alarm or self.current_ph > self.ph_high_alarm + orp_alert = self.current_orp < self.orp_low_alarm_level or self.current_orp > self.orp_high_alarm_level + return ph_alert or orp_alert + + @property + def alert_status(self) -> str: + """Get a human-readable status of any active alerts. + + Returns: + A descriptive string of alert conditions, or 'OK' if no alerts + + Example: + >>> status = csad.alert_status + >>> if status != 'OK': + ... print(f"Chemistry alert: {status}") + """ + alerts = [] + + if self.current_ph < self.ph_low_alarm: + alerts.append(f"pH too low ({self.current_ph:.2f} < {self.ph_low_alarm:.2f})") + elif self.current_ph > self.ph_high_alarm: + alerts.append(f"pH too high ({self.current_ph:.2f} > {self.ph_high_alarm:.2f})") + + if self.current_orp < self.orp_low_alarm_level: + alerts.append(f"ORP too low ({self.current_orp} < {self.orp_low_alarm_level} mV)") + elif self.current_orp > self.orp_high_alarm_level: + alerts.append(f"ORP too high ({self.current_orp} > {self.orp_high_alarm_level} mV)") + + return "; ".join(alerts) if alerts else "OK" + + @property + def current_value(self) -> float: + """Get the primary current value being monitored (pH for most CSAD systems). + + Returns: + Current pH level + + Note: + For ACID type CSAD, this returns pH. For CO2 type, this also returns pH. + Use current_orp property for ORP readings. + """ + return self.current_ph + + @property + def target_value(self) -> float: + """Get the target value the CSAD is trying to maintain (pH target). + + Returns: + Target pH level + + Note: + This is an alias for target_ph for convenience and consistency + with the task requirements. + """ + return self.target_ph + + @property + def ph_offset(self) -> float: + """Calculate how far the current pH is from the target. + + Returns: + Difference between current and target pH (positive = too high, negative = too low) + + Example: + >>> offset = csad.ph_offset + >>> if offset > 0: + ... print(f"pH is {offset:.2f} points above target") + """ + return self.current_ph - self.target_ph + + @property + def is_ready(self) -> bool: + """Check if the CSAD is ready to accept commands. + + A CSAD is considered ready if: + - The backyard is not in service/config mode (checked by parent class) + - It is enabled and in a stable operating mode (AUTO, MONITORING, or FORCE_ON) + - Not in a transitional or error state + + Returns: + True if CSAD can accept commands, False otherwise + + Example: + >>> if csad.is_ready: + ... await csad.set_mode(CSADMode.AUTO) + """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check CSAD-specific readiness + return self.is_on and self.mode in (CSADMode.AUTO, CSADMode.MONITORING, CSADMode.FORCE_ON) diff --git a/pyomnilogic_local/csad_equip.py b/pyomnilogic_local/csad_equip.py new file mode 100644 index 0000000..8ce1e1b --- /dev/null +++ b/pyomnilogic_local/csad_equip.py @@ -0,0 +1,77 @@ +"""CSAD equipment classes for Omnilogic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPCSADEquip + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import CSADEquipmentType + + +class CSADEquipment(OmniEquipment[MSPCSADEquip, None]): + """Represents a CSAD (chemical automation) equipment device. + + CSADEquipment represents an individual physical CSAD device (e.g., AQL-CHEM). + It is controlled by a parent CSAD which manages one or more physical CSAD units. + + The OmniLogic system uses a parent/child CSAD architecture: + - CSAD: User-facing CSAD control (monitoring, dispensing) + - CSADEquipment: Individual physical CSAD devices managed by the parent + + CSAD Equipment Types: + - AQL_CHEM: AquaLink Chemistry System + + Note: Like chlorinator equipment, CSAD equipment does not have separate + telemetry entries. All telemetry is reported through the parent CSAD. + + Attributes: + mspconfig: Configuration data for this physical CSAD equipment + + Properties (Configuration): + equip_type: Equipment type (always "PET_CSAD") + csad_type: Type of CSAD equipment (e.g., AQL_CHEM) + enabled: Whether this CSAD equipment is enabled + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> csad = pool.get_csad() + >>> + >>> # Access physical CSAD equipment + >>> for equip in csad.csad_equipment: + ... print(f"CSAD Equipment: {equip.name}") + ... print(f"Type: {equip.csad_type}") + ... print(f"Enabled: {equip.enabled}") + ... print(f"System ID: {equip.system_id}") + + Important Notes: + - CSADEquipment is read-only (no direct control methods) + - Control CSAD equipment through the parent CSAD instance + - Telemetry is accessed through the parent CSAD, not individual equipment + - Equipment may be disabled but still configured in the system + """ + + mspconfig: MSPCSADEquip + telemetry: None + + def __init__(self, omni: OmniLogic, mspconfig: MSPCSADEquip, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def equip_type(self) -> Literal["PET_CSAD"]: + """Returns the equipment type (always 'PET_CSAD').""" + return self.mspconfig.equip_type + + @property + def csad_type(self) -> CSADEquipmentType | str: + """Returns the type of CSAD equipment (e.g., AQL_CHEM).""" + return self.mspconfig.csad_type + + @property + def enabled(self) -> bool: + """Returns whether the CSAD equipment is enabled in configuration.""" + return self.mspconfig.enabled diff --git a/pyomnilogic_local/decorators.py b/pyomnilogic_local/decorators.py new file mode 100644 index 0000000..9252ad9 --- /dev/null +++ b/pyomnilogic_local/decorators.py @@ -0,0 +1,58 @@ +"""Decorators for equipment control methods.""" + +from __future__ import annotations + +import functools +import logging +from collections.abc import Callable +from typing import Any, cast + +from pyomnilogic_local.util import OmniEquipmentNotReadyError + +_LOGGER = logging.getLogger(__name__) + + +def control_method[F: Callable[..., Any]](func: F) -> F: + """Check readiness and mark state as dirty. + + This decorator ensures equipment is ready before executing control methods and + automatically marks telemetry as dirty after execution. It replaces the common + pattern of checking is_ready and using @dirties_state() separately. + + The decorator: + 1. Checks if equipment is ready (via is_ready property) + 2. Raises OmniEquipmentNotReadyError with descriptive message if not ready + 3. Executes the control method + 4. Marks telemetry as dirty + + Raises: + OmniEquipmentNotReadyError: If equipment is not ready to accept commands + + Example: + @control_method + async def turn_on(self) -> None: + await self._api.async_set_equipment(...) + """ + # Import here to avoid circular dependency + + @functools.wraps(func) + async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + # Check if equipment is ready + if not self.is_ready: + # Generate descriptive error message from function name + action = func.__name__.replace("_", " ") + msg = f"Cannot {action}: equipment is not ready to accept commands" + raise OmniEquipmentNotReadyError(msg) + + # Execute the original function + result = await func(self, *args, **kwargs) + + # Mark telemetry as dirty + if hasattr(self, "_omni"): + self._omni._telemetry_dirty = True + else: + _LOGGER.warning("%s does not have _omni reference, cannot mark state as dirty", self.__class__.__name__) + + return result + + return cast("F", wrapper) diff --git a/pyomnilogic_local/exceptions.py b/pyomnilogic_local/exceptions.py deleted file mode 100644 index 6802807..0000000 --- a/pyomnilogic_local/exceptions.py +++ /dev/null @@ -1,10 +0,0 @@ -class OmniLogicException(Exception): - pass - - -class OmniTimeoutException(OmniLogicException): - pass - - -class OmniParsingException(OmniLogicException): - pass diff --git a/pyomnilogic_local/filter.py b/pyomnilogic_local/filter.py new file mode 100644 index 0000000..a467ec2 --- /dev/null +++ b/pyomnilogic_local/filter.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.models.mspconfig import MSPFilter +from pyomnilogic_local.models.telemetry import TelemetryFilter +from pyomnilogic_local.omnitypes import FilterSpeedPresets, FilterState +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + + +class Filter(OmniEquipment[MSPFilter, TelemetryFilter]): + """Represents a pool/spa filtration pump in the OmniLogic system. + + A filter (also known as a filtration pump) is responsible for circulating and + filtering water through the pool or spa. Most filters support variable speed + operation with configurable presets for energy efficiency. + + The Filter class provides control over pump speed, monitoring of operational + state, and access to power consumption data. Filters can operate at: + - Preset speeds (LOW, MEDIUM, HIGH) configured in the system + - Custom speed percentages (0-100%) + - Variable RPM (for compatible pumps) + + Attributes: + mspconfig: Configuration data for this filter from MSP XML + telemetry: Real-time operational data and state + + Properties (Configuration): + equip_type: Equipment type identifier (e.g., FMT_VARIABLE_SPEED_PUMP) + max_percent: Maximum speed as percentage (0-100) + min_percent: Minimum speed as percentage (0-100) + max_rpm: Maximum speed in RPM + min_rpm: Minimum speed in RPM + priming_enabled: Whether priming mode is enabled + low_speed: Configured low speed preset value + medium_speed: Configured medium speed preset value + high_speed: Configured high speed preset value + + Properties (Telemetry): + state: Current operational state (OFF, ON, PRIMING, etc.) + speed: Current operating speed + valve_position: Current valve position + why_on: Reason code for pump being on + reported_speed: Speed reported by pump + power: Current power consumption in watts + last_speed: Previous speed setting + + Properties (Computed): + is_on: True if filter is currently running + is_ready: True if filter can accept commands + + Control Methods: + turn_on(): Turn on filter at last used speed + turn_off(): Turn off filter + run_preset_speed(speed): Run at LOW, MEDIUM, or HIGH preset + set_speed(speed): Run at specific percentage (0-100) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> filter = pool.filters["Main Filter"] + >>> + >>> # Check current state + >>> print(f"Filter is {'on' if filter.is_on else 'off'}") + >>> print(f"Speed: {filter.speed}%, Power: {filter.power}W") + >>> + >>> # Control filter + >>> await filter.turn_on() # Turn on at last speed + >>> await filter.run_preset_speed(FilterSpeedPresets.LOW) + >>> await filter.set_speed(75) # Set to 75% + >>> await filter.turn_off() + + Note: + - Speed value of 0 will turn the filter off + - The API automatically validates against min_percent/max_percent + - Filter state may transition through PRIMING before reaching ON + - Not all filters support all speed ranges (check min/max values) + """ + + mspconfig: MSPFilter + telemetry: TelemetryFilter + + # Expose MSPConfig attributes + @property + def equip_type(self) -> str: + """The filter type (e.g., FMT_VARIABLE_SPEED_PUMP).""" + return self.mspconfig.equip_type + + @property + def max_percent(self) -> int: + """Maximum pump speed percentage.""" + return self.mspconfig.max_percent + + @property + def min_percent(self) -> int: + """Minimum pump speed percentage.""" + return self.mspconfig.min_percent + + @property + def max_rpm(self) -> int: + """Maximum pump speed in RPM.""" + return self.mspconfig.max_rpm + + @property + def min_rpm(self) -> int: + """Minimum pump speed in RPM.""" + return self.mspconfig.min_rpm + + @property + def priming_enabled(self) -> bool: + """Whether priming is enabled for this filter.""" + return self.mspconfig.priming_enabled + + @property + def low_speed(self) -> int: + """Low speed preset value.""" + return self.mspconfig.low_speed + + @property + def medium_speed(self) -> int: + """Medium speed preset value.""" + return self.mspconfig.medium_speed + + @property + def high_speed(self) -> int: + """High speed preset value.""" + return self.mspconfig.high_speed + + # Expose Telemetry attributes + @property + def state(self) -> FilterState | int: + """Current filter state.""" + return self.telemetry.state + + @property + def speed(self) -> int: + """Current filter speed.""" + return self.telemetry.speed + + @property + def valve_position(self) -> int: + """Current valve position.""" + return self.telemetry.valve_position + + @property + def why_on(self) -> int: + """Reason why the filter is on.""" + return self.telemetry.why_on + + @property + def reported_speed(self) -> int: + """Reported filter speed.""" + return self.telemetry.reported_speed + + @property + def power(self) -> int: + """Current power consumption.""" + return self.telemetry.power + + @property + def last_speed(self) -> int: + """Last speed setting.""" + return self.telemetry.last_speed + + # Computed properties + @property + def is_on(self) -> bool: + """Check if the filter is currently on. + + Returns: + True if filter state is ON (1), False otherwise + """ + return self.state in ( + FilterState.ON, + FilterState.PRIMING, + FilterState.HEATER_EXTEND, + FilterState.CSAD_EXTEND, + FilterState.FILTER_FORCE_PRIMING, + FilterState.FILTER_SUPERCHLORINATE, + ) + + @property + def is_ready(self) -> bool: + """Check if the filter is ready to receive commands. + + A filter is considered ready if: + - The backyard is not in service/config mode (checked by parent class) + - It's not in a transitional state like priming, waiting to turn off, or cooling down + + Returns: + True if filter can accept commands, False otherwise + """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check filter-specific readiness + return self.state in (FilterState.OFF, FilterState.ON) + + # Control methods + @control_method + async def turn_on(self) -> None: + """Turn the filter on. + + This will turn on the filter at its last used speed setting. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + msg = "Filter bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=self.last_speed, + ) + + @control_method + async def turn_off(self) -> None: + """Turn the filter off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + msg = "Filter bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=False, + ) + + @control_method + async def run_preset_speed(self, speed: FilterSpeedPresets) -> None: + """Run the filter at a preset speed. + + Args: + speed: The preset speed to use (LOW, MEDIUM, or HIGH) + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If an invalid speed preset is provided. + """ + if self.bow_id is None or self.system_id is None: + msg = "Filter bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + speed_value: int + match speed: + case FilterSpeedPresets.LOW: + speed_value = self.low_speed + case FilterSpeedPresets.MEDIUM: + speed_value = self.medium_speed + case FilterSpeedPresets.HIGH: + speed_value = self.high_speed + case _: + msg = f"Invalid speed preset: {speed}" + raise ValueError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=speed_value, + ) + + @control_method + async def set_speed(self, speed: int) -> None: + """Set the filter to a specific speed. + + Args: + speed: Speed value (0-100 percent). A value of 0 will turn the filter off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If speed is outside the valid range. + """ + if self.bow_id is None or self.system_id is None: + msg = "Filter bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + if not self.min_percent <= speed <= self.max_percent: + msg = f"Speed {speed} is outside valid range [{self.min_percent}, {self.max_percent}]" + raise ValueError(msg) + + # Note: The API validates against min_percent/max_percent internally + await self._api.async_set_filter_speed( + pool_id=self.bow_id, + equipment_id=self.system_id, + speed=speed, + ) diff --git a/pyomnilogic_local/groups.py b/pyomnilogic_local/groups.py new file mode 100644 index 0000000..2c9be77 --- /dev/null +++ b/pyomnilogic_local/groups.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.models.mspconfig import MSPGroup +from pyomnilogic_local.models.telemetry import TelemetryGroup +from pyomnilogic_local.omnitypes import GroupState +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + + +class Group(OmniEquipment[MSPGroup, TelemetryGroup]): + """Represents a group in the OmniLogic system. + + Groups allow multiple pieces of equipment to be controlled together as a single unit. + When a group is activated, all equipment assigned to that group will turn on/off together. + This provides convenient one-touch control for common pool/spa scenarios. + + Groups are defined in the OmniLogic configuration and can include any combination + of relays, pumps, lights, heaters, and other controllable equipment. + + Within the OmniLogic App and Web Interface Groups are referred to as Themes. + Within this library the term "Group" is used as that is how they are referred to in the + MSPConfig. + + Attributes: + mspconfig: Configuration data for this group from MSP XML + telemetry: Real-time state data for the group + + Properties: + icon_id: The icon identifier for the group (used in UI displays) + state: Current state of the group (ON or OFF) + is_on: True if the group is currently active + + Control Methods: + turn_on(): Activate all equipment in the group + turn_off(): Deactivate all equipment in the group + + Example: + >>> omni = OmniLogic(...) + >>> await omni.connect() + >>> + >>> # Access a group by name + >>> all_features = omni.groups["All Features"] + >>> + >>> # Check current state + >>> if all_features.is_on: + ... print("All features are currently active") + >>> + >>> # Control the group + >>> await all_features.turn_on() # Turn on all equipment in group + >>> await all_features.turn_off() # Turn off all equipment in group + >>> + >>> # Get group properties + >>> print(f"Group: {all_features.name}") + >>> print(f"Icon ID: {all_features.icon_id}") + >>> print(f"System ID: {all_features.system_id}") + + Note: + - Groups control multiple pieces of equipment simultaneously + - Group membership is defined in OmniLogic configuration + - Within the config, there is data for what equipment is in each group, but this library + does not currently expose that membership information within the interaction layer. + """ + + mspconfig: MSPGroup + telemetry: TelemetryGroup + + def __init__(self, omni: OmniLogic, mspconfig: MSPGroup, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def icon_id(self) -> int: + """Returns the icon ID for the group.""" + return self.mspconfig.icon_id + + @property + def state(self) -> GroupState: + """Returns the current state of the group.""" + return self.telemetry.state + + @property + def is_on(self) -> bool: + """Returns whether the group is currently active.""" + return self.state == GroupState.ON + + @control_method + async def turn_on(self) -> None: + """Activate the group, turning on all equipment assigned to it. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + """ + if self.system_id is None: + msg = "Cannot turn on group: system_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_group_enable(self.system_id, True) + + @control_method + async def turn_off(self) -> None: + """Deactivate the group, turning off all equipment assigned to it. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + """ + if self.system_id is None: + msg = "Cannot turn off group: system_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_group_enable(self.system_id, False) diff --git a/pyomnilogic_local/heater.py b/pyomnilogic_local/heater.py new file mode 100644 index 0000000..5f2ee57 --- /dev/null +++ b/pyomnilogic_local/heater.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.collections import EquipmentDict +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.heater_equip import HeaterEquipment +from pyomnilogic_local.models.mspconfig import MSPVirtualHeater +from pyomnilogic_local.models.telemetry import TelemetryVirtualHeater +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import HeaterMode + + +class Heater(OmniEquipment[MSPVirtualHeater, TelemetryVirtualHeater]): + """Represents a heater system in the OmniLogic system. + + A heater maintains water temperature by heating pool or spa water to a + configured set point. The OmniLogic system supports various heater types: + - Gas heaters (natural gas or propane) + - Heat pumps (electric, energy efficient) + - Solar heaters (passive solar collection) + - Hybrid systems (combination of multiple heater types) + + The Heater class is actually a "virtual heater" that can manage one or more + physical heater equipment units. It provides temperature control, mode + selection, and monitoring of heater operation. + + Attributes: + mspconfig: Configuration data for this heater from MSP XML + telemetry: Real-time operational data and state + heater_equipment: Collection of physical heater units (HeaterEquipment) + + Properties (Configuration): + max_temp: Maximum settable temperature (Fahrenheit) + min_temp: Minimum settable temperature (Fahrenheit) + + Properties (Telemetry): + mode: Current heater mode (OFF, HEAT, AUTO, etc.) + current_set_point: Current target temperature (Fahrenheit) + solar_set_point: Solar heater target temperature (Fahrenheit) + enabled: Whether heater is enabled + silent_mode: Silent mode setting (reduced noise operation) + why_on: Reason code for heater being on + is_on: True if heater is enabled + + Control Methods: + turn_on(): Enable the heater + turn_off(): Disable the heater + set_temperature(temp): Set target temperature (Fahrenheit) + set_solar_temperature(temp): Set solar target temperature (Fahrenheit) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> heater = pool.heater + >>> + >>> # Check current state + >>> print(f"Heater enabled: {heater.is_on}") + >>> print(f"Current set point: {heater.current_set_point}°F") + >>> print(f"Mode: {heater.mode}") + >>> + >>> # Control heater + >>> await heater.turn_on() + >>> await heater.set_temperature(85) # Set to 85°F + >>> + >>> # For systems with solar heaters + >>> if heater.solar_set_point > 0: + ... await heater.set_solar_temperature(90) + >>> + >>> await heater.turn_off() + >>> + >>> # Access physical heater equipment + >>> for equip in heater.heater_equipment: + ... print(f"Heater: {equip.name}, Type: {equip.equip_type}") + + Important - Temperature Units: + ALL temperature values in the OmniLogic API are in Fahrenheit, regardless + of the display units configured in the system. This is an internal API + requirement and cannot be changed. + + - All temperature properties return Fahrenheit values + - All temperature parameters must be provided in Fahrenheit + - Use system.units to determine display preference (not API units) + - If your application uses Celsius, convert before calling these methods + + Example conversion: + >>> # If working in Celsius + >>> celsius_target = 29 + >>> fahrenheit_target = (celsius_target * 9/5) + 32 + >>> await heater.set_temperature(int(fahrenheit_target)) + + Note: + - Temperature range is enforced (min_temp to max_temp) + - Multiple physical heaters may be grouped under one virtual heater + - Solar heaters have separate set points from gas/heat pump heaters + - Heater may not turn on immediately if water temp is already at set point + """ + + mspconfig: MSPVirtualHeater + telemetry: TelemetryVirtualHeater + heater_equipment: EquipmentDict[HeaterEquipment] = EquipmentDict() + + def __init__(self, omni: OmniLogic, mspconfig: MSPVirtualHeater, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + def _update_equipment(self, mspconfig: MSPVirtualHeater, telemetry: Telemetry | None) -> None: + """Update both the configuration and telemetry data for the equipment.""" + if telemetry is None: + return + self._update_heater_equipment(mspconfig, telemetry) + + def _update_heater_equipment(self, mspconfig: MSPVirtualHeater, telemetry: Telemetry) -> None: + """Update the heater equipment based on the MSP configuration.""" + if mspconfig.heater_equipment is None: + self.heater_equipment = EquipmentDict() + return + + self.heater_equipment = EquipmentDict([HeaterEquipment(self._omni, equip, telemetry) for equip in mspconfig.heater_equipment]) + + @property + def max_temp(self) -> int: + """Returns the maximum settable temperature. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.mspconfig.max_temp + + @property + def min_temp(self) -> int: + """Returns the minimum settable temperature. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.mspconfig.min_temp + + @property + def mode(self) -> HeaterMode | int: + """Returns the current heater mode from telemetry.""" + return self.telemetry.mode + + @property + def current_set_point(self) -> int: + """Returns the current set point from telemetry. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.telemetry.current_set_point + + @property + def solar_set_point(self) -> int: + """Returns the solar set point from telemetry. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.telemetry.solar_set_point + + @property + def enabled(self) -> bool: + """Returns whether the heater is enabled from telemetry.""" + return self.telemetry.enabled + + @property + def silent_mode(self) -> int: + """Returns the silent mode setting from telemetry.""" + return self.telemetry.silent_mode + + @property + def why_on(self) -> int: + """Returns the reason why the heater is on from telemetry.""" + return self.telemetry.why_on + + @property + def is_on(self) -> bool: + """Returns whether the heater is currently enabled (from telemetry).""" + return self.telemetry.enabled + + @control_method + async def turn_on(self) -> None: + """Turn the heater on (enables it). + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. + """ + if self.bow_id is None or self.system_id is None: + msg = "Cannot turn on heater: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_heater_enable(self.bow_id, self.system_id, True) + + @control_method + async def turn_off(self) -> None: + """Turn the heater off (disables it). + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. + """ + if self.bow_id is None or self.system_id is None: + msg = "Cannot turn off heater: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_heater_enable(self.bow_id, self.system_id, False) + + @control_method + async def set_temperature(self, temperature: int) -> None: + """Set the target temperature for the heater. + + Args: + temperature: The target temperature to set in Fahrenheit. + Must be between min_temp and max_temp. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. + ValueError: If temperature is outside the valid range. + + Note: + Temperature must be provided in Fahrenheit as that is what the OmniLogic + system uses internally. The system.units setting only affects display, + not the API. If your application uses Celsius, you must convert to + Fahrenheit before calling this method. + """ + if self.bow_id is None or self.system_id is None: + msg = "Cannot set heater temperature: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + + if temperature < self.min_temp or temperature > self.max_temp: + msg = f"Temperature {temperature}°F is outside valid range [{self.min_temp}°F, {self.max_temp}°F]" + raise ValueError(msg) + + # Always use Fahrenheit as that's what the OmniLogic system uses internally + await self._api.async_set_heater(self.bow_id, self.system_id, temperature) + + @control_method + async def set_solar_temperature(self, temperature: int) -> None: + """Set the solar heater set point. + + Args: + temperature: The target solar temperature to set in Fahrenheit. + Must be between min_temp and max_temp. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + OmniEquipmentNotReadyError: If the equipment is not ready to accept commands. + ValueError: If temperature is outside the valid range. + + Note: + Temperature must be provided in Fahrenheit as that is what the OmniLogic + system uses internally. The system.units setting only affects display, + not the API. If your application uses Celsius, you must convert to + Fahrenheit before calling this method. + """ + if self.bow_id is None or self.system_id is None: + msg = "Cannot set solar heater temperature: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + + if temperature < self.min_temp or temperature > self.max_temp: + msg = f"Temperature {temperature}°F is outside valid range [{self.min_temp}°F, {self.max_temp}°F]" + raise ValueError(msg) + + # Always use Fahrenheit as that's what the OmniLogic system uses internally + await self._api.async_set_solar_heater(self.bow_id, self.system_id, temperature) diff --git a/pyomnilogic_local/heater_equip.py b/pyomnilogic_local/heater_equip.py new file mode 100644 index 0000000..53a75a8 --- /dev/null +++ b/pyomnilogic_local/heater_equip.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPHeaterEquip +from pyomnilogic_local.models.telemetry import TelemetryHeater +from pyomnilogic_local.omnitypes import HeaterState + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import HeaterType + + +class HeaterEquipment(OmniEquipment[MSPHeaterEquip, TelemetryHeater]): + """Represents physical heater equipment in the OmniLogic system. + + HeaterEquipment represents an individual physical heating device (gas heater, + heat pump, solar panel system, etc.). It is controlled by a parent VirtualHeater + which can manage one or more physical heater units. + + The OmniLogic system uses a virtual/physical heater architecture: + - VirtualHeater: User-facing heater control (turn_on, set_temperature, etc.) + - HeaterEquipment: Individual physical heating devices managed by the virtual heater + + This architecture allows the system to coordinate multiple heating sources + (e.g., solar + gas backup) under a single virtual heater interface. + + Heater Types: + - GAS: Natural gas or propane heater (fast heating) + - HEAT_PUMP: Electric heat pump (energy efficient) + - SOLAR: Solar heating panels (free but weather-dependent) + - HYBRID: Combination systems + + Attributes: + mspconfig: Configuration data for this physical heater + telemetry: Real-time operational state + + Properties (Configuration): + heater_type: Type of heating unit (GAS, HEAT_PUMP, SOLAR) + min_filter_speed: Minimum filter speed required for operation + sensor_id: System ID of the temperature sensor + supports_cooling: Whether this unit can cool + + Properties (Telemetry): + state: Current heater state (OFF, ON, PAUSE) + current_temp: Temperature reading from associated sensor (Fahrenheit) + enabled: Whether heater is enabled + priority: Heater priority for multi-heater systems + maintain_for: Time to maintain current operation + is_on: True if heater is currently running + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> heater = pool.heater + >>> + >>> # Access physical heater equipment + >>> for equip in heater.heater_equipment: + ... print(f"Heater: {equip.name}") + ... print(f"Type: {equip.heater_type}") + ... print(f"State: {equip.state}") + ... print(f"Current temp: {equip.current_temp}°F") + ... print(f"Is on: {equip.is_on}") + ... print(f"Min filter speed: {equip.min_filter_speed}%") + >>> + >>> # Check for cooling support (heat pumps) + >>> gas_heater = heater.heater_equipment["Gas Heater"] + >>> if gas_heater.supports_cooling: + ... print("This unit can cool as well as heat") + + Important - Temperature Units: + ALL temperature values are in Fahrenheit, regardless of system display + settings. The system.units property only affects user interface display, + not internal API values. + + Note: + - HeaterEquipment is read-only (no direct control methods) + - Control heaters through the parent VirtualHeater instance + - Multiple heater equipment can work together (e.g., solar + gas) + - Priority determines which heater runs first in multi-heater systems + - Minimum filter speed must be met for safe heater operation + - State transitions: OFF → ON → PAUSE (when conditions not met) + """ + + mspconfig: MSPHeaterEquip + telemetry: TelemetryHeater + + def __init__(self, omni: OmniLogic, mspconfig: MSPHeaterEquip, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def heater_type(self) -> HeaterType: + """Returns the type of heater (GAS, HEAT_PUMP, SOLAR, etc.).""" + return self.mspconfig.heater_type + + @property + def min_filter_speed(self) -> int: + """Returns the minimum filter speed required for heater operation.""" + return self.mspconfig.min_filter_speed + + @property + def sensor_id(self) -> int: + """Returns the system ID of the sensor associated with this heater.""" + return self.mspconfig.sensor_id + + @property + def supports_cooling(self) -> bool | None: + """Returns whether the heater supports cooling mode, if available.""" + return self.mspconfig.supports_cooling + + @property + def state(self) -> HeaterState | int: + """Returns the current state of the heater equipment (OFF, ON, or PAUSE).""" + return self.telemetry.state + + @property + def current_temp(self) -> int: + """Return the current temperature reading from telemetry. + + Note: Temperature is always in Fahrenheit internally. + Use the system.units property to determine if conversion to Celsius is needed for display. + """ + return self.telemetry.temp + + @property + def enabled(self) -> bool: + """Returns whether the heater equipment is enabled from telemetry.""" + return self.telemetry.enabled + + @property + def priority(self) -> int: + """Returns the priority of this heater equipment.""" + return self.telemetry.priority + + @property + def maintain_for(self) -> int: + """Returns the maintain_for value from telemetry.""" + return self.telemetry.maintain_for + + @property + def is_on(self) -> bool: + """Returns whether the heater equipment is currently on.""" + return self.state == HeaterState.ON diff --git a/pyomnilogic_local/models/__init__.py b/pyomnilogic_local/models/__init__.py index e69de29..320c5d4 100644 --- a/pyomnilogic_local/models/__init__.py +++ b/pyomnilogic_local/models/__init__.py @@ -0,0 +1,16 @@ +"""Pydantic models for the Hayward OmniLogic Local API.""" + +from __future__ import annotations + +from .filter_diagnostics import FilterDiagnostics +from .mspconfig import MSPConfig, MSPConfigType, MSPEquipmentType +from .telemetry import Telemetry, TelemetryType + +__all__ = [ + "FilterDiagnostics", + "MSPConfig", + "MSPConfigType", + "MSPEquipmentType", + "Telemetry", + "TelemetryType", +] diff --git a/pyomnilogic_local/models/exceptions.py b/pyomnilogic_local/models/exceptions.py new file mode 100644 index 0000000..bc486ec --- /dev/null +++ b/pyomnilogic_local/models/exceptions.py @@ -0,0 +1,2 @@ +class OmniParsingError(Exception): + pass diff --git a/pyomnilogic_local/models/filter_diagnostics.py b/pyomnilogic_local/models/filter_diagnostics.py index 9423531..939fa21 100644 --- a/pyomnilogic_local/models/filter_diagnostics.py +++ b/pyomnilogic_local/models/filter_diagnostics.py @@ -3,12 +3,38 @@ from pydantic import BaseModel, ConfigDict, Field from xmltodict import parse as xml_parse +# Example Filter Diagnostics XML: +# +# +# +# GetUIFilterDiagnosticInfoRsp +# +# 7 +# 8 +# 133 +# 4 +# 0 +# 49 +# 48 +# 49 +# 53 +# 32 +# 0 +# 48 +# 48 +# 55 +# 48 +# 32 +# 0 +# +# + class FilterDiagnosticsParameter(BaseModel): model_config = ConfigDict(from_attributes=True) name: str = Field(alias="@name") - dataType: str = Field(alias="@dataType") + data_type: str = Field(alias="@dataType") value: int = Field(alias="#text") @@ -22,11 +48,10 @@ class FilterDiagnostics(BaseModel): model_config = ConfigDict(from_attributes=True) name: str = Field(alias="Name") - # parameters: FilterDiagnosticsParameters = Field(alias="Parameters") parameters: list[FilterDiagnosticsParameter] = Field(alias="Parameters") def get_param_by_name(self, name: str) -> int: - return [param.value for param in self.parameters if param.name == name][0] + return next(param.value for param in self.parameters if param.name == name) @staticmethod def load_xml(xml: str) -> FilterDiagnostics: diff --git a/pyomnilogic_local/models/leadmessage.py b/pyomnilogic_local/models/leadmessage.py index 1034de6..c0a34ab 100644 --- a/pyomnilogic_local/models/leadmessage.py +++ b/pyomnilogic_local/models/leadmessage.py @@ -7,6 +7,19 @@ from .const import XML_NS +# Example Lead Message XML: +# +# +# +# LeadMessage +# +# 1003 +# 3709 +# 4 +# 0 +# +# + class LeadMessage(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/pyomnilogic_local/models/mspconfig.py b/pyomnilogic_local/models/mspconfig.py index 8b98546..b71fafe 100644 --- a/pyomnilogic_local/models/mspconfig.py +++ b/pyomnilogic_local/models/mspconfig.py @@ -1,36 +1,50 @@ +# ruff: noqa: TC001 # pydantic relies on the omnitypes imports at runtime from __future__ import annotations +import contextlib import logging -import sys -from typing import Any, Literal, TypeAlias - -if sys.version_info >= (3, 11): - from typing import Self -else: - from typing_extensions import Self - -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from typing import Any, ClassVar, Literal, Self + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + computed_field, + model_validator, +) from xmltodict import parse as xml_parse -from ..exceptions import OmniParsingException -from ..omnitypes import ( +from pyomnilogic_local.omnitypes import ( BodyOfWaterType, ChlorinatorCellType, ChlorinatorDispenserType, + ChlorinatorType, ColorLogicLightType, - ColorLogicShow, + ColorLogicShow25, + ColorLogicShow40, + ColorLogicShowUCL, + ColorLogicShowUCLV2, + CSADEquipmentType, CSADType, FilterType, HeaterType, + LightShows, + MessageType, OmniType, + PentairShow, PumpFunction, PumpType, RelayFunction, RelayType, + ScheduleDaysActive, SensorType, SensorUnits, + ZodiacShow, ) +from .exceptions import OmniParsingError + _LOGGER = logging.getLogger(__name__) @@ -40,17 +54,16 @@ class OmniBase(BaseModel): _sub_devices: set[str] | None = None system_id: int = Field(alias="System-Id") name: str | None = Field(alias="Name", default=None) - bow_id: int | None = None + bow_id: int = -1 + omni_type: OmniType def without_subdevices(self) -> Self: - data = self.model_dump(exclude=self._sub_devices, round_trip=True) - data = {**data, **{}} + data = self.model_dump(exclude=self._sub_devices, round_trip=True, by_alias=True) copied = self.model_validate(data) _LOGGER.debug("without_subdevices: original=%s, copied=%s", self, copied) return copied - # return self.copy(exclude=self._sub_devices) - def propagate_bow_id(self, bow_id: int | None) -> None: + def propagate_bow_id(self, bow_id: int) -> None: # First we set our own bow_id self.bow_id = bow_id # If we have no devices under us, we have nothing to do @@ -68,45 +81,75 @@ def propagate_bow_id(self, bow_id: int | None) -> None: elif subdevice is not None: subdevice.propagate_bow_id(bow_id) + _YES_NO_FIELDS: ClassVar[set[str]] = set() + + @model_validator(mode="before") + @classmethod + def convert_yes_no_to_bool(cls, data: Any) -> Any: + # Check if data is a dictionary (common when loading from XML/JSON) + if not isinstance(data, dict): + return data + + for key in cls._YES_NO_FIELDS: + raw_value = data.get(key) + + if isinstance(raw_value, str): + lower_value = raw_value.lower() + + if lower_value == "yes": + data[key] = True + elif lower_value == "no": + data[key] = False + + return data + class MSPSystem(BaseModel): model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.SYSTEM + vsp_speed_format: Literal["RPM", "Percent"] = Field(alias="Msp-Vsp-Speed-Format") units: Literal["Standard", "Metric"] = Field(alias="Units") class MSPSensor(OmniBase): omni_type: OmniType = OmniType.SENSOR - type: SensorType | str = Field(alias="Type") - units: SensorUnits | str = Field(alias="Units") + + equip_type: SensorType = Field(alias="Type") + units: SensorUnits = Field(alias="Units") class MSPFilter(OmniBase): + _YES_NO_FIELDS = {"priming_enabled"} + omni_type: OmniType = OmniType.FILTER - type: FilterType | str = Field(alias="Filter-Type") + + equip_type: FilterType = Field(alias="Filter-Type") max_percent: int = Field(alias="Max-Pump-Speed") min_percent: int = Field(alias="Min-Pump-Speed") max_rpm: int = Field(alias="Max-Pump-RPM") min_rpm: int = Field(alias="Min-Pump-RPM") # We should figure out how to coerce this field into a True/False - priming_enabled: Literal["yes", "no"] = Field(alias="Priming-Enabled") + priming_enabled: bool = Field(alias="Priming-Enabled") low_speed: int = Field(alias="Vsp-Low-Pump-Speed") medium_speed: int = Field(alias="Vsp-Medium-Pump-Speed") high_speed: int = Field(alias="Vsp-High-Pump-Speed") class MSPPump(OmniBase): + _YES_NO_FIELDS = {"priming_enabled"} + omni_type: OmniType = OmniType.PUMP - type: PumpType | str = Field(alias="Type") - function: PumpFunction | str = Field(alias="Function") + + equip_type: PumpType = Field(alias="Type") + function: PumpFunction = Field(alias="Function") max_percent: int = Field(alias="Max-Pump-Speed") min_percent: int = Field(alias="Min-Pump-Speed") max_rpm: int = Field(alias="Max-Pump-RPM") min_rpm: int = Field(alias="Min-Pump-RPM") # We should figure out how to coerce this field into a True/False - priming_enabled: Literal["yes", "no"] = Field(alias="Priming-Enabled") + priming_enabled: bool = Field(alias="Priming-Enabled") low_speed: int = Field(alias="Vsp-Low-Pump-Speed") medium_speed: int = Field(alias="Vsp-Medium-Pump-Speed") high_speed: int = Field(alias="Vsp-High-Pump-Speed") @@ -114,26 +157,32 @@ class MSPPump(OmniBase): class MSPRelay(OmniBase): omni_type: OmniType = OmniType.RELAY - type: RelayType | str = Field(alias="Type") - function: RelayFunction | str = Field(alias="Function") + + type: RelayType = Field(alias="Type") + function: RelayFunction = Field(alias="Function") class MSPHeaterEquip(OmniBase): + _YES_NO_FIELDS = {"enabled", "supports_cooling"} + omni_type: OmniType = OmniType.HEATER_EQUIP - type: Literal["PET_HEATER"] = Field(alias="Type") - heater_type: HeaterType | str = Field(alias="Heater-Type") - enabled: Literal["yes", "no"] = Field(alias="Enabled") + + equip_type: Literal["PET_HEATER"] = Field(alias="Type") + heater_type: HeaterType = Field(alias="Heater-Type") + enabled: bool = Field(alias="Enabled") min_filter_speed: int = Field(alias="Min-Speed-For-Operation") sensor_id: int = Field(alias="Sensor-System-Id") - supports_cooling: Literal["yes", "no"] | None = Field(alias="SupportsCooling", default=None) + supports_cooling: bool | None = Field(alias="SupportsCooling", default=None) # This is the entry for the VirtualHeater, it does not use OmniBase because it has no name attribute class MSPVirtualHeater(OmniBase): _sub_devices = {"heater_equipment"} + _YES_NO_FIELDS = {"enabled"} omni_type: OmniType = OmniType.VIRT_HEATER - enabled: Literal["yes", "no"] = Field(alias="Enabled") + + enabled: bool = Field(alias="Enabled") set_point: int = Field(alias="Current-Set-Point") solar_set_point: int | None = Field(alias="SolarSetPoint", default=None) max_temp: int = Field(alias="Max-Settable-Water-Temp") @@ -150,37 +199,69 @@ def __init__(self, **data: Any) -> None: class MSPChlorinatorEquip(OmniBase): + _YES_NO_FIELDS = {"enabled"} + omni_type: OmniType = OmniType.CHLORINATOR_EQUIP - enabled: Literal["yes", "no"] = Field(alias="Enabled") + + equip_type: Literal["PET_CHLORINATOR"] = Field(alias="Type") + chlorinator_type: ChlorinatorType = Field(alias="Chlorinator-Type") + enabled: bool = Field(alias="Enabled") class MSPChlorinator(OmniBase): _sub_devices = {"chlorinator_equipment"} + _YES_NO_FIELDS = {"enabled"} omni_type: OmniType = OmniType.CHLORINATOR - enabled: Literal["yes", "no"] = Field(alias="Enabled") + + enabled: bool = Field(alias="Enabled") timed_percent: int = Field(alias="Timed-Percent") superchlor_timeout: int = Field(alias="SuperChlor-Timeout") orp_timeout: int = Field(alias="ORP-Timeout") - dispenser_type: ChlorinatorDispenserType | str = Field(alias="Dispenser-Type") + dispenser_type: ChlorinatorDispenserType = Field(alias="Dispenser-Type") cell_type: ChlorinatorCellType = Field(alias="Cell-Type") - chlorinator_equipment: list[MSPChlorinatorEquip] | None + chlorinator_equipment: list[MSPChlorinatorEquip] | None = None + + @model_validator(mode="before") + @classmethod + def convert_cell_type(cls, data: Any) -> Any: + """Convert cell_type string to ChlorinatorCellType enum by name.""" + if isinstance(data, dict) and "Cell-Type" in data: + cell_type_str = data["Cell-Type"] + if isinstance(cell_type_str, str): + # Parse by enum member name (e.g., "CELL_TYPE_T15" -> ChlorinatorCellType.CELL_TYPE_T15) + with contextlib.suppress(KeyError): + data["Cell-Type"] = ChlorinatorCellType[cell_type_str] + return data def __init__(self, **data: Any) -> None: super().__init__(**data) - # The heater equipment are nested down inside a list of "Operations", which also includes non Heater-Equipment items. We need to - # first filter down to just the heater equipment items, then populate our self.heater_equipment with parsed versions of those items. - chlorinator_equip_data = [op for op in data.get("Operation", {}) if OmniType.CHLORINATOR_EQUIP in op][0] - self.chlorinator_equipment = [ - MSPChlorinatorEquip.model_validate(equip) for equip in chlorinator_equip_data[OmniType.CHLORINATOR_EQUIP] - ] + # The chlorinator equipment are nested down inside a list of "Operations", which also includes non Chlorinator-Equipment items. + # We need to first filter down to just the chlorinator equipment items, then populate our self.chlorinator_equipment with parsed + # versions of those items. + chlorinator_equip_data = [op[OmniType.CHLORINATOR_EQUIP] for op in data.get("Operation", {}) if OmniType.CHLORINATOR_EQUIP in op] + self.chlorinator_equipment = [MSPChlorinatorEquip.model_validate(equip) for equip in chlorinator_equip_data] + + +class MSPCSADEquip(OmniBase): + _YES_NO_FIELDS = {"enabled"} + + omni_type: OmniType = OmniType.CSAD_EQUIP + + equip_type: Literal["PET_CSAD"] = Field(alias="Type") + csad_type: CSADEquipmentType | str = Field(alias="CSAD-Type") + enabled: bool = Field(alias="Enabled") class MSPCSAD(OmniBase): + _sub_devices = {"csad_equipment"} + _YES_NO_FIELDS = {"enabled"} + omni_type: OmniType = OmniType.CSAD - enabled: Literal["yes", "no"] = Field(alias="Enabled") - type: CSADType | str = Field(alias="Type") + + enabled: bool = Field(alias="Enabled") + equip_type: CSADType = Field(alias="Type") target_value: float = Field(alias="TargetValue") calibration_value: float = Field(alias="CalibrationValue") ph_low_alarm_value: float = Field(alias="PHLowAlarmLevel") @@ -191,25 +272,61 @@ class MSPCSAD(OmniBase): orp_high_alarm_level: int = Field(alias="ORP-High-Alarm-Level") orp_forced_on_time: int = Field(alias="ORP-Forced-On-Time") orp_forced_enabled: bool = Field(alias="ORP-Forced-Enabled") + csad_equipment: list[MSPCSADEquip] | None = None + + def __init__(self, **data: Any) -> None: + super().__init__(**data) + + # The CSAD equipment are nested down inside a list of "Operations", which also includes non CSAD-Equipment items. + # We need to first filter down to just the CSAD equipment items, then populate our self.csad_equipment with parsed + # versions of those items. + csad_equip_data = [op[OmniType.CSAD_EQUIP] for op in data.get("Operation", {}) if OmniType.CSAD_EQUIP in op] + self.csad_equipment = [MSPCSADEquip.model_validate(equip) for equip in csad_equip_data] class MSPColorLogicLight(OmniBase): + _YES_NO_FIELDS = {"v2_active"} + omni_type: OmniType = OmniType.CL_LIGHT - type: ColorLogicLightType | str = Field(alias="Type") - v2_active: Literal["yes", "no"] | None = Field(alias="V2-Active", default=None) - effects: list[ColorLogicShow] | None = None + + equip_type: ColorLogicLightType = Field(alias="Type") + v2_active: bool = Field(alias="V2-Active", default=False) + effects: list[LightShows] | None = None def __init__(self, **data: Any) -> None: super().__init__(**data) - self.effects = list(ColorLogicShow) if self.v2_active == "yes" else [show for show in ColorLogicShow if show.value <= 16] + + # Get the available light shows depending on the light type. + match self.equip_type: + case ColorLogicLightType.TWO_FIVE: + self.effects = list(ColorLogicShow25) + case ColorLogicLightType.FOUR_ZERO: + self.effects = list(ColorLogicShow40) + case ColorLogicLightType.UCL: + if self.v2_active: + self.effects = list(ColorLogicShowUCLV2) + else: + self.effects = list(ColorLogicShowUCL) + case ColorLogicLightType.PENTAIR_COLOR: + self.effects = list(PentairShow) + case ColorLogicLightType.ZODIAC_COLOR: + self.effects = list(ZodiacShow) + + +class MSPGroup(OmniBase): + omni_type: OmniType = OmniType.GROUP + + icon_id: int = Field(alias="Icon-Id") class MSPBoW(OmniBase): _sub_devices = {"filter", "relay", "heater", "sensor", "colorlogic_light", "pump", "chlorinator", "csad"} + _YES_NO_FIELDS = {"supports_spillover"} omni_type: OmniType = OmniType.BOW - type: BodyOfWaterType | str = Field(alias="Type") - supports_spillover: Literal["yes", "no"] = Field(alias="Supports-Spillover") + + equip_type: BodyOfWaterType = Field(alias="Type") + supports_spillover: bool = Field(alias="Supports-Spillover", default=False) filter: list[MSPFilter] | None = Field(alias="Filter", default=None) relay: list[MSPRelay] | None = Field(alias="Relay", default=None) heater: MSPVirtualHeater | None = Field(alias="Heater", default=None) @@ -222,38 +339,107 @@ class MSPBoW(OmniBase): # We override the __init__ here so that we can trigger the propagation of the bow_id down to all of it's sub devices after the bow # itself is initialized def __init__(self, **data: Any) -> None: + # As we are requiring a bow_id on everything in OmniBase, we need to propagate it down now + # before calling super().__init__() so that it will be present for validation. super().__init__(**data) self.propagate_bow_id(self.system_id) class MSPBackyard(OmniBase): _sub_devices = {"sensor", "bow", "colorlogic_light", "relay"} + bow_id: int = -1 omni_type: OmniType = OmniType.BACKYARD - sensor: list[MSPSensor] | None = Field(alias="Sensor", default=None) + bow: list[MSPBoW] | None = Field(alias="Body-of-water", default=None) - relay: list[MSPRelay] | None = Field(alias="Relay", default=None) colorlogic_light: list[MSPColorLogicLight] | None = Field(alias="ColorLogic-Light", default=None) + relay: list[MSPRelay] | None = Field(alias="Relay", default=None) + sensor: list[MSPSensor] | None = Field(alias="Sensor", default=None) class MSPSchedule(OmniBase): omni_type: OmniType = OmniType.SCHEDULE - system_id: int = Field(alias="schedule-system-id") - bow_id: int | None = Field(alias="bow-system-id", default=None) + + bow_id: int = Field(alias="bow-system-id") # pyright: ignore[reportGeneralTypeIssues] equipment_id: int = Field(alias="equipment-id") + system_id: int = Field(alias="schedule-system-id") + event: MessageType = Field(alias="event") + data: int = Field(alias="data") enabled: bool = Field() - - -MSPConfigType: TypeAlias = ( - MSPSystem | MSPSchedule | MSPBackyard | MSPBoW | MSPVirtualHeater | MSPHeaterEquip | MSPRelay | MSPFilter | MSPSensor + start_minute: int = Field(alias="start-minute") + start_hour: int = Field(alias="start-hour") + end_minute: int = Field(alias="end-minute") + end_hour: int = Field(alias="end-hour") + days_active_raw: int = Field(alias="days-active") + recurring: bool = Field(alias="recurring") + + @computed_field # type: ignore[prop-decorator] + @property + def days_active(self) -> list[str]: + """Decode days_active_raw bitmask into a list of active day names. + + Returns: + List of active day names as strings + + Example: + >>> schedule.days_active + ['Monday', 'Wednesday', 'Friday'] + """ + flags = ScheduleDaysActive(self.days_active_raw) + return [flag.name for flag in ScheduleDaysActive if flags & flag and flag.name is not None] + + +type MSPEquipmentType = ( + MSPSchedule + | MSPBackyard + | MSPBoW + | MSPVirtualHeater + | MSPHeaterEquip + | MSPRelay + | MSPFilter + | MSPSensor + | MSPPump + | MSPChlorinator + | MSPChlorinatorEquip + | MSPCSAD + | MSPCSADEquip + | MSPColorLogicLight + | MSPGroup ) +type MSPConfigType = MSPSystem | MSPEquipmentType + class MSPConfig(BaseModel): model_config = ConfigDict(from_attributes=True) system: MSPSystem = Field(alias="System") backyard: MSPBackyard = Field(alias="Backyard") + groups: list[MSPGroup] | None = None + schedules: list[MSPSchedule] | None = None + + def __init__(self, **data: Any) -> None: + # Extract groups from the Groups container if present + group_data: dict[str, Any] | None = None + with contextlib.suppress(KeyError): + group_data = data["Groups"]["Group"] + + if group_data: + data["groups"] = [MSPGroup.model_validate(g) for g in group_data] + else: + data["groups"] = [] + + # Extract schedules from the Schedules container if present + schedule_data: dict[str, Any] | None = None + with contextlib.suppress(KeyError): + schedule_data = data["Schedules"]["sche"] + + if schedule_data: + data["schedules"] = [MSPSchedule.model_validate(s) for s in schedule_data] + else: + data["schedules"] = [] + + super().__init__(**data) @staticmethod def load_xml(xml: str) -> MSPConfig: @@ -263,12 +449,11 @@ def load_xml(xml: str) -> MSPConfig: # everything that *could* be a list into a list to make the parsing more consistent. force_list=( OmniType.BOW_MSP, - OmniType.CHLORINATOR_EQUIP, OmniType.CSAD, OmniType.CL_LIGHT, OmniType.FAVORITES, OmniType.FILTER, - OmniType.GROUPS, + OmniType.GROUP, OmniType.PUMP, OmniType.RELAY, OmniType.SENSOR, @@ -278,4 +463,5 @@ def load_xml(xml: str) -> MSPConfig: try: return MSPConfig.model_validate(data["MSPConfig"], from_attributes=True) except ValidationError as exc: - raise OmniParsingException(f"Failed to parse MSP Configuration: {exc}") from exc + msg = f"Failed to parse MSP Configuration: {exc}" + raise OmniParsingError(msg) from exc diff --git a/pyomnilogic_local/models/telemetry.py b/pyomnilogic_local/models/telemetry.py index ad21385..78663e3 100644 --- a/pyomnilogic_local/models/telemetry.py +++ b/pyomnilogic_local/models/telemetry.py @@ -1,34 +1,45 @@ +# ruff: noqa: TC001 # pydantic relies on the omnitypes imports at runtime from __future__ import annotations -from typing import Any, SupportsInt, TypeAlias, TypeVar, cast, overload +from typing import Any, SupportsInt, cast, overload -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError, computed_field from xmltodict import parse as xml_parse -from ..exceptions import OmniParsingException -from ..omnitypes import ( +from pyomnilogic_local.omnitypes import ( BackyardState, ChlorinatorAlert, ChlorinatorError, ChlorinatorOperatingMode, ChlorinatorStatus, ColorLogicBrightness, + ColorLogicLightType, ColorLogicPowerState, - ColorLogicShow, + ColorLogicShow25, + ColorLogicShow40, + ColorLogicShowUCL, + ColorLogicShowUCLV2, ColorLogicSpeed, CSADMode, + CSADStatus, FilterState, FilterValvePosition, FilterWhyOn, + GroupState, HeaterMode, HeaterState, + LightShows, OmniType, + PentairShow, PumpState, RelayState, RelayWhyOn, ValveActuatorState, + ZodiacShow, ) +from .exceptions import OmniParsingError + # Example telemetry XML data: # # @@ -47,19 +58,40 @@ class TelemetryBackyard(BaseModel): + """Real-time telemetry for the backyard/controller system. + + This is the top-level telemetry object containing system-wide state information. + Always present in telemetry responses. + + Fields: + air_temp: Air temperature in Fahrenheit, None if sensor unavailable + state: Current operational state (ON, OFF, SERVICE_MODE, etc.) + config_checksum: Configuration version identifier for detecting changes + msp_version: Controller firmware version (available in status_version >= 11) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.BACKYARD system_id: int = Field(alias="@systemId") status_version: int = Field(alias="@statusVersion") - air_temp: int = Field(alias="@airTemp") - state: BackyardState | int = Field(alias="@state") + air_temp: int | None = Field(alias="@airTemp") + state: BackyardState = Field(alias="@state") # The below two fields are only available for telemetry with a status_version >= 11 - config_checksum: int | None = Field(alias="@ConfigChksum", default=None) + config_checksum: int = Field(alias="@ConfigChksum", default=0) msp_version: str | None = Field(alias="@mspVersion", default=None) class TelemetryBoW(BaseModel): + """Real-time telemetry for a body of water (pool or spa). + + Contains current water conditions and flow status. + + Fields: + water_temp: Water temperature in Fahrenheit, -1 if sensor unavailable + flow: Flow sensor value, 255 or 1 typically indicate flow detected, 0 for no flow + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.BOW @@ -69,6 +101,22 @@ class TelemetryBoW(BaseModel): class TelemetryChlorinator(BaseModel): + """Real-time telemetry for salt chlorinator systems. + + Includes salt levels, operational status, alerts, and errors. Use computed + properties (status, alerts, errors) for decoded bitmask values. + + Fields: + instant_salt_level: Current salt reading in PPM + avg_salt_level: Average salt level in PPM over time + status_raw: Bitmask of operational status flags (use .status property for decoded properties) + chlr_alert_raw: Bitmask of alert conditions (use .alerts property for decoded properties) + chlr_error_raw: Bitmask of error conditions (use .errors property for decoded properties) + timed_percent: Chlorination output percentage in timed mode (0-100), None if not applicable + operating_mode: DISABLED, TIMED, ORP_AUTO, or ORP_TIMED_RW + enable: Whether chlorinator is enabled for operation + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CHLORINATOR @@ -81,9 +129,10 @@ class TelemetryChlorinator(BaseModel): sc_mode: int = Field(alias="@scMode") operating_state: int = Field(alias="@operatingState") timed_percent: int | None = Field(alias="@Timed-Percent", default=None) - operating_mode: ChlorinatorOperatingMode | int = Field(alias="@operatingMode") + operating_mode: ChlorinatorOperatingMode = Field(alias="@operatingMode") enable: bool = Field(alias="@enable") + @computed_field # type: ignore[prop-decorator] @property def status(self) -> list[str]: """Decode status bitmask into a list of active status flag names. @@ -97,6 +146,7 @@ def status(self) -> list[str]: """ return [flag.name for flag in ChlorinatorStatus if self.status_raw & flag.value and flag.name is not None] + @computed_field # type: ignore[prop-decorator] @property def alerts(self) -> list[str]: """Decode chlrAlert bitmask into a list of active alert flag names. @@ -112,7 +162,6 @@ def alerts(self) -> list[str]: >>> chlorinator.alerts ['SALT_LOW', 'HIGH_CURRENT'] """ - flags = ChlorinatorAlert(self.chlr_alert_raw) high_temp_bits = ChlorinatorAlert.CELL_TEMP_LOW | ChlorinatorAlert.CELL_TEMP_SCALEBACK cell_temp_high = False @@ -127,6 +176,7 @@ def alerts(self) -> list[str]: return final_flags + @computed_field # type: ignore[prop-decorator] @property def errors(self) -> list[str]: """Decode chlrError bitmask into a list of active error flag names. @@ -142,7 +192,6 @@ def errors(self) -> list[str]: >>> chlorinator.errors ['CURRENT_SENSOR_SHORT', 'VOLTAGE_SENSOR_OPEN'] """ - flags = ChlorinatorError(self.chlr_error_raw) cell_comm_loss_bits = ChlorinatorError.CELL_ERROR_TYPE | ChlorinatorError.CELL_ERROR_AUTH cell_comm_loss = False @@ -157,6 +206,7 @@ def errors(self) -> list[str]: return final_flags + @computed_field # type: ignore[prop-decorator] @property def active(self) -> bool: """Check if the chlorinator is actively generating chlorine. @@ -168,56 +218,142 @@ def active(self) -> bool: class TelemetryCSAD(BaseModel): + """Real-time telemetry for Chemistry Sense and Dispense systems. + + Provides current water chemistry readings and dispensing status. + + Fields: + ph: Current pH level reading (typically 0.0-14.0) + orp: Oxidation-Reduction Potential in millivolts + mode: Current operation mode (OFF, AUTO, FORCE_ON, MONITORING, DISPENSING_OFF) + status: Dispensing status (NOT_DISPENSING, DISPENSING) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CSAD system_id: int = Field(alias="@systemId") - status_raw: int = Field(alias="@status") + status: CSADStatus = Field(alias="@status") ph: float = Field(alias="@ph") orp: int = Field(alias="@orp") - mode: CSADMode | int = Field(alias="@mode") + mode: CSADMode = Field(alias="@mode") class TelemetryColorLogicLight(BaseModel): + """Real-time telemetry for ColorLogic LED lighting systems. + + Tracks power state, active show, speed, and brightness settings. Light cannot + accept commands during transitional states (CHANGING_SHOW, POWERING_OFF, COOLDOWN). + + Not all fields are applicable to all light models. + + Fields: + state: Power/operational state (OFF, ACTIVE, transitional states) + show: Currently active light show (type depends on light model) + speed: Animation speed (ONE_SIXTEENTH to SIXTEEN_TIMES) + brightness: Light brightness level (TWENTY_PERCENT to ONE_HUNDRED_PERCENT) + special_effect: Special effect identifier (usage varies by model) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.CL_LIGHT system_id: int = Field(alias="@systemId") - state: ColorLogicPowerState | int = Field(alias="@lightState") - show: ColorLogicShow | int = Field(alias="@currentShow") - speed: ColorLogicSpeed | int = Field(alias="@speed") - brightness: ColorLogicBrightness | int = Field(alias="@brightness") + state: ColorLogicPowerState = Field(alias="@lightState") + show: LightShows = Field(alias="@currentShow") + speed: ColorLogicSpeed = Field(alias="@speed") + brightness: ColorLogicBrightness = Field(alias="@brightness") special_effect: int = Field(alias="@specialEffect") + def show_name( + self, model: ColorLogicLightType, v2: bool + ) -> ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 | PentairShow | ZodiacShow | int: + """Get the current light show depending on the light type. + + Returns: + ColorLogicShowUCL enum member corresponding to the current show, + or None if the show value is invalid. + """ + match model: + case ColorLogicLightType.TWO_FIVE: + return ColorLogicShow25(self.show) + case ColorLogicLightType.FOUR_ZERO: + return ColorLogicShow40(self.show) + case ColorLogicLightType.UCL: + if v2: + return ColorLogicShowUCLV2(self.show) + return ColorLogicShowUCL(self.show) + case ColorLogicLightType.PENTAIR_COLOR: + return PentairShow(self.show) + case ColorLogicLightType.ZODIAC_COLOR: + return ZodiacShow(self.show) + return self.show # Return raw int if type is unknown + class TelemetryFilter(BaseModel): + """Real-time telemetry for filter pump systems. + + Includes operational state, speed settings, and valve position. Filter cannot + accept commands during transitional states (PRIMING, COOLDOWN, etc.). + + Fields: + state: Current operational state (OFF, ON, transitional states) + speed: Current speed setting (percentage 0-100) + valve_position: Current valve position for multi-port systems + why_on: Reason filter is running (MANUAL_ON, TIMED_EVENT, FREEZE_PROTECT, etc.) + reported_speed: Actual reported speed from variable speed pump (percentage 0-100) + power: Current power consumption (watts) + last_speed: Previous speed setting before state change + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.FILTER system_id: int = Field(alias="@systemId") - state: FilterState | int = Field(alias="@filterState") + state: FilterState = Field(alias="@filterState") speed: int = Field(alias="@filterSpeed") - valve_position: FilterValvePosition | int = Field(alias="@valvePosition") - why_on: FilterWhyOn | int = Field(alias="@whyFilterIsOn") + valve_position: FilterValvePosition = Field(alias="@valvePosition") + why_on: FilterWhyOn = Field(alias="@whyFilterIsOn") reported_speed: int = Field(alias="@reportedFilterSpeed") power: int = Field(alias="@power") last_speed: int = Field(alias="@lastSpeed") class TelemetryGroup(BaseModel): + """Real-time telemetry for equipment groups. + + Groups allow controlling multiple pieces of equipment together as a single unit. + + Fields: + state: Current group state (OFF or ON) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.GROUP system_id: int = Field(alias="@systemId") - state: int = Field(alias="@groupState") + state: GroupState = Field(alias="@groupState") class TelemetryHeater(BaseModel): + """Real-time telemetry for physical heater equipment. + + Represents actual heater hardware (gas, heat pump, solar, etc.) controlled + by a VirtualHeater. See TelemetryVirtualHeater for set points and modes. + + Fields: + state: Current heater state (OFF, ON, PAUSE) + temp: Current water temperature reading in Fahrenheit + enabled: Whether heater is enabled for operation + priority: Heater priority for sequencing + maintain_for: Hours to maintain temperature after reaching set point + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.HEATER system_id: int = Field(alias="@systemId") - state: HeaterState | int = Field(alias="@heaterState") + state: HeaterState = Field(alias="@heaterState") temp: int = Field(alias="@temp") enabled: bool = Field(alias="@enable") priority: int = Field(alias="@priority") @@ -225,36 +361,82 @@ class TelemetryHeater(BaseModel): class TelemetryPump(BaseModel): + """Real-time telemetry for auxiliary pump equipment. + + Auxiliary pumps are separate from filter pumps and used for water features, + cleaners, etc. Pump cannot accept commands during transitional states. + + Fields: + state: Current pump state (OFF, ON, FREEZE_PROTECT) + speed: Current speed setting (percentage 0-100 or RPM depending on type) + last_speed: Previous speed setting before state change + why_on: Reason pump is running (usage similar to FilterWhyOn) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.PUMP system_id: int = Field(alias="@systemId") - state: PumpState | int = Field(alias="@pumpState") + state: PumpState = Field(alias="@pumpState") speed: int = Field(alias="@pumpSpeed") last_speed: int = Field(alias="@lastSpeed") why_on: int = Field(alias="@whyOn") class TelemetryRelay(BaseModel): + """Real-time telemetry for relay-controlled equipment. + + Relays provide simple on/off control for lights, water features, and other + accessories not requiring variable speed control. + + Fields: + state: Current relay state (OFF or ON) + why_on: Reason relay is on (MANUAL_ON, SCHEDULE_ON, GROUP_ON, FREEZE_PROTECT, etc.) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.RELAY system_id: int = Field(alias="@systemId") - state: RelayState | int = Field(alias="@relayState") - why_on: RelayWhyOn | int = Field(alias="@whyOn") + state: RelayState = Field(alias="@relayState") + why_on: RelayWhyOn = Field(alias="@whyOn") class TelemetryValveActuator(BaseModel): + """Real-time telemetry for valve actuator equipment. + + Valve actuators control motorized valves for directing water flow. Functionally + similar to relays with on/off states. + + Fields: + state: Current valve state (OFF or ON) + why_on: Reason valve is active (uses RelayWhyOn enum values) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.VALVE_ACTUATOR system_id: int = Field(alias="@systemId") - state: ValveActuatorState | int = Field(alias="@valveActuatorState") + state: ValveActuatorState = Field(alias="@valveActuatorState") # Valve actuators are actually relays, so we can reuse the RelayWhyOn enum here - why_on: RelayWhyOn | int = Field(alias="@whyOn") + why_on: RelayWhyOn = Field(alias="@whyOn") class TelemetryVirtualHeater(BaseModel): + """Real-time telemetry for virtual heater controller. + + Virtual heater acts as the control logic for one or more physical heaters, + managing set points, modes, and sequencing. Each body of water has one virtual heater. + + Fields: + current_set_point: Active temperature target in Fahrenheit + enabled: Whether heating/cooling is enabled + solar_set_point: Solar heater set point in Fahrenheit + mode: Operating mode (HEAT, COOL, or AUTO) + silent_mode: Heat pump quiet mode setting + why_on: Reason heater is active (usage varies) + """ + model_config = ConfigDict(from_attributes=True) omni_type: OmniType = OmniType.VIRT_HEATER @@ -262,15 +444,16 @@ class TelemetryVirtualHeater(BaseModel): current_set_point: int = Field(alias="@Current-Set-Point") enabled: bool = Field(alias="@enable") solar_set_point: int = Field(alias="@SolarSetPoint") - mode: HeaterMode | int = Field(alias="@Mode") + mode: HeaterMode = Field(alias="@Mode") silent_mode: int = Field(alias="@SilentMode") why_on: int = Field(alias="@whyHeaterIsOn") -TelemetryType: TypeAlias = ( +type TelemetryType = ( TelemetryBackyard | TelemetryBoW | TelemetryChlorinator + | TelemetryCSAD | TelemetryColorLogicLight | TelemetryFilter | TelemetryGroup @@ -283,6 +466,30 @@ class TelemetryVirtualHeater(BaseModel): class Telemetry(BaseModel): + """Complete real-time telemetry snapshot from the OmniLogic controller. + + Contains the current state of all equipment in the system. Telemetry is requested + via async_get_telemetry() and should be refreshed periodically to get current values. + + All equipment collections except backyard and bow are optional and will be None + if no equipment of that type exists in the system. + + Fields: + version: Telemetry format version from controller + backyard: System-wide state (always present) + bow: Bodies of water telemetry (always present, one or more) + chlorinator: Salt chlorinator telemetry (optional) + colorlogic_light: LED light telemetry (optional) + csad: Chemistry controller telemetry (optional) + filter: Filter pump telemetry (optional) + group: Equipment group telemetry (optional) + heater: Physical heater telemetry (optional) + pump: Auxiliary pump telemetry (optional) + relay: Relay-controlled equipment telemetry (optional) + valve_actuator: Valve actuator telemetry (optional) + virtual_heater: Heater controller telemetry (optional) + """ + model_config = ConfigDict(from_attributes=True) version: str = Field(alias="@version") @@ -301,16 +508,11 @@ class Telemetry(BaseModel): @staticmethod def load_xml(xml: str) -> Telemetry: - TypeVar("KT") - TypeVar("VT", SupportsInt, Any) - @overload def xml_postprocessor(path: Any, key: Any, value: SupportsInt) -> tuple[Any, SupportsInt]: ... - @overload def xml_postprocessor(path: Any, key: Any, value: Any) -> tuple[Any, Any]: ... - - def xml_postprocessor(path: Any, key: Any, value: SupportsInt | Any) -> tuple[Any, SupportsInt | Any]: + def xml_postprocessor(path: Any, key: Any, value: SupportsInt | Any) -> tuple[Any, SupportsInt | Any]: # noqa: ARG001 # Unused argument is part of the xmltodict postprocessor signature """Post process XML to attempt to convert values to int. Pydantic can coerce values natively, but the Omni API returns values as strings of numbers (I.E. "2", "5", etc) and we need them @@ -351,7 +553,8 @@ def xml_postprocessor(path: Any, key: Any, value: SupportsInt | Any) -> tuple[An try: return Telemetry.model_validate(data["STATUS"]) except ValidationError as exc: - raise OmniParsingException(f"Failed to parse Telemetry: {exc}") from exc + msg = f"Failed to parse Telemetry: {exc}" + raise OmniParsingError(msg) from exc def get_telem_by_systemid(self, system_id: int) -> TelemetryType | None: for field_name, value in self: @@ -359,11 +562,11 @@ def get_telem_by_systemid(self, system_id: int) -> TelemetryType | None: continue if isinstance(value, list): for model in value: - cast_model = cast(TelemetryType, model) + cast_model = cast("TelemetryType", model) if cast_model.system_id == system_id: return cast_model else: - cast_model = cast(TelemetryType, value) + cast_model = cast("TelemetryType", value) if cast_model.system_id == system_id: return cast_model return None diff --git a/pyomnilogic_local/models/util.py b/pyomnilogic_local/models/util.py deleted file mode 100644 index 3163c69..0000000 --- a/pyomnilogic_local/models/util.py +++ /dev/null @@ -1,35 +0,0 @@ -import logging -from collections.abc import Awaitable, Callable -from typing import Any, Literal, TypeVar, cast, overload - -from .filter_diagnostics import FilterDiagnostics -from .mspconfig import MSPConfig -from .telemetry import Telemetry - -_LOGGER = logging.getLogger(__name__) - - -F = TypeVar("F", bound=Callable[..., Awaitable[str]]) - - -def to_pydantic( - pydantic_type: type[Telemetry | MSPConfig | FilterDiagnostics], -) -> Callable[..., Any]: - def inner(func: F, *args: Any, **kwargs: Any) -> F: - """Wrap an API function that returns XML and parse it into a Pydantic model""" - - @overload - async def wrapper(*args: Any, raw: Literal[True], **kwargs: Any) -> str: ... - - @overload - async def wrapper(*args: Any, raw: Literal[False], **kwargs: Any) -> Telemetry | MSPConfig | FilterDiagnostics: ... - - async def wrapper(*args: Any, raw: bool = False, **kwargs: Any) -> Telemetry | MSPConfig | FilterDiagnostics | str: - resp_body = await func(*args, **kwargs) - if raw: - return resp_body - return pydantic_type.load_xml(resp_body) - - return cast(F, wrapper) - - return inner diff --git a/pyomnilogic_local/omnilogic.py b/pyomnilogic_local/omnilogic.py new file mode 100644 index 0000000..cb889e9 --- /dev/null +++ b/pyomnilogic_local/omnilogic.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from typing import TYPE_CHECKING, Any + +from pyomnilogic_local.api import OmniLogicAPI +from pyomnilogic_local.backyard import Backyard +from pyomnilogic_local.collections import EquipmentDict +from pyomnilogic_local.groups import Group +from pyomnilogic_local.schedule import Schedule +from pyomnilogic_local.system import System + +if TYPE_CHECKING: + from pyomnilogic_local._base import OmniEquipment + from pyomnilogic_local.chlorinator import Chlorinator + from pyomnilogic_local.chlorinator_equip import ChlorinatorEquipment + from pyomnilogic_local.colorlogiclight import ColorLogicLight + from pyomnilogic_local.csad import CSAD + from pyomnilogic_local.csad_equip import CSADEquipment + from pyomnilogic_local.filter import Filter + from pyomnilogic_local.heater import Heater + from pyomnilogic_local.heater_equip import HeaterEquipment + from pyomnilogic_local.models import MSPConfig, Telemetry + from pyomnilogic_local.pump import Pump + from pyomnilogic_local.relay import Relay + from pyomnilogic_local.sensor import Sensor + + +_LOGGER = logging.getLogger(__name__) + + +class OmniLogic: + mspconfig: MSPConfig + telemetry: Telemetry + + system: System + backyard: Backyard + groups: EquipmentDict[Group] + schedules: EquipmentDict[Schedule] + + _mspconfig_last_updated: float = 0.0 + _telemetry_last_updated: float = 0.0 + _mspconfig_checksum: int = 0 + _telemetry_dirty: bool = True + _refresh_lock: asyncio.Lock + # This is the minimum supported MSP version for full functionality + # we just string match the value from the start of the string + _min_mspversion: str = "R05" + _warned_mspversion: bool = False + + def __init__(self, host: str, port: int = 10444) -> None: + self.host = host + self.port = port + + self._api = OmniLogicAPI(host, port) + self._refresh_lock = asyncio.Lock() + + def __repr__(self) -> str: + """Return a string representation of the OmniLogic instance for debugging. + + Returns: + A string showing host, port, and counts of various equipment types. + """ + # Only show equipment counts if backyard has been initialized + if hasattr(self, "backyard"): + bow_count = len(self.backyard.bow) + light_count = len(self.all_lights) + relay_count = len(self.all_relays) + pump_count = len(self.all_pumps) + filter_count = len(self.all_filters) + + return ( + f"OmniLogic(host={self.host!r}, port={self.port}, " + f"bows={bow_count}, lights={light_count}, relays={relay_count}, " + f"pumps={pump_count}, filters={filter_count})" + ) + return f"OmniLogic(host={self.host!r}, port={self.port}, not_initialized=True)" + + async def refresh( + self, + *, + if_dirty: bool = True, + if_older_than: float = 10.0, + force: bool = False, + ) -> None: + """Refresh the data from the OmniLogic controller. + + Args: + mspconfig: Whether to refresh MSPConfig data (if conditions are met) + telemetry: Whether to refresh Telemetry data (if conditions are met) + if_dirty: Only refresh if the data has been marked dirty + if_older_than: Only refresh if data is older than this many seconds + force: Force refresh regardless of dirty flag or age + """ + async with self._refresh_lock: + current_time = time.time() + + # Determine if telemetry needs updating + update_telemetry = False + if force or (if_dirty and self._telemetry_dirty) or ((current_time - self._telemetry_last_updated) > if_older_than): + update_telemetry = True + + # Update telemetry if needed + if update_telemetry: + self.telemetry = await self._api.async_get_telemetry() + self._telemetry_last_updated = time.time() + self._telemetry_dirty = False + + # Determine if MSPConfig needs updating + update_mspconfig = False + if force: + update_mspconfig = True + if self.telemetry.backyard.config_checksum != self._mspconfig_checksum: + update_mspconfig = True + + if ( + self.telemetry.backyard.msp_version is not None + and not self._warned_mspversion + and not self.telemetry.backyard.msp_version.startswith(self._min_mspversion) + ): + _LOGGER.warning( + "Detected OmniLogic MSP version %s, which is below the minimum supported version %s. " + "Some features may not work correctly. Please consider updating your OmniLogic controller firmware.", + self.telemetry.backyard.msp_version, + self._min_mspversion, + ) + self._warned_mspversion = True + + # Update MSPConfig if needed + if update_mspconfig: + self.mspconfig = await self._api.async_get_mspconfig() + self._mspconfig_last_updated = time.time() + self._mspconfig_checksum = self.telemetry.backyard.config_checksum + + if update_mspconfig or update_telemetry: + self._update_equipment() + + def _update_equipment(self) -> None: + """Update equipment objects based on the latest MSPConfig and Telemetry data.""" + if not hasattr(self, "mspconfig") or self.mspconfig is None: + _LOGGER.debug("No MSPConfig data available; skipping equipment update") + return + + try: + self.system.update_config(self.mspconfig.system) + except AttributeError: + self.system = System(self.mspconfig.system) + + try: + self.backyard.update(self.mspconfig.backyard, self.telemetry) + except AttributeError: + self.backyard = Backyard(self, self.mspconfig.backyard, self.telemetry) + + # Update groups + if self.mspconfig.groups is None: + self.groups = EquipmentDict() + else: + self.groups = EquipmentDict([Group(self, group_, self.telemetry) for group_ in self.mspconfig.groups]) + + # Update schedules + if self.mspconfig.schedules is None: + self.schedules = EquipmentDict() + else: + self.schedules = EquipmentDict([Schedule(self, schedule_, self.telemetry) for schedule_ in self.mspconfig.schedules]) + + # Equipment discovery properties + @property + def all_lights(self) -> EquipmentDict[ColorLogicLight]: + """Returns all ColorLogicLight instances across all bows in the backyard.""" + lights: list[ColorLogicLight] = [] + # Lights at backyard level + lights.extend(self.backyard.lights.values()) + # Lights in each bow + for bow in self.backyard.bow.values(): + lights.extend(bow.lights.values()) + return EquipmentDict(lights) + + @property + def all_relays(self) -> EquipmentDict[Relay]: + """Returns all Relay instances across all bows in the backyard.""" + relays: list[Relay] = [] + # Relays at backyard level + relays.extend(self.backyard.relays.values()) + # Relays in each bow + for bow in self.backyard.bow.values(): + relays.extend(bow.relays.values()) + return EquipmentDict(relays) + + @property + def all_pumps(self) -> EquipmentDict[Pump]: + """Returns all Pump instances across all bows in the backyard.""" + pumps: list[Pump] = [] + for bow in self.backyard.bow.values(): + pumps.extend(bow.pumps.values()) + return EquipmentDict(pumps) + + @property + def all_filters(self) -> EquipmentDict[Filter]: + """Returns all Filter instances across all bows in the backyard.""" + filters: list[Filter] = [] + for bow in self.backyard.bow.values(): + filters.extend(bow.filters.values()) + return EquipmentDict(filters) + + @property + def all_sensors(self) -> EquipmentDict[Sensor]: + """Returns all Sensor instances across all bows in the backyard.""" + sensors: list[Sensor] = [] + # Sensors at backyard level + sensors.extend(self.backyard.sensors.values()) + # Sensors in each bow + for bow in self.backyard.bow.values(): + sensors.extend(bow.sensors.values()) + return EquipmentDict(sensors) + + @property + def all_heaters(self) -> EquipmentDict[Heater]: + """Returns all Heater (VirtualHeater) instances across all bows in the backyard.""" + heaters = [bow.heater for bow in self.backyard.bow.values() if bow.heater is not None] + return EquipmentDict(heaters) + + @property + def all_heater_equipment(self) -> EquipmentDict[HeaterEquipment]: + """Returns all HeaterEquipment instances across all heaters in the backyard.""" + heater_equipment: list[HeaterEquipment] = [] + for heater in self.all_heaters.values(): + heater_equipment.extend(heater.heater_equipment.values()) + return EquipmentDict(heater_equipment) + + @property + def all_chlorinators(self) -> EquipmentDict[Chlorinator]: + """Returns all Chlorinator instances across all bows in the backyard.""" + chlorinators = [bow.chlorinator for bow in self.backyard.bow.values() if bow.chlorinator is not None] + return EquipmentDict(chlorinators) + + @property + def all_chlorinator_equipment(self) -> EquipmentDict[ChlorinatorEquipment]: + """Returns all ChlorinatorEquipment instances across all chlorinators in the backyard.""" + chlorinator_equipment: list[ChlorinatorEquipment] = [] + for chlorinator in self.all_chlorinators.values(): + chlorinator_equipment.extend(chlorinator.chlorinator_equipment.values()) + return EquipmentDict(chlorinator_equipment) + + @property + def all_csad_equipment(self) -> EquipmentDict[CSADEquipment]: + """Returns all CSADEquipment instances across all CSADs in the backyard.""" + csad_equipment: list[CSADEquipment] = [] + for csad in self.all_csads.values(): + csad_equipment.extend(csad.csad_equipment.values()) + return EquipmentDict(csad_equipment) + + @property + def all_csads(self) -> EquipmentDict[CSAD]: + """Returns all CSAD instances across all bows in the backyard.""" + csads: list[CSAD] = [] + for bow in self.backyard.bow.values(): + csads.extend(bow.csads.values()) + return EquipmentDict(csads) + + # Equipment search methods + def get_equipment_by_name(self, name: str) -> OmniEquipment[Any, Any] | None: + """Find equipment by name across all equipment types. + + Args: + name: The name of the equipment to find + + Returns: + The first equipment with matching name, or None if not found + """ + # Search all equipment types + all_equipment: list[OmniEquipment[Any, Any]] = [] + all_equipment.extend(self.all_lights.values()) + all_equipment.extend(self.all_relays.values()) + all_equipment.extend(self.all_pumps.values()) + all_equipment.extend(self.all_filters.values()) + all_equipment.extend(self.all_sensors.values()) + all_equipment.extend(self.all_heaters.values()) + all_equipment.extend(self.all_heater_equipment.values()) + all_equipment.extend(self.all_chlorinators.values()) + all_equipment.extend(self.all_chlorinator_equipment.values()) + all_equipment.extend(self.all_csads.values()) + all_equipment.extend(self.all_csad_equipment.values()) + all_equipment.extend(self.groups.values()) + + for equipment in all_equipment: + if equipment.name == name: + return equipment + + return None + + def get_equipment_by_id(self, system_id: int) -> OmniEquipment[Any, Any] | None: + """Find equipment by system_id across all equipment types. + + Args: + system_id: The system ID of the equipment to find + + Returns: + The first equipment with matching system_id, or None if not found + """ + # Search all equipment types + all_equipment: list[OmniEquipment[Any, Any]] = [] + all_equipment.extend(self.all_lights.values()) + all_equipment.extend(self.all_relays.values()) + all_equipment.extend(self.all_pumps.values()) + all_equipment.extend(self.all_filters.values()) + all_equipment.extend(self.all_sensors.values()) + all_equipment.extend(self.all_heaters.values()) + all_equipment.extend(self.all_heater_equipment.values()) + all_equipment.extend(self.all_chlorinators.values()) + all_equipment.extend(self.all_chlorinator_equipment.values()) + all_equipment.extend(self.all_csads.values()) + all_equipment.extend(self.all_csad_equipment.values()) + all_equipment.extend(self.groups.values()) + all_equipment.extend(self.schedules.values()) + + for equipment in all_equipment: + if equipment.system_id == system_id: + return equipment + + return None diff --git a/pyomnilogic_local/omnitypes.py b/pyomnilogic_local/omnitypes.py index 24cae6d..26b2296 100644 --- a/pyomnilogic_local/omnitypes.py +++ b/pyomnilogic_local/omnitypes.py @@ -1,10 +1,12 @@ -from enum import Enum, Flag, IntEnum +from __future__ import annotations + +from enum import Flag, IntEnum, StrEnum, auto from .util import PrettyEnum # OmniAPI Enums -class MessageType(Enum): +class MessageType(IntEnum, PrettyEnum): XML_ACK = 0000 REQUEST_CONFIGURATION = 1 SET_FILTER_SPEED = 9 @@ -18,6 +20,7 @@ class MessageType(Enum): SET_EQUIPMENT = 164 CREATE_SCHEDULE = 230 DELETE_SCHEDULE = 231 + EDIT_SCHEDULE = 233 GET_TELEMETRY = 300 SET_STANDALONE_LIGHT_SHOW = 308 SET_SPILLOVER = 311 @@ -32,19 +35,20 @@ class MessageType(Enum): MSP_BLOCKMESSAGE = 1999 -class ClientType(Enum): +class ClientType(IntEnum, PrettyEnum): XML = 0 SIMPLE = 1 OMNI = 3 -class OmniType(str, Enum): +class OmniType(StrEnum): BACKYARD = "Backyard" BOW = "BodyOfWater" BOW_MSP = "Body-of-water" CHLORINATOR = "Chlorinator" CHLORINATOR_EQUIP = "Chlorinator-Equipment" CSAD = "CSAD" + CSAD_EQUIP = "CSAD-Equipment" CL_LIGHT = "ColorLogic-Light" FAVORITES = "Favorites" FILTER = "Filter" @@ -61,12 +65,9 @@ class OmniType(str, Enum): VALVE_ACTUATOR = "ValveActuator" VIRT_HEATER = "VirtualHeater" - def __str__(self) -> str: - return OmniType[self.name].value - # Backyard/BoW -class BackyardState(PrettyEnum): +class BackyardState(IntEnum, PrettyEnum): OFF = 0 ON = 1 SERVICE_MODE = 2 @@ -74,12 +75,12 @@ class BackyardState(PrettyEnum): TIMED_SERVICE_MODE = 4 -class BodyOfWaterState(PrettyEnum): +class BodyOfWaterState(IntEnum, PrettyEnum): NO_FLOW = 0 FLOW = 1 -class BodyOfWaterType(str, PrettyEnum): +class BodyOfWaterType(StrEnum, PrettyEnum): POOL = "BOW_POOL" SPA = "BOW_SPA" @@ -145,53 +146,41 @@ class ChlorinatorError(Flag): AQUARITE_PCB_ERROR = 1 << 14 -class ChlorinatorOperatingMode(IntEnum): +class ChlorinatorOperatingMode(IntEnum, PrettyEnum): DISABLED = 0 TIMED = 1 ORP_AUTO = 2 - ORP_TIMED_RW = 3 # CSAD in ORP mode experienced condition that prevents ORP operation + ORP_TIMED_RW = 3 # Chlorinator in ORP mode experienced condition that prevents ORP operation -class ChlorinatorDispenserType(str, PrettyEnum): +class ChlorinatorType(StrEnum, PrettyEnum): + MAIN_PANEL = "CHLOR_TYPE_MAIN_PANEL" + DISPENSER = "CHLOR_TYPE_DISPENSER" + AQUA_RITE = "CHLOR_TYPE_AQUA_RITE" + + +class ChlorinatorDispenserType(StrEnum, PrettyEnum): SALT = "SALT_DISPENSING" LIQUID = "LIQUID_DISPENSING" TABLET = "TABLET_DISPENSING" -class ChlorinatorCellType(PrettyEnum): - UNKNOWN = "CELL_TYPE_UNKNOWN" - T3 = "CELL_TYPE_T3" - T5 = "CELL_TYPE_T5" - T9 = "CELL_TYPE_T9" - T15 = "CELL_TYPE_T15" - T15_LS = "CELL_TYPE_T15_LS" - TCELLS315 = "CELL_TYPE_TCELLS315" - TCELLS325 = "CELL_TYPE_TCELLS325" - TCELLS340 = "CELL_TYPE_TCELLS340" - LIQUID = "CELL_TYPE_LIQUID" - TABLET = "CELL_TYPE_TABLET" - - # There is probably an easier way to do this - def __int__(self) -> int: - return ChlorinatorCellInt[self.name].value - - -class ChlorinatorCellInt(IntEnum): - UNKNOWN = 0 - T3 = 1 - T5 = 2 - T9 = 3 - T15 = 4 - T15_LS = 5 - TCELLS315 = 6 - TCELLS325 = 7 - TCELLS340 = 8 - LIQUID = 9 - TABLET = 10 +class ChlorinatorCellType(IntEnum, PrettyEnum): + CELL_TYPE_UNKNOWN = 0 + CELL_TYPE_T3 = 1 + CELL_TYPE_T5 = 2 + CELL_TYPE_T9 = 3 + CELL_TYPE_T15 = 4 + CELL_TYPE_T15_LS = 5 + CELL_TYPE_TCELLS315 = 6 + CELL_TYPE_TCELLS325 = 7 + CELL_TYPE_TCELLS340 = 8 + CELL_TYPE_LIQUID = 9 + CELL_TYPE_TABLET = 10 # Lights -class ColorLogicSpeed(PrettyEnum): +class ColorLogicSpeed(IntEnum, PrettyEnum): ONE_SIXTEENTH = 0 ONE_EIGHTH = 1 ONE_QUARTER = 2 @@ -203,7 +192,7 @@ class ColorLogicSpeed(PrettyEnum): SIXTEEN_TIMES = 8 -class ColorLogicBrightness(PrettyEnum): +class ColorLogicBrightness(IntEnum, PrettyEnum): TWENTY_PERCENT = 0 FOURTY_PERCENT = 1 SIXTY_PERCENT = 2 @@ -211,7 +200,60 @@ class ColorLogicBrightness(PrettyEnum): ONE_HUNDRED_PERCENT = 4 -class ColorLogicShow(PrettyEnum): +type LightShows = ColorLogicShow25 | ColorLogicShow40 | ColorLogicShowUCL | ColorLogicShowUCLV2 | PentairShow | ZodiacShow + + +class ColorLogicShow25(IntEnum, PrettyEnum): + VOODOO_LOUNGE = 0 + DEEP_BLUE_SEA = 1 + AFTERNOON_SKY = 2 + EMERALD = 3 + SANGRIA = 4 + CLOUD_WHITE = 5 + TWILIGHT = 6 + TRANQUILITY = 7 + GEMSTONE = 8 + USA = 9 + MARDI_GRAS = 10 + COOL_CABARET = 11 + + +class ColorLogicShow40(IntEnum, PrettyEnum): + VOODOO_LOUNGE = 0 + DEEP_BLUE_SEA = 1 + AFTERNOON_SKY = 2 + EMERALD = 3 + SANGRIA = 4 + CLOUD_WHITE = 5 + TWILIGHT = 6 + TRANQUILITY = 7 + GEMSTONE = 8 + USA = 9 + MARDI_GRAS = 10 + COOL_CABARET = 11 + + +class ColorLogicShowUCL(IntEnum, PrettyEnum): + VOODOO_LOUNGE = 0 + DEEP_BLUE_SEA = 1 + ROYAL_BLUE = 2 + AFTERNOON_SKY = 3 + AQUA_GREEN = 4 + EMERALD = 5 + CLOUD_WHITE = 6 + WARM_RED = 7 + FLAMINGO = 8 + VIVID_VIOLET = 9 + SANGRIA = 10 + TWILIGHT = 11 + TRANQUILITY = 12 + GEMSTONE = 13 + USA = 14 + MARDI_GRAS = 15 + COOL_CABARET = 16 + + +class ColorLogicShowUCLV2(IntEnum, PrettyEnum): VOODOO_LOUNGE = 0 DEEP_BLUE_SEA = 1 ROYAL_BLUE = 2 @@ -241,11 +283,40 @@ class ColorLogicShow(PrettyEnum): WARM_WHITE = 25 BRIGHT_YELLOW = 26 - def __str__(self) -> str: - return self.name - -class ColorLogicPowerState(PrettyEnum): +class PentairShow(IntEnum, PrettyEnum): + SAM = 0 + PARTY = 1 + ROMANCE = 2 + CARIBBEAN = 3 + AMERICAN = 4 + CALIFORNIA_SUNSET = 5 + ROYAL = 6 + BLUE = 7 + GREEN = 8 + RED = 9 + WHITE = 10 + MAGENTA = 11 + + +class ZodiacShow(IntEnum, PrettyEnum): + ALPINE_WHITE = 0 + SKY_BLUE = 1 + COBALT_BLUE = 2 + CARIBBEAN_BLUE = 3 + SPRING_GREEN = 4 + EMERALD_GREEN = 5 + EMERALD_ROSE = 6 + MAGENTA = 7 + VIOLET = 8 + SLOW_COLOR_SPLASH = 9 + FAST_COLOR_SPLASH = 10 + AMERICA_THE_BEAUTIFUL = 11 + FAT_TUESDAY = 12 + DISCO_TECH = 13 + + +class ColorLogicPowerState(IntEnum, PrettyEnum): OFF = 0 POWERING_OFF = 1 CHANGING_SHOW = 3 @@ -254,27 +325,35 @@ class ColorLogicPowerState(PrettyEnum): COOLDOWN = 7 -class ColorLogicLightType(str, PrettyEnum): +class ColorLogicLightType(StrEnum, PrettyEnum): UCL = "COLOR_LOGIC_UCL" FOUR_ZERO = "COLOR_LOGIC_4_0" TWO_FIVE = "COLOR_LOGIC_2_5" + SAM = "COLOR_LOGIC_SAM" + PENTAIR_COLOR = "CL_P_COLOR" + ZODIAC_COLOR = "CL_Z_COLOR" def __str__(self) -> str: + """Return the string representation of the ColorLogicLightType.""" return ColorLogicLightType[self.name].value -class CSADType(str, PrettyEnum): +class CSADType(StrEnum, PrettyEnum): ACID = "ACID" CO2 = "CO2" +class CSADEquipmentType(StrEnum, PrettyEnum): + AQL_CHEM = "AQL-CHEM" + + # Chemistry Sense and Dispense -class CSADStatus(PrettyEnum): +class CSADStatus(IntEnum, PrettyEnum): NOT_DISPENSING = 0 DISPENSING = 1 -class CSADMode(PrettyEnum): +class CSADMode(IntEnum, PrettyEnum): OFF = 0 AUTO = 1 FORCE_ON = 2 @@ -283,7 +362,7 @@ class CSADMode(PrettyEnum): # Filters -class FilterState(PrettyEnum): +class FilterState(IntEnum, PrettyEnum): OFF = 0 ON = 1 PRIMING = 2 @@ -298,13 +377,13 @@ class FilterState(PrettyEnum): FILTER_WAITING_TURN_OFF = 11 -class FilterType(str, PrettyEnum): +class FilterType(StrEnum, PrettyEnum): VARIABLE_SPEED = "FMT_VARIABLE_SPEED_PUMP" DUAL_SPEED = "FMT_DUAL_SPEED" SINGLE_SPEED = "FMT_SINGLE_SPEED" -class FilterValvePosition(PrettyEnum): +class FilterValvePosition(IntEnum, PrettyEnum): POOL_ONLY = 1 SPA_ONLY = 2 SPILLOVER = 3 @@ -312,63 +391,80 @@ class FilterValvePosition(PrettyEnum): HIGH_PRIO_HEAT = 5 -class FilterWhyOn(PrettyEnum): +class FilterWhyOn(IntEnum, PrettyEnum): OFF = 0 NO_WATER_FLOW = 1 COOLDOWN = 2 - PH_REDUCE_EXTEND = 3 + CSAD_EXTEND = 3 HEATER_EXTEND = 4 - PAUSED = 5 - VALVE_CHANGING = 6 + PAUSE = 5 + OFF_VALVE_CHANGING = 6 FORCE_HIGH_SPEED = 7 - OFF_EXTERNAL_INTERLOCK = 8 + EXTERNAL_INTERLOCK = 8 SUPER_CHLORINATE = 9 - COUNTDOWN = 10 + COUNTDOWN_TIMER = 10 MANUAL_ON = 11 MANUAL_SPILLOVER = 12 - TIMER_SPILLOVER = 13 - TIMER_ON = 14 + TIMED_SPILLOVER = 13 + TIMED_EVENT = 14 FREEZE_PROTECT = 15 - UNKNOWN_16 = 16 - UNKNOWN_17 = 17 - UNKNOWN_18 = 18 + SET_POOL_SPA_SPILLOVER = 16 + SPILLOVER_COUNTDOWN_TIMER = 17 + GROUP_COMMAND = 18 + SPILLOVER_INTERLOCK = 19 + MAX_VALUE = 20 + + +class FilterSpeedPresets(StrEnum, PrettyEnum): + LOW = auto() + MEDIUM = auto() + HIGH = auto() + + +# Groups +class GroupState(IntEnum, PrettyEnum): + OFF = 0 + ON = 1 # Heaters -class HeaterState(PrettyEnum): +class HeaterState(IntEnum, PrettyEnum): OFF = 0 ON = 1 PAUSE = 2 -class HeaterType(str, PrettyEnum): +class HeaterType(StrEnum, PrettyEnum): GAS = "HTR_GAS" HEAT_PUMP = "HTR_HEAT_PUMP" SOLAR = "HTR_SOLAR" ELECTRIC = "HTR_ELECTRIC" GEOTHERMAL = "HTR_GEOTHERMAL" SMART = "HTR_SMART" + CHILLER = "HTR_CHILLER" + SMART_HEAT_PUMP = "HTR_SMART_HEAT_PUMP" -class HeaterMode(PrettyEnum): +class HeaterMode(IntEnum, PrettyEnum): HEAT = 0 COOL = 1 AUTO = 2 # Pumps -class PumpState(PrettyEnum): +class PumpState(IntEnum, PrettyEnum): OFF = 0 ON = 1 + FREEZE_PROTECT = 2 # This is an assumption that 2 means freeze protect, ref: https://github.com/cryptk/haomnilogic-local/issues/147 -class PumpType(str, PrettyEnum): +class PumpType(StrEnum, PrettyEnum): SINGLE_SPEED = "PMP_SINGLE_SPEED" DUAL_SPEED = "PMP_DUAL_SPEED" VARIABLE_SPEED = "PMP_VARIABLE_SPEED_PUMP" -class PumpFunction(str, PrettyEnum): +class PumpFunction(StrEnum, PrettyEnum): PUMP = "PMP_PUMP" WATER_FEATURE = "PMP_WATER_FEATURE" CLEANER = "PMP_CLEANER" @@ -385,8 +481,14 @@ class PumpFunction(str, PrettyEnum): CLEANER_IN_FLOOR = "PMP_CLEANER_IN_FLOOR" +class PumpSpeedPresets(StrEnum, PrettyEnum): + LOW = auto() + MEDIUM = auto() + HIGH = auto() + + # Relays -class RelayFunction(str, PrettyEnum): +class RelayFunction(StrEnum, PrettyEnum): WATER_FEATURE = "RLY_WATER_FEATURE" LIGHT = "RLY_LIGHT" BACKYARD_LIGHT = "RLY_BACKYARD_LIGHT" @@ -406,28 +508,34 @@ class RelayFunction(str, PrettyEnum): CLEANER_IN_FLOOR = "RLY_CLEANER_IN_FLOOR" -class RelayState(PrettyEnum): +class RelayState(IntEnum, PrettyEnum): OFF = 0 ON = 1 -class RelayType(str, PrettyEnum): +class RelayType(StrEnum, PrettyEnum): VALVE_ACTUATOR = "RLY_VALVE_ACTUATOR" HIGH_VOLTAGE = "RLY_HIGH_VOLTAGE_RELAY" LOW_VOLTAGE = "RLY_LOW_VOLTAGE_RELAY" -class RelayWhyOn(PrettyEnum): - OFF = 0 - ON = 1 - FREEZE_PROTECT = 2 - WAITING_FOR_INTERLOCK = 3 - PAUSED = 4 - WAITING_FOR_FILTER = 5 +class RelayWhyOn(IntEnum, PrettyEnum): + NO_MESSAGE = 0 + MANUAL_OFF = 1 + COUNTDOWN_DONE = 2 + END_SCHEDULE = 3 + GROUP_OFF = 4 + MANUAL_ON = 5 + COUNTDOWN_TIMER = 6 + SCHEDULE_ON = 7 + GROUP_ON = 8 + FREEZE_PROTECT = 9 + INTERLOCK = 10 + MAX_ACTION = 11 # Sensors -class SensorType(str, PrettyEnum): +class SensorType(StrEnum, PrettyEnum): AIR_TEMP = "SENSOR_AIR_TEMP" SOLAR_TEMP = "SENSOR_SOLAR_TEMP" WATER_TEMP = "SENSOR_WATER_TEMP" @@ -436,7 +544,7 @@ class SensorType(str, PrettyEnum): EXT_INPUT = "SENSOR_EXT_INPUT" -class SensorUnits(str, PrettyEnum): +class SensorUnits(StrEnum, PrettyEnum): FAHRENHEIT = "UNITS_FAHRENHEIT" CELSIUS = "UNITS_CELSIUS" PPM = "UNITS_PPM" @@ -447,6 +555,17 @@ class SensorUnits(str, PrettyEnum): # Valve Actuators -class ValveActuatorState(PrettyEnum): +class ValveActuatorState(IntEnum, PrettyEnum): OFF = 0 ON = 1 + + +# Schedules +class ScheduleDaysActive(Flag, PrettyEnum): + MONDAY = 1 << 0 + TUESDAY = 1 << 1 + WEDNESDAY = 1 << 2 + THURSDAY = 1 << 3 + FRIDAY = 1 << 4 + SATURDAY = 1 << 5 + SUNDAY = 1 << 6 diff --git a/pyomnilogic_local/pump.py b/pyomnilogic_local/pump.py new file mode 100644 index 0000000..1c47c49 --- /dev/null +++ b/pyomnilogic_local/pump.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.models.mspconfig import MSPPump +from pyomnilogic_local.models.telemetry import TelemetryPump +from pyomnilogic_local.omnitypes import PumpSpeedPresets, PumpState +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + + +class Pump(OmniEquipment[MSPPump, TelemetryPump]): + """Represents a pump in the OmniLogic system. + + Pumps are used for various functions including water circulation, waterfalls, + water features, spillover, and other hydraulic functions. Pumps can be + single-speed, multi-speed, or variable speed depending on the model. + + The Pump class provides control over pump speed and operation, with support + for preset speeds and custom speed percentages for variable speed pumps. + + Attributes: + mspconfig: Configuration data for this pump from MSP XML + telemetry: Real-time operational data and state + + Properties (Configuration): + equip_type: Equipment type (e.g., PMP_VARIABLE_SPEED_PUMP) + function: Pump function (e.g., PMP_PUMP, PMP_WATER_FEATURE, PMP_SPILLOVER) + max_percent: Maximum speed as percentage (0-100) + min_percent: Minimum speed as percentage (0-100) + max_rpm: Maximum speed in RPM + min_rpm: Minimum speed in RPM + priming_enabled: Whether priming mode is enabled + low_speed: Configured low speed preset value + medium_speed: Configured medium speed preset value + high_speed: Configured high speed preset value + + Properties (Telemetry): + state: Current operational state (OFF, ON) + speed: Current operating speed + last_speed: Previous speed setting + why_on: Reason code for pump being on + + Properties (Computed): + is_on: True if pump is currently running + is_ready: True if pump can accept commands + + Control Methods: + turn_on(): Turn on pump at last used speed + turn_off(): Turn off pump + run_preset_speed(speed): Run at LOW, MEDIUM, or HIGH preset + set_speed(speed): Run at specific percentage (0-100) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> pump = pool.pumps["Waterfall Pump"] + >>> + >>> # Check current state + >>> if pump.is_on: + ... print(f"Pump is running at {pump.speed}%") + >>> + >>> # Control pump + >>> await pump.turn_on() # Turn on at last speed + >>> await pump.run_preset_speed(PumpSpeedPresets.MEDIUM) + >>> await pump.set_speed(60) # Set to 60% + >>> await pump.turn_off() + >>> + >>> # Check pump function + >>> if pump.function == "PMP_WATER_FEATURE": + ... print("This is a water feature pump") + + Note: + - Speed value of 0 will turn the pump off + - The API automatically validates against min_percent/max_percent + - Not all pumps support variable speed operation + - Pump function determines its purpose (circulation, feature, spillover, etc.) + """ + + mspconfig: MSPPump + telemetry: TelemetryPump + + # Expose MSPConfig attributes + @property + def equip_type(self) -> str: + """The pump type (e.g., PMP_VARIABLE_SPEED_PUMP).""" + return self.mspconfig.equip_type + + @property + def function(self) -> str: + """The pump function (e.g., PMP_PUMP, PMP_WATER_FEATURE).""" + return self.mspconfig.function + + @property + def max_percent(self) -> int: + """Maximum pump speed percentage.""" + return self.mspconfig.max_percent + + @property + def min_percent(self) -> int: + """Minimum pump speed percentage.""" + return self.mspconfig.min_percent + + @property + def max_rpm(self) -> int: + """Maximum pump speed in RPM.""" + return self.mspconfig.max_rpm + + @property + def min_rpm(self) -> int: + """Minimum pump speed in RPM.""" + return self.mspconfig.min_rpm + + @property + def priming_enabled(self) -> bool: + """Whether priming is enabled for this pump.""" + return self.mspconfig.priming_enabled + + @property + def low_speed(self) -> int: + """Low speed preset value.""" + return self.mspconfig.low_speed + + @property + def medium_speed(self) -> int: + """Medium speed preset value.""" + return self.mspconfig.medium_speed + + @property + def high_speed(self) -> int: + """High speed preset value.""" + return self.mspconfig.high_speed + + # Expose Telemetry attributes + @property + def state(self) -> PumpState | int: + """Current pump state.""" + return self.telemetry.state + + @property + def speed(self) -> int: + """Current pump speed.""" + return self.telemetry.speed + + @property + def last_speed(self) -> int: + """Last speed setting.""" + return self.telemetry.last_speed + + @property + def why_on(self) -> int: + """Reason why the pump is on.""" + return self.telemetry.why_on + + # Computed properties + @property + def is_on(self) -> bool: + """Check if the pump is currently on. + + Returns: + True if pump state is ON (1), False otherwise + """ + return self.state == PumpState.ON + + @property + def is_ready(self) -> bool: + """Check if the pump is ready to receive commands. + + A pump is considered ready if: + - The backyard is not in service/config mode (checked by parent class) + - It's in a stable state (ON or OFF) + + Returns: + True if pump can accept commands, False otherwise + """ + # First check if backyard is ready + if not super().is_ready: + return False + + # Then check pump-specific readiness + return self.state in (PumpState.OFF, PumpState.ON) + + # Control methods + @control_method + async def turn_on(self) -> None: + """Turn the pump on. + + This will turn on the pump at its last used speed setting. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + msg = "Pump bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=True, + ) + + @control_method + async def turn_off(self) -> None: + """Turn the pump off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + msg = "Pump bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=False, + ) + + @control_method + async def run_preset_speed(self, speed: PumpSpeedPresets) -> None: + """Run the pump at a preset speed. + + Args: + speed: The preset speed to use (LOW, MEDIUM, or HIGH) + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If an invalid speed preset is provided. + """ + if self.bow_id is None or self.system_id is None: + msg = "Pump bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + speed_value: int + match speed: + case PumpSpeedPresets.LOW: + speed_value = self.low_speed + case PumpSpeedPresets.MEDIUM: + speed_value = self.medium_speed + case PumpSpeedPresets.HIGH: + speed_value = self.high_speed + case _: + msg = f"Invalid speed preset: {speed}" + raise ValueError(msg) + + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=speed_value, + ) + + @control_method + async def set_speed(self, speed: int) -> None: + """Set the pump to a specific speed. + + Args: + speed: Speed value (0-100 percent). A value of 0 will turn the pump off. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + ValueError: If speed is outside the valid range. + """ + if self.bow_id is None or self.system_id is None: + msg = "Pump bow_id and system_id must be set" + raise OmniEquipmentNotInitializedError(msg) + + if not self.min_percent <= speed <= self.max_percent: + msg = f"Speed {speed} is outside valid range [{self.min_percent}, {self.max_percent}]" + raise ValueError(msg) + + # Note: The API validates against min_percent/max_percent internally + await self._api.async_set_equipment( + pool_id=self.bow_id, + equipment_id=self.system_id, + is_on=speed, + ) diff --git a/pyomnilogic_local/relay.py b/pyomnilogic_local/relay.py new file mode 100644 index 0000000..dd3a4de --- /dev/null +++ b/pyomnilogic_local/relay.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.models.mspconfig import MSPRelay +from pyomnilogic_local.models.telemetry import TelemetryRelay +from pyomnilogic_local.omnitypes import RelayState +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import RelayFunction, RelayType, RelayWhyOn + + +class Relay(OmniEquipment[MSPRelay, TelemetryRelay]): + """Represents a relay in the OmniLogic system. + + Relays are ON/OFF switches that control various pool and spa equipment that + doesn't require variable speed control. Common relay applications include: + - Pool/spa lights (non-ColorLogic) + - Water features and fountains + - Deck jets and bubblers + - Auxiliary equipment (blowers, misters, etc.) + - Landscape lighting + - Accessory equipment + + Each relay has a configured function that determines its purpose and behavior. + Relays can be controlled manually or automatically based on schedules and + other system conditions. + + Attributes: + mspconfig: Configuration data for this relay from MSP XML + telemetry: Real-time state and status data + + Properties: + relay_type: Type of relay (e.g., VALVE_ACTUATOR, HIGH_VOLTAGE_RELAY, LOW_VOLTAGE_RELAY) + function: Relay function (e.g., WATER_FEATURE, CLEANER, etc) + state: Current state (ON or OFF) + why_on: Reason code for relay being on (manual, schedule, etc.) + is_on: True if relay is currently energized + + Control Methods: + turn_on(): Energize the relay (turn equipment on) + turn_off(): De-energize the relay (turn equipment off) + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> deck_jets = pool.relays["Deck Jets"] + >>> + >>> # Check current state + >>> if deck_jets.is_on: + ... print("Deck jets are currently running") + >>> + >>> # Control relay + >>> await deck_jets.turn_on() + >>> await deck_jets.turn_off() + >>> + >>> # Check function + >>> print(f"Relay function: {deck_jets.function}") + >>> print(f"Relay type: {deck_jets.relay_type}") + >>> + >>> # Check why the relay is on + >>> if deck_jets.is_on: + ... print(f"Why on: {deck_jets.why_on}") + + Note: + - Relays are binary ON/OFF devices (no speed or intensity control) + - The why_on property indicates if control is manual or automatic + - Relay state changes are immediate (no priming or delay states) + """ + + mspconfig: MSPRelay + telemetry: TelemetryRelay + + def __init__(self, omni: OmniLogic, mspconfig: MSPRelay, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def relay_type(self) -> RelayType: + """Returns the type of the relay.""" + return self.mspconfig.type + + @property + def function(self) -> RelayFunction: + """Returns the function of the relay.""" + return self.mspconfig.function + + @property + def state(self) -> RelayState: + """Returns the current state of the relay.""" + return self.telemetry.state + + @property + def why_on(self) -> RelayWhyOn: + """Returns the reason why the relay is on.""" + return self.telemetry.why_on + + @property + def is_on(self) -> bool: + """Returns whether the relay is currently on.""" + return self.state == RelayState.ON + + @control_method + async def turn_on(self) -> None: + """Turn on the relay. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + msg = "Cannot turn on relay: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_equipment(self.bow_id, self.system_id, True) + + @control_method + async def turn_off(self) -> None: + """Turn off the relay. + + Raises: + OmniEquipmentNotInitializedError: If bow_id or system_id is None. + """ + if self.bow_id is None or self.system_id is None: + msg = "Cannot turn off relay: bow_id or system_id is None" + raise OmniEquipmentNotInitializedError(msg) + await self._api.async_set_equipment(self.bow_id, self.system_id, False) diff --git a/pyomnilogic_local/schedule.py b/pyomnilogic_local/schedule.py new file mode 100644 index 0000000..347ba89 --- /dev/null +++ b/pyomnilogic_local/schedule.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.models.mspconfig import MSPSchedule +from pyomnilogic_local.util import OmniEquipmentNotInitializedError + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + + +class Schedule(OmniEquipment[MSPSchedule, None]): + """Represents a schedule in the OmniLogic system. + + Schedules control automatic timing of equipment operations. Each schedule defines + when equipment should turn on/off or change state, what days of the week it should + run, and whether it should repeat. + + Attributes: + mspconfig: Configuration data for this schedule from MSP XML + telemetry: None (schedules do not have telemetry data) + + Properties: + bow_id: The Body of Water ID this schedule belongs to + equipment_id: The equipment system ID controlled by this schedule + controlled_equipment: The actual equipment instance controlled by this schedule + event: The MessageType/action that will be executed + data: The data value for the action (e.g., speed, on/off state) + enabled: Whether the schedule is currently enabled + start_hour: Hour to start (0-23) + start_minute: Minute to start (0-59) + end_hour: Hour to end (0-23) + end_minute: Minute to end (0-59) + days_active_raw: Bitmask of active days (1=Mon, 2=Tue, 4=Wed, etc.) + days_active: List of active day names (e.g., ['Monday', 'Wednesday']) + recurring: Whether the schedule repeats + + Control Methods: + turn_on(): Enable the schedule + turn_off(): Disable the schedule + + Example: + >>> omni = OmniLogic(...) + >>> await omni.connect() + >>> + >>> # Access schedules (when implemented in OmniLogic) + >>> schedule = omni.schedules[15] # Access by system_id + >>> + >>> # Check schedule details + >>> print(f"Controls equipment ID: {schedule.equipment_id}") + >>> print(f"Runs on: {', '.join(schedule.days_active)}") + >>> print(f"Time: {schedule.start_hour}:{schedule.start_minute:02d} - {schedule.end_hour}:{schedule.end_minute:02d}") + >>> print(f"Enabled: {schedule.enabled}") + >>> + >>> # Control schedule + >>> await schedule.turn_on() # Enable the schedule + >>> await schedule.turn_off() # Disable the schedule + + Note: + - Schedules do not have telemetry; state is only in configuration + - Turning on/off a schedule only changes its enabled state + - All other schedule parameters (timing, days, equipment) remain unchanged + - The schedule-system-id is used to identify which schedule to edit + """ + + mspconfig: MSPSchedule + telemetry: None + + def __init__(self, omni: OmniLogic, mspconfig: MSPSchedule, telemetry: Telemetry) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def equipment_id(self) -> int: + """Returns the equipment ID controlled by this schedule.""" + return self.mspconfig.equipment_id + + @property + def event(self) -> int: + """Returns the event/action ID that will be executed.""" + return self.mspconfig.event.value + + @property + def data(self) -> int: + """Returns the data value for the scheduled action.""" + return self.mspconfig.data + + @property + def enabled(self) -> bool: + """Returns whether the schedule is currently enabled.""" + return self.mspconfig.enabled + + @property + def start_hour(self) -> int: + """Returns the hour the schedule starts (0-23).""" + return self.mspconfig.start_hour + + @property + def start_minute(self) -> int: + """Returns the minute the schedule starts (0-59).""" + return self.mspconfig.start_minute + + @property + def end_hour(self) -> int: + """Returns the hour the schedule ends (0-23).""" + return self.mspconfig.end_hour + + @property + def end_minute(self) -> int: + """Returns the minute the schedule ends (0-59).""" + return self.mspconfig.end_minute + + @property + def days_active_raw(self) -> int: + """Returns the raw bitmask of active days.""" + return self.mspconfig.days_active_raw + + @property + def days_active(self) -> list[str]: + """Returns a list of active day names.""" + return self.mspconfig.days_active + + @property + def recurring(self) -> bool: + """Returns whether the schedule repeats.""" + return self.mspconfig.recurring + + @property + def controlled_equipment(self) -> OmniEquipment[Any, Any] | None: + """Returns the equipment controlled by this schedule. + + Uses the schedule's equipment_id to dynamically look up the actual + equipment instance from the OmniLogic parent. + + Returns: + The equipment instance controlled by this schedule, or None if not found. + + Example: + >>> schedule = omni.schedules[15] + >>> equipment = schedule.controlled_equipment + >>> if equipment: + ... print(f"This schedule controls: {equipment.name}") + """ + return self._omni.get_equipment_by_id(self.equipment_id) + + @control_method + async def turn_on(self) -> None: + """Enable the schedule. + + Sends an edit command with all current schedule parameters but sets + the enabled state to True. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + """ + if self.system_id is None: + msg = "Cannot turn on schedule: system_id is None" + raise OmniEquipmentNotInitializedError(msg) + + await self._api.async_edit_schedule( + equipment_id=self.system_id, # This is the schedule-system-id + data=self.data, + action_id=self.event, + start_time_hours=self.start_hour, + start_time_minutes=self.start_minute, + end_time_hours=self.end_hour, + end_time_minutes=self.end_minute, + days_active=self.days_active_raw, + is_enabled=True, # Enable the schedule + recurring=self.recurring, + ) + + @control_method + async def turn_off(self) -> None: + """Disable the schedule. + + Sends an edit command with all current schedule parameters but sets + the enabled state to False. + + Raises: + OmniEquipmentNotInitializedError: If system_id is None. + """ + if self.system_id is None: + msg = "Cannot turn off schedule: system_id is None" + raise OmniEquipmentNotInitializedError(msg) + + await self._api.async_edit_schedule( + equipment_id=self.system_id, # This is the schedule-system-id + data=self.data, + action_id=self.event, + start_time_hours=self.start_hour, + start_time_minutes=self.start_minute, + end_time_hours=self.end_hour, + end_time_minutes=self.end_minute, + days_active=self.days_active_raw, + is_enabled=False, # Disable the schedule + recurring=self.recurring, + ) diff --git a/pyomnilogic_local/sensor.py b/pyomnilogic_local/sensor.py new file mode 100644 index 0000000..deab9c4 --- /dev/null +++ b/pyomnilogic_local/sensor.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomnilogic_local._base import OmniEquipment +from pyomnilogic_local.models.mspconfig import MSPSensor + +if TYPE_CHECKING: + from pyomnilogic_local.models.telemetry import Telemetry + from pyomnilogic_local.omnilogic import OmniLogic + from pyomnilogic_local.omnitypes import SensorType, SensorUnits + + +class Sensor(OmniEquipment[MSPSensor, None]): + """Represents a sensor in the OmniLogic system. + + Sensors are monitoring devices that measure various environmental and system + parameters. Unlike other equipment, sensors do not have their own telemetry + data structure - instead, they contribute readings to the telemetry of other + equipment (Backyard, BoW, Heater, etc.). + + Sensor Types: + - AIR_TEMP: Measures ambient air temperature + - SOLAR_TEMP: Measures solar collector temperature + - WATER_TEMP: Measures water temperature in pool/spa + - FLOW: Detects water flow (binary on/off) + - ORP: Measures Oxidation-Reduction Potential (chlorine effectiveness) + - EXT_INPUT: External input sensor (various purposes) + + Sensors are read-only monitoring devices with no control methods. + Their readings appear in the telemetry of associated equipment: + - Air temperature → Backyard telemetry + - Water temperature → BoW (Body of Water) telemetry + - Solar temperature → Heater telemetry + - Flow → BoW telemetry + - ORP → CSAD telemetry + + Attributes: + mspconfig: Configuration data for this sensor from MSP XML + telemetry: Always None (sensors don't have their own telemetry) + + Properties: + sensor_type: Type of sensor (AIR_TEMP, WATER_TEMP, FLOW, etc.) + units: Units of measurement (FAHRENHEIT, CELSIUS, MILLIVOLTS, etc.) + name: Sensor name from configuration + system_id: Unique system identifier + + Example: + >>> pool = omni.backyard.bow["Pool"] + >>> sensors = pool.sensors + >>> + >>> # Iterate through sensors + >>> for sensor in sensors: + ... print(f"{sensor.name}: {sensor.sensor_type} ({sensor.units})") + >>> + >>> # Get readings from parent equipment telemetry + >>> # Water temp sensor → BoW telemetry + >>> water_temp = pool.water_temp + >>> + >>> # Air temp sensor → Backyard telemetry + >>> air_temp = omni.backyard.air_temp + >>> + >>> # Flow sensor → BoW telemetry + >>> has_flow = pool.flow > 0 + + Important: + Sensors do NOT have their own telemetry or state. To get sensor readings, + access the telemetry of the parent equipment: + + - For water temperature: Use bow.water_temp + - For air temperature: Use backyard.air_temp + - For flow: Use bow.flow + - For ORP: Use csad.current_orp + + Note: + - Sensors are passive monitoring devices (no control methods) + - Sensor readings update as part of parent equipment telemetry refresh + - Temperature sensors may use Fahrenheit or Celsius (check units property) + - Flow sensors typically return 255 for flow, 0 for no flow + - ORP sensors measure in millivolts (typically 400-800 mV) + """ + + mspconfig: MSPSensor + + def __init__(self, omni: OmniLogic, mspconfig: MSPSensor, telemetry: Telemetry | None) -> None: + super().__init__(omni, mspconfig, telemetry) + + @property + def sensor_type(self) -> SensorType | str: + """Returns the type of sensor. + + Can be AIR_TEMP, SOLAR_TEMP, WATER_TEMP, FLOW, ORP, or EXT_INPUT. + """ + return self.mspconfig.equip_type + + @property + def units(self) -> SensorUnits | str: + """Returns the units used by the sensor. + + Can be FAHRENHEIT, CELSIUS, PPM, GRAMS_PER_LITER, MILLIVOLTS, + NO_UNITS, or ACTIVE_INACTIVE. + """ + return self.mspconfig.units diff --git a/pyomnilogic_local/system.py b/pyomnilogic_local/system.py new file mode 100644 index 0000000..5c63918 --- /dev/null +++ b/pyomnilogic_local/system.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pyomnilogic_local.models.mspconfig import MSPSystem + + +class System: + """Represents the main system equipment in the OmniLogic system.""" + + mspconfig: MSPSystem + + def __init__(self, mspconfig: MSPSystem) -> None: + self.update_config(mspconfig) + + @property + def vsp_speed_format(self) -> str | None: + """The VSP speed format of the system.""" + return self.mspconfig.vsp_speed_format + + @property + def units(self) -> str | None: + """The units of the system.""" + return self.mspconfig.units + + def update_config(self, mspconfig: MSPSystem) -> None: + """Update the configuration data for the equipment.""" + self.mspconfig = mspconfig diff --git a/pyomnilogic_local/util.py b/pyomnilogic_local/util.py index b2b0cfb..0bcc49f 100644 --- a/pyomnilogic_local/util.py +++ b/pyomnilogic_local/util.py @@ -1,10 +1,42 @@ -import sys +from __future__ import annotations + from enum import Enum +from typing import Self + + +class OmniLogicLocalError(Exception): + """Base exception for python-omnilogic-local.""" + + +class OmniEquipmentNotReadyError(OmniLogicLocalError): + """Raised when equipment cannot accept commands due to its current state. + + Examples: + - Light in FIFTEEN_SECONDS_WHITE state + - Light in CHANGING_SHOW state + - Light in POWERING_OFF state + - Light in COOLDOWN state + - Equipment performing initialization or calibration + """ + + +class OmniEquipmentNotInitializedError(OmniLogicLocalError): + """Raised when equipment has not been properly initialized. + + This typically occurs when required identifiers (bow_id or system_id) are None, + indicating the equipment hasn't been populated from telemetry data yet. + """ + + +class OmniConnectionError(OmniLogicLocalError): + """Raised when communication with the OmniLogic controller fails. -if sys.version_info >= (3, 11): - from typing import Self -else: - from typing_extensions import Self + Examples: + - UDP socket timeout + - Network unreachable + - Invalid response from controller + - Protocol errors + """ class PrettyEnum(Enum): diff --git a/pyproject.toml b/pyproject.toml index 7372820..a37a1aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,52 +2,47 @@ name = "python-omnilogic-local" version = "0.19.0" description = "A library for local control of Hayward OmniHub/OmniLogic pool controllers using their local API" +readme = "README.md" +requires-python = ">=3.13,<4.0.0" authors = [ {name = "Chris Jowett",email = "421501+cryptk@users.noreply.github.com"}, {name = "djtimca"}, {name = "garionphx"} ] -license = {text = "Apache-2.0"} -readme = "README.md" -requires-python = ">=3.12,<4.0.0" +license-files = ["LICENSE"] dependencies = [ "pydantic >=2.0.0,<3.0.0", - "xmltodict >=0.13.0,<2.0.0", "click >=8.0.0,<8.4.0", + "xmltodict >=1.0.1,<2.0.0", ] [project.scripts] omnilogic = "pyomnilogic_local.cli.cli:entrypoint" -[tool.poetry] -packages = [{include = "pyomnilogic_local"}] - -[tool.poetry.group.dev.dependencies] -pre-commit = "^4.0.0" -mypy = "^1.18.2" -pylint = "^4.0.0" -pytest = "^8.0.0" -pytest-cov = "^7.0.0" -pytest-asyncio = "^1.2.0" - -[tool.poetry.group.cli.dependencies] -scapy = "^2.6.1" +[project.optional-dependencies] +cli = [ + "scapy>=2.6.1,<3.0.0", +] [build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["uv_build>=0.9.8,<0.10.0"] +build-backend = "uv_build" -[tool.black] -line-length=140 - -[tool.isort] -# https://github.com/PyCQA/isort/wiki/isort-Settings -profile = "black" +[dependency-groups] +dev = [ + "pre-commit>=4.0.0,<5.0.0", + "mypy>=1.18.2,<2.0.0", + "types-xmltodict >=1.0.1,<2.0.0", + "pytest>=8.0.0,<9.0.0", + "pytest-cov>=7.0.0,<8.0.0", + "pytest-asyncio>=1.2.0,<2.0.0", + "pytest-subtests>=0.15.0,<1.0.0", +] [tool.mypy] python_version = "3.13" plugins = [ - "pydantic.mypy", + "pydantic.mypy" ] follow_imports = "silent" strict = true @@ -55,89 +50,64 @@ ignore_missing_imports = true disallow_subclassing_any = false warn_return_any = false -[tool.pydantic-mypy] -init_forbid_extra = true -init_typed = true -warn_required_dynamic_aliases = true -warn_untyped_fields = true +[tool.ruff] +line-length = 140 -[tool.pylint.MAIN] -py-version = "3.13" -extension-pkg-allow-list = [ - "pydantic", +[tool.ruff.lint] +select = [ + "ERA", # eradicate commented code + "ASYNC", # flake8-async + "B", # flake8-bugbear + "A", # flake8-builtins + "EM", # flake8-errmsg + "FIX", # flake8-fixme + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "G", # flake8-logging-format + "INP", # flake8-no-pep420 + "PIE", # flake8-pie + "T20", # flake8-print + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SIM", # flake8-simplify + "PTH", # flake8-use-pathlib + "TID252", # flake8-tidy-imports + "TD", # flake8-todos + "TC", # flake8-type-checking + "ARG", # flake8-unused-arguments + "I", # isort + "N", # pep8-naming + "PERF", # perflint + "E", # pycodestyle errors + "W", # pycodestyle warnings + # "DOC", # pydoclint, only available with ruff preview mode enabled + "D", # pydocstyle + "F", # pyflakes + "UP", # pyupgrade + "RUF", # ruff-specific rules + "TRY", ] ignore = [ - "tests", -] -# Use a conservative default here; 2 should speed up most setups and not hurt -# any too bad. Override on command line as appropriate. -jobs = 2 -load-plugins = [ - "pylint.extensions.code_style", - "pylint.extensions.typing", + "D100", # Disabled until we get everything documented. + "D101", # Disabled until we get everything documented. + "D102", # Disabled until we get everything documented. + "D107", # Disabled until we get everything documented. ] +future-annotations = true -[tool.pylint."FORMAT"] -expected-line-ending-format = "LF" -# Maximum number of characters on a single line. -max-line-length=140 - -[tool.pylint."MESSAGES CONTROL"] -# Reasons disabled: -# format - handled by black -# locally-disabled - it spams too much -# duplicate-code - unavoidable -# cyclic-import - doesn't test if both import on load -# abstract-class-little-used - prevents from setting right foundation -# unused-argument - generic callbacks and setup methods create a lot of warnings -# too-many-* - are not enforced for the sake of readability -# too-few-* - same as too-many-* -# abstract-method - with intro of async there are always methods missing -# inconsistent-return-statements - doesn't handle raise -# too-many-ancestors - it's too strict. -# wrong-import-order - isort guards this -# consider-using-f-string - str.format sometimes more readable -# --- -# Pylint CodeStyle plugin -# consider-using-namedtuple-or-dataclass - too opinionated -# consider-using-assignment-expr - decision to use := better left to devs -disable = [ - "format", - "abstract-method", - "cyclic-import", - "duplicate-code", - "inconsistent-return-statements", - "locally-disabled", - "not-context-manager", - "too-few-public-methods", - "too-many-ancestors", - "too-many-arguments", - "too-many-branches", - "too-many-instance-attributes", - "too-many-lines", - "too-many-locals", - "too-many-public-methods", - "too-many-return-statements", - "too-many-statements", - "too-many-boolean-expressions", - "unused-argument", - "wrong-import-order", - "wrong-import-position", - "consider-using-f-string", - # The below are only here for now, we should fully document once the codebase stops fluctuating so much - "missing-class-docstring", - "missing-function-docstring", - "missing-module-docstring", -] -enable = [ - "useless-suppression", - "use-symbolic-message-instead", -] - -[tool.ruff] -line-length = 140 +[tool.ruff.lint.pydocstyle] +convention = "google" [tool.semantic_release] branch = "main" -version_toml = "pyproject.toml:project.version" -build_command = "pip install poetry && poetry build" +version_toml = ["pyproject.toml:project.version"] +build_command = "uv build" +allow_zero_version = true + +[tool.uv] +package = true + +[tool.uv.build-backend] +module-name = "pyomnilogic_local" +module-root = "" diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..38d770e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Hayward OmniLogic Local package.""" diff --git a/tests/fixtures/issue-144.json b/tests/fixtures/issue-144.json new file mode 100644 index 0000000..2690ca5 --- /dev/null +++ b/tests/fixtures/issue-144.json @@ -0,0 +1,4 @@ +{ + "mspconfig": "\n\n\n \n Percent\n 12 Hour Format\n 600\n yes\n on\n Metric\n Salt\n English\n standard\n Yes\n Yes\n Yes\n Yes\n \n \n MSP Configuration\n 0\n Backyard\n 0\n \n 10\n AirSensor\n SENSOR_AIR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_AIR_TEMP\n 2068\n 4\n 0\n \n \n \n \n 01\n 3\n Pool\n BOW_POOL\n BOW_NO_EQUIPMENT_SHARED\n SHARED_EQUIPMENT_LOW_PRIORITY\n 0\n no\n no\n 13738\n \n 4\n Filter Pump\n BOW_NO_EQUIPMENT_SHARED\n FMT_VARIABLE_SPEED_PUMP\n 100\n 45\n 3000\n 600\n 30\n yes\n 120\n 300\n 300\n yes\n 900\n no\n 35\n no\n 38\n 90\n 0\n FLT_DONT_CHANGE_VALVES\n 45\n 75\n 100\n 80\n 7200\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 2078\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_FLT_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_FLT_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n \n 5\n pH\n CSAD_AUTO\n ACID\n yes\n 7.5\n -1.0\n 7200\n no\n 6.9\n 8.1\n 300\n 540\n 540\n 350\n 950\n 0\n no\n \n PEO_STATUS_GET\n \n ACT_FNC_CSAD_STATUS_GET\n 2077\n 0\n 0\n \n \n \n PEO_REVISION_GET\n \n ACT_FNC_CSAD_REVISION_GET\n 2077\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CSAD_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CSAD_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_CSAD_EQUIPMENT\n \n 8\n ChemSense1\n PET_CSAD\n AQL-CHEM\n yes\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 2055\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 2055\n 1\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 2055\n 1\n 0\n \n \n \n \n \n \n 6\n Chlorinator\n BOW_NO_EQUIPMENT_SHARED\n yes\n CHLOR_OP_MODE_ORP_AUTO\n 50\n 8\n CELL_TYPE_T15\n SALT_DISPENSING\n 86400\n -1\n \n PEO_INIT\n \n ACT_FNC_CHL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CHL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_CHLORINATOR_EQUIPMENT\n \n 7\n Chlorinator1\n PET_CHLORINATOR\n CHLOR_TYPE_MAIN_PANEL\n yes\n \n PEO_STATUS_GET\n \n ACT_FNC_CHL_STATUS_GET\n 2072\n 0\n 0\n \n \n \n PEO_PARAMS_SET\n \n ACT_FNC_CHL_PARAMS_SET\n 2072\n 0\n 0\n \n \n \n PEO_PAUSE\n \n ACT_FNC_CHL_PAUSE_CONTINUE\n 2072\n 1\n 0\n \n \n \n PEO_CONTINUE\n \n ACT_FNC_CHL_PAUSE_CONTINUE\n 2072\n 2\n 0\n \n \n \n PEO_ERRORS_GET\n \n ACT_FNC_CHL_ERRORS_GET\n 2072\n 0\n 0\n \n \n \n PEO_ALERTS_GET\n \n ACT_FNC_CHL_ALERTS_GET\n 2072\n 0\n 0\n \n \n \n PEO_SUPER_CHLOR_ON\n \n ACT_FNC_CHL_SUPER_CHLOR_SET\n 2072\n 1\n 0\n \n \n \n PEO_SUPER_CHLOR_OFF\n \n ACT_FNC_CHL_SUPER_CHLOR_SET\n 2072\n 0\n 0\n \n \n \n PEO_SALT_CALC_RESTART\n \n ACT_FNC_SALT_CALC_RESTART\n 2072\n 0\n 0\n \n \n \n PEO_POLARITY_REVERSE\n \n ACT_FNC_CHL_RELAY_POLARITY_REVERSE\n 2072\n 0\n 0\n \n \n \n PEO_CELL_RUNTIME_RESTART\n \n ACT_FNC_CHL_CELL_RUNTIME_RESTART\n 2072\n 0\n 0\n \n \n \n \n \n \n 9\n UCL\n COLOR_LOGIC_UCL\n 0\n no\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 2056\n 2\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 2056\n 2\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 2056\n 2\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n \n 11\n WaterSensor\n SENSOR_WATER_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_TEMP\n 2067\n 2\n 0\n \n \n \n \n 12\n FlowSensor\n SENSOR_FLOW\n UNITS_ACTIVE_INACTIVE\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_FLOW\n 2065\n 0\n 0\n \n \n \n \n 15\n BOW_NO_EQUIPMENT_SHARED\n yes\n 84\n 104\n 55\n 104\n yes\n no\n 300\n 900\n \n PEO_INIT\n \n ACT_FNC_HEATER_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_HEATER_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_HEATER_EQUIPMENT\n \n 16\n Heat Pump\n PET_HEATER\n HTR_HEAT_PUMP\n yes\n HTR_PRIORITY_1\n HTR_MAINTAINS_PRIORITY_FOR_AS_LONG_AS_VALID\n yes\n 80\n no\n 180\n 46\n 46\n 10\n -1\n \n PEO_TURN_ON\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2051\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2051\n 1\n 0\n \n \n \n \n \n \n \n \n \n 3\n 4\n 13\n 164\n 45\n 1\n 0\n 9\n 0\n 14\n 127\n 1\n \n \n 3\n 9\n 14\n 164\n 0\n 0\n 30\n 20\n 0\n 22\n 127\n 1\n \n \n 3\n 15\n 17\n 164\n 84\n 1\n 0\n 11\n 0\n 14\n 127\n 1\n \n \n 3\n 15\n 19\n 164\n 65\n 1\n 0\n 14\n 0\n 11\n 127\n 1\n \n \n 3\n 4\n 20\n 164\n 45\n 0\n 0\n 21\n 0\n 10\n 127\n 1\n \n \n 3\n 15\n 21\n 164\n 86\n 0\n 0\n 21\n 30\n 8\n 127\n 1\n \n \n 3\n 4\n 22\n 164\n 45\n 0\n 0\n 15\n 0\n 21\n 127\n 1\n \n \n \n \n MP\n MP\n 2048\n \n \n LVR1\n RELAY\n 2051\n \n \n LVR2\n RELAY\n 2052\n \n \n LVR3\n RELAY\n 2053\n \n \n LVR4\n RELAY\n 2054\n \n \n HVR1\n RELAY\n 2055\n \n \n HVR2\n RELAY\n 2056\n \n \n HVR3\n RELAY\n 2057\n \n \n HVR4\n RELAY\n 2058\n \n \n HVR5\n RELAY\n 2059\n \n \n HVR6\n RELAY\n 2060\n \n \n ACR1\n RELAY\n 2061\n \n \n ACR2\n RELAY\n 2062\n \n \n ACR3\n RELAY\n 2063\n \n \n ACR4\n RELAY\n 2064\n \n \n SNS1\n SENSOR\n 2065\n \n \n SNS2\n SENSOR\n 2066\n \n \n SNS3\n SENSOR\n 2067\n \n \n SNS4\n SENSOR\n 2068\n \n \n SNS5\n SENSOR\n 2069\n \n \n SNS6\n SENSOR\n 2070\n \n \n SNS7\n SENSOR\n 2071\n \n \n CHLR1\n CHLORINATOR\n 2072\n \n \n \n \n RB\n RB\n 2050\n \n \n HVR1\n RELAY\n 2073\n \n \n HVR2\n RELAY\n 2074\n \n \n HVR3\n RELAY\n 2075\n \n \n HVR4\n RELAY\n 2076\n \n \n \n \n VSP\n EPNS\n 2078\n \n \n \n L.Chem SM\n LCSM\n 2077\n \n \n \n 2059497\n\n", + "telemetry": "\n\n \n \n \n \n \n \n \n \n\n" +} diff --git a/tests/fixtures/issue-163.json b/tests/fixtures/issue-163.json new file mode 100644 index 0000000..67ea59b --- /dev/null +++ b/tests/fixtures/issue-163.json @@ -0,0 +1,4 @@ +{ + "mspconfig": "\n\n\n \n Percent\n 24 Hour Format\n -420\n no\n on\n Standard\n Salt\n English\n standard\n Yes\n Yes\n Yes\n Yes\n \n \n MSP Configuration\n 0\n Backyard\n 0\n \n 16\n AirSensor\n SENSOR_AIR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_AIR_TEMP\n 20\n 4\n 0\n \n \n \n \n 01\n 10\n Pool\n BOW_POOL\n BOW_NO_EQUIPMENT_SHARED\n SHARED_EQUIPMENT_LOW_PRIORITY\n 0\n no\n no\n 14000\n \n 11\n Filter Pump\n BOW_NO_EQUIPMENT_SHARED\n FMT_VARIABLE_SPEED_PUMP\n 100\n 18\n 3450\n 600\n 30\n yes\n 180\n 300\n 300\n no\n 900\n no\n 35\n yes\n 36\n 50\n 0\n FLT_DONT_CHANGE_VALVES\n 18\n 60\n 100\n 18\n 7200\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 1\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_FLT_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_FLT_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n \n 12\n Chlorinator\n BOW_NO_EQUIPMENT_SHARED\n no\n CHLOR_OP_MODE_TIMED\n 100\n 16\n CELL_TYPE_TCELLS340\n SALT_DISPENSING\n 86400\n -1\n \n PEO_INIT\n \n ACT_FNC_CHL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CHL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_CHLORINATOR_EQUIPMENT\n \n 13\n Chlorinator1\n PET_CHLORINATOR\n CHLOR_TYPE_MAIN_PANEL\n yes\n \n PEO_STATUS_GET\n \n ACT_FNC_CHL_STATUS_GET\n 24\n 0\n 0\n \n \n \n PEO_PARAMS_SET\n \n ACT_FNC_CHL_PARAMS_SET\n 24\n 0\n 0\n \n \n \n PEO_PAUSE\n \n ACT_FNC_CHL_PAUSE_CONTINUE\n 24\n 1\n 0\n \n \n \n PEO_CONTINUE\n \n ACT_FNC_CHL_PAUSE_CONTINUE\n 24\n 2\n 0\n \n \n \n PEO_ERRORS_GET\n \n ACT_FNC_CHL_ERRORS_GET\n 24\n 0\n 0\n \n \n \n PEO_ALERTS_GET\n \n ACT_FNC_CHL_ALERTS_GET\n 24\n 0\n 0\n \n \n \n PEO_SUPER_CHLOR_ON\n \n ACT_FNC_CHL_SUPER_CHLOR_SET\n 24\n 1\n 0\n \n \n \n PEO_SUPER_CHLOR_OFF\n \n ACT_FNC_CHL_SUPER_CHLOR_SET\n 24\n 0\n 0\n \n \n \n PEO_SALT_CALC_RESTART\n \n ACT_FNC_SALT_CALC_RESTART\n 24\n 0\n 0\n \n \n \n PEO_POLARITY_REVERSE\n \n ACT_FNC_CHL_RELAY_POLARITY_REVERSE\n 24\n 0\n 0\n \n \n \n PEO_CELL_RUNTIME_RESTART\n \n ACT_FNC_CHL_CELL_RUNTIME_RESTART\n 24\n 0\n 0\n \n \n \n \n \n \n 14\n Fountain\n PMP_SINGLE_SPEED\n PMP_FOUNTAIN\n yes\n 100\n no\n 1800\n no\n 180\n 3450\n 600\n 18\n 100\n 100\n 100\n 100\n 100\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 7\n 4\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 7\n 4\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_PUMP_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_PUMP_TEARDOWN\n 0\n 0\n 0\n \n \n \n \n 15\n Color Lights\n COLOR_LOGIC_4_0\n 0\n no\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 6\n 2\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 6\n 2\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 6\n 2\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n \n 17\n WaterSensor\n SENSOR_WATER_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_TEMP\n 19\n 2\n 0\n \n \n \n \n 18\n FlowSensor\n SENSOR_FLOW\n UNITS_ACTIVE_INACTIVE\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_FLOW\n 17\n 0\n 0\n \n \n \n \n \n \n \n 10\n 11\n 31\n 164\n 60\n 1\n 0\n 8\n 0\n 16\n 127\n 1\n \n \n 10\n 12\n 38\n 164\n 100\n 1\n 0\n 10\n 0\n 16\n 127\n 1\n \n \n \n \n 22\n 1\n 15\n 0\n 2\n 1\n \n \n 23\n 2\n 11\n 1\n 268435440\n 1\n \n \n 24\n 3\n 12\n 2\n 0\n 0\n \n \n 25\n 4\n 14\n 3\n 0\n 1\n \n \n 37\n 5\n 36\n 4\n 268435455\n 1\n \n \n \n \n 36\n Theme1\n 0\n \n TurnOnOffForGroup\n \n 10\n 11\n 70\n 0\n \n \n \n SetUISuperCHLORCmd\n \n 10\n 12\n 0\n \n \n \n TurnOnOffForGroup\n \n 10\n 14\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 10\n 15\n 263168\n 0\n \n \n \n \n \n \n VSP\n EPNS\n 1\n \n \n \n OPLMP\n OPLMP\n 2\n \n \n LVR1\n RELAY\n 3\n \n \n LVR2\n RELAY\n 4\n \n \n HVR1\n RELAY\n 5\n \n \n HVR2\n RELAY\n 6\n \n \n HVR3\n RELAY\n 7\n \n \n HVR4\n RELAY\n 8\n \n \n HVR5\n RELAY\n 9\n \n \n HVR6\n RELAY\n 10\n \n \n HVR7\n RELAY\n 11\n \n \n HVR8\n RELAY\n 12\n \n \n ACR1\n RELAY\n 13\n \n \n ACR2\n RELAY\n 14\n \n \n ACR3\n RELAY\n 15\n \n \n ACR4\n RELAY\n 16\n \n \n SNS1\n SENSOR\n 17\n \n \n SNS2\n SENSOR\n 18\n \n \n SNS3\n SENSOR\n 19\n \n \n SNS4\n SENSOR\n 20\n \n \n SNS5\n SENSOR\n 21\n \n \n SNS6\n SENSOR\n 22\n \n \n SNS7\n SENSOR\n 23\n \n \n CHLR1\n CHLORINATOR\n 24\n \n \n \n \n 1730790\n\n", + "telemetry": "\n\n \n \n \n \n \n \n \n\n" +} diff --git a/tests/fixtures/issue-60.json b/tests/fixtures/issue-60.json new file mode 100644 index 0000000..6d0c502 --- /dev/null +++ b/tests/fixtures/issue-60.json @@ -0,0 +1,4 @@ +{ + "mspconfig": "\n\n\n \n Percent\n 12 Hour Format\n Standard\n Salt\n English\n standard\n No\n Yes\n Yes\n Yes\n \n \n MSP Configuration\n 0\n Backyard\n 0\n \n 16\n AirSensor\n SENSOR_AIR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_AIR_TEMP\n 19\n 4\n 0\n \n \n \n \n 27\n Yard Lights\n RLY_HIGH_VOLTAGE_RELAY\n RLY_LIGHT\n no\n no\n 0\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 10\n 16\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 10\n 16\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 10\n 16\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_LV_RELAY_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_LV_RELAY_TEARDOWN\n 0\n 0\n 0\n \n \n \n \n 01\n 1\n Pool\n BOW_POOL\n BOW_SHARED_EQUIPMENT\n SHARED_EQUIPMENT_LOW_PRIORITY\n 8\n no\n no\n 0\n \n 3\n Filter Pump\n BOW_SHARED_EQUIPMENT\n FMT_VARIABLE_SPEED_PUMP\n 100\n 18\n 3450\n 600\n 30\n no\n 180\n 300\n 300\n no\n 900\n no\n 35\n no\n 38\n 50\n 1800\n FLT_DONT_CHANGE_VALVES\n 50\n 75\n 90\n 65\n 7200\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 24\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_FLT_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_FLT_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_SET_VALVES_FOR_SPILLOVER\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 13\n 2\n 0\n \n \n ACT_FNC_HW_ACTIVATE_VALVE\n 12\n 1\n 1\n \n \n \n PEO_SET_VALVES_FOR_FILTER\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 13\n 2\n 0\n \n \n ACT_FNC_HW_ACTIVATE_VALVE\n 12\n 1\n 0\n \n \n \n \n 4\n BOW_SHARED_EQUIPMENT\n no\n 84\n 86\n 104\n 65\n 104\n no\n no\n 300\n 900\n \n PEO_INIT\n \n ACT_FNC_HEATER_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_HEATER_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_HEATER_EQUIPMENT\n \n 5\n Gas\n PET_HEATER\n HTR_GAS\n no\n HTR_PRIORITY_2\n HTR_MAINTAINS_PRIORITY_FOR_AS_LONG_AS_VALID\n yes\n 80\n no\n 180\n 8\n 2\n -1\n 12\n \n PEO_TURN_ON\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2\n 1\n 0\n \n \n \n \n \n PEO_HEATER_EQUIPMENT\n \n 18\n Solar\n PET_HEATER\n HTR_SOLAR\n yes\n HTR_PRIORITY_1\n HTR_MAINTAINS_PRIORITY_FOR_8HRS\n yes\n 75\n yes\n 180\n 8\n 2\n 20\n yes\n 19\n \n PEO_OPEN_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 15\n 8\n 1\n \n \n \n PEO_CLOSE_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 15\n 8\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_VA_RELAY\n 15\n 8\n 0\n \n \n \n \n \n \n 6\n WaterSensor\n SENSOR_WATER_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_TEMP\n 18\n 2\n 0\n \n \n \n \n 20\n SolarSensor\n SENSOR_SOLAR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_SOLAR_TEMP\n 20\n 5\n 0\n \n \n \n \n 22\n Water Ft\n RLY_VALVE_ACTUATOR\n RLY_WATER_FEATURE\n no\n no\n 1800\n 85\n \n PEO_INIT\n \n ACT_FNC_LV_RELAY_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_LV_RELAY_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_OPEN_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 14\n 4\n 1\n \n \n \n PEO_CLOSE_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 14\n 4\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_VA_RELAY\n 14\n 4\n 0\n \n \n \n \n 23\n Pool Light\n COLOR_LOGIC_UCL\n 0\n no\n yes\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 6\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 6\n 1\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 6\n 1\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n PEO_V2_TOGGLE\n \n ACT_FNC_V2_TOGGLE_RELAY\n 6\n 1\n 1\n \n \n \n \n 24\n Sheer Lts\n COLOR_LOGIC_UCL\n 0\n no\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 9\n 8\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 9\n 8\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 9\n 8\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n \n 36\n UV Ozone\n RLY_HIGH_VOLTAGE_RELAY\n RLY_ACCESSORY\n no\n no\n 1800\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 11\n 32\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 11\n 32\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_LV_RELAY_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_LV_RELAY_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 11\n 32\n 0\n \n \n \n \n \n 02\n 8\n Spa\n BOW_SPA\n BOW_SHARED_EQUIPMENT\n SHARED_EQUIPMENT_HIGH_PRIORITY\n 1\n no\n no\n 0\n \n 10\n Filter Pump\n BOW_SHARED_EQUIPMENT\n FMT_VARIABLE_SPEED_PUMP\n 100\n 18\n 3450\n 600\n 30\n no\n 180\n 300\n 300\n no\n 900\n no\n 35\n no\n 38\n 50\n 1800\n FLT_DONT_CHANGE_VALVES\n 50\n 75\n 90\n 100\n 7200\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 24\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_FLT_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_FLT_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_SET_VALVES_FOR_SPILLOVER\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 13\n 2\n 0\n \n \n ACT_FNC_HW_ACTIVATE_VALVE\n 12\n 1\n 1\n \n \n \n PEO_SET_VALVES_FOR_FILTER\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 13\n 2\n 1\n \n \n ACT_FNC_HW_ACTIVATE_VALVE\n 12\n 1\n 1\n \n \n \n \n 11\n BOW_SHARED_EQUIPMENT\n no\n 99\n 101\n 104\n 65\n 104\n no\n no\n 300\n 900\n \n PEO_INIT\n \n ACT_FNC_HEATER_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_HEATER_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_HEATER_EQUIPMENT\n \n 12\n Gas\n PET_HEATER\n HTR_GAS\n yes\n HTR_PRIORITY_1\n HTR_MAINTAINS_PRIORITY_FOR_AS_LONG_AS_VALID\n yes\n 80\n no\n 180\n 8\n 2\n -1\n 5\n \n PEO_TURN_ON\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2\n 1\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_TURN_ON_LV_RELAY\n 2\n 1\n 0\n \n \n \n \n \n PEO_HEATER_EQUIPMENT\n \n 19\n Solar\n PET_HEATER\n HTR_SOLAR\n no\n HTR_PRIORITY_2\n HTR_MAINTAINS_PRIORITY_FOR_8HRS\n yes\n 75\n yes\n 180\n 8\n 2\n 38\n yes\n 18\n \n PEO_OPEN_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 15\n 8\n 1\n \n \n \n PEO_CLOSE_VALVE\n \n ACT_FNC_HW_ACTIVATE_VALVE\n 15\n 8\n 0\n \n \n \n PEO_GET_TIME_VALVE_LAST_TURNED\n \n ACT_FNC_HW_SEC_SINCE_VALVES_LAST_TURNED\n 0\n 0\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_VA_RELAY\n 15\n 8\n 0\n \n \n \n \n \n \n 13\n Spa Light\n COLOR_LOGIC_UCL\n 0\n no\n yes\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 7\n 2\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 7\n 2\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 7\n 2\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_CLL_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_CLL_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_TIME_LIGHT_HAS_BEEN_ON\n \n ACT_FNC_HW_SEC_LIGHTS_HAVE_BEEN_ON\n 0\n 0\n 0\n \n \n \n PEO_V2_TOGGLE\n \n ACT_FNC_V2_TOGGLE_RELAY\n 7\n 2\n 1\n \n \n \n \n 14\n Jet\n PMP_VARIABLE_SPEED_PUMP\n PMP_JETS\n no\n 100\n no\n 1800\n no\n 180\n 3450\n 600\n 18\n 100\n 75\n 100\n 100\n 50\n \n PEO_VSP_SET_SPEED\n \n ACT_FNC_VSP_SET_SPEED\n 25\n 0\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_PUMP_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_PUMP_TEARDOWN\n 0\n 0\n 0\n \n \n \n \n 15\n Blower\n RLY_HIGH_VOLTAGE_RELAY\n RLY_BLOWER\n no\n no\n 1800\n \n PEO_TURN_ON\n \n ACT_FNC_HW_FILTER_TURN_ON\n 8\n 4\n 1\n \n \n \n PEO_TURN_OFF\n \n ACT_FNC_HW_FILTER_TURN_ON\n 8\n 4\n 0\n \n \n \n PEO_INIT\n \n ACT_FNC_LV_RELAY_STARTUP\n 0\n 0\n 0\n \n \n \n PEO_TEAR_DOWN\n \n ACT_FNC_LV_RELAY_TEARDOWN\n 0\n 0\n 0\n \n \n \n PEO_GET_VALUE\n \n ACT_FNC_GET_HV_RELAY\n 8\n 4\n 0\n \n \n \n \n 17\n WaterSensor\n SENSOR_WATER_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_WATER_TEMP\n 18\n 2\n 0\n \n \n \n \n 21\n SolarSensor\n SENSOR_SOLAR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_SOLAR_TEMP\n 20\n 5\n 0\n \n \n \n \n 38\n SolarSensor\n SENSOR_SOLAR_TEMP\n UNITS_FAHRENHEIT\n \n PEO_GET_VALUE\n \n ACT_FNC_GET_SOLAR_TEMP\n 20\n 5\n 0\n \n \n \n \n \n \n \n 1\n 3\n 7\n 164\n 55\n 1\n 0\n 8\n 0\n 12\n 127\n 1\n \n \n 1\n 23\n 29\n 164\n 263180\n 0\n 20\n 17\n 40\n 23\n 127\n 1\n \n \n 8\n 13\n 30\n 164\n 263180\n 0\n 20\n 17\n 40\n 20\n 127\n 1\n \n \n 0\n 27\n 31\n 164\n 1\n 0\n 43\n 16\n 40\n 23\n 127\n 1\n \n \n 1\n 36\n 37\n 164\n 1\n 1\n 0\n 0\n 59\n 23\n 127\n 1\n \n \n 1\n 4\n 39\n 164\n 21588\n 1\n 0\n 11\n 0\n 16\n 31\n 1\n \n \n 1\n 4\n 42\n 164\n 22102\n 1\n 0\n 10\n 0\n 16\n 96\n 1\n \n \n 1\n 3\n 43\n 164\n 65\n 1\n 0\n 12\n 0\n 16\n 127\n 1\n \n \n \n \n 28\n 1\n 3\n 0\n 268435440\n 1\n \n \n 34\n 2\n 33\n 1\n 268435455\n 1\n \n \n 41\n 3\n 40\n 2\n 268435455\n 1\n \n \n \n \n 33\n All Off\n 0\n \n TurnOnOffForGroup\n \n 0\n 27\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 3\n 0\n 0\n \n \n \n SetHeaterScheduleCmd\n \n 1\n 4\n 90\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n SetUITemporaryHeaterPriorityCmd\n \n 1\n 5\n 18\n -1\n -1\n -1\n \n \n \n SetUITemporaryHeaterMaintainPriorityCmd\n \n 1\n 4\n 24\n 8\n 255\n 255\n 255\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 4\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 5\n 1\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 18\n 0\n \n \n \n SetSolarScheduleCmd\n \n 1\n 4\n 71\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 22\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 23\n 263180\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 24\n 263168\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 10\n 0\n 0\n \n \n \n SetHeaterScheduleCmd\n \n 8\n 11\n 70\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n SetUITemporaryHeaterPriorityCmd\n \n 8\n 12\n 19\n -1\n -1\n -1\n \n \n \n SetUITemporaryHeaterMaintainPriorityCmd\n \n 8\n 11\n 24\n 8\n 255\n 255\n 255\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 11\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 12\n 1\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 19\n 0\n \n \n \n SetSolarScheduleCmd\n \n 8\n 11\n 70\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 13\n 263180\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 14\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 15\n 0\n 0\n \n \n \n \n 40\n 4th of July\n 0\n \n TurnOnOffForGroup\n \n 0\n 27\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 3\n 85\n 0\n \n \n \n SetHeaterScheduleAltCmd\n \n 1\n 4\n 85\n 85\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n SetUITemporaryHeaterPriorityCmd\n \n 1\n 18\n 5\n -1\n -1\n -1\n \n \n \n SetUITemporaryHeaterMaintainPriorityCmd\n \n 1\n 4\n 8\n 24\n 255\n 255\n 255\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 4\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 5\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 1\n 18\n 1\n \n \n \n TurnOnOffForGroup\n \n 1\n 22\n 1\n 0\n \n \n \n TurnOnOffForGroup\n \n 1\n 23\n 263182\n 1\n \n \n \n TurnOnOffForGroup\n \n 1\n 24\n 263182\n 1\n \n \n \n TurnOnOffForGroup\n \n 1\n 36\n 1\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 10\n 0\n 0\n \n \n \n SetHeaterScheduleAltCmd\n \n 8\n 11\n 100\n 101\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n 0\n \n \n \n SetUITemporaryHeaterPriorityCmd\n \n 8\n 12\n 19\n -1\n -1\n -1\n \n \n \n SetUITemporaryHeaterMaintainPriorityCmd\n \n 8\n 11\n 24\n 8\n 255\n 255\n 255\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 11\n 0\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 12\n 1\n \n \n \n SetUITemporaryHeaterEnable\n \n 8\n 19\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 13\n 263182\n 1\n \n \n \n TurnOnOffForGroup\n \n 8\n 14\n 0\n 0\n \n \n \n TurnOnOffForGroup\n \n 8\n 15\n 0\n 0\n \n \n \n \n \n \n MP\n MP\n 1\n \n \n LVR1\n RELAY\n 2\n \n \n LVR2\n RELAY\n 3\n \n \n LVR3\n RELAY\n 4\n \n \n LVR4\n RELAY\n 5\n \n \n HVR1\n RELAY\n 6\n \n \n HVR2\n RELAY\n 7\n \n \n HVR3\n RELAY\n 8\n \n \n HVR4\n RELAY\n 9\n \n \n HVR5\n RELAY\n 10\n \n \n HVR6\n RELAY\n 11\n \n \n ACR1\n RELAY\n 12\n \n \n ACR2\n RELAY\n 13\n \n \n ACR3\n RELAY\n 14\n \n \n ACR4\n RELAY\n 15\n \n \n SNS1\n SENSOR\n 16\n \n \n SNS2\n SENSOR\n 17\n \n \n SNS3\n SENSOR\n 18\n \n \n SNS4\n SENSOR\n 19\n \n \n SNS5\n SENSOR\n 20\n \n \n SNS6\n SENSOR\n 21\n \n \n SNS7\n SENSOR\n 22\n \n \n CHLR1\n CHLORINATOR\n 23\n \n \n \n \n VSP\n EPNS\n 24\n \n \n \n VSP\n EPNS\n 25\n \n \n \n 5483444\n\n", + "telemetry": "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n" +} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2700a4a --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,555 @@ +"""Comprehensive tests for the OmniLogic API layer. + +Focuses on: +- Validation function tests (table-driven) +- API initialization tests +- XML message generation tests +- Transport/protocol integration tests +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock, patch +from xml.etree import ElementTree as ET + +import pytest + +from pyomnilogic_local.api.api import OmniLogicAPI, _validate_id, _validate_speed, _validate_temperature +from pyomnilogic_local.api.constants import MAX_SPEED_PERCENT, MAX_TEMPERATURE_F, MIN_SPEED_PERCENT, MIN_TEMPERATURE_F, XML_NAMESPACE +from pyomnilogic_local.api.exceptions import OmniValidationError +from pyomnilogic_local.omnitypes import ColorLogicBrightness, ColorLogicShow40, ColorLogicSpeed, HeaterMode, MessageType + +if TYPE_CHECKING: + from pytest_subtests import SubTests + +# ============================================================================ +# Helper Functions +# ============================================================================ + + +def _get_xml_tag(element: ET.Element) -> str: + """Strip namespace from XML tag for easier assertions.""" + return element.tag.split("}")[-1] if "}" in element.tag else element.tag + + +def _find_elem(root: ET.Element, path: str) -> ET.Element: + """Find element with namespace support, raising if not found.""" + elem = root.find(f".//{{{XML_NAMESPACE}}}{path}") + if elem is None: + msg = f"Element {path} not found in XML" + raise AssertionError(msg) + return elem + + +def _find_param(root: ET.Element, name: str) -> ET.Element: + """Find parameter by name attribute.""" + elem = root.find(f".//{{{XML_NAMESPACE}}}Parameter[@name='{name}']") + if elem is None: + msg = f"Parameter {name} not found in XML" + raise AssertionError(msg) + return elem + + +# ============================================================================ +# Validation Function Tests (Table-Driven) +# ============================================================================ + + +def test_validate_temperature(subtests: SubTests) -> None: + """Test temperature validation with various inputs using table-driven approach.""" + test_cases: list[tuple[Any, str, bool, str]] = [ + # (temperature, param_name, should_pass, description) # noqa: ERA001 + (MIN_TEMPERATURE_F, "temp", True, "minimum valid temperature"), + (MAX_TEMPERATURE_F, "temp", True, "maximum valid temperature"), + (80, "temp", True, "mid-range valid temperature"), + (MIN_TEMPERATURE_F - 1, "temp", False, "below minimum temperature"), + (MAX_TEMPERATURE_F + 1, "temp", False, "above maximum temperature"), + ("80", "temp", False, "string instead of int"), + (80.5, "temp", False, "float instead of int"), + (None, "temp", False, "None value"), + ] + + for temperature, param_name, should_pass, description in test_cases: + with subtests.test(msg=description, temperature=temperature): + if should_pass: + _validate_temperature(temperature, param_name) # Should not raise + else: + with pytest.raises(OmniValidationError): + _validate_temperature(temperature, param_name) + + +def test_validate_speed(subtests: SubTests) -> None: + """Test speed validation with various inputs using table-driven approach.""" + test_cases: list[tuple[Any, str, bool, str]] = [ + # (speed, param_name, should_pass, description) # noqa: ERA001 + (MIN_SPEED_PERCENT, "speed", True, "minimum valid speed (0)"), + (MAX_SPEED_PERCENT, "speed", True, "maximum valid speed (100)"), + (50, "speed", True, "mid-range valid speed"), + (MIN_SPEED_PERCENT - 1, "speed", False, "below minimum speed"), + (MAX_SPEED_PERCENT + 1, "speed", False, "above maximum speed"), + ("50", "speed", False, "string instead of int"), + (50.5, "speed", False, "float instead of int"), + (None, "speed", False, "None value"), + ] + + for speed, param_name, should_pass, description in test_cases: + with subtests.test(msg=description, speed=speed): + if should_pass: + _validate_speed(speed, param_name) # Should not raise + else: + with pytest.raises(OmniValidationError): + _validate_speed(speed, param_name) + + +def test_validate_id(subtests: SubTests) -> None: + """Test ID validation with various inputs using table-driven approach.""" + test_cases: list[tuple[Any, str, bool, str]] = [ + # (id_value, param_name, should_pass, description) # noqa: ERA001 + (0, "pool_id", True, "zero ID"), + (1, "pool_id", True, "positive ID"), + (999999, "pool_id", True, "large positive ID"), + (-1, "pool_id", False, "negative ID"), + ("1", "pool_id", False, "string instead of int"), + (1.5, "pool_id", False, "float instead of int"), + (None, "pool_id", False, "None value"), + ] + + for id_value, param_name, should_pass, description in test_cases: + with subtests.test(msg=description, id_value=id_value): + if should_pass: + _validate_id(id_value, param_name) # Should not raise + else: + with pytest.raises(OmniValidationError): + _validate_id(id_value, param_name) + + +# ============================================================================ +# OmniLogicAPI Constructor Tests +# ============================================================================ + + +def test_api_init_valid() -> None: + """Test OmniLogicAPI initialization with valid parameters.""" + api = OmniLogicAPI("192.168.1.100") + assert api.controller_ip == "192.168.1.100" + assert api.controller_port == 10444 + assert api.response_timeout == 5.0 + + +def test_api_init_custom_params() -> None: + """Test OmniLogicAPI initialization with custom parameters.""" + api = OmniLogicAPI("10.0.0.50", controller_port=12345, response_timeout=10.0) + assert api.controller_ip == "10.0.0.50" + assert api.controller_port == 12345 + assert api.response_timeout == 10.0 + + +def test_api_init_validation(subtests: SubTests) -> None: + """Test OmniLogicAPI initialization validation using table-driven approach.""" + test_cases: list[tuple[Any, Any, Any, bool, str]] = [ + # (ip, port, timeout, should_pass, description) # noqa: ERA001 + ("", 10444, 5.0, False, "empty IP address"), + ("192.168.1.100", 0, 5.0, False, "zero port"), + ("192.168.1.100", -1, 5.0, False, "negative port"), + ("192.168.1.100", 65536, 5.0, False, "port too high"), + ("192.168.1.100", "10444", 5.0, False, "port as string"), + ("192.168.1.100", 10444, 0, False, "zero timeout"), + ("192.168.1.100", 10444, -1, False, "negative timeout"), + ("192.168.1.100", 10444, "5.0", False, "timeout as string"), + ] + + for ip, port, timeout, should_pass, description in test_cases: + with subtests.test(msg=description): + if should_pass: + api = OmniLogicAPI(ip, port, timeout) + assert api is not None + else: + with pytest.raises(OmniValidationError): + OmniLogicAPI(ip, port, timeout) + + +# ============================================================================ +# Message Generation Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_async_get_mspconfig_generates_valid_xml() -> None: + """Test that async_get_mspconfig generates valid XML request.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = 'Configuration' + + await api.async_get_mspconfig(raw=True) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "RequestConfiguration" + + +@pytest.mark.asyncio +async def test_async_get_telemetry_generates_valid_xml() -> None: + """Test that async_get_telemetry generates valid XML request.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = 'Telemetry' + + await api.async_get_telemetry(raw=True) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "RequestTelemetryData" + + +@pytest.mark.asyncio +async def test_async_get_filter_diagnostics_generates_valid_xml() -> None: + """Test that async_get_filter_diagnostics generates valid XML with correct parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = 'FilterDiagnostics' + + await api.async_get_filter_diagnostics(pool_id=1, equipment_id=2, raw=True) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "GetUIFilterDiagnosticInfo" + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "equipmentId").text == "2" + + +@pytest.mark.asyncio +async def test_async_set_heater_generates_valid_xml() -> None: + """Test that async_set_heater generates valid XML with correct parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_heater(pool_id=1, equipment_id=2, temperature=75) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetUIHeaterCmd" + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "HeaterID").text == "2" + temp_param = _find_param(root, "Temp") + assert temp_param.text == "75" + assert temp_param.get("unit") == "F" + + +@pytest.mark.asyncio +async def test_async_set_filter_speed_generates_valid_xml() -> None: + """Test that async_set_filter_speed generates valid XML with correct parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_filter_speed(pool_id=1, equipment_id=2, speed=75) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetUIFilterSpeedCmd" + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "FilterID").text == "2" + assert _find_param(root, "Speed").text == "75" + + +@pytest.mark.asyncio +async def test_async_set_equipment_generates_valid_xml() -> None: + """Test that async_set_equipment generates valid XML with correct parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_equipment( + pool_id=1, + equipment_id=2, + is_on=True, + is_countdown_timer=False, + start_time_hours=10, + start_time_minutes=30, + end_time_hours=14, + end_time_minutes=45, + days_active=127, + recurring=True, + ) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetUIEquipmentCmd" + + # Verify all parameters + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "equipmentId").text == "2" + assert _find_param(root, "isOn").text == "1" + assert _find_param(root, "IsCountDownTimer").text == "0" + assert _find_param(root, "StartTimeHours").text == "10" + assert _find_param(root, "StartTimeMinutes").text == "30" + assert _find_param(root, "EndTimeHours").text == "14" + assert _find_param(root, "EndTimeMinutes").text == "45" + assert _find_param(root, "DaysActive").text == "127" + assert _find_param(root, "Recurring").text == "1" + + +@pytest.mark.asyncio +async def test_async_set_heater_mode_generates_valid_xml() -> None: + """Test that async_set_heater_mode generates valid XML with correct enum values.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_heater_mode(pool_id=1, equipment_id=2, mode=HeaterMode.HEAT) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetUIHeaterModeCmd" + assert _find_param(root, "Mode").text == str(HeaterMode.HEAT.value) + + +@pytest.mark.asyncio +async def test_async_set_light_show_generates_valid_xml() -> None: + """Test that async_set_light_show generates valid XML with correct enum values.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_light_show( + pool_id=1, + equipment_id=2, + show=ColorLogicShow40.DEEP_BLUE_SEA, + speed=ColorLogicSpeed.TWO_TIMES, + brightness=ColorLogicBrightness.EIGHTY_PERCENT, + ) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetStandAloneLightShow" + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "LightID").text == "2" + assert _find_param(root, "Show").text == str(ColorLogicShow40.DEEP_BLUE_SEA.value) + assert _find_param(root, "Speed").text == str(ColorLogicSpeed.TWO_TIMES.value) + assert _find_param(root, "Brightness").text == str(ColorLogicBrightness.EIGHTY_PERCENT.value) + + +@pytest.mark.asyncio +async def test_async_set_chlorinator_enable_boolean_conversion(subtests: SubTests) -> None: + """Test that async_set_chlorinator_enable properly converts boolean to int.""" + api = OmniLogicAPI("192.168.1.100") + + test_cases = [ + (True, "1", "boolean True"), + (False, "0", "boolean False"), + (1, "1", "int 1"), + (0, "0", "int 0"), + ] + + for enabled, expected, description in test_cases: + with subtests.test(msg=description), patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_chlorinator_enable(pool_id=1, enabled=enabled) + + call_args = mock_send.call_args + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _find_param(root, "Enabled").text == expected + + +@pytest.mark.asyncio +async def test_async_set_heater_enable_boolean_conversion(subtests: SubTests) -> None: + """Test that async_set_heater_enable properly converts boolean to int.""" + api = OmniLogicAPI("192.168.1.100") + + test_cases = [ + (True, "1", "boolean True"), + (False, "0", "boolean False"), + (1, "1", "int 1"), + (0, "0", "int 0"), + ] + + for enabled, expected, description in test_cases: + with subtests.test(msg=description), patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_heater_enable(pool_id=1, equipment_id=2, enabled=enabled) + + call_args = mock_send.call_args + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _find_param(root, "Enabled").text == expected + + +@pytest.mark.asyncio +async def test_async_set_chlorinator_params_generates_valid_xml() -> None: + """Test that async_set_chlorinator_params generates valid XML with all parameters.""" + api = OmniLogicAPI("192.168.1.100") + + with patch.object(api, "async_send_message", new_callable=AsyncMock) as mock_send: + mock_send.return_value = None + + await api.async_set_chlorinator_params( + pool_id=1, + equipment_id=2, + timed_percent=50, + cell_type=3, + op_mode=1, + sc_timeout=24, + bow_type=0, + orp_timeout=12, + cfg_state=3, + ) + + mock_send.assert_called_once() + call_args = mock_send.call_args + + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + + assert _get_xml_tag(root) == "Request" + assert _find_elem(root, "Name").text == "SetCHLORParams" + + # Verify all parameters + assert _find_param(root, "poolId").text == "1" + assert _find_param(root, "ChlorID").text == "2" + assert _find_param(root, "CfgState").text == "3" + assert _find_param(root, "OpMode").text == "1" + assert _find_param(root, "BOWType").text == "0" + assert _find_param(root, "CellType").text == "3" + assert _find_param(root, "TimedPercent").text == "50" + assert _find_param(root, "SCTimeout").text == "24" + assert _find_param(root, "ORPTimout").text == "12" + + +# ============================================================================ +# async_send_message Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_async_send_message_creates_transport() -> None: + """Test that async_send_message creates a UDP transport.""" + api = OmniLogicAPI("192.168.1.100", controller_port=10444) + + mock_transport = MagicMock() + mock_protocol = AsyncMock() + mock_protocol.send_message = AsyncMock() + + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) + + await api.async_send_message(MessageType.REQUEST_CONFIGURATION, "test", need_response=False) + + # Verify endpoint was created with correct parameters + mock_loop.return_value.create_datagram_endpoint.assert_called_once() + call_kwargs = mock_loop.return_value.create_datagram_endpoint.call_args[1] + assert call_kwargs["remote_addr"] == ("192.168.1.100", 10444) + + # Verify transport was closed + mock_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_send_message_with_response() -> None: + """Test that async_send_message with need_response=True calls send_and_receive.""" + api = OmniLogicAPI("192.168.1.100") + + mock_transport = MagicMock() + mock_protocol = AsyncMock() + mock_protocol.send_and_receive = AsyncMock(return_value="test response") + + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) + + result = await api.async_send_message(MessageType.REQUEST_CONFIGURATION, "test", need_response=True) + + assert result == "test response" + mock_protocol.send_and_receive.assert_called_once() + mock_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_send_message_without_response() -> None: + """Test that async_send_message with need_response=False calls send_message.""" + api = OmniLogicAPI("192.168.1.100") + + mock_transport = MagicMock() + mock_protocol = AsyncMock() + mock_protocol.send_message = AsyncMock() + + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) + + result = await api.async_send_message(MessageType.REQUEST_CONFIGURATION, "test", need_response=False) # type: ignore[func-returns-value] + + assert result is None + mock_protocol.send_message.assert_called_once() + mock_transport.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_send_message_closes_transport_on_error() -> None: + """Test that async_send_message closes transport even when an error occurs.""" + api = OmniLogicAPI("192.168.1.100") + + mock_transport = MagicMock() + mock_protocol = AsyncMock() + mock_protocol.send_message = AsyncMock(side_effect=Exception("Test error")) + + with patch("asyncio.get_running_loop") as mock_loop: + mock_loop.return_value.create_datagram_endpoint = AsyncMock(return_value=(mock_transport, mock_protocol)) + + with pytest.raises(Exception, match="Test error"): + await api.async_send_message(MessageType.REQUEST_CONFIGURATION, "test", need_response=False) + + # Verify transport was still closed despite the error + mock_transport.close.assert_called_once() diff --git a/tests/test_chlorinator_bitmask.py b/tests/test_chlorinator_bitmask.py index caa9289..b802903 100644 --- a/tests/test_chlorinator_bitmask.py +++ b/tests/test_chlorinator_bitmask.py @@ -1,5 +1,7 @@ """Tests for chlorinator bitmask decoding.""" +from __future__ import annotations + from pyomnilogic_local.models.telemetry import TelemetryChlorinator @@ -9,7 +11,7 @@ def test_chlorinator_status_decoding() -> None: # Bit 1: ALERT_PRESENT (2) # Bit 2: GENERATING (4) # Bit 7: K2_ACTIVE (128) - # Total: 2 + 4 + 128 = 134 + # Total: 2 + 4 + 128 = 134 # noqa: ERA001 data = { "@systemId": 5, "@status": 134, @@ -73,7 +75,7 @@ def test_chlorinator_error_decoding() -> None: # Create a chlorinator with chlrError = 257 (0b100000001) # Bit 0: CURRENT_SENSOR_SHORT (1) # Bit 8: K1_RELAY_SHORT (256) - # Total: 1 + 256 = 257 + # Total: 1 + 256 = 257 # noqa: ERA001 data = { "@systemId": 5, "@status": 1, # ERROR_PRESENT @@ -125,11 +127,11 @@ def test_chlorinator_no_flags() -> None: def test_chlorinator_complex_alerts() -> None: """Test complex multi-bit alert combinations.""" - # chlrAlert = 67 (0b01000011) + # chlrAlert = 67 (0b01000011) # noqa: ERA001 # Bit 0: SALT_LOW (1) # Bit 1: SALT_VERY_LOW (2) # Bit 6: BOARD_TEMP_HIGH (64) - # Total: 1 + 2 + 64 = 67 + # Total: 1 + 2 + 64 = 67 # noqa: ERA001 data = { "@systemId": 5, "@status": 2, diff --git a/tests/test_chlorinator_multibit.py b/tests/test_chlorinator_multibit.py index 2a2ae13..92435df 100644 --- a/tests/test_chlorinator_multibit.py +++ b/tests/test_chlorinator_multibit.py @@ -1,5 +1,7 @@ """Tests for chlorinator multi-bit field special case handling.""" +from __future__ import annotations + from pyomnilogic_local.models.telemetry import TelemetryChlorinator diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 0000000..9b52874 --- /dev/null +++ b/tests/test_decorators.py @@ -0,0 +1,188 @@ +"""Tests for equipment control method decorators. + +Focuses on: +- @control_method decorator behavior +- Readiness checking +- State dirtying +- Error message generation +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from pyomnilogic_local.decorators import control_method +from pyomnilogic_local.util import OmniEquipmentNotReadyError + +# ============================================================================ +# Test Fixtures +# ============================================================================ + + +class MockEquipment: + """Mock equipment class for testing decorators.""" + + def __init__(self, is_ready: bool = True): + """Initialize mock equipment. + + Args: + is_ready: Whether equipment should report as ready + """ + self.is_ready = is_ready + self._omni = MagicMock() + self._omni._telemetry_dirty = False + self.method_called = False + self.method_args: tuple[Any, ...] | None = None + self.method_kwargs: dict[str, Any] | None = None + + @control_method + async def turn_on(self) -> None: + """Mock turn_on method.""" + self.method_called = True + + @control_method + async def turn_off(self) -> None: + """Mock turn_off method.""" + self.method_called = True + + @control_method + async def set_temperature(self, temperature: int) -> None: + """Mock set_temperature method with args.""" + self.method_called = True + self.method_args = (temperature,) + + @control_method + async def set_complex_operation(self, param1: int, param2: str, flag: bool = False) -> str: + """Mock method with args, kwargs, and return value.""" + self.method_called = True + self.method_args = (param1, param2) + self.method_kwargs = {"flag": flag} + return f"result: {param1}, {param2}, {flag}" + + +# ============================================================================ +# @control_method Decorator Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_control_method_when_ready_executes_function() -> None: + """Test that control_method executes the wrapped function when equipment is ready.""" + equipment = MockEquipment(is_ready=True) + + await equipment.turn_on() + + assert equipment.method_called is True + + +@pytest.mark.asyncio +async def test_control_method_when_not_ready_raises_error() -> None: + """Test that control_method raises OmniEquipmentNotReadyError when equipment is not ready.""" + equipment = MockEquipment(is_ready=False) + + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.turn_on() + + assert "Cannot turn on: equipment is not ready to accept commands" in str(exc_info.value) + assert equipment.method_called is False + + +@pytest.mark.asyncio +async def test_control_method_marks_telemetry_dirty() -> None: + """Test that control_method marks telemetry as dirty after successful execution.""" + equipment = MockEquipment(is_ready=True) + + assert equipment._omni._telemetry_dirty is False + + await equipment.turn_on() + + assert equipment._omni._telemetry_dirty is True + + +@pytest.mark.asyncio +async def test_control_method_does_not_mark_dirty_if_not_ready() -> None: + """Test that control_method does not mark state dirty if readiness check fails.""" + equipment = MockEquipment(is_ready=False) + + with pytest.raises(OmniEquipmentNotReadyError): + await equipment.turn_on() + + assert equipment._omni._telemetry_dirty is False + + +@pytest.mark.asyncio +async def test_control_method_passes_arguments() -> None: + """Test that control_method properly passes arguments to wrapped function.""" + equipment = MockEquipment(is_ready=True) + + await equipment.set_temperature(75) + + assert equipment.method_called is True + assert equipment.method_args == (75,) + + +@pytest.mark.asyncio +async def test_control_method_passes_kwargs() -> None: + """Test that control_method properly passes keyword arguments to wrapped function.""" + equipment = MockEquipment(is_ready=True) + + result = await equipment.set_complex_operation(42, "test", flag=True) + + assert equipment.method_called is True + assert equipment.method_args == (42, "test") + assert equipment.method_kwargs == {"flag": True} + assert result == "result: 42, test, True" + + +@pytest.mark.asyncio +async def test_control_method_error_message_for_different_methods() -> None: + """Test that control_method generates appropriate error messages for different method names.""" + equipment = MockEquipment(is_ready=False) + + # Test turn_on + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.turn_on() + assert "Cannot turn on:" in str(exc_info.value) + + # Test turn_off + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.turn_off() + assert "Cannot turn off:" in str(exc_info.value) + + # Test set_temperature + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.set_temperature(75) + assert "Cannot set temperature:" in str(exc_info.value) + + # Test complex operation + with pytest.raises(OmniEquipmentNotReadyError) as exc_info: + await equipment.set_complex_operation(1, "test") + assert "Cannot set complex operation:" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_control_method_preserves_function_metadata() -> None: + """Test that control_method preserves the wrapped function's metadata.""" + equipment = MockEquipment(is_ready=True) + + # Check that functools.wraps preserved the original function name and docstring + assert equipment.turn_on.__name__ == "turn_on" + assert equipment.turn_on.__doc__ == "Mock turn_on method." + assert equipment.set_temperature.__name__ == "set_temperature" + assert equipment.set_temperature.__doc__ is not None + assert "args" in equipment.set_temperature.__doc__ + + +@pytest.mark.asyncio +async def test_control_method_without_omni_reference() -> None: + """Test that control_method logs warning when equipment lacks _omni reference.""" + equipment = MockEquipment(is_ready=True) + del equipment._omni + + # Should still execute the function without error, just log a warning + await equipment.turn_on() + + assert equipment.method_called is True diff --git a/tests/test_effects_collection.py b/tests/test_effects_collection.py new file mode 100644 index 0000000..2b73e22 --- /dev/null +++ b/tests/test_effects_collection.py @@ -0,0 +1,161 @@ +"""Tests for the EffectsCollection class.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from pyomnilogic_local.collections import EffectsCollection +from pyomnilogic_local.omnitypes import ColorLogicShow25, ColorLogicShow40, ColorLogicShowUCL + +if TYPE_CHECKING: + from pyomnilogic_local.collections import LightEffectsCollection + + +class TestEffectsCollection: + """Test suite for EffectsCollection.""" + + def test_attribute_access(self) -> None: + """Test that we can access effects by attribute name.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + # Test attribute access + assert effects.VOODOO_LOUNGE == ColorLogicShow25.VOODOO_LOUNGE + assert effects.EMERALD == ColorLogicShow25.EMERALD + assert effects.DEEP_BLUE_SEA == ColorLogicShow25.DEEP_BLUE_SEA + + def test_dict_like_access(self) -> None: + """Test that we can access effects by string key.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + # Test dict-like access + assert effects["VOODOO_LOUNGE"] == ColorLogicShow25.VOODOO_LOUNGE + assert effects["EMERALD"] == ColorLogicShow25.EMERALD + assert effects["DEEP_BLUE_SEA"] == ColorLogicShow25.DEEP_BLUE_SEA + + def test_index_access(self) -> None: + """Test that we can access effects by index.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + # Test index access + assert effects[0] == ColorLogicShow25.VOODOO_LOUNGE + assert effects[1] == ColorLogicShow25.DEEP_BLUE_SEA + assert effects[-1] == ColorLogicShow25.COOL_CABARET + + def test_attribute_error(self) -> None: + """Test that accessing non-existent effect raises AttributeError.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + with pytest.raises(AttributeError, match="Light effect 'NONEXISTENT' is not available"): + _ = effects.NONEXISTENT + + def test_key_error(self) -> None: + """Test that accessing non-existent effect by key raises KeyError.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + with pytest.raises(KeyError, match="Light effect 'NONEXISTENT' is not available"): + _ = effects["NONEXISTENT"] + + def test_index_error(self) -> None: + """Test that accessing out of range index raises IndexError.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + with pytest.raises(IndexError): + _ = effects[999] + + def test_type_error_invalid_key(self) -> None: + """Test that accessing with invalid key type raises TypeError.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + with pytest.raises(TypeError, match="indices must be integers or strings"): + _ = effects[3.14] # type: ignore + + def test_contains_string(self) -> None: + """Test membership testing with string names.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + assert "VOODOO_LOUNGE" in effects + assert "EMERALD" in effects + assert "NONEXISTENT" not in effects + + def test_contains_enum(self) -> None: + """Test membership testing with enum values.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + assert ColorLogicShow25.VOODOO_LOUNGE in effects + assert ColorLogicShow25.EMERALD in effects + # Test that an enum from a different type is not in the collection + # Note: We need to check by type since enum values might overlap + ucl_effects = EffectsCollection(list(ColorLogicShowUCL)) + assert ColorLogicShowUCL.ROYAL_BLUE in ucl_effects + assert ColorLogicShowUCL.ROYAL_BLUE not in effects # type: ignore # ROYAL_BLUE doesn't exist in Show25 + + def test_iteration(self) -> None: + """Test iterating over effects.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + # Test that we can iterate + effects_list = list(effects) + assert len(effects_list) == len(ColorLogicShow25) + assert effects_list[0] == ColorLogicShow25.VOODOO_LOUNGE + + # Test iteration in for loop + count = 0 + for effect in effects: + assert isinstance(effect, ColorLogicShow25) + count += 1 + assert count == len(ColorLogicShow25) + + def test_length(self) -> None: + """Test that len() works correctly.""" + effects_25 = EffectsCollection(list(ColorLogicShow25)) + effects_40 = EffectsCollection(list(ColorLogicShow40)) + effects_ucl = EffectsCollection(list(ColorLogicShowUCL)) + + assert len(effects_25) == len(ColorLogicShow25) + assert len(effects_40) == len(ColorLogicShow40) + assert len(effects_ucl) == len(ColorLogicShowUCL) + + def test_repr(self) -> None: + """Test string representation.""" + effects = EffectsCollection(list(ColorLogicShow25)) + + repr_str = repr(effects) + assert "EffectsCollection" in repr_str + assert "VOODOO_LOUNGE" in repr_str + assert "EMERALD" in repr_str + + def test_to_list(self) -> None: + """Test converting back to a list.""" + original_list = list(ColorLogicShow25) + effects = EffectsCollection(original_list) + + result_list = effects.to_list() + assert result_list == original_list + # Verify it's a copy + assert result_list is not original_list + + def test_type_alias(self) -> None: + """Test that the LightEffectsCollection type alias works.""" + effects: LightEffectsCollection = EffectsCollection(list(ColorLogicShow25)) + + # Should work with any LightShows type + assert effects.VOODOO_LOUNGE == ColorLogicShow25.VOODOO_LOUNGE + + def test_different_show_types(self) -> None: + """Test that different show types are correctly distinguished.""" + effects_25 = EffectsCollection(list(ColorLogicShow25)) + effects_ucl = EffectsCollection(list(ColorLogicShowUCL)) + + # UCL has ROYAL_BLUE, 2.5 doesn't + assert "ROYAL_BLUE" not in effects_25 + assert "ROYAL_BLUE" in effects_ucl + + # Both have VOODOO_LOUNGE and they're from different enums + assert effects_25.VOODOO_LOUNGE is ColorLogicShow25.VOODOO_LOUNGE + assert effects_ucl.VOODOO_LOUNGE is ColorLogicShowUCL.VOODOO_LOUNGE + # Even though they have the same value (0), they're different enum types + assert type(effects_25.VOODOO_LOUNGE) is not type(effects_ucl.VOODOO_LOUNGE) # type: ignore + assert isinstance(effects_25.VOODOO_LOUNGE, ColorLogicShow25) + assert isinstance(effects_ucl.VOODOO_LOUNGE, ColorLogicShowUCL) diff --git a/tests/test_filter_pump.py b/tests/test_filter_pump.py new file mode 100644 index 0000000..93e411d --- /dev/null +++ b/tests/test_filter_pump.py @@ -0,0 +1,346 @@ +"""Tests for Filter and Pump equipment classes.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock + +import pytest + +from pyomnilogic_local.filter import Filter +from pyomnilogic_local.models.mspconfig import MSPFilter, MSPPump +from pyomnilogic_local.models.telemetry import Telemetry, TelemetryFilter, TelemetryPump +from pyomnilogic_local.omnitypes import ( + FilterSpeedPresets, + FilterState, + FilterType, + PumpFunction, + PumpState, + PumpType, +) +from pyomnilogic_local.pump import Pump + + +@pytest.fixture +def mock_omni() -> Mock: + """Create a mock OmniLogic instance.""" + omni = Mock() + omni._api = Mock() + return omni + + +@pytest.fixture +def sample_filter_config() -> MSPFilter: + """Create a sample filter configuration.""" + return MSPFilter.model_validate( + { + "System-Id": 8, + "Name": "Test Filter", + "Filter-Type": FilterType.VARIABLE_SPEED, + "Max-Pump-Speed": 100, + "Min-Pump-Speed": 30, + "Max-Pump-RPM": 3450, + "Min-Pump-RPM": 1000, + "Priming-Enabled": True, + "Vsp-Low-Pump-Speed": 40, + "Vsp-Medium-Pump-Speed": 60, + "Vsp-High-Pump-Speed": 80, + }, + ) + + +@pytest.fixture +def sample_filter_telemetry() -> TelemetryFilter: + """Create sample filter telemetry.""" + return TelemetryFilter.model_validate( + { + "@systemId": 8, + "@filterState": FilterState.ON, + "@filterSpeed": 60, + "@valvePosition": 1, + "@whyFilterIsOn": 14, + "@reportedFilterSpeed": 60, + "@power": 500, + "@lastSpeed": 50, + }, + ) + + +@pytest.fixture +def sample_pump_config() -> MSPPump: + """Create a sample pump configuration.""" + return MSPPump.model_validate( + { + "System-Id": 15, + "Name": "Test Pump", + "Type": PumpType.VARIABLE_SPEED, + "Function": PumpFunction.PUMP, + "Max-Pump-Speed": 100, + "Min-Pump-Speed": 30, + "Max-Pump-RPM": 3450, + "Min-Pump-RPM": 1000, + "Priming-Enabled": True, + "Vsp-Low-Pump-Speed": 40, + "Vsp-Medium-Pump-Speed": 60, + "Vsp-High-Pump-Speed": 80, + }, + ) + + +@pytest.fixture +def sample_pump_telemetry() -> TelemetryPump: + """Create sample pump telemetry.""" + return TelemetryPump.model_validate( + { + "@systemId": 15, + "@pumpState": PumpState.ON, + "@pumpSpeed": 60, + "@lastSpeed": 50, + "@whyOn": 11, + }, + ) + + +@pytest.fixture +def mock_telemetry(sample_filter_telemetry: TelemetryFilter, sample_pump_telemetry: TelemetryPump) -> Mock: + """Create a mock Telemetry object.""" + telemetry = Mock(spec=Telemetry) + telemetry.get_telem_by_systemid = Mock( + side_effect=lambda sid: sample_filter_telemetry if sid == 8 else sample_pump_telemetry if sid == 15 else None + ) + return telemetry + + +class TestFilter: + """Tests for Filter class.""" + + def test_filter_properties_config(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test that filter config properties are correctly exposed.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + assert filter_obj.equip_type == "FMT_VARIABLE_SPEED_PUMP" + assert filter_obj.max_percent == 100 + assert filter_obj.min_percent == 30 + assert filter_obj.max_rpm == 3450 + assert filter_obj.min_rpm == 1000 + assert filter_obj.priming_enabled is True + assert filter_obj.low_speed == 40 + assert filter_obj.medium_speed == 60 + assert filter_obj.high_speed == 80 + + def test_filter_properties_telemetry(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test that filter telemetry properties are correctly exposed.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + assert filter_obj.state == FilterState.ON + assert filter_obj.speed == 60 + assert filter_obj.valve_position == 1 + assert filter_obj.why_on == 14 + assert filter_obj.reported_speed == 60 + assert filter_obj.power == 500 + assert filter_obj.last_speed == 50 + + def test_filter_is_on_true(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test is_on returns True when filter is on.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + assert filter_obj.is_on is True + + def test_filter_is_on_false(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test is_on returns False when filter is off.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj.telemetry.state = FilterState.OFF + + assert filter_obj.is_on is False + + def test_filter_is_ready_true(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test is_ready returns True for stable states.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + # ON state + filter_obj.telemetry.state = FilterState.ON + assert filter_obj.is_ready is True + + # OFF state + filter_obj.telemetry.state = FilterState.OFF + assert filter_obj.is_ready is True + + def test_filter_is_ready_false(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test is_ready returns False for transitional states.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + + # PRIMING state + filter_obj.telemetry.state = FilterState.PRIMING + assert filter_obj.is_ready is False + + # WAITING_TURN_OFF state + filter_obj.telemetry.state = FilterState.WAITING_TURN_OFF + assert filter_obj.is_ready is False + + @pytest.mark.asyncio + async def test_filter_turn_on(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test turn_on method calls API correctly.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] + + await filter_obj.turn_on() + + filter_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=8, + is_on=filter_obj.last_speed, + ) + + @pytest.mark.asyncio + async def test_filter_turn_off(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test turn_off method calls API correctly.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] + + await filter_obj.turn_off() + + filter_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=8, + is_on=False, + ) + + @pytest.mark.asyncio + async def test_filter_run_preset_speed_low(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test run_preset_speed with LOW preset.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] + + await filter_obj.run_preset_speed(FilterSpeedPresets.LOW) + + filter_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=8, + is_on=40, + ) + + @pytest.mark.asyncio + async def test_filter_run_preset_speed_medium(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test run_preset_speed with MEDIUM preset.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] + + await filter_obj.run_preset_speed(FilterSpeedPresets.MEDIUM) + + filter_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=8, + is_on=filter_obj.medium_speed, + ) + + @pytest.mark.asyncio + async def test_filter_run_preset_speed_high(self, mock_omni: Mock, sample_filter_config: MSPFilter, mock_telemetry: Mock) -> None: + """Test run_preset_speed with HIGH preset.""" + sample_filter_config.bow_id = 7 + filter_obj = Filter(mock_omni, sample_filter_config, mock_telemetry) + filter_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] + + await filter_obj.run_preset_speed(FilterSpeedPresets.HIGH) + + filter_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=8, + is_on=filter_obj.high_speed, + ) + + +class TestPump: + """Tests for Pump class.""" + + def test_pump_properties_config(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: + """Test that pump config properties are correctly exposed.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + + assert pump_obj.equip_type == "PMP_VARIABLE_SPEED_PUMP" + assert pump_obj.function == "PMP_PUMP" + assert pump_obj.max_percent == 100 + assert pump_obj.min_percent == 30 + assert pump_obj.max_rpm == 3450 + assert pump_obj.min_rpm == 1000 + assert pump_obj.priming_enabled is True + assert pump_obj.low_speed == 40 + assert pump_obj.medium_speed == 60 + assert pump_obj.high_speed == 80 + + def test_pump_properties_telemetry(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: + """Test that pump telemetry properties are correctly exposed.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + + assert pump_obj.state == PumpState.ON + assert pump_obj.speed == 60 + assert pump_obj.last_speed == 50 + assert pump_obj.why_on == 11 + + def test_pump_is_on_true(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: + """Test is_on returns True when pump is on.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + + assert pump_obj.is_on is True + + def test_pump_is_on_false(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: + """Test is_on returns False when pump is off.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + pump_obj.telemetry.state = PumpState.OFF + + assert pump_obj.is_on is False + + def test_pump_is_ready(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: + """Test is_ready returns True for stable states.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + + # ON state + pump_obj.telemetry.state = PumpState.ON + assert pump_obj.is_ready is True + + # OFF state + pump_obj.telemetry.state = PumpState.OFF + assert pump_obj.is_ready is True + + @pytest.mark.asyncio + async def test_pump_turn_on(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: + """Test turn_on method calls API correctly.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + pump_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] + + await pump_obj.turn_on() + + pump_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=15, + is_on=True, + ) + + @pytest.mark.asyncio + async def test_pump_turn_off(self, mock_omni: Mock, sample_pump_config: MSPPump, mock_telemetry: Mock) -> None: + """Test turn_off method calls API correctly.""" + sample_pump_config.bow_id = 7 + pump_obj = Pump(mock_omni, sample_pump_config, mock_telemetry) + pump_obj._api.async_set_equipment = AsyncMock() # type: ignore[method-assign] + + await pump_obj.turn_off() + + pump_obj._api.async_set_equipment.assert_called_once_with( + pool_id=7, + equipment_id=15, + is_on=False, + ) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..b4f41eb --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,430 @@ +"""Tests for validating real-world fixture data from GitHub issues. + +This test suite uses JSON fixture files from tests/fixtures/ directory, which contain +actual MSPConfig and Telemetry XML data from real OmniLogic hardware. Each fixture +represents a specific configuration reported in GitHub issues. + +The tests validate: +- MSPConfig: System IDs, equipment names, counts, and types +- Telemetry: System IDs, state values, and telemetry counts +""" + +from __future__ import annotations + +import json +import pathlib +from typing import TYPE_CHECKING, Any + +import pytest + +from pyomnilogic_local.models.mspconfig import MSPConfig +from pyomnilogic_local.models.telemetry import Telemetry +from pyomnilogic_local.omnitypes import OmniType + +if TYPE_CHECKING: + from pytest_subtests import SubTests + + from pyomnilogic_local._base import OmniEquipment + +# Path to fixtures directory +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" + + +def load_fixture(filename: str) -> dict[str, str]: + """Load a fixture file and return the mspconfig and telemetry XML strings. + + Args: + filename: Name of the fixture file (e.g., "issue-60.json") + + Returns: + Dictionary with 'mspconfig' and 'telemetry' keys containing XML strings + """ + fixture_path = FIXTURES_DIR / filename + with fixture_path.open(encoding="utf-8") as f: + return json.load(f) + + +def get_equipment_by_type(msp: MSPConfig, omni_type: OmniType) -> list[Any]: + """Get all equipment of a specific type from MSPConfig. + + Args: + msp: Parsed MSPConfig + omni_type: Type of equipment to find + + Returns: + List of equipment matching the type + """ + equipment: list[OmniEquipment[Any, Any]] = [] + # Check backyard-level equipment + for attr_name in ("relay", "sensor", "colorlogic_light"): + if items := getattr(msp.backyard, attr_name, None): + equipment.extend(item for item in items if item.omni_type == omni_type) + + # Check BoW-level equipment + if msp.backyard.bow: + for bow in msp.backyard.bow: + for attr_name in ("filter", "heater", "pump", "relay", "sensor", "colorlogic_light", "chlorinator"): + if items := getattr(bow, attr_name, None): + # Handle single items or lists + items_list = items if isinstance(items, list) else [items] + for item in items_list: + if item.omni_type == omni_type: + equipment.append(item) + # Check child equipment (e.g., heater equipment within virtual heater) + if hasattr(item, "heater_equipment") and item.heater_equipment: + equipment.extend(child for child in item.heater_equipment if child.omni_type == omni_type) + return equipment + + +class TestIssue144: + """Tests for issue-144.json fixture. + + System configuration: + - 1 Body of Water (Pool) + - ColorLogic UCL light + - Heat pump + - Filter pump + """ + + @pytest.fixture + def fixture_data(self) -> dict[str, str]: + """Load issue-144 fixture data.""" + return load_fixture("issue-144.json") + + def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test MSPConfig parsing for issue-144.""" + msp = MSPConfig.load_xml(fixture_data["mspconfig"]) + + with subtests.test(msg="backyard exists"): + assert msp.backyard is not None + assert msp.backyard.name == "Backyard" + + with subtests.test(msg="body of water count"): + assert msp.backyard.bow is not None + assert len(msp.backyard.bow) == 1 + + with subtests.test(msg="pool configuration"): + assert msp.backyard.bow is not None + pool = msp.backyard.bow[0] + assert pool.system_id == 3 + assert pool.name == "Pool" + assert pool.omni_type == OmniType.BOW + + with subtests.test(msg="filter configuration"): + filters = get_equipment_by_type(msp, OmniType.FILTER) + assert len(filters) == 1 + assert filters[0].system_id == 4 + assert filters[0].name == "Filter Pump" + + with subtests.test(msg="heater equipment configuration"): + heaters = get_equipment_by_type(msp, OmniType.HEATER_EQUIP) + assert len(heaters) == 1 + assert heaters[0].system_id == 16 + assert heaters[0].name == "Heat Pump" + + with subtests.test(msg="colorlogic light configuration"): + lights = get_equipment_by_type(msp, OmniType.CL_LIGHT) + assert len(lights) == 1 + assert lights[0].system_id == 9 + assert lights[0].name == "UCL" + + def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test Telemetry parsing for issue-144.""" + telem = Telemetry.load_xml(fixture_data["telemetry"]) + + with subtests.test(msg="backyard telemetry"): + assert telem.backyard is not None + assert telem.backyard.system_id == 0 + assert telem.backyard.air_temp == 66 + + with subtests.test(msg="body of water telemetry"): + assert len(telem.bow) == 1 + pool = telem.bow[0] + assert pool.system_id == 3 + assert pool.water_temp == -1 # No valid reading + + with subtests.test(msg="filter telemetry"): + assert telem.filter is not None + assert len(telem.filter) == 1 + filter_telem = telem.filter[0] + assert filter_telem.system_id == 4 + + with subtests.test(msg="virtual heater telemetry"): + assert telem.virtual_heater is not None + assert len(telem.virtual_heater) == 1 + vh = telem.virtual_heater[0] + assert vh.system_id == 15 + assert vh.current_set_point == 65 + + with subtests.test(msg="heater equipment telemetry"): + assert telem.heater is not None + assert len(telem.heater) == 1 + heater = telem.heater[0] + assert heater.system_id == 16 + + with subtests.test(msg="colorlogic light telemetry"): + assert telem.colorlogic_light is not None + assert len(telem.colorlogic_light) == 1 + light = telem.colorlogic_light[0] + assert light.system_id == 9 + + +class TestIssue163: + """Tests for issue-163.json fixture. + + System configuration: + - 1 Body of Water (Pool) + - Salt chlorinator + - Variable speed pump + - Fountain pump + """ + + @pytest.fixture + def fixture_data(self) -> dict[str, str]: + """Load issue-163 fixture data.""" + return load_fixture("issue-163.json") + + def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test MSPConfig parsing for issue-163.""" + msp = MSPConfig.load_xml(fixture_data["mspconfig"]) + + with subtests.test(msg="backyard exists"): + assert msp.backyard is not None + assert msp.backyard.name == "Backyard" + + with subtests.test(msg="body of water count"): + assert msp.backyard.bow is not None + assert len(msp.backyard.bow) == 1 + + with subtests.test(msg="pool configuration"): + assert msp.backyard.bow is not None + pool = msp.backyard.bow[0] + assert pool.system_id == 10 + assert pool.name == "Pool" + assert pool.omni_type == OmniType.BOW + + with subtests.test(msg="filter configuration"): + filters = get_equipment_by_type(msp, OmniType.FILTER) + assert len(filters) == 1 + assert filters[0].system_id == 11 + assert filters[0].name == "Filter Pump" + + with subtests.test(msg="chlorinator configuration"): + chlorinators = get_equipment_by_type(msp, OmniType.CHLORINATOR) + assert len(chlorinators) == 1 + assert chlorinators[0].system_id == 12 + assert chlorinators[0].name == "Chlorinator" + + with subtests.test(msg="pump configuration"): + pumps = get_equipment_by_type(msp, OmniType.PUMP) + assert len(pumps) == 1 + assert pumps[0].system_id == 14 + assert pumps[0].name == "Fountain" + + with subtests.test(msg="backyard sensors"): + assert msp.backyard.sensor is not None + assert len(msp.backyard.sensor) == 1 + assert msp.backyard.sensor[0].system_id == 16 + + def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test Telemetry parsing for issue-163.""" + telem = Telemetry.load_xml(fixture_data["telemetry"]) + + with subtests.test(msg="backyard telemetry"): + assert telem.backyard is not None + assert telem.backyard.system_id == 0 + assert telem.backyard.air_temp == 110 + + with subtests.test(msg="body of water telemetry"): + assert len(telem.bow) == 1 + pool = telem.bow[0] + assert pool.system_id == 10 + assert pool.water_temp == 84 + + with subtests.test(msg="filter telemetry"): + assert telem.filter is not None + assert len(telem.filter) == 1 + filter_telem = telem.filter[0] + assert filter_telem.system_id == 11 + assert filter_telem.speed == 60 + assert filter_telem.state.name == "ON" + + with subtests.test(msg="chlorinator telemetry"): + assert telem.chlorinator is not None + assert len(telem.chlorinator) == 1 + chlor = telem.chlorinator[0] + assert chlor.system_id == 12 + assert chlor.status_raw == 68 + assert chlor.avg_salt_level == 2942 + + with subtests.test(msg="pump telemetry"): + assert telem.pump is not None + assert len(telem.pump) == 1 + pump = telem.pump[0] + assert pump.system_id == 14 + assert pump.speed == 0 + assert pump.state.name == "OFF" + + +class TestIssue60: + """Tests for issue-60.json fixture. + + System configuration: + - 2 Bodies of Water (Pool and Spa) + - Multiple lights, relays, pumps, heaters + - ColorLogic lights with V2 support + - Solar and gas heaters + """ + + @pytest.fixture + def fixture_data(self) -> dict[str, str]: + """Load issue-60 fixture data.""" + return load_fixture("issue-60.json") + + def test_mspconfig(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test MSPConfig parsing for issue-60.""" + msp = MSPConfig.load_xml(fixture_data["mspconfig"]) + + with subtests.test(msg="backyard exists"): + assert msp.backyard is not None + assert msp.backyard.name == "Backyard" + + with subtests.test(msg="body of water count"): + assert msp.backyard.bow is not None + assert len(msp.backyard.bow) == 2 + + with subtests.test(msg="pool configuration"): + assert msp.backyard.bow is not None + pool = msp.backyard.bow[0] + assert pool.system_id == 1 + assert pool.name == "Pool" + assert pool.omni_type == OmniType.BOW + + with subtests.test(msg="spa configuration"): + assert msp.backyard.bow is not None + spa = msp.backyard.bow[1] + assert spa.system_id == 8 + assert spa.name == "Spa" + + with subtests.test(msg="colorlogic light count"): + lights = get_equipment_by_type(msp, OmniType.CL_LIGHT) + assert len(lights) == 3 + # Pool has 2 lights, Spa has 1 + light_ids = sorted([light.system_id for light in lights]) + assert light_ids == [13, 23, 24] + + with subtests.test(msg="relay count"): + relays = get_equipment_by_type(msp, OmniType.RELAY) + assert len(relays) >= 3 + relay_names = [relay.name for relay in relays] + assert "Yard Lights" in relay_names + assert "Blower" in relay_names + + with subtests.test(msg="pump count"): + pumps = get_equipment_by_type(msp, OmniType.PUMP) + assert len(pumps) == 1 + assert pumps[0].system_id == 14 + assert pumps[0].name == "Jet" + + with subtests.test(msg="filter count"): + filters = get_equipment_by_type(msp, OmniType.FILTER) + # Should have 2 filters (one per BoW) + assert len(filters) == 2 + + with subtests.test(msg="heater equipment"): + heaters = get_equipment_by_type(msp, OmniType.HEATER_EQUIP) + # Should have 4 heater equipment (2 gas + 2 solar) + assert len(heaters) == 4 + heater_names = [h.name for h in heaters] + assert heater_names.count("Gas") == 2 + assert heater_names.count("Solar") == 2 + + def test_telemetry(self, fixture_data: dict[str, str], subtests: SubTests) -> None: + """Test Telemetry parsing for issue-60.""" + telem = Telemetry.load_xml(fixture_data["telemetry"]) + + with subtests.test(msg="backyard telemetry"): + assert telem.backyard is not None + assert telem.backyard.system_id == 0 + assert telem.backyard.air_temp == 67 + + with subtests.test(msg="body of water telemetry count"): + assert len(telem.bow) == 2 + bow_ids = sorted([bow.system_id for bow in telem.bow]) + assert bow_ids == [1, 8] + + with subtests.test(msg="pool water temp"): + pool_bow = next(bow for bow in telem.bow if bow.system_id == 1) + assert pool_bow.water_temp == 74 + + with subtests.test(msg="spa water temp"): + spa_bow = next(bow for bow in telem.bow if bow.system_id == 8) + assert spa_bow.water_temp == -1 # No valid reading + + with subtests.test(msg="filter telemetry"): + assert telem.filter is not None + assert len(telem.filter) == 2 + # Pool filter running + pool_filter = next(f for f in telem.filter if f.system_id == 3) + assert pool_filter.speed == 31 + assert pool_filter.power == 79 + + with subtests.test(msg="colorlogic light telemetry"): + assert telem.colorlogic_light is not None + assert len(telem.colorlogic_light) == 3 + light_ids = sorted([light.system_id for light in telem.colorlogic_light]) + assert light_ids == [13, 23, 24] + + with subtests.test(msg="relay telemetry"): + assert telem.relay is not None + assert len(telem.relay) == 3 + # Check yard lights relay is on + yard_relay = next(r for r in telem.relay if r.system_id == 27) + assert yard_relay.state.value == 1 # ON + + with subtests.test(msg="pump telemetry"): + assert telem.pump is not None + assert len(telem.pump) == 1 + assert telem.pump[0].system_id == 14 + assert telem.pump[0].state.value == 0 # OFF + + with subtests.test(msg="heater telemetry"): + assert telem.heater is not None + assert len(telem.heater) == 4 + heater_ids = sorted([h.system_id for h in telem.heater]) + assert heater_ids == [5, 12, 18, 19] + + with subtests.test(msg="group telemetry"): + assert telem.group is not None + assert len(telem.group) == 2 + group_ids = sorted([g.system_id for g in telem.group]) + assert group_ids == [33, 40] + + +# Add a parametrized test to quickly check all fixtures can be parsed +FIXTURE_FILES = sorted([f.name for f in FIXTURES_DIR.glob("issue-*.json")]) + + +@pytest.mark.parametrize("fixture_file", FIXTURE_FILES) +def test_fixture_parses_without_error(fixture_file: str) -> None: + """Verify that all fixture files can be parsed without errors. + + This is a smoke test to ensure basic parsing works for all fixtures. + Detailed validation is done in fixture-specific test classes. + + Args: + fixture_file: Name of the fixture file to test + """ + data = load_fixture(fixture_file) + + # Parse MSPConfig + if data.get("mspconfig"): + msp = MSPConfig.load_xml(data["mspconfig"]) + assert msp is not None + assert msp.backyard is not None + + # Parse Telemetry + if data.get("telemetry"): + telem = Telemetry.load_xml(data["telemetry"]) + assert telem is not None + assert telem.backyard is not None diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 5fd8b6e..d4c2459 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,15 +1,44 @@ +"""Enhanced comprehensive tests for the OmniLogic protocol layer. + +Focuses on: +- OmniLogicMessage parsing and serialization (table-driven) +- Protocol error handling +- Message fragmentation and reassembly +- ACK waiting and retry logic +- Connection lifecycle +""" + +from __future__ import annotations + import asyncio -from unittest.mock import MagicMock, patch +import struct +import time +import zlib +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch +from xml.etree import ElementTree as ET import pytest -from pyomnilogic_local.exceptions import OmniTimeoutException +from pyomnilogic_local.api.exceptions import ( + OmniFragmentationError, + OmniMessageFormatError, + OmniTimeoutError, +) +from pyomnilogic_local.api.protocol import OmniLogicMessage, OmniLogicProtocol from pyomnilogic_local.omnitypes import ClientType, MessageType -from pyomnilogic_local.protocol import OmniLogicMessage, OmniLogicProtocol + +if TYPE_CHECKING: + from pytest_subtests import SubTests + + +# ============================================================================ +# OmniLogicMessage Tests +# ============================================================================ def test_parse_basic_ack() -> None: - """Validate that we can parse a basic ACK packet""" + """Validate that we can parse a basic ACK packet.""" bytes_ack = b"\x99_\xd1l\x00\x00\x00\x00dv\x8f\xc11.20\x00\x00\x03\xea\x03\x00\x00\x00" message = OmniLogicMessage.from_bytes(bytes_ack) assert message.id == 2573193580 @@ -19,7 +48,7 @@ def test_parse_basic_ack() -> None: def test_create_basic_ack() -> None: - """Validate that we can create a valid basic ACK packet""" + """Validate that we can create a valid basic ACK packet.""" bytes_ack = b"\x99_\xd1l\x00\x00\x00\x00dv\x8f\xc11.20\x00\x00\x03\xea\x03\x00\x00\x00" message = OmniLogicMessage(2573193580, MessageType.ACK, payload=None, version="1.20") message.client_type = ClientType.OMNI @@ -27,7 +56,7 @@ def test_create_basic_ack() -> None: assert bytes(message) == bytes_ack -def test_parse_leadmessate() -> None: +def test_parse_leadmessage() -> None: """Validate that we can parse an MSP LeadMessage.""" bytes_leadmessage = ( b'\x00\x00\x90v\x00\x00\x00\x00dv\x92\xc11.20\x00\x00\x07\xce\x03\x00\x01\x00' @@ -37,7 +66,6 @@ def test_parse_leadmessate() -> None: b"\x00" ) message = OmniLogicMessage.from_bytes(bytes_leadmessage) - print(message.timestamp) assert message.id == 36982 assert message.type is MessageType.MSP_LEADMESSAGE assert message.timestamp == 1685492417 @@ -46,7 +74,7 @@ def test_parse_leadmessate() -> None: def test_create_leadmessage() -> None: - """Validate that we can create a valid MSP LeadMessage""" + """Validate that we can create a valid MSP LeadMessage.""" bytes_leadmessage = ( b'\x00\x00\x90v\x00\x00\x00\x00dv\x92\xc11.20\x00\x00\x07\xce\x03\x00\x01\x00' b'LeadMessage' @@ -67,46 +95,695 @@ def test_create_leadmessage() -> None: assert bytes(message) == bytes_leadmessage +def test_message_from_bytes_errors(subtests: SubTests) -> None: + """Test OmniLogicMessage.from_bytes with various error conditions using table-driven approach.""" + test_cases = [ + # (data, expected_error, description) # noqa: ERA001 + (b"short", OmniMessageFormatError, "message too short"), + (b"\x00" * 10, OmniMessageFormatError, "header too short"), + ] + + for data, expected_error, description in test_cases: + with subtests.test(msg=description), pytest.raises(expected_error): + OmniLogicMessage.from_bytes(data) + + +def test_message_from_bytes_invalid_message_type() -> None: + """Test parsing with an invalid message type.""" + # Create a valid header but with invalid message type (9999) + header = struct.pack( + "!LQ4sLBBBB", + 12345, # msg_id + int(time.time()), # timestamp + b"1.20", # version + 9999, # invalid message type + 0, # client_type + 0, # reserved + 0, # compressed + 0, # reserved + ) + + with pytest.raises(OmniMessageFormatError, match="Unknown message type"): + OmniLogicMessage.from_bytes(header + b"payload") + + +def test_message_from_bytes_invalid_client_type() -> None: + """Test parsing with an invalid client type.""" + # Create a valid header but with invalid client type (99) + header = struct.pack( + "!LQ4sLBBBB", + 12345, # msg_id + int(time.time()), # timestamp + b"1.20", # version + MessageType.ACK.value, # valid message type + 99, # invalid client_type + 0, # reserved + 0, # compressed + 0, # reserved + ) + + with pytest.raises(OmniMessageFormatError, match="Unknown client type"): + OmniLogicMessage.from_bytes(header + b"payload") + + +def test_message_repr_with_blockmessage() -> None: + """Test that __repr__ for MSP_BLOCKMESSAGE doesn't include body.""" + message = OmniLogicMessage(123, MessageType.MSP_BLOCKMESSAGE, payload="test") + repr_str = str(message) + assert "Body:" not in repr_str + assert "MSP_BLOCKMESSAGE" in repr_str + + +def test_message_telemetry_always_compressed() -> None: + """Test that MSP_TELEMETRY_UPDATE is always marked as compressed.""" + header = struct.pack( + "!LQ4sLBBBB", + 12345, # msg_id + int(time.time()), # timestamp + b"1.20", # version + MessageType.MSP_TELEMETRY_UPDATE.value, + 0, # client_type + 0, # reserved + 0, # compressed flag is 0, but should be set to True + 0, # reserved + ) + + message = OmniLogicMessage.from_bytes(header + b"payload") + assert message.compressed is True # Should be True even though flag was 0 + + +def test_message_client_type_xml_vs_simple() -> None: + """Test that messages with payload use XML client type.""" + msg_with_payload = OmniLogicMessage(123, MessageType.REQUEST_CONFIGURATION, payload="") + assert msg_with_payload.client_type == ClientType.XML + + msg_without_payload = OmniLogicMessage(456, MessageType.ACK, payload=None) + assert msg_without_payload.client_type == ClientType.SIMPLE + + +def test_message_payload_null_termination() -> None: + """Test that payload is properly null-terminated.""" + message = OmniLogicMessage(123, MessageType.REQUEST_CONFIGURATION, payload="test") + assert message.payload == b"test\x00" + + +# ============================================================================ +# OmniLogicProtocol Initialization and Connection Tests +# ============================================================================ + + +def test_protocol_initialization() -> None: + """Test that protocol initializes with correct queue sizes.""" + protocol = OmniLogicProtocol() + assert protocol.data_queue.maxsize == 100 + assert protocol.error_queue.maxsize == 100 + + +def test_protocol_connection_made() -> None: + """Test that connection_made sets the transport.""" + protocol = OmniLogicProtocol() + mock_transport = MagicMock() + + protocol.connection_made(mock_transport) + + assert protocol.transport is mock_transport + + +def test_protocol_connection_lost_with_exception() -> None: + """Test that connection_lost raises exception if provided.""" + protocol = OmniLogicProtocol() + test_exception = RuntimeError("Connection error") + + with pytest.raises(RuntimeError, match="Connection error"): + protocol.connection_lost(test_exception) + + +def test_protocol_connection_lost_without_exception() -> None: + """Test that connection_lost without exception doesn't raise.""" + protocol = OmniLogicProtocol() + protocol.connection_lost(None) # Should not raise + + +# ============================================================================ +# Datagram Received Tests +# ============================================================================ + + +def test_datagram_received_valid_message() -> None: + """Test that valid messages are added to the queue.""" + protocol = OmniLogicProtocol() + valid_data = bytes(OmniLogicMessage(123, MessageType.ACK)) + + protocol.datagram_received(valid_data, ("127.0.0.1", 12345)) + + assert protocol.data_queue.qsize() == 1 + message = protocol.data_queue.get_nowait() + assert message.id == 123 + assert message.type == MessageType.ACK + + def test_datagram_received_with_corrupt_data(caplog: pytest.LogCaptureFixture) -> None: """Test that corrupt datagram data is handled gracefully and logged.""" protocol = OmniLogicProtocol() - # Provide invalid/corrupt data (too short for header) corrupt_data = b"short" + with caplog.at_level("ERROR"): protocol.datagram_received(corrupt_data, ("127.0.0.1", 12345)) + assert any("Failed to parse incoming datagram" in r.message for r in caplog.records) + assert protocol.error_queue.qsize() == 1 def test_datagram_received_queue_overflow(caplog: pytest.LogCaptureFixture) -> None: """Test that queue overflow is handled and logged.""" protocol = OmniLogicProtocol() - # Fill the queue to capacity protocol.data_queue = asyncio.Queue(maxsize=1) protocol.data_queue.put_nowait(OmniLogicMessage(1, MessageType.ACK)) - # Now send another valid message + valid_data = bytes(OmniLogicMessage(2, MessageType.ACK)) with caplog.at_level("ERROR"): protocol.datagram_received(valid_data, ("127.0.0.1", 12345)) + assert any("Data queue is full" in r.message for r in caplog.records) +def test_datagram_received_unexpected_exception(caplog: pytest.LogCaptureFixture) -> None: + """Test that unexpected exceptions during datagram processing are handled.""" + protocol = OmniLogicProtocol() + + # Patch OmniLogicMessage.from_bytes to raise an unexpected exception + with ( + patch("pyomnilogic_local.api.protocol.OmniLogicMessage.from_bytes", side_effect=RuntimeError("Unexpected")), + caplog.at_level("ERROR"), + ): + protocol.datagram_received(b"data", ("127.0.0.1", 12345)) + + assert any("Unexpected error processing datagram" in r.message for r in caplog.records) + assert protocol.error_queue.qsize() == 1 + + +def test_error_received() -> None: + """Test that error_received puts errors in the error queue.""" + protocol = OmniLogicProtocol() + test_error = RuntimeError("UDP error") + + protocol.error_received(test_error) + + assert protocol.error_queue.qsize() == 1 + error = protocol.error_queue.get_nowait() + assert error is test_error + + +# ============================================================================ +# _wait_for_ack Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_wait_for_ack_success() -> None: + """Test successful ACK waiting.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put an ACK message in the queue + ack_message = OmniLogicMessage(123, MessageType.ACK) + await protocol.data_queue.put(ack_message) + + # Should return without raising + await protocol._wait_for_ack(123) + + +@pytest.mark.asyncio +async def test_wait_for_ack_wrong_id_continues_waiting() -> None: + """Test that wrong ACK IDs are consumed and waiting continues for the correct one.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put wrong ID first, then correct ID + wrong_ack = OmniLogicMessage(999, MessageType.ACK) + correct_ack = OmniLogicMessage(123, MessageType.ACK) + + await protocol.data_queue.put(wrong_ack) + await protocol.data_queue.put(correct_ack) + + await protocol._wait_for_ack(123) + # Queue should be empty after consuming both messages + assert protocol.data_queue.qsize() == 0 + + +@pytest.mark.asyncio +async def test_wait_for_ack_leadmessage_instead(caplog: pytest.LogCaptureFixture) -> None: + """Test that LeadMessage with matching ID is accepted (ACK was dropped).""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put a LeadMessage with matching ID (simulating dropped ACK) + leadmsg = OmniLogicMessage(123, MessageType.MSP_LEADMESSAGE) + await protocol.data_queue.put(leadmsg) + + with caplog.at_level("DEBUG"): + await protocol._wait_for_ack(123) + + # With matching ID, it's treated as the ACK we're looking for + assert any("Received ACK for message ID 123" in r.message for r in caplog.records) + # LeadMessage should NOT be in queue since IDs matched + assert protocol.data_queue.qsize() == 0 + + +@pytest.mark.asyncio +async def test_wait_for_ack_leadmessage_wrong_id(caplog: pytest.LogCaptureFixture) -> None: + """Test that LeadMessage with wrong ID is put back in queue and waiting continues.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put a LeadMessage with wrong ID, then correct ACK + leadmsg = OmniLogicMessage(999, MessageType.MSP_LEADMESSAGE) + correct_ack = OmniLogicMessage(123, MessageType.ACK) + + await protocol.data_queue.put(leadmsg) + await protocol.data_queue.put(correct_ack) + + with caplog.at_level("DEBUG"): + await protocol._wait_for_ack(123) + + # Should log that ACK was dropped and put LeadMessage back + assert any("ACK was dropped" in r.message for r in caplog.records) + # Both messages were consumed and LeadMessage was put back, so queue should have 1 item + # But the ACK was also consumed, so we actually end up with just the LeadMessage back + # Actually, looking at the code: LeadMessage gets put back, then we return + # So BOTH the correct ACK and the LeadMessage should be in the queue + assert protocol.data_queue.qsize() == 2 # LeadMessage put back, correct ACK also still there + + +@pytest.mark.asyncio +async def test_wait_for_ack_error_in_queue() -> None: + """Test that errors from error queue are raised.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + test_error = RuntimeError("Test error") + await protocol.error_queue.put(test_error) + + with pytest.raises(RuntimeError, match="Test error"): + await protocol._wait_for_ack(123) + + +# ============================================================================ +# _ensure_sent Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_ensure_sent_ack_message() -> None: + """Test that ACK messages don't wait for ACK.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + ack_message = OmniLogicMessage(123, MessageType.ACK) + + # Should return immediately without waiting + await protocol._ensure_sent(ack_message) + + protocol.transport.sendto.assert_called_once() + + +@pytest.mark.asyncio +async def test_ensure_sent_xml_ack_message() -> None: + """Test that XML_ACK messages don't wait for ACK.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + xml_ack_message = OmniLogicMessage(123, MessageType.XML_ACK, payload="") + + await protocol._ensure_sent(xml_ack_message) + + protocol.transport.sendto.assert_called_once() + + +@pytest.mark.asyncio +async def test_ensure_sent_success_first_attempt() -> None: + """Test successful send on first attempt.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Mock _wait_for_ack to succeed immediately + with patch.object(protocol, "_wait_for_ack", new_callable=AsyncMock) as mock_wait: + message = OmniLogicMessage(123, MessageType.REQUEST_CONFIGURATION) + await protocol._ensure_sent(message, max_attempts=3) + + protocol.transport.sendto.assert_called_once() + mock_wait.assert_called_once_with(123) + + @pytest.mark.asyncio async def test_ensure_sent_timeout_and_retry_logs(caplog: pytest.LogCaptureFixture) -> None: """Test that _ensure_sent logs retries and raises on repeated timeout.""" protocol = OmniLogicProtocol() protocol.transport = MagicMock() - # Patch _wait_for_ack to always timeout using patch.object - async def always_timeout(*args: object, **kwargs: object) -> None: + async def always_timeout(*args: object, **kwargs: object) -> None: # noqa: ARG001 await asyncio.sleep(0) - raise TimeoutError() + raise TimeoutError message = OmniLogicMessage(123, MessageType.REQUEST_CONFIGURATION) - with patch.object(protocol, "_wait_for_ack", always_timeout): - with caplog.at_level("WARNING"): - with pytest.raises(OmniTimeoutException): - await protocol._ensure_sent(message, max_attempts=3) # pylint: disable=protected-access - # Should log retries and final error + with patch.object(protocol, "_wait_for_ack", always_timeout), caplog.at_level("WARNING"), pytest.raises(OmniTimeoutError): + await protocol._ensure_sent(message, max_attempts=3) + assert any("attempt 1/3" in r.message for r in caplog.records) assert any("attempt 2/3" in r.message for r in caplog.records) assert any("after 3 attempts" in r.message for r in caplog.records) + assert protocol.transport.sendto.call_count == 3 + + +# ============================================================================ +# send_message and send_and_receive Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_send_message_generates_random_id() -> None: + """Test that send_message generates a random ID when none provided.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + with patch.object(protocol, "_ensure_sent", new_callable=AsyncMock) as mock_ensure: + await protocol.send_message(MessageType.REQUEST_CONFIGURATION, None, msg_id=None) + + mock_ensure.assert_called_once() + sent_message = mock_ensure.call_args[0][0] + assert sent_message.id != 0 # Should have a random ID + + +@pytest.mark.asyncio +async def test_send_message_uses_provided_id() -> None: + """Test that send_message uses provided ID.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + with patch.object(protocol, "_ensure_sent", new_callable=AsyncMock) as mock_ensure: + await protocol.send_message(MessageType.REQUEST_CONFIGURATION, None, msg_id=12345) + + mock_ensure.assert_called_once() + sent_message = mock_ensure.call_args[0][0] + assert sent_message.id == 12345 + + +@pytest.mark.asyncio +async def test_send_and_receive() -> None: + """Test send_and_receive calls send_message and _receive_file.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + with ( + patch.object(protocol, "send_message", new_callable=AsyncMock) as mock_send, + patch.object(protocol, "_receive_file", new_callable=AsyncMock) as mock_receive, + ): + mock_receive.return_value = "test response" + + result = await protocol.send_and_receive(MessageType.REQUEST_CONFIGURATION, "payload", 123) + + mock_send.assert_called_once_with(MessageType.REQUEST_CONFIGURATION, "payload", 123) + mock_receive.assert_called_once() + assert result == "test response" + + +# ============================================================================ +# _send_ack Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_send_ack_generates_xml() -> None: + """Test that _send_ack generates proper XML ACK message.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + with patch.object(protocol, "send_message", new_callable=AsyncMock) as mock_send: + await protocol._send_ack(12345) + + mock_send.assert_called_once() + call_args = mock_send.call_args + assert call_args[0][0] == MessageType.XML_ACK + assert call_args[0][2] == 12345 + + # Verify XML structure + xml_payload = call_args[0][1] + root = ET.fromstring(xml_payload) + assert "Request" in root.tag + name_elem = root.find(".//{http://nextgen.hayward.com/api}Name") + assert name_elem is not None + assert name_elem.text == "Ack" + + +# ============================================================================ +# _receive_file Tests - Simple Response +# ============================================================================ + + +@pytest.mark.asyncio +async def test_receive_file_simple_response() -> None: + """Test receiving a simple (non-fragmented) response.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create a simple response message + response_msg = OmniLogicMessage(123, MessageType.GET_TELEMETRY, payload="") + await protocol.data_queue.put(response_msg) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock) as mock_ack: + result = await protocol._receive_file() + + mock_ack.assert_called_once_with(123) + assert result == "" + + +@pytest.mark.asyncio +async def test_receive_file_skips_duplicate_acks(caplog: pytest.LogCaptureFixture) -> None: + """Test that duplicate ACKs are skipped.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Put duplicate ACKs followed by real message + ack1 = OmniLogicMessage(111, MessageType.ACK) + ack2 = OmniLogicMessage(222, MessageType.XML_ACK) + response = OmniLogicMessage(333, MessageType.GET_TELEMETRY, payload="") + + await protocol.data_queue.put(ack1) + await protocol.data_queue.put(ack2) + await protocol.data_queue.put(response) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock), caplog.at_level("DEBUG"): + result = await protocol._receive_file() + + assert any("Skipping duplicate ACK" in r.message for r in caplog.records) + assert result == "" + + +@pytest.mark.asyncio +async def test_receive_file_decompresses_data() -> None: + """Test that compressed responses are decompressed.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create compressed payload + original = b"This is test data that will be compressed" + compressed = zlib.compress(original) + + # Create message with compressed payload + response_msg = OmniLogicMessage(123, MessageType.GET_TELEMETRY) + response_msg.compressed = True + response_msg.payload = compressed + + await protocol.data_queue.put(response_msg) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock): + result = await protocol._receive_file() + + assert result == original.decode("utf-8") + + +@pytest.mark.asyncio +async def test_receive_file_decompression_error() -> None: + """Test that decompression errors are handled.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create message with invalid compressed data + response_msg = OmniLogicMessage(123, MessageType.GET_TELEMETRY) + response_msg.compressed = True + response_msg.payload = b"invalid compressed data" + + await protocol.data_queue.put(response_msg) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock), pytest.raises(OmniMessageFormatError, match="Failed to decompress"): + await protocol._receive_file() + + +# ============================================================================ +# _receive_file Tests - Fragmented Response +# ============================================================================ + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_response() -> None: + """Test receiving a fragmented response with LeadMessage and BlockMessages.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create LeadMessage + leadmsg_payload = ( + 'LeadMessage' + '100324' + '20' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + # Create BlockMessages with 8-byte header + block1 = OmniLogicMessage(101, MessageType.MSP_BLOCKMESSAGE) + block1.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"first_part" + + block2 = OmniLogicMessage(102, MessageType.MSP_BLOCKMESSAGE) + block2.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"second_part" + + await protocol.data_queue.put(leadmsg) + await protocol.data_queue.put(block1) + await protocol.data_queue.put(block2) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock) as mock_ack: + result = await protocol._receive_file() + + # Should send ACK for LeadMessage and each BlockMessage + assert mock_ack.call_count == 3 + assert result == "first_partsecond_part" + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_out_of_order() -> None: + """Test that fragments received out of order are reassembled correctly.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + leadmsg_payload = ( + 'LeadMessage' + '100330' + '30' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + # Create blocks out of order (IDs: 102, 100, 101) + block2 = OmniLogicMessage(102, MessageType.MSP_BLOCKMESSAGE) + block2.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"third" + + block0 = OmniLogicMessage(100, MessageType.MSP_BLOCKMESSAGE) + block0.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"first" + + block1 = OmniLogicMessage(101, MessageType.MSP_BLOCKMESSAGE) + block1.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"second" + + await protocol.data_queue.put(leadmsg) + await protocol.data_queue.put(block2) # Out of order + await protocol.data_queue.put(block0) + await protocol.data_queue.put(block1) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock): + result = await protocol._receive_file() + + # Should be reassembled in ID order + assert result == "firstsecondthird" + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_invalid_leadmessage() -> None: + """Test that invalid LeadMessage XML raises error.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + # Create LeadMessage with invalid XML + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload="invalid xml") + await protocol.data_queue.put(leadmsg) + + with ( + patch.object(protocol, "_send_ack", new_callable=AsyncMock), + pytest.raises(OmniFragmentationError, match="Failed to parse LeadMessage"), + ): + await protocol._receive_file() + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_timeout_waiting() -> None: + """Test timeout while waiting for fragments.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + leadmsg_payload = ( + 'LeadMessage' + '100324' + '20' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + await protocol.data_queue.put(leadmsg) + # Don't put any BlockMessages - will timeout + + with ( + patch.object(protocol, "_send_ack", new_callable=AsyncMock), + pytest.raises(OmniFragmentationError, match="Timeout receiving fragment"), + ): + await protocol._receive_file() + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_max_wait_time_exceeded() -> None: + """Test that MAX_FRAGMENT_WAIT_TIME timeout is enforced.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + leadmsg_payload = ( + 'LeadMessage' + '100324' + '20' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + await protocol.data_queue.put(leadmsg) + + # Mock time to simulate timeout + with patch.object(protocol, "_send_ack", new_callable=AsyncMock), patch("time.time") as mock_time: + mock_time.side_effect = [0, 31] # Start at 0, then 31 seconds later (> 30s max) + + with pytest.raises(OmniFragmentationError, match="Timeout waiting for fragments"): + await protocol._receive_file() + + +@pytest.mark.asyncio +async def test_receive_file_fragmented_ignores_non_block_messages(caplog: pytest.LogCaptureFixture) -> None: + """Test that non-BlockMessages during fragmentation are ignored.""" + protocol = OmniLogicProtocol() + protocol.transport = MagicMock() + + leadmsg_payload = ( + 'LeadMessage' + '100310' + '10' + "" + ) + leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload) + + # Put LeadMessage, then an ACK (should be ignored), then the actual block + ack_msg = OmniLogicMessage(999, MessageType.ACK) + block1 = OmniLogicMessage(101, MessageType.MSP_BLOCKMESSAGE) + block1.payload = b"\x00\x00\x00\x00\x00\x00\x00\x00" + b"data" + + await protocol.data_queue.put(leadmsg) + await protocol.data_queue.put(ack_msg) + await protocol.data_queue.put(block1) + + with patch.object(protocol, "_send_ack", new_callable=AsyncMock), caplog.at_level("DEBUG"): + result = await protocol._receive_file() + + assert any("other than a blockmessage" in r.message for r in caplog.records) + assert result == "data" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..96c0dd6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,516 @@ +version = 1 +revision = 3 +requires-python = ">=3.13, <4.0.0" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/32/e6/7c4006cf689ed7a4aa75dcf1f14acbc04e585714c220b5cc6d231096685a/coverage-7.11.2.tar.gz", hash = "sha256:ae43149b7732df15c3ca9879b310c48b71d08cd8a7ba77fda7f9108f78499e93", size = 814849, upload-time = "2025-11-08T20:26:33.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/00/57f3f8adaced9e4c74f482932e093176df7e400b4bb95dc1f3cd499511b5/coverage-7.11.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:38a5509fe7fabb6fb3161059b947641753b6529150ef483fc01c4516a546f2ad", size = 217125, upload-time = "2025-11-08T20:24:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/ff1a55673161608c895080950cdfbb6485c95e6fa57a92d2cd1e463717b3/coverage-7.11.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7e01ab8d69b6cffa2463e78a4d760a6b69dfebe5bf21837eabcc273655c7e7b3", size = 217499, upload-time = "2025-11-08T20:24:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/eaac01709ffbef291a12ca2526b6247f55ab17724e2297cc70921cd9a81f/coverage-7.11.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4776c6555a9f378f37fa06408f2e1cc1d06e4c4e06adb3d157a4926b549efbe", size = 248479, upload-time = "2025-11-08T20:24:54.825Z" }, + { url = "https://files.pythonhosted.org/packages/75/25/d846d2d08d182eeb30d1eba839fabdd9a3e6c710a1f187657b9c697bab23/coverage-7.11.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f70fa1ef17cba5dada94e144ea1b6e117d4f174666842d1da3aaf765d6eb477", size = 251074, upload-time = "2025-11-08T20:24:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7a/34c9402ad12bce609be4be1146a7d22a7fae8e9d752684b6315cce552a65/coverage-7.11.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:811bff1f93566a8556a9aeb078bd82573e37f4d802a185fba4cbe75468615050", size = 252318, upload-time = "2025-11-08T20:24:57.987Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/292fe3cea4cc1c4b8fb060fa60e565ab1b3bfc67bda74bedefb24b4a2407/coverage-7.11.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d0e80c9946da61cc0bf55dfd90d65707acc1aa5bdcb551d4285ea8906255bb33", size = 248641, upload-time = "2025-11-08T20:24:59.642Z" }, + { url = "https://files.pythonhosted.org/packages/c5/af/33ccb2aa2f43bbc330a1fccf84a396b90f2e61c00dccb7b72b2993a3c795/coverage-7.11.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:10f10c9acf584ef82bfaaa7296163bd11c7487237f1670e81fc2fa7e972be67b", size = 250457, upload-time = "2025-11-08T20:25:01.358Z" }, + { url = "https://files.pythonhosted.org/packages/bd/91/4b5b58f34e0587fbc5c1a28d644d9c20c13349c1072aea507b6e372c8f20/coverage-7.11.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fd3f7cc6cb999e3eff91a2998a70c662b0fcd3c123d875766147c530ca0d3248", size = 248421, upload-time = "2025-11-08T20:25:02.895Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d5/5c5ed220b15f490717522d241629c522fa22275549a6ccfbc96a3654b009/coverage-7.11.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e52a028a56889d3ad036c0420e866e4a69417d3203e2fc5f03dcb8841274b64c", size = 248244, upload-time = "2025-11-08T20:25:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/1e/27/504088aba40735132db838711d966e1314931ff9bddcd0e2ea6bc7e345a7/coverage-7.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f6f985e175dfa1fb8c0a01f47186720ae25d5e20c181cc5f3b9eba95589b8148", size = 250004, upload-time = "2025-11-08T20:25:06.633Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/4d61c0ad0d39656bd5e73fe41a93a34b063c90333258e6307aadcfcdbb97/coverage-7.11.2-cp313-cp313-win32.whl", hash = "sha256:e48b95abe2983be98cdf52900e07127eb7fe7067c87a700851f4f1f53d2b00e6", size = 219639, upload-time = "2025-11-08T20:25:08.27Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a7/a298afa025ebe7a2afd6657871a1ac2d9c49666ce00f9a35ee9df61a3bd8/coverage-7.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:ea910cc737ee8553c81ad5c104bc5b135106ebb36f88be506c3493e001b4c733", size = 220445, upload-time = "2025-11-08T20:25:09.906Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a1/1825f5eadc0a0a6ea1c6e678827e1ec8c0494dbd23270016fccfc3358fbf/coverage-7.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:ef2d3081562cd83f97984a96e02e7a294efa28f58d5e7f4e28920f59fd752b41", size = 219077, upload-time = "2025-11-08T20:25:11.777Z" }, + { url = "https://files.pythonhosted.org/packages/c0/61/98336c6f4545690b482e805c3a1a83fb2db4c19076307b187db3d421b5b3/coverage-7.11.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:87d7c7b0b2279e174f36d276e2afb7bf16c9ea04e824d4fa277eea1854f4cfd4", size = 217818, upload-time = "2025-11-08T20:25:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/ee/6dca6e5f1a4affba8d3224996d0e9145e6d67817da753cc436e48bb8d0e6/coverage-7.11.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:940d195f4c8ba3ec6e7c302c9f546cdbe63e57289ed535452bc52089b1634f1c", size = 218170, upload-time = "2025-11-08T20:25:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/ec/17/9c9ca3ef09d3576027e77cf580eb599d8d655f9ca2456a26ca50c53e07e3/coverage-7.11.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3b92e10ca996b5421232dd6629b9933f97eb57ce374bca800ab56681fbeda2b", size = 259466, upload-time = "2025-11-08T20:25:17.373Z" }, + { url = "https://files.pythonhosted.org/packages/53/96/2001a596827a0b91ba5f627f21b5ce998fa1f27d861a8f6d909f5ea663ff/coverage-7.11.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61d6a7cc1e7a7a761ac59dcc88cee54219fd4231face52bd1257cfd3df29ae9f", size = 261530, upload-time = "2025-11-08T20:25:19.085Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bb/fea7007035fdc3c40fcca0ab740da549ff9d38fa50b0d37cd808fbbf9683/coverage-7.11.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bee1911c44c52cad6b51d436aa8c6ff5ca5d414fa089c7444592df9e7b890be9", size = 263963, upload-time = "2025-11-08T20:25:21.168Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b3/7452071353441b632ebea42f6ad328a7ab592e4bc50a31f9921b41667017/coverage-7.11.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4c4423ea9c28749080b41e18ec74d658e6c9f148a6b47e719f3d7f56197f8227", size = 258644, upload-time = "2025-11-08T20:25:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/e6/05/6e56b1c2b3308f587508ad4b0a4cb76c8d6179fea2df148e071979b3eb77/coverage-7.11.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:689d3b4dd0d4c912ed8bfd7a1b5ff2c5ecb1fa16571840573174704ff5437862", size = 261539, upload-time = "2025-11-08T20:25:25.277Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/7afeeac2a49f651318e4a83f1d5f4d3d4f4092f1d451ac4aec8069cddbdb/coverage-7.11.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:75ef769be19d69ea71b0417d7fbf090032c444792579cdf9b166346a340987d5", size = 259153, upload-time = "2025-11-08T20:25:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/08f3b5c7500b2031cee74e8a01f9a5bc407f781ff6a826707563bb9dd5b7/coverage-7.11.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6681164bc697b93676945c8c814b76ac72204c395e11b71ba796a93b33331c24", size = 258043, upload-time = "2025-11-08T20:25:30.087Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/8e080e7622bd7c82df0f8324bbe0461ed1032a638b80046f1a53a88ea3a8/coverage-7.11.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4aa799c61869318d2b86c0d3c413d6805546aec42069f009cbb27df2eefb2790", size = 260243, upload-time = "2025-11-08T20:25:31.722Z" }, + { url = "https://files.pythonhosted.org/packages/dc/75/da033d8589661527b4a6d30c414005467e48fbccc0f3c10898af183e14e1/coverage-7.11.2-cp313-cp313t-win32.whl", hash = "sha256:9a6468e1a3a40d3d1f9120a9ff221d3eacef4540a6f819fff58868fe0bd44fa9", size = 220309, upload-time = "2025-11-08T20:25:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/29/ef/8a477d41dbcde1f1179c13c43c9f77ee926b793fe3e5f1cf5d868a494679/coverage-7.11.2-cp313-cp313t-win_amd64.whl", hash = "sha256:30c437e8b51ce081fe3903c9e368e85c9a803b093fd062c49215f3bf4fd1df37", size = 221374, upload-time = "2025-11-08T20:25:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/4c3cdd737ed1f630b821430004c2d5f1088b9bc0a7115aa5ad7c40d7d5cb/coverage-7.11.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a35701fe0b5ee9d4b67d31aa76555237af32a36b0cf8dd33f8a74470cf7cd2f5", size = 219648, upload-time = "2025-11-08T20:25:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/52/d1/43d17c299249085d6e0df36db272899e92aa09e68e27d3e92a4cf8d9523e/coverage-7.11.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f933bc1fead57373922e383d803e1dd5ec7b5a786c220161152ebee1aa3f006", size = 217170, upload-time = "2025-11-08T20:25:39.254Z" }, + { url = "https://files.pythonhosted.org/packages/78/66/f21c03307079a0b7867b364af057430018a3d4a18ed1b99e1adaf5a0f305/coverage-7.11.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f80cb5b328e870bf3df0568b41643a85ee4b8ccd219a096812389e39aa310ea4", size = 217497, upload-time = "2025-11-08T20:25:41.277Z" }, + { url = "https://files.pythonhosted.org/packages/f0/dd/0a2257154c32f442fe3b4622501ab818ae4bd7cde33bd7a740630f6bd24c/coverage-7.11.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6b2498f86f2554ed6cb8df64201ee95b8c70fb77064a8b2ae8a7185e7a4a5f0", size = 248539, upload-time = "2025-11-08T20:25:43.349Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ca/c55ab0ee5ebfc4ab56cfc1b3585cba707342dc3f891fe19f02e07bc0c25f/coverage-7.11.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a913b21f716aa05b149a8656e9e234d9da04bc1f9842136ad25a53172fecc20e", size = 251057, upload-time = "2025-11-08T20:25:45.083Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/a149b88ebe714b76d95427d609e629446d1df5d232f4bdaec34e471da124/coverage-7.11.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5769159986eb174f0f66d049a52da03f2d976ac1355679371f1269e83528599", size = 252393, upload-time = "2025-11-08T20:25:47.272Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a4/a992c805e95c46f0ac1b83782aa847030cb52bbfd8fc9015cff30f50fb9e/coverage-7.11.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89565d7c9340858424a5ca3223bfefe449aeb116942cdc98cd76c07ca50e9db8", size = 248534, upload-time = "2025-11-08T20:25:49.034Z" }, + { url = "https://files.pythonhosted.org/packages/78/01/318ed024ae245dbc76152bc016919aef69c508a5aac0e2da5de9b1efea61/coverage-7.11.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7fc943097fa48de00d14d2a2f3bcebfede024e031d7cd96063fe135f8cbe96e", size = 250412, upload-time = "2025-11-08T20:25:51.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/f05c7984ef48c8d1c6c1ddb243223b344dcd8c6c0d54d359e4e325e2fa7e/coverage-7.11.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:72a3d109ac233666064d60b29ae5801dd28bc51d1990e69f183a2b91b92d4baf", size = 248367, upload-time = "2025-11-08T20:25:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ac/461ed0dcaba0c727b760057ffa9837920d808a35274e179ff4a94f6f755a/coverage-7.11.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4648c90cf741fb61e142826db1557a44079de0ca868c5c5a363c53d852897e84", size = 248187, upload-time = "2025-11-08T20:25:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bf/8510ce8c7b1a8d682726df969e7523ee8aac23964b2c8301b8ce2400c1b4/coverage-7.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f1aa017b47e1879d7bac50161b00d2b886f2ff3882fa09427119e1b3572ede1", size = 249849, upload-time = "2025-11-08T20:25:57.186Z" }, + { url = "https://files.pythonhosted.org/packages/75/6f/ea1c8990ca35d607502c9e531f164573ea59bb6cd5cd4dc56d7cc3d1fcb5/coverage-7.11.2-cp314-cp314-win32.whl", hash = "sha256:44b6e04bb94e59927a2807cd4de86386ce34248eaea95d9f1049a72f81828c38", size = 219908, upload-time = "2025-11-08T20:25:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/1e/04/a64e2a8b9b65ae84670207dc6073e3d48ee9192646440b469e9b8c335d1f/coverage-7.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:7ea36e981a8a591acdaa920704f8dc798f9fff356c97dbd5d5702046ae967ce1", size = 220724, upload-time = "2025-11-08T20:26:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/73/df/eb4e9f9d0d55f7ec2b55298c30931a665c2249c06e3d1d14c5a6df638c77/coverage-7.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:4aaf2212302b6f748dde596424b0f08bc3e1285192104e2480f43d56b6824f35", size = 219296, upload-time = "2025-11-08T20:26:02.918Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b5/e9bb3b17a65fe92d1c7a2363eb5ae9893fafa578f012752ed40eee6aa3c8/coverage-7.11.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84e8e0f5ab5134a2d32d4ebadc18b433dbbeddd0b73481f816333b1edd3ff1c8", size = 217905, upload-time = "2025-11-08T20:26:04.633Z" }, + { url = "https://files.pythonhosted.org/packages/38/6f/1f38dd0b63a9d82fb3c9d7fbe1c9dab26ae77e5b45e801d129664e039034/coverage-7.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5db683000ff6217273071c752bd6a1d341b6dc5d6aaa56678c53577a4e70e78a", size = 218172, upload-time = "2025-11-08T20:26:06.677Z" }, + { url = "https://files.pythonhosted.org/packages/fd/5d/2aeb513c6841270783b216478c6edc65b128c6889850c5f77568aa3a3098/coverage-7.11.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2970c03fefee2a5f1aebc91201a0706a7d0061cc71ab452bb5c5345b7174a349", size = 259537, upload-time = "2025-11-08T20:26:08.481Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/ddd9b22ec1b5c69cc579b149619c354f981aaaafc072b92574f2d3d6c267/coverage-7.11.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9f28b900d96d83e2ae855b68d5cf5a704fa0b5e618999133fd2fb3bbe35ecb1", size = 261648, upload-time = "2025-11-08T20:26:10.551Z" }, + { url = "https://files.pythonhosted.org/packages/29/e2/8743b7281decd3f73b964389fea18305584dd6ba96f0aff91b4880b50310/coverage-7.11.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8b9a7ebc6a29202fb095877fd8362aab09882894d1c950060c76d61fb116114", size = 264061, upload-time = "2025-11-08T20:26:12.306Z" }, + { url = "https://files.pythonhosted.org/packages/00/1b/46daea7c4349c4530c62383f45148cc878845374b7a632e3ac2769b2f26a/coverage-7.11.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f8f6bcaa7fe162460abb38f7a5dbfd7f47cfc51e2a0bf0d3ef9e51427298391", size = 258580, upload-time = "2025-11-08T20:26:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/d7/53/f9b1c2d921d585dd6499e05bd71420950cac4e800f71525eb3d2690944fe/coverage-7.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:461577af3f8ad4da244a55af66c0731b68540ce571dbdc02598b5ec9e7a09e73", size = 261526, upload-time = "2025-11-08T20:26:16.353Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/55acee453a71a71b08b05848d718ce6ac4559d051b4a2c407b0940aa72be/coverage-7.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5b284931d57389ec97a63fb1edf91c68ec369cee44bc40b37b5c3985ba0a2914", size = 259135, upload-time = "2025-11-08T20:26:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3f/cf1e0217efdebab257eb0f487215fe02ff2b6f914cea641b2016c33358e1/coverage-7.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2ca963994d28e44285dc104cf94b25d8a7fd0c6f87cf944f46a23f473910703f", size = 257959, upload-time = "2025-11-08T20:26:19.894Z" }, + { url = "https://files.pythonhosted.org/packages/68/0e/e9be33e55346e650c3218a313e888df80418415462c63bceaf4b31e36ab5/coverage-7.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7d3fccd5781c5d29ca0bd1ea272630f05cd40a71d419e7e6105c0991400eb14", size = 260290, upload-time = "2025-11-08T20:26:22.05Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1d/9e93937c2a9bd255bb5efeff8c5df1c8322e508371f76f21a58af0e36a31/coverage-7.11.2-cp314-cp314t-win32.whl", hash = "sha256:f633da28958f57b846e955d28661b2b323d8ae84668756e1eea64045414dbe34", size = 220691, upload-time = "2025-11-08T20:26:24.043Z" }, + { url = "https://files.pythonhosted.org/packages/bf/30/893b5a67e2914cf2be8e99c511b8084eaa8c0585e42d8b3cd78208f5f126/coverage-7.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:410cafc1aba1f7eb8c09823d5da381be30a2c9b3595758a4c176fcfc04732731", size = 221800, upload-time = "2025-11-08T20:26:26.24Z" }, + { url = "https://files.pythonhosted.org/packages/2b/8b/6d93448c494a35000cc97d8d5d9c9b3774fa2b0c0d5be55f16877f962d71/coverage-7.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:595c6bb2b565cc2d930ee634cae47fa959dfd24cc0e8ae4cf2b6e7e131e0d1f7", size = 219838, upload-time = "2025-11-08T20:26:28.479Z" }, + { url = "https://files.pythonhosted.org/packages/05/7a/99766a75c88e576f47c2d9a06416ff5d95be9b42faca5c37e1ab77c4cd1a/coverage-7.11.2-py3-none-any.whl", hash = "sha256:2442afabe9e83b881be083238bb7cf5afd4a10e47f29b6094470338d2336b33c", size = 208891, upload-time = "2025-11-08T20:26:30.739Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-subtests" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, +] + +[[package]] +name = "python-omnilogic-local" +version = "0.19.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "xmltodict" }, +] + +[package.optional-dependencies] +cli = [ + { name = "scapy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-subtests" }, + { name = "types-xmltodict" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0.0,<8.4.0" }, + { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, + { name = "scapy", marker = "extra == 'cli'", specifier = ">=2.6.1,<3.0.0" }, + { name = "xmltodict", specifier = ">=1.0.1,<2.0.0" }, +] +provides-extras = ["cli"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.18.2,<2.0.0" }, + { name = "pre-commit", specifier = ">=4.0.0,<5.0.0" }, + { name = "pytest", specifier = ">=8.0.0,<9.0.0" }, + { name = "pytest-asyncio", specifier = ">=1.2.0,<2.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" }, + { name = "pytest-subtests", specifier = ">=0.15.0,<1.0.0" }, + { name = "types-xmltodict", specifier = ">=1.0.1,<2.0.0" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "scapy" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/2f/035d3888f26d999e9680af8c7ddb7ce4ea0fd8d0e01c000de634c22dcf13/scapy-2.6.1.tar.gz", hash = "sha256:7600d7e2383c853e5c3a6e05d37e17643beebf2b3e10d7914dffcc3bc3c6e6c5", size = 2247754, upload-time = "2024-11-05T08:43:23.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/34/8695b43af99d0c796e4b7933a0d7df8925f43a8abdd0ff0f6297beb4de3a/scapy-2.6.1-py3-none-any.whl", hash = "sha256:88a998572049b511a1f3e44f4aa7c62dd39c6ea2aa1bb58434f503956641789d", size = 2420670, upload-time = "2024-11-05T08:43:21.285Z" }, +] + +[[package]] +name = "types-xmltodict" +version = "1.0.1.20250920" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/a7/a3bd65abc7ca906c4d658140c63ce1def95dcd25497726d0eed05c45d245/types_xmltodict-1.0.1.20250920.tar.gz", hash = "sha256:3a2a97b7c3247251d715452e7bd86b9f0567fb91e407164344c93390d9bbbe88", size = 8705, upload-time = "2025-09-20T02:45:14.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/de/fd8b3ad5ef409bdb796b70e66d46a795785ece05823a2fffc916d8e166ba/types_xmltodict-1.0.1.20250920-py3-none-any.whl", hash = "sha256:2acd1bd50e226f4939507165e5bbbd3a3f69718439ba31c142755ce353343ff3", size = 8380, upload-time = "2025-09-20T02:45:13.629Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "xmltodict" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/aa/917ceeed4dbb80d2f04dbd0c784b7ee7bba8ae5a54837ef0e5e062cd3cfb/xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649", size = 25725, upload-time = "2025-09-17T21:59:26.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/20/69a0e6058bc5ea74892d089d64dfc3a62ba78917ec5e2cfa70f7c92ba3a5/xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d", size = 13893, upload-time = "2025-09-17T21:59:24.859Z" }, +]