From c50bcef39073d65a8eb59fe594cc5cfc71602d5b Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 20 Oct 2025 23:05:25 -0700 Subject: [PATCH 1/6] Refactor: PEP 257 docstrings, 80 char line limit, and comprehensive documentation Major Changes: - Applied PEP 257 docstring conventions across all Python modules - Migrated from 100 to 80 character line limit per PEP 8 - Comprehensive documentation refactor with improved organization Code Changes: - Updated all docstrings to follow PEP 257 format (one-line summaries, proper spacing) - Reformatted code to adhere to 80 character line limit - Enhanced inline documentation throughout the codebase - Improved type hints and function signatures Documentation Changes: - Reorganized Sphinx documentation for better discoverability - Created structured sections: Installation, Quickstart, Python API, Protocol, Guides, Development - Added comprehensive API client, MQTT client, and Auth client documentation - Created user guides for Time of Use, Reservations, Energy Monitoring, and Events - Added protocol documentation for REST API and MQTT messages - Included complete module-level API reference with autodoc - Added OpenEI integration example for TOU schedules - Removed redundant/outdated documentation files New Documentation Files: - docs/installation.rst - Installation instructions - docs/quickstart.rst - Quick start guide - docs/configuration.rst - Configuration reference - docs/python_api/*.rst - Python API documentation (auth, api_client, mqtt_client, models, etc.) - docs/protocol/*.rst - REST API and MQTT protocol documentation - docs/guides/*.rst - User guides (TOU, reservations, energy monitoring, events) - docs/development/*.rst - Development and contribution guides Removed Files: - Consolidated and removed obsolete RST files (API_CLIENT.rst, MQTT_CLIENT.rst, etc.) - Removed temporary scripts and status files All changes maintain backward compatibility and pass CI checks (linting, type checking, tests). --- CONTRIBUTING.rst | 2 +- docs/API_CLIENT.rst | 556 ------ docs/API_REFERENCE.rst | 31 - docs/AUTHENTICATION.rst | 605 ------- docs/AUTO_RECOVERY_QUICK.rst | 192 --- docs/EVENT_EMITTER.rst | 691 -------- docs/MQTT_CLIENT.rst | 1490 ----------------- docs/MQTT_MESSAGES.rst | 1001 ----------- docs/configuration.rst | 331 ++++ docs/contributing.rst | 1 - docs/development/contributing.rst | 1 + .../history.rst} | 16 +- .../auto_recovery.rst} | 4 +- .../command_queue.rst} | 6 +- .../energy_monitoring.rst} | 6 +- docs/guides/event_system.rst | 513 ++++++ docs/guides/reservations.rst | 687 ++++++++ .../time_of_use.rst} | 244 ++- docs/index.rst | 392 +---- docs/installation.rst | 153 ++ .../device_features.rst} | 8 +- .../device_status.rst} | 6 +- .../error_codes.rst} | 4 +- .../firmware_tracking.rst} | 0 docs/protocol/mqtt_protocol.rst | 475 ++++++ docs/protocol/rest_api.rst | 424 +++++ docs/python_api/api_client.rst | 529 ++++++ docs/python_api/auth_client.rst | 497 ++++++ docs/python_api/cli.rst | 654 ++++++++ docs/python_api/constants.rst | 374 +++++ docs/python_api/events.rst | 358 ++++ docs/python_api/exceptions.rst | 429 +++++ docs/python_api/models.rst | 718 ++++++++ docs/python_api/mqtt_client.rst | 1084 ++++++++++++ docs/quickstart.rst | 278 +++ docs/readme.rst | 2 - examples/README.md | 48 + examples/tou_openei_example.py | 321 ++++ pyproject.toml | 2 +- src/nwp500/__init__.py | 6 + src/nwp500/api_client.py | 41 +- src/nwp500/auth.py | 92 +- src/nwp500/cli.py | 3 +- src/nwp500/cli/__init__.py | 6 +- src/nwp500/cli/__main__.py | 118 +- src/nwp500/cli/commands.py | 157 +- src/nwp500/cli/monitoring.py | 8 +- src/nwp500/cli/output_formatters.py | 3 +- src/nwp500/cli/token_storage.py | 8 +- src/nwp500/config.py | 4 +- src/nwp500/constants.py | 10 +- src/nwp500/encoding.py | 45 +- src/nwp500/events.py | 64 +- src/nwp500/models.py | 318 +++- src/nwp500/mqtt_client.py | 291 ++-- src/nwp500/mqtt_command_queue.py | 23 +- src/nwp500/mqtt_connection.py | 40 +- src/nwp500/mqtt_device_control.py | 85 +- src/nwp500/mqtt_periodic.py | 88 +- src/nwp500/mqtt_reconnection.py | 39 +- src/nwp500/mqtt_subscriptions.py | 186 +- src/nwp500/mqtt_utils.py | 21 +- src/nwp500/utils.py | 11 +- tests/test_command_queue.py | 8 +- tests/test_utils.py | 4 +- 65 files changed, 9401 insertions(+), 5411 deletions(-) delete mode 100644 docs/API_CLIENT.rst delete mode 100644 docs/API_REFERENCE.rst delete mode 100644 docs/AUTHENTICATION.rst delete mode 100644 docs/AUTO_RECOVERY_QUICK.rst delete mode 100644 docs/EVENT_EMITTER.rst delete mode 100644 docs/MQTT_CLIENT.rst delete mode 100644 docs/MQTT_MESSAGES.rst create mode 100644 docs/configuration.rst delete mode 100644 docs/contributing.rst create mode 100644 docs/development/contributing.rst rename docs/{DEVELOPMENT.rst => development/history.rst} (96%) rename docs/{AUTO_RECOVERY.rst => guides/auto_recovery.rst} (99%) rename docs/{COMMAND_QUEUE.rst => guides/command_queue.rst} (97%) rename docs/{ENERGY_MONITORING.rst => guides/energy_monitoring.rst} (98%) create mode 100644 docs/guides/event_system.rst create mode 100644 docs/guides/reservations.rst rename docs/{TIME_OF_USE.rst => guides/time_of_use.rst} (77%) create mode 100644 docs/installation.rst rename docs/{DEVICE_FEATURE_FIELDS.rst => protocol/device_features.rst} (97%) rename docs/{DEVICE_STATUS_FIELDS.rst => protocol/device_status.rst} (99%) rename docs/{ERROR_CODES.rst => protocol/error_codes.rst} (99%) rename docs/{FIRMWARE_TRACKING.rst => protocol/firmware_tracking.rst} (100%) create mode 100644 docs/protocol/mqtt_protocol.rst create mode 100644 docs/protocol/rest_api.rst create mode 100644 docs/python_api/api_client.rst create mode 100644 docs/python_api/auth_client.rst create mode 100644 docs/python_api/cli.rst create mode 100644 docs/python_api/constants.rst create mode 100644 docs/python_api/events.rst create mode 100644 docs/python_api/exceptions.rst create mode 100644 docs/python_api/models.rst create mode 100644 docs/python_api/mqtt_client.rst create mode 100644 docs/quickstart.rst delete mode 100644 docs/readme.rst create mode 100755 examples/tou_openei_example.py diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index f43cb3a..ac99262 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -120,7 +120,7 @@ Clone the repository #. You should run:: - pip install -U pip setuptools -e . + pip install -U pip setuptools -e . .. note:: diff --git a/docs/API_CLIENT.rst b/docs/API_CLIENT.rst deleted file mode 100644 index a59abb8..0000000 --- a/docs/API_CLIENT.rst +++ /dev/null @@ -1,556 +0,0 @@ - -REST API Client Module -====================== - -The ``nwp500.api_client`` module provides a high-level client for interacting with the Navien Smart Control REST API. - -Overview --------- - -The API client implements all endpoints from the OpenAPI specification and automatically handles: - -* Authentication and token management -* Automatic token refresh -* Request formatting with correct headers -* Response parsing with data models -* Error handling and retry logic - -Usage Examples --------------- - -Basic Usage -^^^^^^^^^^^ - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient - - async with NavienAuthClient("user@example.com", "password") as auth_client: - - # Create API client - api_client = NavienAPIClient(auth_client=auth_client) - - # List devices - devices = await api_client.list_devices() - - for device in devices: - print(f"{device.device_info.device_name}: {device.device_info.mac_address}") - -Get First Device -^^^^^^^^^^^^^^^^ - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient - - async with NavienAuthClient("user@example.com", "password") as auth_client: - api_client = NavienAPIClient(auth_client=auth_client) - - # Get first device - device = await api_client.get_first_device() - if device: - print(f"Found: {device.device_info.device_name}") - -API Reference -------------- - -NavienAPIClient -^^^^^^^^^^^^^^^ - -Main API client class. - -**Constructor** - -.. code-block:: python - - NavienAPIClient( - auth_client: NavienAuthClient, - base_url: str = "https://nlus.naviensmartcontrol.com/api/v2.1", - session: Optional[aiohttp.ClientSession] = None - ) - -**Parameters:** - * ``auth_client``: Authenticated NavienAuthClient instance (required) - * ``base_url``: Base URL for the API (default: official Navien API) - * ``session``: Optional aiohttp session (uses auth_client's session if not provided) - -**Methods:** - -``list_devices(offset: int = 0, count: int = 20) -> List[Device]`` - List all devices associated with the user. - - Args: - * ``offset``: Pagination offset (default: 0) - * ``count``: Number of devices to return (default: 20, max: 20) - - Returns: - List of Device objects - - Raises: - * ``APIError``: If API request fails - * ``AuthenticationError``: If not authenticated - -``get_device_info(mac_address: str, additional_value: str = "") -> Device`` - Get detailed information about a specific device. - - Args: - * ``mac_address``: Device MAC address - * ``additional_value``: Additional device identifier (optional) - - Returns: - Device object with detailed information - - Raises: - * ``APIError``: If API request fails - -``get_firmware_info(mac_address: str, additional_value: str = "") -> List[FirmwareInfo]`` - Get firmware information for a specific device. - - Args: - * ``mac_address``: Device MAC address - * ``additional_value``: Additional device identifier (optional) - - Returns: - List of FirmwareInfo objects - - Raises: - * ``APIError``: If API request fails - -``get_tou_info(mac_address: str, additional_value: str, controller_id: str, user_type: str = "O") -> TOUInfo`` - Get Time of Use (TOU) information for a device. - - Args: - * ``mac_address``: Device MAC address - * ``additional_value``: Additional device identifier - * ``controller_id``: Controller ID - * ``user_type``: User type (default: "O") - - Returns: - TOUInfo object - - Raises: - * ``APIError``: If API request fails - - See :doc:`TIME_OF_USE` for detailed information on TOU pricing and configuration. - -``update_push_token(push_token: str, ...) -> bool`` - Update push notification token. - - Args: - * ``push_token``: Push notification token - * ``model_name``: Device model name (optional) - * ``app_version``: Application version (optional) - * ``os``: Operating system (optional) - * ``os_version``: OS version (optional) - - Returns: - True if successful - -``get_first_device() -> Optional[Device]`` - Get the first device associated with the user. - - Returns: - First Device object or None if no devices - -**Properties:** - -``is_authenticated: bool`` - Check if client is authenticated (via auth_client). - -Data Models ------------ - -Device -^^^^^^ - -Complete device information including location. - -.. code-block:: python - - @dataclass - class Device: - device_info: DeviceInfo - location: Location - -DeviceInfo -^^^^^^^^^^ - -Device information from API. - -.. code-block:: python - - @dataclass - class DeviceInfo: - home_seq: int - mac_address: str - additional_value: str - device_type: int - device_name: str - connected: int - install_type: Optional[str] = None - -**Fields:** - * ``home_seq``: Home sequence number - * ``mac_address``: Device MAC address (e.g., "aabbccddeeff") - * ``additional_value``: Additional device identifier (e.g., "5322") - * ``device_type``: Device type code (52 for NWP500) - * ``device_name``: Device name (e.g., "NWP500") - * ``connected``: Connection status (2 = connected) - * ``install_type``: Installation type (e.g., "R" for residential) - -Location -^^^^^^^^ - -Location information for a device. - -.. code-block:: python - - @dataclass - class Location: - state: Optional[str] = None - city: Optional[str] = None - address: Optional[str] = None - latitude: Optional[float] = None - longitude: Optional[float] = None - altitude: Optional[float] = None - -FirmwareInfo -^^^^^^^^^^^^ - -Firmware information for a device. - -.. code-block:: python - - @dataclass - class FirmwareInfo: - mac_address: str - additional_value: str - device_type: int - cur_sw_code: int - cur_version: int - downloaded_version: Optional[int] = None - device_group: Optional[str] = None - -TOUInfo -^^^^^^^ - -Time of Use (TOU) information. - -.. code-block:: python - - @dataclass - class TOUInfo: - register_path: str - source_type: str - controller_id: str - manufacture_id: str - name: str - utility: str - zip_code: int - schedule: List[TOUSchedule] - -**Fields:** - * ``register_path``: Path where TOU data is stored - * ``source_type``: Source of rate data (e.g., "openei") - * ``controller_id``: Controller serial number - * ``manufacture_id``: Manufacturer ID - * ``name``: Rate plan name - * ``utility``: Utility company name - * ``zip_code``: ZIP code - * ``schedule``: List of TOU schedule periods - -See :doc:`TIME_OF_USE` for detailed information on TOU pricing configuration, OpenEI API integration, and usage examples. - -Exceptions ----------- - -APIError -^^^^^^^^ - -Raised when API returns an error response. - -.. code-block:: python - - class APIError(Exception): - message: str - code: Optional[int] - response: Optional[Dict] - -**Attributes:** - * ``message``: Error message - * ``code``: HTTP status code or API error code - * ``response``: Raw API response dictionary - -Usage Examples --------------- - -Example 1: List All Devices -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import asyncio - from nwp500 import NavienAuthClient, NavienAPIClient - - async def list_my_devices(): - async with NavienAuthClient("user@example.com", "password") as auth_client: - - api_client = NavienAPIClient(auth_client=auth_client) - devices = await api_client.list_devices() - - for device in devices: - info = device.device_info - loc = device.location - - print(f"Device: {info.device_name}") - print(f" MAC: {info.mac_address}") - print(f" Type: {info.device_type}") - print(f" Location: {loc.city}, {loc.state}") - - asyncio.run(list_my_devices()) - -Example 2: Get Device Details -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - async def get_device_details(): - async with NavienAuthClient("user@example.com", "password") as auth_client: - - api_client = NavienAPIClient(auth_client=auth_client) - - # Get first device - device = await api_client.get_first_device() - - if device: - mac = device.device_info.mac_address - additional = device.device_info.additional_value - - # Get detailed info - details = await api_client.get_device_info(mac, additional) - - print(f"Device: {details.device_info.device_name}") - print(f"Install Type: {details.device_info.install_type}") - print(f"Coordinates: {details.location.latitude}, " - f"{details.location.longitude}") - -Example 3: Get Firmware Info -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - async def check_firmware(): - async with NavienAuthClient("user@example.com", "password") as auth_client: - - api_client = NavienAPIClient(auth_client=auth_client) - device = await api_client.get_first_device() - - if device: - mac = device.device_info.mac_address - additional = device.device_info.additional_value - - firmwares = await api_client.get_firmware_info(mac, additional) - - for fw in firmwares: - print(f"SW Code: {fw.cur_sw_code}") - print(f"Version: {fw.cur_version}") - -Example 4: Error Handling -^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient, APIError - from nwp500.auth import AuthenticationError - - async def safe_api_call(): - try: - async with NavienAuthClient("user@example.com", "password") as auth_client: - - api_client = NavienAPIClient(auth_client=auth_client) - devices = await api_client.list_devices() - - for device in devices: - print(device.device_info.device_name) - - except AuthenticationError as e: - print(f"Authentication failed: {e.message}") - except APIError as e: - print(f"API error: {e.message} (code: {e.code})") - except Exception as e: - print(f"Unexpected error: {str(e)}") - -Example 5: Pagination -^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - async def paginate_devices(): - async with NavienAuthClient("user@example.com", "password") as auth_client: - - api_client = NavienAPIClient(auth_client=auth_client) - - offset = 0 - count = 10 - all_devices = [] - - while True: - devices = await api_client.list_devices(offset=offset, count=count) - - if not devices: - break - - all_devices.extend(devices) - offset += count - - if len(devices) < count: - break - - print(f"Total devices: {len(all_devices)}") - -Integration with Authentication -------------------------------- - -The API client requires an authenticated NavienAuthClient: - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient - - # Create auth client and authenticate - async with NavienAuthClient("user@example.com", "password") as auth_client: - - # Pass auth client to API client - api_client = NavienAPIClient(auth_client=auth_client) - devices = await api_client.list_devices() - -Session Management ------------------- - -The API client uses the auth client's session by default: - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient - - async def efficient_requests(): - # Auth client manages the session - async with NavienAuthClient("user@example.com", "password") as auth_client: - - # API client reuses auth client's session - api_client = NavienAPIClient(auth_client=auth_client) - - # Make multiple requests with same session - devices = await api_client.list_devices() - - for device in devices: - mac = device.device_info.mac_address - additional = device.device_info.additional_value - - # Reuses same session - info = await api_client.get_device_info(mac, additional) - firmware = await api_client.get_firmware_info(mac, additional) - -Response Format ---------------- - -All API responses follow this structure: - -.. code-block:: json - - { - "code": 200, - "msg": "SUCCESS", - "data": {} - } - -Error responses: - -.. code-block:: json - - { - "code": 601, - "msg": "DEVICE_NOT_FOUND", - "data": null - } - -Common error codes: - -* ``200``: Success -* ``401``: Unauthorized (authentication failed) -* ``601``: Device not found -* ``602``: Invalid parameters - -Testing -------- - -Run the API client test: - -.. code-block:: bash - - # Set credentials - export NAVIEN_EMAIL='your_email@example.com' - export NAVIEN_PASSWORD='your_password' - - # Run test - python test_api_client.py - - # Test convenience function - python test_api_client.py --convenience - -Best Practices --------------- - -1. **Always use context manager for auth client** - Ensures proper cleanup - - .. code-block:: python - - async with NavienAuthClient("user@example.com", "password") as auth_client: - api_client = NavienAPIClient(auth_client=auth_client) - # Your code here - -2. **Handle errors appropriately** - - .. code-block:: python - - try: - devices = await api_client.list_devices() - except APIError as e: - logger.error(f"API error: {e.message}") - -3. **Share auth client between API and MQTT clients** - - .. code-block:: python - - async with NavienAuthClient("user@example.com", "password") as auth_client: - api_client = NavienAPIClient(auth_client=auth_client) - mqtt_client = NavienMqttClient(auth_client) - -4. **Check authentication status** - - .. code-block:: python - - if auth_client.is_authenticated: - api_client = NavienAPIClient(auth_client=auth_client) - -5. **Use convenience functions for simple tasks** - - .. code-block:: python - - devices = await get_devices(email, password) - -Limitations ------------ - -* Maximum 20 devices per request (use pagination for more) -* Rate limiting may apply (implement exponential backoff) -* Some endpoints require device-specific configuration (e.g., TOU) - -Further Reading ---------------- - -* :doc:`AUTHENTICATION` - Authentication details -* :doc:`TIME_OF_USE` - Time of Use pricing configuration and OpenEI API integration -* `OpenAPI Specification `__ - Complete API specification - -For questions or issues, please refer to the project repository. diff --git a/docs/API_REFERENCE.rst b/docs/API_REFERENCE.rst deleted file mode 100644 index ccfd427..0000000 --- a/docs/API_REFERENCE.rst +++ /dev/null @@ -1,31 +0,0 @@ -API Reference -============= - -This document provides the complete REST API reference for the Navien Smart Control API, generated from the OpenAPI specification. - -Overview --------- - -The Navien Smart Control API provides a RESTful interface for managing and controlling Navien NWP500 water heaters. The API uses JWT-based authentication and returns JSON responses. - -**Base URL**: ``https://nlus.naviensmartcontrol.com/api/v2.1`` - -**Version**: 2.1.0 - -Authentication --------------- - -The API uses JWT (JSON Web Tokens) for authentication: - -1. **Sign-In**: POST to ``/user/sign-in`` with email and password -2. **Receive Tokens**: Get ``idToken``, ``accessToken``, and ``refreshToken`` -3. **Authorize Requests**: Include ``accessToken`` in the ``authorization`` header (lowercase, no "Bearer" prefix) -4. **Refresh Token**: POST to ``/auth/refresh`` with ``refreshToken`` when ``accessToken`` expires - -.. note:: - The Navien API uses a non-standard authorization header format. The header name is lowercase ``authorization`` (not ``Authorization``), and the token value is sent directly without the ``Bearer`` prefix. - -API Endpoints -------------- - -.. openapi:: openapi.yaml diff --git a/docs/AUTHENTICATION.rst b/docs/AUTHENTICATION.rst deleted file mode 100644 index dad5f24..0000000 --- a/docs/AUTHENTICATION.rst +++ /dev/null @@ -1,605 +0,0 @@ - -Authentication Module -===================== - -The ``nwp500.auth`` module provides comprehensive authentication functionality for the Navien Smart Control REST API. - -.. important:: - **Non-Standard Authorization Header** - - The Navien Smart Control API uses a **non-standard authorization header format**: - - * Header name: **lowercase** ``authorization`` (not ``Authorization``) - * Header value: **raw token** (no ``Bearer`` prefix) - - Example: ``{"authorization": "eyJraWQi..."}`` - - This differs from standard OAuth2/JWT Bearer token authentication. Always use - ``client.get_auth_headers()`` to ensure correct header format. - -Overview --------- - -The Navien Smart Control API uses JWT (JSON Web Tokens) for authentication. The authentication flow is simplified: - -1. **Initialize with Credentials**: Provide email and password to ``NavienAuthClient`` -2. **Automatic Authentication**: Authentication happens when entering the async context -3. **Use Access Token**: Include access token in API requests -4. **Refresh Token**: Refresh the access token before it expires - -Authentication Flow -------------------- - -.. code-block:: - - ┌──────────┐ - │ Client │ - │ created │ - │ with │ - │ creds │ - └────┬─────┘ - │ - │ Async context enter - │ (automatic sign-in) - ▼ - ┌──────────────────┐ - │ Navien API │ - │ POST /sign-in │ - └────┬─────────────┘ - │ - │ Returns: - │ - idToken - │ - accessToken (expires in 3600s) - │ - refreshToken - │ - AWS credentials (optional) - ▼ - ┌──────────┐ - │ Client │ - │ Ready │ - │ to use │ - └────┬─────┘ - │ - │ Use accessToken in API requests - │ authorization: - │ - │ When token expires... - │ POST /auth/refresh - │ { refreshToken } - ▼ - ┌──────────────────┐ - │ New Tokens │ - └──────────────────┘ - -Usage Examples --------------- - -Basic Authentication -^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - from nwp500.auth import NavienAuthClient - - # Create client with credentials - authentication happens automatically - async with NavienAuthClient("user@example.com", "password") as client: - # Already authenticated! - print(f"Welcome {client.current_user.full_name}") - print(f"Logged in as: {client.user_email}") - - # Get authentication headers for API requests - # IMPORTANT: Uses lowercase 'authorization' with raw token (no 'Bearer ') - headers = client.get_auth_headers() - - # Access tokens directly - print(f"Access token expires at: {client.current_tokens.expires_at}") - -Convenience Functions -^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - from nwp500.auth import authenticate, refresh_access_token - - # One-shot authentication - response = await authenticate("user@example.com", "password") - print(f"Authenticated as: {response.user_info.full_name}") - - # One-shot token refresh - new_tokens = await refresh_access_token(response.tokens.refresh_token) - -Automatic Token Management -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - async with NavienAuthClient("user@example.com", "password") as client: - # Client automatically tracks token expiration - # Refresh if needed - valid_tokens = await client.ensure_valid_token() - - # Always use current valid token - if valid_tokens: - headers = client.get_auth_headers() - -API Reference -------------- - -NavienAuthClient -^^^^^^^^^^^^^^^^ - -Main authentication client class. - -**Constructor** - -.. code-block:: python - - NavienAuthClient( - user_id: str, - password: str, - base_url: str = "https://nlus.naviensmartcontrol.com/api/v2.1", - session: Optional[aiohttp.ClientSession] = None, - timeout: int = 30 - ) - -**Parameters:** - * ``user_id``: User email address (required) - * ``password``: User password (required) - * ``base_url``: Base URL for the API - * ``session``: Optional aiohttp session (created automatically if not provided) - * ``timeout``: Request timeout in seconds - -**Note:** - Authentication is performed automatically when entering the async context manager. - You do not need to call ``sign_in()`` manually. - -**Methods:** - -``sign_in(user_id: str, password: str) -> AuthenticationResponse`` - Authenticate user and obtain tokens. - - Raises: - * ``InvalidCredentialsError``: If credentials are invalid - * ``AuthenticationError``: If authentication fails - -``refresh_token(refresh_token: str) -> AuthTokens`` - Refresh access token using refresh token. - - Raises: - * ``TokenRefreshError``: If token refresh fails - -``ensure_valid_token() -> Optional[AuthTokens]`` - Ensure we have a valid access token, refreshing if necessary. - - Returns valid tokens or None if not authenticated. - -**Properties:** - -``is_authenticated: bool`` - Check if client is currently authenticated. - -``current_user: Optional[UserInfo]`` - Get current authenticated user information. - -``current_tokens: Optional[AuthTokens]`` - Get current authentication tokens. - -``user_email: Optional[str]`` - Get the authenticated user's email address. - -Data Classes -^^^^^^^^^^^^ - -AuthenticationResponse -~~~~~~~~~~~~~~~~~~~~~~ - -Complete authentication response. - -.. code-block:: python - - @dataclass - class AuthenticationResponse: - user_info: UserInfo - tokens: AuthTokens - legal: List[Dict[str, Any]] - code: int - message: str - -AuthTokens -~~~~~~~~~~ - -Authentication tokens and metadata. - -.. code-block:: python - - @dataclass - class AuthTokens: - id_token: str - access_token: str - refresh_token: str - authentication_expires_in: int - access_key_id: Optional[str] - secret_key: Optional[str] - session_token: Optional[str] - authorization_expires_in: Optional[int] - issued_at: datetime - -**Properties:** - * ``expires_at: datetime`` - When the token expires - * ``is_expired: bool`` - Whether the token has expired (with 5 min buffer) - * ``time_until_expiry: timedelta`` - Time remaining until expiration - * ``bearer_token: str`` - Formatted "Bearer " for Authorization header - -UserInfo -~~~~~~~~ - -User information from authentication. - -.. code-block:: python - - @dataclass - class UserInfo: - user_type: str - user_first_name: str - user_last_name: str - user_status: str - user_seq: int - -**Properties:** - * ``full_name: str`` - User's full name - -Exceptions -^^^^^^^^^^ - -All exceptions inherit from ``AuthenticationError``. - -**AuthenticationError** - Base exception for authentication errors. - - Attributes: - * ``message: str`` - Error message - * ``code: Optional[int]`` - HTTP status code - * ``response: Optional[Dict]`` - Raw API response - -**InvalidCredentialsError** - Raised when credentials are invalid. - -**TokenExpiredError** - Raised when a token has expired. - -**TokenRefreshError** - Raised when token refresh fails. - -Usage Examples --------------- - -Example 1: Simple Authentication -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import asyncio - from nwp500.auth import NavienAuthClient, InvalidCredentialsError, AuthenticationError - - async def main(): - try: - async with NavienAuthClient("user@example.com", "password") as client: - print(f"Logged in as: {client.current_user.full_name}") - print(f"Token valid until: {client.current_tokens.expires_at}") - except InvalidCredentialsError: - print("Invalid email or password") - except AuthenticationError as e: - print(f"Authentication failed: {e.message}") - - asyncio.run(main()) - -Example 2: Token Refresh -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import asyncio - from nwp500.auth import NavienAuthClient - - async def main(): - async with NavienAuthClient("user@example.com", "password") as client: - # Check token status - if client.current_tokens.is_expired: - print("Token expired, refreshing...") - new_tokens = await client.refresh_token( - client.current_tokens.refresh_token - ) - print("Token refreshed successfully") - else: - print(f"Token valid for: {client.current_tokens.time_until_expiry}") - - asyncio.run(main()) - -Example 3: Long-Running Session -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import asyncio - from nwp500.auth import NavienAuthClient - - async def make_api_request(client, endpoint): - """Make an authenticated API request.""" - # Ensure we have a valid token - tokens = await client.ensure_valid_token() - if not tokens: - raise RuntimeError("Not authenticated") - - headers = client.get_auth_headers() - # Make your API request here... - return headers - - async def main(): - async with NavienAuthClient("user@example.com", "password") as client: - # Make multiple requests over time - for i in range(10): - headers = await make_api_request(client, f"/api/endpoint/{i}") - print(f"Request {i} - authenticated") - await asyncio.sleep(60) # Wait 1 minute between requests - - asyncio.run(main()) - -Example 4: Error Handling -^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import asyncio - from nwp500.auth import ( - NavienAuthClient, - InvalidCredentialsError, - TokenRefreshError, - AuthenticationError - ) - - async def safe_authenticate(email, password): - """Authenticate with comprehensive error handling.""" - try: - async with NavienAuthClient(email, password) as client: - print(f"✅ Successfully authenticated as {client.current_user.full_name}") - return client.current_tokens - - except InvalidCredentialsError as e: - print(f"❌ Invalid credentials") - print(f" Message: {e.message}") - print(f" Code: {e.code}") - return None - - except TokenRefreshError as e: - print(f"❌ Token refresh failed") - print(f" Message: {e.message}") - return None - - except AuthenticationError as e: - print(f"❌ Authentication error") - print(f" Message: {e.message}") - if e.response: - print(f" Response: {e.response}") - return None - - except Exception as e: - print(f"❌ Unexpected error: {str(e)}") - return None - - async def main(): - tokens = await safe_authenticate("user@example.com", "password") - if tokens: - print(f"Token expires at: {tokens.expires_at}") - - asyncio.run(main()) - -Example 5: Session Reuse -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import asyncio - import aiohttp - from nwp500.auth import NavienAuthClient - - async def main(): - # Create a shared session for better performance - async with aiohttp.ClientSession() as session: - # Pass the session to the auth client - async with NavienAuthClient( - "user@example.com", - "password", - session=session - ) as client: - # Use the same session for API requests - headers = client.get_auth_headers() - async with session.get( - "https://nlus.naviensmartcontrol.com/api/v2.1/device/list", - headers=headers, - json={"offset": 0, "count": 20, "userId": "user@example.com"} - ) as resp: - data = await resp.json() - print(f"Devices: {data}") - - asyncio.run(main()) - -Testing -------- - -A test script is provided to verify authentication: - -.. code-block:: bash - - # Run interactive authentication test - python test_auth.py - - # Test convenience functions - python test_auth.py --convenience - -The test will prompt for credentials and verify: - -1. Sign-in functionality -2. Token refresh -3. Automatic token management -4. Bearer token formatting - -Security Considerations ------------------------ - -**Token Storage** - * Never commit tokens to source control - * Store tokens securely (e.g., encrypted storage, environment variables) - * Tokens expire after 3600 seconds (1 hour) by default - -**Credential Management** - * Use environment variables for credentials - * Never hardcode passwords in code - * Consider using a secrets management system - -**Token Refresh** - * Tokens are automatically refreshed when within 5 minutes of expiration - * Always use ``ensure_valid_token()`` for long-running sessions - * Handle ``TokenRefreshError`` gracefully - -**Network Security** - * All API communication uses HTTPS - * Bearer tokens are transmitted in Authorization header - * Session tokens include AWS credentials for IoT communication - -API Endpoints -------------- - -Sign In -^^^^^^^ - -**Endpoint:** ``POST /user/sign-in`` - -**Request:** - -.. code-block:: json - - { - "userId": "user@example.com", - "password": "password" - } - -**Response:** - -.. code-block:: json - - { - "code": 200, - "msg": "SUCCESS", - "data": { - "userInfo": { - "userType": "O", - "userFirstName": "John", - "userLastName": "Doe", - "userStatus": "NORMAL", - "userSeq": 36283 - }, - "legal": [], - "token": { - "idToken": "eyJraWQiOiJ...", - "accessToken": "eyJraWQiOiJ...", - "refreshToken": "eyJjdHkiOiJ...", - "authenticationExpiresIn": 3600, - "accessKeyId": "ASIA...", - "secretKey": "...", - "sessionToken": "IQoJb3...", - "authorizationExpiresIn": 3600 - } - } - } - -Refresh Token -^^^^^^^^^^^^^ - -**Endpoint:** ``POST /auth/refresh`` - -**Request:** - -.. code-block:: json - - { - "refreshToken": "eyJjdHkiOiJ..." - } - -**Response:** - -.. code-block:: json - - { - "code": 200, - "msg": "SUCCESS", - "data": { - "idToken": "eyJraWQiOiJ...", - "accessToken": "eyJraWQiOiJ...", - "refreshToken": "eyJjdHkiOiJ...", - "authenticationExpiresIn": 3600 - } - } - -Troubleshooting ---------------- - -**Invalid Credentials Error** - * Verify email and password are correct - * Check if account is active - * Ensure no typos in credentials - -**Token Refresh Fails** - * Refresh token may have expired (longer lifetime than access token) - * Re-authenticate with credentials - * Check network connectivity - -**Network Errors** - * Verify internet connection - * Check if API endpoint is accessible - * Review firewall settings - -**Timeout Errors** - * Increase timeout value in NavienAuthClient constructor - * Check network latency - * Verify API is responding - -Integration with Other Modules ------------------------------- - -The authentication module integrates with other components: - -**Device API** - Use authenticated tokens to access device information and control: - -.. code-block:: python - - from nwp500.auth import NavienAuthClient - from nwp500.api_client import NavienAPIClient - - async with NavienAuthClient("user@example.com", "password") as auth_client: - # Use with device API - api_client = NavienAPIClient(auth_client=auth_client) - devices = await api_client.list_devices() - print(f"Found {len(devices)} device(s)") - -**MQTT Client** - AWS credentials from authentication enable MQTT connection: - -.. code-block:: python - - from nwp500.auth import NavienAuthClient - from nwp500.mqtt_client import NavienMqttClient - - async with NavienAuthClient("user@example.com", "password") as auth_client: - # Use AWS credentials for MQTT/IoT connection - mqtt_client = NavienMqttClient(auth_client) - await mqtt_client.connect() - print(f"Connected to MQTT: {mqtt_client.client_id}") - -Further Reading ---------------- - -* :doc:`MQTT_MESSAGES` - MQTT protocol documentation -* :doc:`DEVICE_STATUS_FIELDS` - Available device data -* :doc:`MQTT_CLIENT` - MQTT client usage guide - -For questions or issues, please refer to the project repository. diff --git a/docs/AUTO_RECOVERY_QUICK.rst b/docs/AUTO_RECOVERY_QUICK.rst deleted file mode 100644 index 8e585da..0000000 --- a/docs/AUTO_RECOVERY_QUICK.rst +++ /dev/null @@ -1,192 +0,0 @@ -=================================== -Quick Reference: MQTT Auto-Recovery -=================================== - -TL;DR - Just Give Me The Code! -=============================== - -Copy this class into your project for production-ready automatic recovery: - -.. code-block:: python - - import asyncio - from nwp500 import NavienMqttClient - from nwp500.mqtt_client import MqttConnectionConfig - - class ResilientMqttClient: - """MQTT client with automatic recovery from permanent connection failures.""" - - def __init__(self, auth_client, config=None): - self.auth_client = auth_client - self.config = config or MqttConnectionConfig() - self.mqtt_client = None - self.device = None - self.status_callback = None - self.recovery_attempt = 0 - self.max_recovery_attempts = 10 - self.recovery_delay = 60.0 - - async def connect(self, device, status_callback=None): - self.device = device - self.status_callback = status_callback - await self._create_client() - - async def _create_client(self): - if self.mqtt_client and self.mqtt_client.is_connected: - await self.mqtt_client.disconnect() - - self.mqtt_client = NavienMqttClient(self.auth_client, self.config) - self.mqtt_client.on("reconnection_failed", self._handle_recovery) - await self.mqtt_client.connect() - - if self.device and self.status_callback: - await self.mqtt_client.subscribe_device_status( - self.device, self.status_callback - ) - await self.mqtt_client.start_periodic_device_status_requests(self.device) - - async def _handle_recovery(self, attempts): - self.recovery_attempt += 1 - if self.recovery_attempt >= self.max_recovery_attempts: - return # Give up - - await asyncio.sleep(self.recovery_delay) - - try: - await self.auth_client.refresh_token() - await self._create_client() - self.recovery_attempt = 0 # Reset on success - except Exception as e: - # Log the error instead of silently passing - import logging - - logging.getLogger(__name__).warning(f"Recovery attempt failed: {e}") - # Will retry on next reconnection_failed - - async def disconnect(self): - if self.mqtt_client: - await self.mqtt_client.disconnect() - - @property - def is_connected(self): - return self.mqtt_client and self.mqtt_client.is_connected - -**Usage:** - -.. code-block:: python - - client = ResilientMqttClient(auth_client) - await client.connect(device, status_callback=on_status) - - # Your client will now automatically recover from connection failures! - -How It Works -============ - -1. **Normal operation**: MQTT client connects and operates normally -2. **Connection lost**: Client tries to reconnect automatically (10 attempts with exponential backoff) -3. **Reconnection fails**: After 10 attempts (~6 minutes), ``reconnection_failed`` event fires -4. **Auto-recovery kicks in**: - - * Waits 60 seconds - * Refreshes authentication tokens - * Creates new MQTT client - * Restores all subscriptions - * Tries up to 10 recovery cycles - -Configuration -============= - -Tune the behavior: - -.. code-block:: python - - config = MqttConnectionConfig( - max_reconnect_attempts=10, # Built-in reconnection attempts - max_reconnect_delay=120.0, # Max 2 min between attempts - ) - - client = ResilientMqttClient(auth_client, config=config) - client.max_recovery_attempts = 10 # Recovery cycles - client.recovery_delay = 60.0 # Seconds between recovery attempts - -Complete Examples -================= - -See these files for full working examples: - -* ``examples/simple_auto_recovery.py`` - Production-ready pattern (recommended) -* ``examples/auto_recovery_example.py`` - All 4 strategies explained -* ``docs/AUTO_RECOVERY.rst`` - Complete documentation - -Timeline Example -================ - -With default settings: - -.. code-block:: text - - 00:00 - Connection lost - 00:00 - Reconnect attempt 1 (1s delay) - 00:01 - Reconnect attempt 2 (2s delay) - 00:03 - Reconnect attempt 3 (4s delay) - 00:07 - Reconnect attempt 4 (8s delay) - 00:15 - Reconnect attempt 5 (16s delay) - 00:31 - Reconnect attempt 6 (32s delay) - 01:03 - Reconnect attempt 7 (64s delay) - 02:07 - Reconnect attempt 8 (120s delay, capped) - 04:07 - Reconnect attempt 9 (120s delay) - 06:07 - Reconnect attempt 10 (120s delay) - - 06:07 - reconnection_failed event emitted - 06:07 - Recovery cycle 1 starts - 07:07 - Token refresh + client recreation - 07:07 - If successful, back to normal operation - 07:07 - If failed, wait for next reconnection_failed event - - [Process repeats up to max_recovery_attempts times] - -Events You Can Listen To -======================== - -.. code-block:: python - - # Built-in MQTT events - mqtt_client.on('connection_interrupted', lambda err: print(f"Interrupted: {err}")) - mqtt_client.on('connection_resumed', lambda rc, sp: print("Resumed!")) - mqtt_client.on('reconnection_failed', lambda attempts: print(f"Failed after {attempts}")) - - # In ResilientMqttClient, reconnection_failed is handled automatically - -Testing -======= - -Test automatic recovery: - -1. Start your application -2. Disconnect internet for ~2 minutes -3. Reconnect internet -4. Watch automatic recovery in logs - -Production Considerations -========================== - -**DO:** - -* ✅ Use ``ResilientMqttClient`` wrapper -* ✅ Set reasonable ``max_recovery_attempts`` (10-20) -* ✅ Log recovery events for monitoring -* ✅ Send alerts when recovery is triggered -* ✅ Monitor token expiration - -**DON'T:** - -* ❌ Set recovery delay too low (causes server load) -* ❌ Set max_recovery_attempts too high (wastes resources) -* ❌ Ignore the ``reconnection_failed`` event -* ❌ Forget to restore subscriptions after recovery - -Need More Info? -================ - -Read the full documentation: :doc:`AUTO_RECOVERY` diff --git a/docs/EVENT_EMITTER.rst b/docs/EVENT_EMITTER.rst deleted file mode 100644 index 8003884..0000000 --- a/docs/EVENT_EMITTER.rst +++ /dev/null @@ -1,691 +0,0 @@ -Event Emitter -===================== - -.. contents:: - :local: - :depth: 2 - -Overview -======== - -The nwp500 library implements an event-driven architecture that allows multiple listeners to respond to device state changes. This pattern enables cleaner code organization, better separation of concerns, and easier integration with other systems. - -The event emitter provides automatic state change detection, priority-based execution, and full support for both synchronous and asynchronous event handlers. - -Key Features -============ - -- **Multiple Listeners** - Register multiple handlers for the same event -- **Async Support** - Native support for async/await event handlers -- **Priority-Based Execution** - Control the order in which handlers execute -- **One-Time Listeners** - Handlers that automatically remove themselves after first execution -- **Automatic State Detection** - Events fire only when values actually change -- **Thread-Safe** - Safe event emission from MQTT callback threads -- **Dynamic Management** - Add/remove listeners at runtime -- **Event Statistics** - Track event counts and listener registration - -Quick Start -=========== - -Basic Usage ------------ - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienMqttClient - import asyncio - - async def main(): - # Authenticate and create MQTT client - async with NavienAuthClient("email@example.com", "password") as auth_client: - mqtt_client = NavienMqttClient(auth_client) - await mqtt_client.connect() - - # Register event handlers - mqtt_client.on('temperature_changed', handle_temperature) - mqtt_client.on('error_detected', handle_error) - - # Subscribe to device updates - devices = await api_client.list_devices() - device = devices[0] - await mqtt_client.subscribe_device_status(device, lambda s: None) - - # Events will now fire automatically! - await asyncio.sleep(300) # Listen for 5 minutes - - def handle_temperature(old_temp: float, new_temp: float): - """Called when temperature changes.""" - print(f"Temperature changed: {old_temp}°F → {new_temp}°F") - - def handle_error(error_code: str, status): - """Called when error is detected.""" - print(f"Error detected: {error_code}") - - asyncio.run(main()) - -Available Events -================ - -The MQTT client automatically emits the following events: - -Status Events -------------- - -.. list-table:: - :header-rows: 1 - :widths: 30 40 30 - - * - Event Name - - Arguments - - Description - * - ``status_received`` - - ``(status: DeviceStatus)`` - - Raw status update from device - * - ``temperature_changed`` - - ``(old: float, new: float)`` - - DHW temperature changed - * - ``mode_changed`` - - ``(old: int, new: int)`` - - Operation mode changed - * - ``power_changed`` - - ``(old: float, new: float)`` - - Power consumption changed - * - ``heating_started`` - - ``(status: DeviceStatus)`` - - Device started heating - * - ``heating_stopped`` - - ``(status: DeviceStatus)`` - - Device stopped heating - * - ``error_detected`` - - ``(code: str, status: DeviceStatus)`` - - Error code detected - * - ``error_cleared`` - - ``(code: str)`` - - Error code cleared - -Connection Events ------------------ - -.. list-table:: - :header-rows: 1 - :widths: 30 40 30 - - * - Event Name - - Arguments - - Description - * - ``connection_interrupted`` - - ``(error)`` - - MQTT connection lost - * - ``connection_resumed`` - - ``(return_code, session_present)`` - - MQTT connection restored - -Feature Events --------------- - -.. list-table:: - :header-rows: 1 - :widths: 30 40 30 - - * - Event Name - - Arguments - - Description - * - ``feature_received`` - - ``(feature: DeviceFeature)`` - - Device feature/info received - -Usage Patterns -============== - -Simple Handler --------------- - -.. code-block:: python - - def on_temp_change(old_temp: float, new_temp: float): - """Simple synchronous handler.""" - print(f"Temperature: {old_temp}°F → {new_temp}°F") - - mqtt_client.on('temperature_changed', on_temp_change) - -Async Handler -------------- - -.. code-block:: python - - async def save_to_database(old_temp: float, new_temp: float): - """Async handler for I/O operations.""" - async with database.transaction(): - await database.insert_temperature(old_temp, new_temp) - - mqtt_client.on('temperature_changed', save_to_database) - -Multiple Handlers ------------------ - -.. code-block:: python - - # All handlers will be called in order - mqtt_client.on('temperature_changed', log_temperature) - mqtt_client.on('temperature_changed', update_ui) - mqtt_client.on('temperature_changed', send_notification) - -Priority-Based Execution ------------------------- - -Higher priority handlers execute first (default priority is 50): - -.. code-block:: python - - # Critical operations (execute first) - mqtt_client.on('error_detected', emergency_shutdown, priority=100) - - # Normal operations (execute second) - mqtt_client.on('error_detected', log_error, priority=50) - - # Low priority operations (execute last) - mqtt_client.on('error_detected', send_notification, priority=10) - -One-Time Handlers ------------------ - -.. code-block:: python - - def initialize_device(status): - """Called only once, then automatically removed.""" - print(f"Device initialized at {status.dhwTemperature}°F") - - mqtt_client.once('status_received', initialize_device) - -Dynamic Handler Management --------------------------- - -.. code-block:: python - - # Add handler - mqtt_client.on('temperature_changed', handler) - - # Remove specific handler - mqtt_client.off('temperature_changed', handler) - - # Remove all handlers for an event - mqtt_client.off('temperature_changed') - - # Check how many handlers are registered - count = mqtt_client.listener_count('temperature_changed') - print(f"Handlers registered: {count}") - -Wait for Event --------------- - -.. code-block:: python - - # Wait for a specific event - await mqtt_client.wait_for('device_ready', timeout=30) - - # Wait and capture event arguments - old_temp, new_temp = await mqtt_client.wait_for('temperature_changed') - print(f"Temperature changed to {new_temp}°F") - -Integration Examples -==================== - -Home Assistant Integration ---------------------------- - -.. code-block:: python - - async def sync_to_homeassistant(old_temp: float, new_temp: float): - """Sync temperature changes to Home Assistant.""" - await hass.states.async_set( - 'sensor.water_heater_temperature', - new_temp, - { - 'unit_of_measurement': '°F', - 'previous_value': old_temp, - 'device_class': 'temperature' - } - ) - - mqtt_client.on('temperature_changed', sync_to_homeassistant) - -Database Logging ----------------- - -.. code-block:: python - - async def log_all_status_updates(status): - """Log every status update to database.""" - await db.execute(''' - INSERT INTO device_status ( - timestamp, temperature, mode, power, heating - ) VALUES (?, ?, ?, ?, ?) - ''', ( - datetime.now(), - status.dhwTemperature, - status.dhwOperationSetting, - status.currentInstPower, - status.compUse or status.heatUpperUse or status.heatLowerUse - )) - - mqtt_client.on('status_received', log_all_status_updates, priority=10) - -Alert System ------------- - -.. code-block:: python - - def send_critical_alert(error_code: str, status): - """Send push notification for critical errors.""" - if error_code in ['E001', 'E002', 'E003']: - push_service.send( - title="Water Heater Critical Error", - message=f"Error code {error_code} requires attention", - priority="high" - ) - - mqtt_client.on('error_detected', send_critical_alert, priority=100) - -Statistics Tracking -------------------- - -.. code-block:: python - - class DeviceStatistics: - def __init__(self): - self.heating_cycles = 0 - self.total_heating_time = 0 - self.heating_start_time = None - - def on_heating_started(self, status): - """Track when heating starts.""" - self.heating_start_time = datetime.now() - self.heating_cycles += 1 - - def on_heating_stopped(self, status): - """Calculate heating duration.""" - if self.heating_start_time: - duration = (datetime.now() - self.heating_start_time).total_seconds() - self.total_heating_time += duration - self.heating_start_time = None - - def get_average_cycle_time(self): - """Calculate average heating cycle duration.""" - if self.heating_cycles == 0: - return 0 - return self.total_heating_time / self.heating_cycles - - stats = DeviceStatistics() - mqtt_client.on('heating_started', stats.on_heating_started) - mqtt_client.on('heating_stopped', stats.on_heating_stopped) - -API Reference -============= - -EventEmitter Methods --------------------- - -The ``NavienMqttClient`` inherits from ``EventEmitter`` and provides these methods: - -on(event, callback, priority=50) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Register an event listener. - -:param event: Event name to listen for -:type event: str -:param callback: Function to call when event is emitted (can be sync or async) -:type callback: Callable -:param priority: Execution priority (higher values execute first, default: 50) -:type priority: int -:return: None - -.. code-block:: python - - mqtt_client.on('temperature_changed', handle_temp_change) - mqtt_client.on('error_detected', critical_handler, priority=100) - -once(event, callback, priority=50) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Register a one-time event listener that automatically removes itself after first execution. - -:param event: Event name to listen for -:type event: str -:param callback: Function to call when event is emitted -:type callback: Callable -:param priority: Execution priority (default: 50) -:type priority: int -:return: None - -.. code-block:: python - - mqtt_client.once('device_ready', initialize) - -off(event, callback=None) -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Remove event listener(s). - -:param event: Event name -:type event: str -:param callback: Specific callback to remove, or None to remove all for event -:type callback: Optional[Callable] -:return: Number of listeners removed -:rtype: int - -.. code-block:: python - - # Remove specific listener - mqtt_client.off('temperature_changed', handler) - - # Remove all listeners for event - mqtt_client.off('temperature_changed') - -emit(event, \*args, \*\*kwargs) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Emit an event to all registered listeners (async method). - -:param event: Event name to emit -:type event: str -:param args: Positional arguments to pass to listeners -:param kwargs: Keyword arguments to pass to listeners -:return: Number of listeners that were called -:rtype: int - -.. note:: - This method is called automatically by the MQTT client. You typically don't need to call it directly. - -.. code-block:: python - - # Usually called internally, but you can emit custom events - await mqtt_client.emit('custom_event', data1, data2) - -wait_for(event, timeout=None) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Wait for an event to be emitted (async method). - -:param event: Event name to wait for -:type event: str -:param timeout: Maximum time to wait in seconds (None = wait forever) -:type timeout: Optional[float] -:return: Tuple of arguments passed to the event -:rtype: tuple -:raises asyncio.TimeoutError: If timeout is reached - -.. code-block:: python - - # Wait for device to be ready - await mqtt_client.wait_for('device_ready', timeout=30) - - # Wait and capture event data - old_temp, new_temp = await mqtt_client.wait_for('temperature_changed') - -listener_count(event) -^^^^^^^^^^^^^^^^^^^^^ - -Get the number of listeners registered for an event. - -:param event: Event name -:type event: str -:return: Number of registered listeners -:rtype: int - -.. code-block:: python - - count = mqtt_client.listener_count('temperature_changed') - print(f"Handlers registered: {count}") - -event_count(event) -^^^^^^^^^^^^^^^^^^ - -Get the number of times an event has been emitted. - -:param event: Event name -:type event: str -:return: Number of times event was emitted -:rtype: int - -.. code-block:: python - - count = mqtt_client.event_count('temperature_changed') - print(f"Event emitted {count} times") - -event_names() -^^^^^^^^^^^^^ - -Get list of all registered event names. - -:return: List of event names with active listeners -:rtype: list[str] - -.. code-block:: python - - events = mqtt_client.event_names() - print(f"Active events: {', '.join(events)}") - -remove_all_listeners(event=None) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Remove all listeners for specific event or all events. - -:param event: Event name, or None to remove all listeners -:type event: Optional[str] -:return: Number of listeners removed -:rtype: int - -.. code-block:: python - - # Remove all listeners for one event - mqtt_client.remove_all_listeners('temperature_changed') - - # Remove ALL listeners - mqtt_client.remove_all_listeners() - -Best Practices -============== - -Do's ------ - -- **Register handlers before connecting** - Set up event handlers before calling ``connect()`` -- **Use priority for critical operations** - High priority (>50) for safety/shutdown logic -- **Keep handlers lightweight** - Event handlers should be fast; delegate heavy work -- **Use async for I/O** - Use async handlers for database, network, or file operations -- **Remove handlers when done** - Clean up handlers to prevent memory leaks -- **Check event counts for debugging** - Use ``listener_count()`` and ``event_count()`` to debug - -.. code-block:: python - - # Good practice - mqtt_client.on('error_detected', emergency_shutdown, priority=100) - mqtt_client.on('temperature_changed', async_db_save) - await mqtt_client.connect() - -Don'ts -------- - -- **Don't block in sync handlers** - Avoid ``time.sleep()`` or long computations -- **Don't register from MQTT threads** - Always register from main thread -- **Don't raise uncaught exceptions** - Exceptions are logged but break the handler -- **Don't register duplicates** - Check if handler is already registered -- **Don't forget to subscribe** - Must call ``subscribe_device_status()`` to receive events - -.. code-block:: python - - # Bad practice - def bad_handler(old, new): - time.sleep(10) # Blocks event loop! - raise Exception() # Breaks handler execution - -Troubleshooting -=============== - -Handler Not Being Called -------------------------- - -**Check 1: Is the handler registered?** - -.. code-block:: python - - count = mqtt_client.listener_count('temperature_changed') - if count == 0: - print("No handlers registered!") - -**Check 2: Are you subscribed to device updates?** - -.. code-block:: python - - # Must subscribe to receive events - await mqtt_client.subscribe_device_status(device, lambda s: None) - -**Check 3: Is the event being emitted?** - -.. code-block:: python - - emissions = mqtt_client.event_count('temperature_changed') - print(f"Event emitted {emissions} times") - -"No Running Event Loop" Error ------------------------------- - -This error occurs when trying to emit events before ``connect()`` is called. - -**Solution:** - -.. code-block:: python - - # Correct order - mqtt_client = NavienMqttClient(auth_client) - await mqtt_client.connect() # This captures the event loop - mqtt_client.on('temperature_changed', handler) - await mqtt_client.subscribe_device_status(device, callback) - -Events Firing Multiple Times ------------------------------ - -This usually happens when subscribing to the same device multiple times. - -**Solution:** - -.. code-block:: python - - # Subscribe only once - await mqtt_client.subscribe_device_status(device, callback) - - # Or use once() for one-time handlers - mqtt_client.once('temperature_changed', handler) - -Enable Debug Logging --------------------- - -.. code-block:: python - - import logging - - # Enable debug logging - logging.basicConfig(level=logging.DEBUG) - logging.getLogger('nwp500.events').setLevel(logging.DEBUG) - logging.getLogger('nwp500.mqtt_client').setLevel(logging.DEBUG) - -Trace All Events ----------------- - -.. code-block:: python - - def trace_all_events(event_name): - """Create a tracer for an event.""" - def tracer(*args, **kwargs): - print(f"[{event_name}] args={args}, kwargs={kwargs}") - return tracer - - # Trace specific events - for event in ['status_received', 'temperature_changed', 'error_detected']: - mqtt_client.on(event, trace_all_events(event)) - -Technical Details -================= - -Thread Safety -------------- - -The event emitter implementation is thread-safe: - -- MQTT callbacks run in separate threads (e.g., 'Dummy-1') -- Event handlers always execute in the main event loop -- Thread-safe scheduling via ``asyncio.run_coroutine_threadsafe()`` -- The event loop reference is captured during ``connect()`` - -State Change Detection ----------------------- - -The MQTT client automatically detects state changes by comparing the current device status with the previous status. Events are only emitted when values actually change: - -.. code-block:: python - - # Temperature change detection (internal) - if status.dhwTemperature != prev.dhwTemperature: - await self.emit('temperature_changed', - prev.dhwTemperature, - status.dhwTemperature) - -Error Handling --------------- - -Errors in event handlers are isolated and logged but don't affect other handlers: - -.. code-block:: python - - # Even if handler1 raises an exception, handler2 still executes - mqtt_client.on('temperature_changed', handler1) # May raise exception - mqtt_client.on('temperature_changed', handler2) # Still executes - -Performance ------------ - -- **Event emission:** O(n) where n = number of listeners -- **Listener registration:** O(n log n) due to priority sorting -- **Memory overhead:** ~100 bytes per registered listener -- **No performance impact** when events are not used - -Backward Compatibility -====================== - -The event emitter pattern is **fully backward compatible** with existing code: - -.. code-block:: python - - # Traditional callback pattern (still works) - async def on_status(status: DeviceStatus): - print(f"Temperature: {status.dhwTemperature}°F") - - await mqtt_client.subscribe_device_status(device, on_status) - - # New event emitter pattern (works alongside) - mqtt_client.on('temperature_changed', handle_temp_change) - -Both patterns can be used simultaneously in the same application. - -See Also -======== - -- :doc:`MQTT_CLIENT` - MQTT client documentation -- :doc:`DEVICE_STATUS_FIELDS` - Complete status field reference -- :doc:`API_CLIENT` - REST API client -- :doc:`AUTHENTICATION` - Authentication and tokens - -Example Code -============ - -Complete working examples can be found in the ``examples/`` directory: - -- ``examples/event_emitter_demo.py`` - Comprehensive event emitter demonstration - -For unit tests and additional usage patterns, see: - -- ``tests/test_events.py`` - Event emitter unit tests - -.. note:: - This feature is part of Phase 1 of the event emitter implementation. Future phases may add additional features like event filtering, wildcards, and event history. diff --git a/docs/MQTT_CLIENT.rst b/docs/MQTT_CLIENT.rst deleted file mode 100644 index f784740..0000000 --- a/docs/MQTT_CLIENT.rst +++ /dev/null @@ -1,1490 +0,0 @@ -MQTT Client Documentation -========================= - -Overview --------- - -The Navien MQTT Client provides real-time communication with Navien -NWP500 water heaters using AWS IoT Core WebSocket connections. It -enables: - -- Real-time device status monitoring -- Device control (temperature, mode, power) -- Bidirectional communication over MQTT -- Automatic reconnection and error handling -- **Non-blocking async operations** (compatible with Home Assistant and other async applications) - -The client is designed to be fully non-blocking and integrates seamlessly -with async event loops, avoiding the "blocking I/O detected" warnings -commonly seen in Home Assistant and similar applications. - -Prerequisites -------------- - -.. code:: bash - - pip install awsiotsdk>=1.20.0 - -Usage Examples --------------- - -1. Basic Connection -~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - import asyncio - from nwp500 import NavienAuthClient, NavienMqttClient - - async def main(): - # Authenticate - async with NavienAuthClient("email@example.com", "password") as auth_client: - - # Create MQTT client with auth client - mqtt_client = NavienMqttClient(auth_client) - - # Connect to AWS IoT - await mqtt_client.connect() - print(f"Connected! Client ID: {mqtt_client.client_id}") - - # Disconnect when done - await mqtt_client.disconnect() - - asyncio.run(main()) - -2. Subscribe to Device Messages -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - def message_handler(topic: str, message: dict): - print(f"Received message on {topic}") - if 'response' in message: - status = message['response'].get('status', {}) - print(f"DHW Temperature: {status.get('dhwTemperature')}°F") - - # Subscribe to all messages from a device - await mqtt_client.subscribe_device(device, message_handler) - -3. Request Device Status -~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - # Request current device status - await mqtt_client.request_device_status(device) - -4. Control Device -~~~~~~~~~~~~~~~~~ - -.. code:: python - - # Turn device on/off - await mqtt_client.set_power(device, power_on=True) - - # Set DHW mode (1=Heat Pump Only, 2=Electric Only, 3=Energy Saver, 4=High Demand, 5=Vacation) - await mqtt_client.set_dhw_mode(device, mode_id=3) - - # Set vacation mode with duration - await mqtt_client.set_dhw_mode(device, mode_id=5, vacation_days=7) - - # Set target temperature - await mqtt_client.set_dhw_temperature(device, temperature=120) - -Complete Example ----------------- - -.. code:: python - - import asyncio - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient - - async def main(): - # Step 1: Authenticate - async with NavienAuthClient("email@example.com", "password") as auth_client: - - # Step 2: Get device list - api_client = NavienAPIClient(auth_client=auth_client) - devices = await api_client.list_devices() - - device = devices[0] - - print(f"Connecting to device: {device.device_info.device_name}") - - # Step 3: Connect MQTT - mqtt_client = NavienMqttClient(auth_client) - await mqtt_client.connect() - - # Step 4: Subscribe and send commands - messages_received = [] - - def handle_message(topic, message): - messages_received.append(message) - print(f"Message: {message}") - - await mqtt_client.subscribe_device(device, handle_message) - - # Signal app connection - await mqtt_client.signal_app_connection(device) - - # Request status - await mqtt_client.request_device_status(device) - - # Wait for responses - await asyncio.sleep(10) - - print(f"Received {len(messages_received)} messages") - - # Step 5: Disconnect - await mqtt_client.disconnect() - - asyncio.run(main()) - -API Reference -------------- - -NavienMqttClient -~~~~~~~~~~~~~~~~ - -Constructor -^^^^^^^^^^^ - -.. code:: python - - NavienMqttClient( - auth_client: NavienAuthClient, - config: Optional[MqttConnectionConfig] = None, - on_connection_interrupted: Optional[Callable] = None, - on_connection_resumed: Optional[Callable] = None - ) - -**Parameters:** - ``auth_client``: Authenticated NavienAuthClient -instance (required) - ``config``: Optional connection configuration - -``on_connection_interrupted``: Callback for connection interruption - -``on_connection_resumed``: Callback for connection resumption - -Automatic Reconnection -^^^^^^^^^^^^^^^^^^^^^^ - -The MQTT client automatically reconnects when the connection is interrupted, -using exponential backoff to avoid overwhelming the server. - -**Reconnection Behavior:** - -- Automatically triggered when connection is lost (unless manually disconnected) -- Uses exponential backoff: 1s, 2s, 4s, 8s, 16s, ... up to max delay -- Continues until max attempts reached or connection restored -- All subscriptions are maintained by AWS IoT SDK - -**Default Configuration:** - -.. code:: python - - config = MqttConnectionConfig( - auto_reconnect=True, # Enable automatic reconnection - max_reconnect_attempts=10, # Maximum retry attempts - initial_reconnect_delay=1.0, # Initial delay in seconds - max_reconnect_delay=120.0, # Maximum delay cap - reconnect_backoff_multiplier=2.0 # Exponential multiplier - ) - -**Custom Reconnection Example:** - -.. code:: python - - from nwp500.mqtt_client import MqttConnectionConfig - - # Create custom configuration - config = MqttConnectionConfig( - auto_reconnect=True, - max_reconnect_attempts=15, - initial_reconnect_delay=2.0, # Start with 2 seconds - max_reconnect_delay=60.0, # Cap at 1 minute - ) - - # Callbacks to monitor reconnection - def on_interrupted(error): - print(f"Connection lost: {error}") - - def on_resumed(return_code, session_present): - print(f"Reconnected! Code: {return_code}") - - # Create client with custom config - mqtt_client = NavienMqttClient( - auth_client, - config=config, - on_connection_interrupted=on_interrupted, - on_connection_resumed=on_resumed - ) - - await mqtt_client.connect() - - # Check reconnection status - if mqtt_client.is_reconnecting: - print(f"Reconnecting: attempt {mqtt_client.reconnect_attempts}") - -**Properties:** - -- ``is_connected`` - Check if currently connected -- ``is_reconnecting`` - Check if reconnection in progress -- ``reconnect_attempts`` - Number of reconnection attempts made - -Command Queue -^^^^^^^^^^^^^ - -The MQTT client automatically queues commands sent while disconnected and sends -them when the connection is restored. This ensures no commands are lost during -network interruptions. - -**Queue Behavior:** - -- Commands are queued automatically when sent while disconnected -- Queue is processed in FIFO (first-in-first-out) order on reconnection -- Integrates seamlessly with automatic reconnection -- Configurable queue size with automatic oldest-command-dropping when full -- No user intervention required - -**Default Configuration:** - -.. code:: python - - config = MqttConnectionConfig( - enable_command_queue=True, # Enable command queuing - max_queued_commands=100, # Maximum queue size - ) - -**Queue Usage Example:** - -.. code:: python - - from nwp500.mqtt_client import MqttConnectionConfig - - # Configure command queue - config = MqttConnectionConfig( - enable_command_queue=True, - max_queued_commands=50, # Limit to 50 commands - auto_reconnect=True, - ) - - mqtt_client = NavienMqttClient(auth_client, config=config) - await mqtt_client.connect() - - # Commands sent while disconnected are automatically queued - await mqtt_client.request_device_status(device) # Queued if disconnected - await mqtt_client.set_dhw_temperature_display(device, 130) # Also queued - - # Check queue status - queue_size = mqtt_client.queued_commands_count - print(f"Commands queued: {queue_size}") - - # Clear queue manually if needed - cleared = mqtt_client.clear_command_queue() - print(f"Cleared {cleared} commands") - -**Disable Command Queue:** - -.. code:: python - - # Disable queuing if desired - config = MqttConnectionConfig( - enable_command_queue=False, # Disabled - ) - - mqtt_client = NavienMqttClient(auth_client, config=config) - - # Now commands sent while disconnected will raise RuntimeError - -**Properties:** - -- ``queued_commands_count`` - Get number of commands currently queued - -**Methods:** - -- ``clear_command_queue()`` - Clear all queued commands, returns count cleared - -Connection Methods -^^^^^^^^^^^^^^^^^^ - -connect() -''''''''' - -.. code:: python - - await mqtt_client.connect() -> bool - -Establish WebSocket connection to AWS IoT Core. - -**Returns:** ``True`` if connection successful - -**Raises:** ``Exception`` if connection fails - -disconnect() -'''''''''''' - -.. code:: python - - await mqtt_client.disconnect() - -Disconnect from AWS IoT Core and cleanup resources. - -Subscription Methods -^^^^^^^^^^^^^^^^^^^^ - -subscribe() -''''''''''' - -.. code:: python - - await mqtt_client.subscribe( - topic: str, - callback: Callable[[str, Dict], None], - qos: mqtt.QoS = mqtt.QoS.AT_LEAST_ONCE - ) -> int - -Subscribe to an MQTT topic. - -**Parameters:** - ``topic``: MQTT topic (supports wildcards like ``#`` -and ``+``) - ``callback``: Function called when messages arrive -``(topic, message) -> None`` - ``qos``: Quality of Service level - -**Returns:** Subscription packet ID - -subscribe_device() -'''''''''''''''''' - -.. code:: python - - await mqtt_client.subscribe_device( - device: Device, - callback: Callable[[str, Dict], None] - ) -> int - -Subscribe to all messages from a specific device. - -**Parameters:** - ``device``: Device object from API client - -``callback``: Message handler function - -**Returns:** Subscription packet ID - -unsubscribe() -''''''''''''' - -.. code:: python - - await mqtt_client.unsubscribe(topic: str) - -Unsubscribe from an MQTT topic. - -Publishing Methods -^^^^^^^^^^^^^^^^^^ - -publish() -''''''''' - -.. code:: python - - await mqtt_client.publish( - topic: str, - payload: Dict[str, Any], - qos: mqtt.QoS = mqtt.QoS.AT_LEAST_ONCE - ) -> int - -Publish a message to an MQTT topic. - -**Parameters:** - ``topic``: MQTT topic - ``payload``: Message payload -(will be JSON-encoded) - ``qos``: Quality of Service level - -**Returns:** Publish packet ID - -Device Command Methods -^^^^^^^^^^^^^^^^^^^^^^ - -Complete MQTT API Reference -'''''''''''''''''''''''''''' - -This section provides a comprehensive reference of all available MQTT client methods for requesting data and controlling devices. - -**Request Methods & Corresponding Subscriptions** - -+------------------------------------+---------------------------------------+----------------------------------------+ -| Request Method | Subscribe Method | Response Type | -+====================================+=======================================+========================================+ -| ``request_device_status()`` | ``subscribe_device_status()`` | ``DeviceStatus`` object | -+------------------------------------+---------------------------------------+----------------------------------------+ -| ``request_device_info()`` | ``subscribe_device_feature()`` | ``DeviceFeature`` object | -+------------------------------------+---------------------------------------+----------------------------------------+ -| ``request_energy_usage()`` | ``subscribe_energy_usage()`` | ``EnergyUsageResponse`` object | -+------------------------------------+---------------------------------------+----------------------------------------+ -| ``set_power()`` | ``subscribe_device_status()`` | Updated ``DeviceStatus`` | -+------------------------------------+---------------------------------------+----------------------------------------+ -| ``set_dhw_mode()`` | ``subscribe_device_status()`` | Updated ``DeviceStatus`` | -+------------------------------------+---------------------------------------+----------------------------------------+ -| ``set_dhw_temperature()`` | ``subscribe_device_status()`` | Updated ``DeviceStatus`` | -+------------------------------------+---------------------------------------+----------------------------------------+ -| ``set_dhw_temperature_display()`` | ``subscribe_device_status()`` | Updated ``DeviceStatus`` | -+------------------------------------+---------------------------------------+----------------------------------------+ - -**Generic Subscriptions** - -+------------------------------------+---------------------------------------+----------------------------------------+ -| Method | Purpose | Response Type | -+====================================+=======================================+========================================+ -| ``subscribe_device()`` | Subscribe to all device messages | Raw ``dict`` (all message types) | -+------------------------------------+---------------------------------------+----------------------------------------+ -| ``subscribe()`` | Subscribe to any MQTT topic | Raw ``dict`` | -+------------------------------------+---------------------------------------+----------------------------------------+ - -request_device_status() -''''''''''''''''''''''' - -.. code:: python - - await mqtt_client.request_device_status(device: Device) -> int - -Request current device status including temperatures, operation mode, power consumption, and error codes. - -**Command:** ``16777219`` - -**Topic:** ``cmd/{device_type}/navilink-{device_id}/st`` - -**Response:** Subscribe with ``subscribe_device_status()`` to receive ``DeviceStatus`` objects - -**Example:** - -.. code:: python - - def on_status(status: DeviceStatus): - print(f"Water Temp: {status.dhwTemperature}°F") - print(f"Mode: {status.operationMode}") - print(f"Power: {status.currentInstPower}W") - - await mqtt_client.subscribe_device_status(device, on_status) - await mqtt_client.request_device_status(device) - -request_device_info() -''''''''''''''''''''' - -.. code:: python - - await mqtt_client.request_device_info(device: Device) -> int - -Request device information including firmware version, serial number, temperature limits, and capabilities. - -**Command:** ``16777217`` - -**Topic:** ``cmd/{device_type}/navilink-{device_id}/st/did`` - -**Response:** Subscribe with ``subscribe_device_feature()`` to receive ``DeviceFeature`` objects - -**Example:** - -.. code:: python - - def on_feature(feature: DeviceFeature): - print(f"Firmware: {feature.controllerSwVersion}") - print(f"Serial: {feature.controllerSerialNumber}") - print(f"Temp Range: {feature.dhwTemperatureMin}-{feature.dhwTemperatureMax}°F") - - await mqtt_client.subscribe_device_feature(device, on_feature) - await mqtt_client.request_device_info(device) - -request_energy_usage() -'''''''''''''''''''''' - -.. code:: python - - await mqtt_client.request_energy_usage(device: Device, year: int, months: list[int]) -> int - -Request historical daily energy usage data for specified month(s). Returns heat pump and electric heating element consumption with daily breakdown. - -**Command:** ``16777225`` - -**Topic:** ``cmd/{device_type}/navilink-{device_id}/st/energy-usage-daily-query/rd`` - -**Response:** Subscribe with ``subscribe_energy_usage()`` to receive ``EnergyUsageResponse`` objects - -**Parameters:** - -- ``year``: Year to query (e.g., 2025) -- ``months``: List of months to query (1-12). Can request multiple months. - -**Example:** - -.. code:: python - - def on_energy(energy: EnergyUsageResponse): - print(f"Total Usage: {energy.total.total_usage} Wh") - print(f"Heat Pump: {energy.total.heat_pump_percentage:.1f}%") - for day in energy.daily: - print(f"Day {day.day}: {day.total_usage} Wh") - - await mqtt_client.subscribe_energy_usage(device, on_energy) - await mqtt_client.request_energy_usage(device, year=2025, months=[9]) - -set_power() -''''''''''' - -.. code:: python - - await mqtt_client.set_power(device: Device, power_on: bool) -> int - -Turn device on or off. - -**Command:** ``33554433`` - -**Mode:** ``power-on`` or ``power-off`` - -**Response:** Device status is updated; subscribe with ``subscribe_device_status()`` to see changes - -set_dhw_mode() -'''''''''''''' - -.. code:: python - - await mqtt_client.set_dhw_mode(device: Device, mode_id: int) -> int - -Set DHW (Domestic Hot Water) operation mode. This sets the ``dhwOperationSetting`` field, which determines what heating mode the device will use when it needs to heat water. - -**Command:** ``33554433`` - -**Mode:** ``dhw-mode`` - -**Mode IDs (command values):** - -* ``1``: Heat Pump Only (most efficient, longest recovery) -* ``2``: Electric Only (least efficient, fastest recovery) -* ``3``: Energy Saver (default, balanced - Hybrid: Efficiency) -* ``4``: High Demand (faster recovery - Hybrid: Boost) -* ``5``: Vacation (suspend heating for 0-99 days) - -**Response:** Device status is updated; subscribe with ``subscribe_device_status()`` to see changes - -**Important:** Setting the mode updates ``dhwOperationSetting`` but does not immediately change ``operationMode``. The ``operationMode`` field reflects the device's current operational state and changes automatically when the device starts/stops heating. See :doc:`DEVICE_STATUS_FIELDS` for details on the relationship between these fields. - -set_dhw_temperature() -''''''''''''''''''''' - -.. code:: python - - await mqtt_client.set_dhw_temperature(device: Device, temperature: int) -> int - -Set DHW target temperature using the **MESSAGE value** (20°F lower than display). - -**Command:** ``33554433`` - -**Mode:** ``dhw-temperature`` - -**Parameters:** - -- ``temperature``: Target temperature in Fahrenheit (message value, not display value) - -**Response:** Device status is updated; subscribe with ``subscribe_device_status()`` to see changes - -**Important:** The temperature in the message is 20°F lower than what displays on the device/app: - -- Message value 120°F → Display shows 140°F -- Message value 130°F → Display shows 150°F - -set_dhw_temperature_display() -'''''''''''''''''''''''''''''' - -.. code:: python - - await mqtt_client.set_dhw_temperature_display(device: Device, display_temperature: int) -> int - -Set DHW target temperature using the **DISPLAY value** (what you see on device/app). This is a convenience method that automatically converts display temperature to message value. - -**Parameters:** - -- ``display_temperature``: Target temperature as shown on display/app (Fahrenheit) - -**Response:** Device status is updated; subscribe with ``subscribe_device_status()`` to see changes - -**Example:** - -.. code:: python - - # Set display temperature to 140°F (sends 120°F in message) - await mqtt_client.set_dhw_temperature_display(device, 140) - -signal_app_connection() -''''''''''''''''''''''' - -.. code:: python - - await mqtt_client.signal_app_connection(device: Device) -> int - -Signal that the app has connected. - -**Topic:** ``evt/{device_type}/navilink-{device_id}/app-connection`` - -Subscription Methods -'''''''''''''''''''' - -subscribe_device_status() -......................... - -.. code:: python - - await mqtt_client.subscribe_device_status( - device: Device, - callback: Callable[[DeviceStatus], None] - ) -> int - -Subscribe to device status messages with automatic parsing into ``DeviceStatus`` objects. Use this after calling ``request_device_status()`` or any control commands to receive updates. - -**Emits Events:** - -- ``status_received``: Every status update (DeviceStatus) -- ``temperature_changed``: Temperature changed (old_temp, new_temp) -- ``mode_changed``: Operation mode changed (old_mode, new_mode) -- ``power_changed``: Power consumption changed (old_power, new_power) -- ``heating_started``: Device started heating (status) -- ``heating_stopped``: Device stopped heating (status) -- ``error_detected``: Error code detected (error_code, status) -- ``error_cleared``: Error code cleared (error_code) - -subscribe_device_feature() -.......................... - -.. code:: python - - await mqtt_client.subscribe_device_feature( - device: Device, - callback: Callable[[DeviceFeature], None] - ) -> int - -Subscribe to device feature/info messages with automatic parsing into ``DeviceFeature`` objects. Use this after calling ``request_device_info()`` to receive device capabilities and firmware info. - -**Emits Events:** - -- ``feature_received``: Feature/info received (DeviceFeature) - -subscribe_energy_usage() -........................ - -.. code:: python - - await mqtt_client.subscribe_energy_usage( - device: Device, - callback: Callable[[EnergyUsageResponse], None] - ) -> int - -Subscribe to energy usage query responses with automatic parsing into ``EnergyUsageResponse`` objects. Use this after calling ``request_energy_usage()`` to receive historical energy data. - -subscribe_device() -.................. - -.. code:: python - - await mqtt_client.subscribe_device( - device: Device, - callback: Callable[[str, dict], None] - ) -> int - -Subscribe to all messages from a device (no parsing). Receives all message types as raw dictionaries. Use the specific subscription methods above for automatic parsing. - -subscribe() -........... - -.. code:: python - - await mqtt_client.subscribe( - topic: str, - callback: Callable[[str, dict], None], - qos: mqtt.QoS = mqtt.QoS.AT_LEAST_ONCE - ) -> int - -Subscribe to any MQTT topic. Supports wildcards (``#``, ``+``). Receives raw dictionary messages. - -Periodic Request Methods (Optional) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -These optional helper methods automate regular device updates. - -start_periodic_requests() -''''''''''''''''''''''''' - -.. code:: python - - await mqtt_client.start_periodic_requests( - device: Device, - request_type: PeriodicRequestType = PeriodicRequestType.DEVICE_STATUS, - period_seconds: float = 300.0 - ) -> None - -Start sending periodic requests for device information or status. - -**Parameters:** - ``device``: Device object from API client - -``request_type``: Type of request (``PeriodicRequestType.DEVICE_INFO`` -or ``PeriodicRequestType.DEVICE_STATUS``) - ``period_seconds``: Time -between requests in seconds (default: 300 = 5 minutes) - -**Example:** - -.. code:: python - - from nwp500 import PeriodicRequestType - - # Default: periodic status requests every 5 minutes - await mqtt_client.start_periodic_requests(device) - - # Periodic device info requests - await mqtt_client.start_periodic_requests( - device, - request_type=PeriodicRequestType.DEVICE_INFO - ) - - # Custom period (1 minute) - await mqtt_client.start_periodic_requests( - device, - period_seconds=60 - ) - - # Both types simultaneously - await mqtt_client.start_periodic_requests( - device, - request_type=PeriodicRequestType.DEVICE_STATUS, - period_seconds=300 - ) - await mqtt_client.start_periodic_requests( - device, - request_type=PeriodicRequestType.DEVICE_INFO, - period_seconds=600 - ) - -**Notes:** -- Only one task per request type per device -- Tasks automatically stop when client disconnects -- Continues running even if connection is interrupted (skips requests when disconnected) - -stop_periodic_requests() -'''''''''''''''''''''''' - -.. code:: python - - await mqtt_client.stop_periodic_requests( - device: Device, - request_type: Optional[PeriodicRequestType] = None - ) -> None - -Stop sending periodic requests for a device. - -**Parameters:** - ``device``: Device object from API client - -``request_type``: Type to stop. If None, stops all types for this -device. - -**Example:** - -.. code:: python - - # Stop specific type - await mqtt_client.stop_periodic_requests( - device, - PeriodicRequestType.DEVICE_STATUS - ) - - # Stop all types for device - await mqtt_client.stop_periodic_requests(device) - -Convenience Methods -''''''''''''''''''' - -For ease of use, these wrapper methods are also available: - -**start_periodic_device_info_requests()** - -.. code-block:: python - - await mqtt_client.start_periodic_device_info_requests( - device: Device, - period_seconds: float = 300.0 - ) -> None - -**start_periodic_device_status_requests()** - -.. code-block:: python - - await mqtt_client.start_periodic_device_status_requests( - device: Device, - period_seconds: float = 300.0 - ) -> None - -**stop_periodic_device_info_requests()** - -.. code-block:: python - - await mqtt_client.stop_periodic_device_info_requests(device: Device) -> None - -**stop_periodic_device_status_requests()** - -.. code-block:: python - - await mqtt_client.stop_periodic_device_status_requests(device: Device) -> None - -stop_all_periodic_tasks() -''''''''''''''''''''''''' - -.. code-block:: python - - await mqtt_client.stop_all_periodic_tasks() -> None - -Stop all periodic request tasks. This is automatically called when -disconnecting. - -**Example:** - -.. code-block:: python - - await mqtt_client.stop_all_periodic_tasks() - -Properties -^^^^^^^^^^ - -is_connected -'''''''''''' - -.. code:: python - - mqtt_client.is_connected -> bool - -Check if client is connected to AWS IoT. - -client_id -''''''''' - -.. code:: python - - mqtt_client.client_id -> str - -Get the MQTT client ID. - -session_id -'''''''''' - -.. code:: python - - mqtt_client.session_id -> str - -Get the current session ID. - -MqttConnectionConfig -~~~~~~~~~~~~~~~~~~~~ - -Configuration for MQTT connection. - -.. code:: python - - MqttConnectionConfig( - endpoint: str = "a1t30mldyslmuq-ats.iot.us-east-1.amazonaws.com", - region: str = "us-east-1", - client_id: Optional[str] = None, - clean_session: bool = True, - keep_alive_secs: int = 1200 - ) - -**Parameters:** - ``endpoint``: AWS IoT endpoint - ``region``: AWS -region - ``client_id``: MQTT client ID (auto-generated if not provided) -- ``clean_session``: Start with clean session - ``keep_alive_secs``: -Keep-alive interval - -MQTT Topics ------------ - -Command Topics -~~~~~~~~~~~~~~ - -Commands are sent to topics with this structure: - -:: - - cmd/{device_type}/navilink-{device_id}/{command_suffix} - -Examples: - Status request: ``cmd/52/navilink-aabbccddeeff/st`` - Device -info: ``cmd/52/navilink-aabbccddeeff/st/did`` - Control: -``cmd/52/navilink-aabbccddeeff/ctrl`` - -Response Topics -~~~~~~~~~~~~~~~ - -Responses are received on topics with this structure: - -:: - - cmd/{device_type}/navilink-{device_id}/{client_id}/res/{response_suffix} - -Use wildcards to subscribe to all responses: - -:: - - cmd/52/navilink-aabbccddeeff/{client_id}/res/# - -Event Topics -~~~~~~~~~~~~ - -Events are published to: - -:: - - evt/{device_type}/navilink-{device_id}/{event_type} - -Example: - App connection: -``evt/52/navilink-aabbccddeeff/app-connection`` - -Message Structure ------------------ - -Command Message -~~~~~~~~~~~~~~~ - -.. code:: json - - { - "clientID": "navien-client-abc123", - "sessionID": "def456", - "protocolVersion": 2, - "request": { - "command": 16777219, - "deviceType": 52, - "macAddress": "aabbccddeeff", - "additionalValue": "5322", - "mode": "power-on", - "param": [], - "paramStr": "" - }, - "requestTopic": "cmd/52/navilink-aabbccddeeff/ctrl", - "responseTopic": "cmd/52/navilink-aabbccddeeff/navien-client-abc123/res" - } - -Response Message -~~~~~~~~~~~~~~~~ - -.. code:: json - - { - "sessionID": "def456", - "response": { - "status": { - "dhwTemperature": 120, - "tankUpperTemperature": 115, - "tankLowerTemperature": 110, - "operationMode": 64, - "dhwOperationSetting": 3, - "dhwUse": true, - "compUse": false - } - } - } - -Note: ``operationMode`` shows the current operational state (64 = Energy Saver actively heating), while ``dhwOperationSetting`` shows the configured mode preference (3 = Energy Saver). See :doc:`DEVICE_STATUS_FIELDS` for the distinction between these fields. - -Error Handling --------------- - -.. code:: python - - from nwp500.mqtt_client import NavienMqttClient - - try: - async with NavienAuthClient("email@example.com", "password") as auth_client: - mqtt_client = NavienMqttClient(auth_client) - await mqtt_client.connect() - - # Use client... - - except ValueError as e: - print(f"Configuration error: {e}") - except RuntimeError as e: - print(f"Connection error: {e}") - except Exception as e: - print(f"Unexpected error: {e}") - finally: - if mqtt_client.is_connected: - await mqtt_client.disconnect() - -Advanced Usage --------------- - -Non-Blocking Implementation -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The MQTT client is designed to be fully compatible with async event loops -and will not block or interfere with other async operations. This makes it -suitable for integration with Home Assistant, web servers, and other -async applications. - -**Implementation Details:** - -- AWS IoT SDK operations return ``concurrent.futures.Future`` objects that are converted to asyncio Futures using ``asyncio.wrap_future()`` -- Connection, disconnection, subscription, and publishing operations are fully non-blocking -- No thread pool resources are used for MQTT operations (more efficient than executor-based approaches) -- The client maintains full compatibility with the existing API -- No additional configuration required for non-blocking behavior - -**Home Assistant Integration:** - -.. code:: python - - # Safe for use in Home Assistant custom integrations - class MyCoordinator(DataUpdateCoordinator): - async def _async_update_data(self): - # This will not trigger "blocking I/O detected" warnings - await self.mqtt_client.request_device_status(self.device) - return self.latest_data - -**Concurrent Operations:** - -.. code:: python - - # MQTT operations will not block other async tasks - async def main(): - # Both tasks run concurrently without blocking - await asyncio.gather( - mqtt_client.connect(), - some_other_async_operation(), - web_server.start(), - ) - -Custom Connection Configuration -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - from nwp500.mqtt_client import MqttConnectionConfig - - config = MqttConnectionConfig( - client_id="my-custom-client", - keep_alive_secs=600, - clean_session=False - ) - - mqtt_client = NavienMqttClient(auth_tokens, config=config) - -Connection Callbacks -~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - def on_interrupted(error): - print(f"Connection interrupted: {error}") - - def on_resumed(return_code, session_present): - print(f"Connection resumed: {return_code}") - - mqtt_client = NavienMqttClient( - auth_client, - on_connection_interrupted=on_interrupted, - on_connection_resumed=on_resumed - ) - -Multiple Device Subscriptions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: python - - devices = [device1, device2] - - for device in devices: - await mqtt_client.subscribe_device( - device, - lambda topic, msg: print(f"{device.device_info.mac_address}: {msg}") - ) - -Periodic Requests -~~~~~~~~~~~~~~~~~ - -Automatically request device information or status at regular intervals: - -.. code:: python - - from nwp500 import PeriodicRequestType - - # Device status requests (default) - every 5 minutes - await mqtt_client.start_periodic_requests(device) - - # Device info requests - every 10 minutes - await mqtt_client.start_periodic_requests( - device, - request_type=PeriodicRequestType.DEVICE_INFO, - period_seconds=600 - ) - - # Monitor updates - def on_message(topic: str, message: dict): - response = message.get('response', {}) - if 'status' in response: - print(f"Status: {response['status'].get('dhwTemperature')}°F") - if 'feature' in response: - print(f"Firmware: {response['feature'].get('controllerSwVersion')}") - - await mqtt_client.subscribe_device(device, on_message) - - # Keep running... - await asyncio.sleep(3600) # Run for 1 hour - - # Stop when done - await mqtt_client.stop_periodic_requests(device) - -**Use Cases:** - Monitor firmware updates automatically - Keep device -status current without manual polling - Detect when devices go -offline/online - Track configuration changes - Automated monitoring -applications - -**Multiple Request Types:** - -.. code:: python - - # Run both status and info requests simultaneously - await mqtt_client.start_periodic_requests( - device, - request_type=PeriodicRequestType.DEVICE_STATUS, - period_seconds=300 # Every 5 minutes - ) - - await mqtt_client.start_periodic_requests( - device, - request_type=PeriodicRequestType.DEVICE_INFO, - period_seconds=1800 # Every 30 minutes - ) - - # Stop specific type - await mqtt_client.stop_periodic_requests(device, PeriodicRequestType.DEVICE_INFO) - - # Stop all types for device - # Stop all types for device - await mqtt_client.stop_periodic_requests(device) - -**Convenience Methods:** - -.. code:: python - - # These are wrappers around start_periodic_requests() - await mqtt_client.start_periodic_device_info_requests(device) - await mqtt_client.start_periodic_device_status_requests(device) - -Advanced Features ------------------ - -Vacation Mode -~~~~~~~~~~~~~ - -Set the device to vacation mode to save energy during extended absences: - -.. code:: python - - # Set vacation mode for 7 days - await mqtt_client.set_dhw_mode(device, mode_id=5, vacation_days=7) - - # Check vacation status in device status - def on_status(topic: str, message: dict): - status = message.get('response', {}).get('status', {}) - if status.get('dhwOperationSetting') == 5: - days_set = status.get('vacationDaySetting', 0) - days_elapsed = status.get('vacationDayElapsed', 0) - days_remaining = days_set - days_elapsed - print(f"Vacation mode: {days_remaining} days remaining") - - await mqtt_client.subscribe_device(device, on_status) - await mqtt_client.request_device_status(device) - -Reservation Management -~~~~~~~~~~~~~~~~~~~~~~ - -Manage programmed temperature and mode changes: - -.. code:: python - - # Create a reservation for weekday mornings - reservation = { - "enable": 1, # 1=enabled, 2=disabled - "week": 124, # Weekdays (Mon-Fri) - "hour": 6, - "min": 30, - "mode": 4, # High Demand mode - "param": 120 # Target temperature (140°F display = 120°F message) - } - - # Send reservation update - await mqtt_client.publish( - topic=f"cmd/52/{device.device_info.mac_address}/ctrl/rsv/rd", - payload={ - "clientID": mqtt_client.client_id, - "protocolVersion": 2, - "request": { - "command": 16777226, - "deviceType": 52, - "macAddress": device.device_info.mac_address, - "reservationUse": 1, # Enable reservations - "reservation": [reservation] - }, - "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl/rsv/rd", - "responseTopic": "...", - "sessionID": str(int(time.time() * 1000)) - } - ) - -**Week Bitfield Values:** - -* ``127`` - All days (Sunday through Saturday) -* ``62`` - Weekdays (Monday through Friday) -* ``65`` - Weekend (Saturday and Sunday) -* ``31`` - Sunday through Thursday - -Time of Use (TOU) Pricing -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Configure energy pricing schedules for demand response: - -.. code:: python - - # Define TOU periods - tou_periods = [ - { - "season": 31, # All seasons - "week": 124, # Weekdays - "startHour": 0, - "startMinute": 0, - "endHour": 14, - "endMinute": 59, - "priceMin": 34831, # $0.34831 per kWh - "priceMax": 34831, - "decimalPoint": 5 # Divide by 100000 - }, - { - "season": 31, - "week": 124, - "startHour": 15, - "startMinute": 0, - "endHour": 20, - "endMinute": 59, - "priceMin": 45000, # $0.45 per kWh (peak pricing) - "priceMax": 45000, - "decimalPoint": 5 - } - ] - - # Send TOU settings - await mqtt_client.publish( - topic=f"cmd/52/{device.device_info.mac_address}/ctrl/tou/rd", - payload={ - "clientID": mqtt_client.client_id, - "protocolVersion": 2, - "request": { - "command": 33554439, - "deviceType": 52, - "macAddress": device.device_info.mac_address, - "controllerSerialNumber": device.controller_serial_number, - "reservationUse": 2, # Enable TOU - "reservation": tou_periods - }, - "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl/tou/rd", - "responseTopic": "...", - "sessionID": str(int(time.time() * 1000)) - } - ) - -**Note:** TOU settings help the device optimize operation based on energy prices, potentially reducing costs during peak pricing periods. - -Anti-Legionella Monitoring -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Monitor the Anti-Legionella protection cycle that prevents bacterial growth: - -.. code:: python - - # Check Anti-Legionella status - def on_status(topic: str, message: dict): - status = message.get('response', {}).get('status', {}) - - # Check if feature is enabled - anti_legionella_enabled = status.get('antiLegionellaUse') == 2 - - # Get cycle period in days - period_days = status.get('antiLegionellaPeriod', 0) - - # Check if currently running - is_running = status.get('antiLegionellaOperationBusy') == 2 - - print(f"Anti-Legionella: {'Enabled' if anti_legionella_enabled else 'Disabled'}") - print(f"Cycle Period: Every {period_days} days") - print(f"Status: {'Running' if is_running else 'Idle'}") - - if is_running: - print("Device is heating to 140°F for bacterial disinfection") - - await mqtt_client.subscribe_device(device, on_status) - await mqtt_client.request_device_status(device) - -**Controlling Anti-Legionella:** - -.. code:: python - - import time - - # Enable Anti-Legionella with 7-day cycle - await mqtt_client.publish( - topic=f"cmd/52/{device.device_info.mac_address}/ctrl", - payload={ - "clientID": mqtt_client.client_id, - "protocolVersion": 2, - "request": { - "command": 33554472, - "deviceType": 52, - "macAddress": device.device_info.mac_address, - "mode": "anti-leg-on", - "param": [7], # 7-day cycle period - "paramStr": "" - }, - "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl", - "responseTopic": "...", - "sessionID": str(int(time.time() * 1000)) - } - ) - - # Disable Anti-Legionella (not recommended - health risk) - await mqtt_client.publish( - topic=f"cmd/52/{device.device_info.mac_address}/ctrl", - payload={ - "clientID": mqtt_client.client_id, - "protocolVersion": 2, - "request": { - "command": 33554473, - "deviceType": 52, - "macAddress": device.device_info.mac_address, - "mode": "anti-leg-off", - "param": [], - "paramStr": "" - }, - "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl", - "responseTopic": "...", - "sessionID": str(int(time.time() * 1000)) - } - ) - -**Important Safety Notes:** - -* Anti-Legionella heats water to 140°F (60°C) to kill Legionella bacteria -* Requires a mixing valve to prevent scalding at taps -* Cycle period is typically 7 days but can be configured for 1-30 days -* During the cycle, the device will heat the entire tank to the disinfection temperature -* This is a health safety feature recommended for all water heaters -* **WARNING**: Disabling Anti-Legionella increases health risks - consult local codes - -TOU Quick Enable/Disable -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Toggle TOU functionality without modifying the schedule: - -.. code:: python - - import time - - # Enable TOU - await mqtt_client.publish( - topic=f"cmd/52/{device.device_info.mac_address}/ctrl", - payload={ - "clientID": mqtt_client.client_id, - "protocolVersion": 2, - "request": { - "command": 33554476, - "deviceType": 52, - "macAddress": device.device_info.mac_address, - "mode": "tou-on", - "param": [], - "paramStr": "" - }, - "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl", - "responseTopic": "...", - "sessionID": str(int(time.time() * 1000)) - } - ) - - # Disable TOU - await mqtt_client.publish( - topic=f"cmd/52/{device.device_info.mac_address}/ctrl", - payload={ - "clientID": mqtt_client.client_id, - "protocolVersion": 2, - "request": { - "command": 33554475, - "deviceType": 52, - "macAddress": device.device_info.mac_address, - "mode": "tou-off", - "param": [], - "paramStr": "" - }, - "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl", - "responseTopic": "...", - "sessionID": str(int(time.time() * 1000)) - } - ) - -**Note:** The TOU schedule remains stored when disabled and will resume when re-enabled. - -Troubleshooting ---------------- - -Connection Issues -~~~~~~~~~~~~~~~~~ - -**Problem:** ``AWS_IO_DNS_INVALID_NAME`` error - -**Solution:** Verify the endpoint is correct: -``a1t30mldyslmuq-ats.iot.us-east-1.amazonaws.com`` - --------------- - -**Problem:** ``AWS credentials not available`` - -**Solution:** Ensure authentication returns AWS credentials: - -.. code:: python - - async with NavienAuthClient(email, password) as auth_client: - if not auth_client.current_tokens.access_key_id: - print("No AWS credentials in response") - -No Messages Received -~~~~~~~~~~~~~~~~~~~~ - -**Problem:** Commands sent but no responses - -**Possible causes:** 1. Device is offline 2. Wrong topic subscription 3. -Device object not properly configured - -**Solution:** - -.. code:: python - - # Correct - use Device object from API - device = await api_client.get_first_device() - await mqtt_client.request_device_status(device) - -Session Expiration -~~~~~~~~~~~~~~~~~~ - -AWS credentials expire after a certain time. The auth client -automatically handles token refresh: - -.. code:: python - - async with NavienAuthClient("email@example.com", "password") as auth_client: - - # Auth client automatically manages token refresh - mqtt_client = NavienMqttClient(auth_client) - await mqtt_client.connect() - -Examples --------- - -See the ``examples/`` directory: - -- ``mqtt_client_example.py``: Complete example with device discovery and communication -- ``test_mqtt_connection.py``: Simple connection test - -References ----------- - -- :doc:`MQTT_MESSAGES`: Complete MQTT protocol documentation -- `AWS IoT Device SDK for Python v2 `__ -- `OpenAPI Specification `__: REST API specification diff --git a/docs/MQTT_MESSAGES.rst b/docs/MQTT_MESSAGES.rst deleted file mode 100644 index e8b57fe..0000000 --- a/docs/MQTT_MESSAGES.rst +++ /dev/null @@ -1,1001 +0,0 @@ - -Navien MQTT Protocol Documentation -================================== - -This document describes the MQTT protocol used by Navien devices for monitoring and control. - -Topics ------- - -The MQTT topics have a hierarchical structure. The main categories are ``cmd`` for commands and ``evt`` for events. - -Command Topics (\ ``cmd/...``\ ) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - - -* ``cmd/{deviceType}/{deviceId}/ctrl``\ : Used to send control commands to the device. -* ``cmd/{deviceType}/{deviceId}/st/...``\ : Used to request status updates from the device. -* ``cmd/{deviceType}/{...}/{...}/{clientId}/res/...``\ : Used by the device to send responses to status and control requests. - -Event Topics (\ ``evt/...``\ ) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - - -* ``evt/{deviceType}/{deviceId}/app-connection``\ : Used to signal that an app has connected. - -Control Messages (\ ``/ctrl``\ ) --------------------------------- - -Control messages are sent to the ``cmd/{deviceType}/{deviceId}/ctrl`` topic. The payload is a JSON object with the following structure: - -.. code-block:: text - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": , - "deviceType": 52, - "macAddress": "...", - "mode": "{mode}", - "param": [], - "paramStr": "" - }, - "requestTopic": "cmd/{deviceType}/{deviceId}/ctrl", - "responseTopic": "cmd/{deviceType}/{...}/{...}/{clientId}/res", - "sessionID": "..." - } - -**Note**: The ``command`` field uses different values for different control types: - -* Power control: 33554433 (power-off) or 33554434 (power-on) -* DHW mode control: 33554437 -* DHW temperature control: 33554464 -* Reservation read: 16777222 (read current schedule via ``/st/rsv/rd``) -* Reservation management: 16777226 (write/update schedule via ``/ctrl/rsv/rd``) -* TOU (Time of Use) settings: 33554439 (write via MQTT; read via REST API) -* Anti-Legionella control: 33554471 (disable) or 33554472 (enable) -* TOU enable/disable: 33554475 (disable) or 33554476 (enable) - -Power Control -^^^^^^^^^^^^^ - - -* - ``mode``: "power-on" - - - * Turns the device on. - * ``param``\ : ``[]`` - * ``paramStr``\ : ``""`` - -* - ``mode``: "power-off" - - - * Turns the device off. - * ``param``\ : ``[]`` - * ``paramStr``\ : ``""`` - -DHW Mode -^^^^^^^^ - - -* ``mode``: "dhw-mode" - - * Changes the Domestic Hot Water (DHW) mode. - * ``param``\ : ``[]`` or ``[, ]`` for vacation mode - * ``paramStr``\ : ``""`` - -.. list-table:: - :header-rows: 1 - - * - ``mode_id`` - - Mode - - Description - * - 1 - - Heat Pump Only - - Most energy-efficient mode, using only the heat pump. Longest recovery time but uses least electricity. - * - 2 - - Electric Only - - Uses only electric heating elements. Least efficient but provides fastest recovery time. - * - 3 - - Energy Saver - - Balanced mode combining heat pump and electric heater as needed. Good balance of efficiency and recovery time. - * - 4 - - High Demand - - Maximum heating mode using all available components as needed for fastest recovery with higher capacity. - * - 5 - - Vacation Mode - - Suspends heating to save energy during extended absences. Requires vacation days parameter (e.g., ``[5, 4]`` for 4-day vacation). - -.. note:: - Additional modes may appear in status responses: - - * Mode 0: Standby (device in idle state) - * Mode 6: Power Off (device is powered off) - -**Vacation Mode Parameters:** - -When setting vacation mode (mode 5), provide two parameters: - -* ``param[0]``: Mode ID (5) -* ``param[1]``: Number of vacation days (1-30) - -Example: ``"param": [5, 7]`` sets vacation mode for 7 days. - - -Set DHW Temperature -^^^^^^^^^^^^^^^^^^^ - - -* ``mode``: "dhw-temperature" - - * Sets the DHW temperature. - * ``param``\ : ``[]`` - * ``paramStr``\ : ``""`` - - **IMPORTANT**: The temperature value in the message is **20 degrees Fahrenheit LOWER** than what displays on the device/app. - - * Message value: 121°F → Display shows: 141°F - * Message value: 131°F → Display shows: 151°F (capped at 150°F max) - - Valid message range: ~95-131°F (displays as ~115-151°F, max 150°F) - -Anti-Legionella Control -^^^^^^^^^^^^^^^^^^^^^^^^ - -* **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl`` -* **Command Codes**: - - * ``33554471`` - Disable Anti-Legionella - * ``33554472`` - Enable Anti-Legionella (with cycle period) - -* ``mode``: "anti-leg-on" (for enable) or "anti-leg-off" (for disable) - - * Enables or configures Anti-Legionella protection - * ``param``\ : ``[]`` for enable (1-30 days), ``[]`` for disable - * ``paramStr``\ : ``""`` - -**Enable Anti-Legionella Example:** - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 33554472, - "deviceType": 52, - "macAddress": "...", - "mode": "anti-leg-on", - "param": [7], - "paramStr": "" - }, - "requestTopic": "cmd/52/navilink-04786332fca0/ctrl", - "responseTopic": "...", - "sessionID": "..." - } - -**Observed Response After Enable:** - -After sending the enable command, the device status shows: - -* ``antiLegionellaUse`` changes from 1 (disabled) to 2 (enabled) -* ``antiLegionellaPeriod`` is set to the specified period value - -**Disable Anti-Legionella Example:** - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 33554471, - "deviceType": 52, - "macAddress": "...", - "mode": "anti-leg-off", - "param": [], - "paramStr": "" - }, - "requestTopic": "cmd/52/navilink-04786332fca0/ctrl", - "responseTopic": "...", - "sessionID": "..." - } - -**Observed Response After Disable:** - -After sending the disable command, the device status shows: - -* ``antiLegionellaUse`` changes from 2 (enabled) to 1 (disabled) -* ``antiLegionellaPeriod`` retains its previous value - -.. warning:: - Disabling Anti-Legionella protection may increase health risks. Legionella bacteria can grow - in water heaters maintained at temperatures below 140°F (60°C). Consult local health codes - before disabling this safety feature. - -**Period Parameter:** - -* Valid range: 1-30 days -* Typical value: 7 days (weekly disinfection) -* Longer periods may increase bacterial growth risk -* Shorter periods use more energy but provide better protection - -Reservation Management -^^^^^^^^^^^^^^^^^^^^^^ - -**Writing Reservations:** - -* **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl/rsv/rd`` -* **Command Code**: ``16777226`` (RESERVATION_MANAGEMENT) -* ``mode``: Not used for reservations - - * Manages programmed reservations for temperature changes - * ``reservationUse``\ : ``1`` (enable) or ``2`` (disable) - * ``reservation``\ : Array of reservation objects - -**Reading Reservations:** - -* **Topic**: ``cmd/{deviceType}/{deviceId}/st/rsv/rd`` -* **Command Code**: ``16777222`` (RESERVATION_READ) -* Returns current reservation schedule from device - -**Important Note on Read/Write Topics:** - -* **Write operations** use ``/ctrl/`` path (control) -* **Read operations** use ``/st/`` path (status) -* Reading and writing use different command codes - -**Reservation Object Fields:** - -* ``enable``\ : ``1`` (enabled) or ``2`` (disabled) -* ``week``\ : Bitfield for days of week (e.g., ``62`` = weekdays, ``65`` = weekend) -* ``hour``\ : Hour (0-23) -* ``min``\ : Minute (0-59) -* ``mode``\ : Operation mode to set (1-5) -* ``param``\ : Temperature or other parameter (temperature is 20°F less than display value) - -**Response Format:** - -When reading reservations, the device returns data in hex-encoded format: - -.. code-block:: json - - { - "response": { - "deviceType": 52, - "macAddress": "04786332fca0", - "additionalValue": "5322", - "reservationUse": 1, - "reservation": "013e061e0478..." - } - } - -The ``reservation`` field is a hex string where each 6-byte sequence represents one reservation entry: - -* Byte 0: ``enable`` (1=enabled, 2=disabled) -* Byte 1: ``week`` bitfield -* Byte 2: ``hour`` (0-23) -* Byte 3: ``minute`` (0-59) -* Byte 4: ``mode`` (1-5) -* Byte 5: ``param`` (temperature or parameter value) - -**Example**: ``013e061e0478`` decodes to: - -* enable=1 (enabled) -* week=62 (0x3E = Monday-Friday) -* hour=6, minute=30 (6:30 AM) -* mode=4 (High Demand) -* param=120 → 140°F display temperature (120 + 20) - -**Write Example Payload:** - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 16777226, - "deviceType": 52, - "macAddress": "...", - "reservationUse": 1, - "reservation": [ - { - "enable": 2, - "week": 24, - "hour": 12, - "min": 10, - "mode": 1, - "param": 98 - } - ] - }, - "requestTopic": "cmd/52/navilink-04786332fca0/ctrl/rsv/rd", - "responseTopic": "...", - "sessionID": "..." - } - -**Week Bitfield Values:** - -The ``week`` field uses a bitfield where each bit represents a day: - -* Bit 0 (1): Sunday -* Bit 1 (2): Monday -* Bit 2 (4): Tuesday -* Bit 3 (8): Wednesday -* Bit 4 (16): Thursday -* Bit 5 (32): Friday -* Bit 6 (64): Saturday - -Common combinations: - -* ``127`` (all days): Sunday through Saturday -* ``62`` (weekdays): Monday through Friday (2+4+8+16+32=62) -* ``65`` (weekend): Saturday and Sunday (64+1=65) - -Common combinations: - -* ``127`` (all days): Sunday through Saturday -* ``62`` (weekdays): Monday through Friday -* ``65`` (weekend): Saturday and Sunday -* ``24`` (mid-week): Wednesday and Thursday (8+16 = 24) - -TOU (Time of Use) Settings -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -**Important: TOU data retrieval differs from other settings** - -* **Reading TOU settings**: Use REST API, not MQTT - - * Endpoint: ``GET /api/v2.1/device/tou`` - * Required parameters: ``controllerId``, ``macAddress``, ``additionalValue``, ``userId``, ``userType`` - * Returns: Full TOU schedule with utility information - -* **Writing TOU settings**: Use MQTT - - * **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl/tou/rd`` - * **Command Code**: ``33554439`` - -**Why REST API for Reading?** - -The device does not respond to MQTT TOU read requests. The Navien mobile app retrieves TOU settings -from the cloud API, which stores the configured schedule. This allows TOU settings to include -utility-specific information (utility name, rate schedule name, zip code) that isn't stored on -the device itself. - -**REST API TOU Response:** - -.. code-block:: json - - { - "code": 200, - "msg": "SUCCESS", - "data": { - "registerPath": "wifi", - "sourceType": "openei", - "touInfo": { - "name": "E-TOU-C Residential Time of Use...", - "utility": "Pacific Gas & Electric Co", - "zipCode": "94903", - "controllerId": "56496061BT22230408", - "manufactureId": "...", - "schedule": [...] - } - } - } - -**MQTT Write Settings:** - -* Manages Time of Use energy pricing schedules via MQTT -* ``reservationUse``\ : ``1`` (enable) or ``2`` (disable) -* ``reservation``\ : Array of TOU period objects -* ``controllerSerialNumber``\ : Device controller serial number (required) - -**Getting Controller Serial Number:** - -The controller serial number is required for TOU commands and can be retrieved via MQTT: - -* Request device feature information (command ``16777217``) -* Extract ``controllerSerialNumber`` from the response -* Or use the CLI: ``nwp-cli --get-controller-serial`` - -**TOU Period Object Fields:** - -* ``season``\ : Season identifier (bitfield, e.g., ``31`` for specific months) -* ``week``\ : Days of week bitfield (same as reservation management) -* ``startHour``\ : Start hour (0-23) -* ``startMinute``\ : Start minute (0-59) -* ``endHour``\ : End hour (0-23) -* ``endMinute``\ : End minute (0-59) -* ``priceMin``\ : Minimum price (integer, scaled by decimal point) -* ``priceMax``\ : Maximum price (integer, scaled by decimal point) -* ``decimalPoint``\ : Decimal places for price (e.g., ``5`` means divide by 100000) - -**Example Payload:** - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 33554439, - "deviceType": 52, - "macAddress": "...", - "controllerSerialNumber": "56496061BT22230408", - "reservationUse": 2, - "reservation": [ - { - "season": 31, - "week": 124, - "startHour": 0, - "startMinute": 0, - "endHour": 14, - "endMinute": 59, - "priceMin": 34831, - "priceMax": 34831, - "decimalPoint": 5 - }, - { - "season": 31, - "week": 124, - "startHour": 15, - "startMinute": 0, - "endHour": 15, - "endMinute": 59, - "priceMin": 36217, - "priceMax": 36217, - "decimalPoint": 5 - } - ] - }, - "requestTopic": "cmd/52/navilink-04786332fca0/ctrl/tou/rd", - "responseTopic": "...", - "sessionID": "..." - } - -**Price Calculation:** - -The actual price is calculated as: ``price_value / (10 ^ decimalPoint)`` - -For example, with ``priceMin: 34831`` and ``decimalPoint: 5``: ``34831 / 100000 = 0.34831`` - -TOU Enable/Disable Control -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -* **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl`` -* **Command Codes**: - - * ``33554475`` - Disable TOU - * ``33554476`` - Enable TOU - -* ``mode``: "tou-off" or "tou-on" - - * Quick enable/disable of TOU functionality - * ``param``\ : ``[]`` - * ``paramStr``\ : ``""`` - -**Enable TOU Example:** - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 33554476, - "deviceType": 52, - "macAddress": "...", - "mode": "tou-on", - "param": [], - "paramStr": "" - }, - "requestTopic": "cmd/52/navilink-04786332fca0/ctrl", - "responseTopic": "...", - "sessionID": "..." - } - -**Disable TOU Example:** - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 33554475, - "deviceType": 52, - "macAddress": "...", - "mode": "tou-off", - "param": [], - "paramStr": "" - }, - "requestTopic": "cmd/52/navilink-04786332fca0/ctrl", - "responseTopic": "...", - "sessionID": "..." - } - -.. note:: - These commands provide quick enable/disable without modifying the TOU schedule. - The schedule configured via command 33554439 remains stored and can be re-enabled. - -Response Messages (\ ``/res``\ ) --------------------------------- - -The device sends a response to a control message on the ``responseTopic`` specified in the request. The payload of the response contains the updated status of the device. - -The ``sessionID`` in the response corresponds to the ``sessionID`` of the request. - -The ``response`` object contains a ``status`` object that reflects the new state. For example, after a ``dhw-mode`` command with ``param`` ``[3]`` (Energy Saver), the ``dhwOperationSetting`` field in the ``status`` object will be ``3``. Note that ``operationMode`` may still show ``0`` (STANDBY) if the device is not currently heating. See :doc:`DEVICE_STATUS_FIELDS` for the important distinction between ``dhwOperationSetting`` (configured mode) and ``operationMode`` (current operational state). - -Device Status Messages ----------------------- - -The device status is sent in the ``status`` object of the response messages. For a complete description of all fields found in the ``status`` object, see :doc:`DEVICE_STATUS_FIELDS`. - -**Status Command Field:** - -The ``status`` object includes a ``command`` field that indicates the type of status data: - -* ``67108883`` (0x04000013) - Standard status snapshot -* ``67108892`` (0x0400001C) - Extended status snapshot - -These command codes are informational and indicate which status fields are populated in the response. - -**Vacation Mode Status Fields:** - -When the device is in vacation mode (``dhwOperationSetting: 5``), the status includes: - -* ``vacationDaySetting``\ : Total vacation days configured -* ``vacationDayElapsed``\ : Days elapsed since vacation mode started -* ``dhwOperationSetting``\ : Set to ``5`` when in vacation mode -* ``operationMode``\ : Current operational state (typically ``0`` for standby during vacation) - -**Reservation Status Fields:** - -* ``programReservationType``\ : Type of reservation program (0 = none, 1 = active) -* ``reservationUse``\ : Whether reservations are enabled (1 = enabled, 2 = disabled) - -**Anti-Legionella Status Fields:** - -The device includes Anti-Legionella protection that periodically heats water to 140°F (60°C) to prevent bacterial growth: - -* ``antiLegionellaUse``\ : Anti-Legionella enable flag - - * **1** = disabled - * **2** = enabled - -* ``antiLegionellaPeriod``\ : Days between Anti-Legionella cycles (typically 7 days, range 1-30) -* ``antiLegionellaOperationBusy``\ : Currently performing Anti-Legionella cycle - - * **1** = OFF (not currently running) - * **2** = ON (currently heating to disinfection temperature) - -.. note:: - Anti-Legionella is a safety feature that heats the water tank to 140°F at programmed intervals - to kill Legionella bacteria. This requires a mixing valve to prevent scalding at taps. - The feature can be configured for 1-30 day intervals. When the - enable command (33554472) is sent with a period parameter, ``antiLegionellaUse`` changes - from 1 (disabled) to 2 (enabled), and ``antiLegionellaPeriod`` is updated to the specified value. - -Status Request Messages ------------------------ - -Status request messages are sent to topics starting with ``cmd/{deviceType}/{deviceId}/st/``. The payload is a JSON object with a ``request`` object that contains the command. - -Request Device Information -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -* **Topic**: ``cmd/{deviceType}/{deviceId}/st/did`` -* **Description**: Request device information. -* **Command Code**: ``16777217`` -* **Payload**: - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 16777217, - "deviceType": 52, - "macAddress": "..." - }, - "requestTopic": "...", - "responseTopic": "...", - "sessionID": "..." - } - -Request General Device Status -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -* **Topic**: ``cmd/{deviceType}/{deviceId}/st`` -* **Description**: Request general device status. -* **Command Code**: ``16777219`` -* **Payload**: - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 16777219, - "deviceType": 52, - "macAddress": "..." - }, - "requestTopic": "...", - "responseTopic": "...", - "sessionID": "..." - } - -Request Reservation Information -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -* **Topic**: ``cmd/{deviceType}/{deviceId}/st/rsv/rd`` -* **Description**: Request reservation information. -* **Command Code**: ``16777222`` -* **Payload**: - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 16777222, - "deviceType": 52, - "macAddress": "..." - }, - "requestTopic": "...", - "responseTopic": "...", - "sessionID": "..." - } - -Request Daily Energy Usage Data -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -* **Topic**: ``cmd/{deviceType}/{deviceId}/st/energy-usage-daily-query/rd`` -* **Description**: Request daily energy usage data for specified month(s). -* **Command Code**: ``16777225`` -* **Payload**: - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 16777225, - "deviceType": 52, - "macAddress": "...", - "month": [9], - "year": 2025 - }, - "requestTopic": "...", - "responseTopic": "...", - "sessionID": "..." - } - -* **Response Topic**: ``cmd/{deviceType}/{clientId}/res/energy-usage-daily-query/rd`` -* **Response Fields**: - - * ``typeOfUsage``\ : Type of usage data (1 = daily) - * ``total``\ : Total energy usage across queried period - - * ``heUsage``\ : Total heat element energy consumption (Wh) - * ``hpUsage``\ : Total heat pump energy consumption (Wh) - * ``heTime``\ : Total heat element operating time (hours) - * ``hpTime``\ : Total heat pump operating time (hours) - - * ``usage``\ : Array of monthly data - - * ``year``\ : Year - * ``month``\ : Month (1-12) - * ``data``\ : Array of daily usage (one per day of month) - - * ``heUsage``\ : Heat element energy consumption for that day (Wh) - * ``hpUsage``\ : Heat pump energy consumption for that day (Wh) - * ``heTime``\ : Heat element operating time for that day (hours) - * ``hpTime``\ : Heat pump operating time for that day (hours) - -Request Software Download Information -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -* **Topic**: ``cmd/{deviceType}/{deviceId}/st/dl-sw-info`` -* **Description**: Request software download information. -* **Command Code**: ``16777227`` -* **Payload**: - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 16777227, - "deviceType": 52, - "macAddress": "..." - }, - "requestTopic": "...", - "responseTopic": "...", - "sessionID": "..." - } - -Request Reservation Information -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -**Note**: This section describes reading reservations. See "Reservation Management" above for writing reservations. - -* **Topic**: ``cmd/{deviceType}/{deviceId}/st/rsv/rd`` (status path, not control) -* **Description**: Request current reservation settings from device. -* **Command Code**: ``16777222`` (RESERVATION_READ) -* **Payload**: - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 16777226, - "deviceType": 52, - "macAddress": "..." - }, - "requestTopic": "cmd/52/navilink-{macAddress}/st/rsv/rd", - "responseTopic": "...", - "sessionID": "..." - } - -* **Response Topic**: ``cmd/{deviceType}/{...}/res/rsv/rd`` -* **Response**: Contains ``reservationUse`` and hex-encoded ``reservation`` string (see "Reservation Management" section above for hex format details) - -Request TOU Information -^^^^^^^^^^^^^^^^^^^^^^^ - -**Note**: TOU information should be retrieved via REST API, not MQTT. See "TOU (Time of Use) Settings" section above. - -The device does not respond to MQTT TOU read requests. Use the REST API endpoint: - -* **REST API**: ``GET /api/v2.1/device/tou`` -* **Parameters**: ``controllerId``, ``macAddress``, ``additionalValue``, ``userId``, ``userType`` - -For quick enable/disable of TOU functionality without retrieving settings, use command codes ``33554475`` (disable) or ``33554476`` (enable). - -End Connection -^^^^^^^^^^^^^^ - -* **Topic**: ``cmd/{deviceType}/{deviceId}/st/end`` -* **Description**: End the connection. -* **Command Code**: ``16777218`` -* **Payload**: - -.. code-block:: json - - { - "clientID": "...", - "protocolVersion": 2, - "request": { - "additionalValue": "...", - "command": 16777218, - "deviceType": 52, - "macAddress": "..." - }, - "requestTopic": "...", - "responseTopic": "...", - "sessionID": "..." - } - -Energy Usage Query Details -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The energy usage query (command ``16777225``\ ) provides historical energy consumption data. This is used by the "EMS" (Energy Management System) tab in the Navien app. - -**Request Parameters**\ : - - -* ``month``\ : Array of months to query (e.g., ``[7, 8, 9]`` for July-September) -* ``year``\ : Year to query (e.g., ``2025``\ ) - -**Response Data**\ : - -The response contains: - - -* **Total statistics** for the entire queried period -* **Daily breakdown** for each day of each requested month - -Each data point includes: - - -* Energy consumption in Watt-hours (Wh) for heat pump (\ ``hpUsage``\ ) and electric elements (\ ``heUsage``\ ) -* Operating time in hours for heat pump (\ ``hpTime``\ ) and electric elements (\ ``heTime``\ ) - -**Example Usage**\ : - -.. code-block:: python - - # Request September 2025 energy data - await mqtt_client.request_energy_usage( - device_id="aabbccddeeff", - year=2025, - months=[9] - ) - - # Subscribe to energy usage responses - def on_energy_usage(energy: EnergyUsageResponse): - print(f"Total Usage: {energy.total.total_usage} Wh") - print(f"Heat Pump: {energy.total.heat_pump_percentage:.1f}%") - print(f"Heat Element: {energy.total.heat_element_percentage:.1f}%") - - await mqtt_client.subscribe_energy_usage(device_id, on_energy_usage) - -Response Messages ------------------ - -Response messages are published to topics matching the pattern ``cmd/{deviceType}/{...}/res/...``\ . The response structure generally includes: - -.. code-block:: text - - { - "protocolVersion": 2, - "clientID": "...", - "sessionID": "...", - "requestTopic": "...", - "response": { - "deviceType": 52, - "macAddress": "...", - "additionalValue": "...", - ... - } - } - -Command Code Reference ----------------------- - -Complete reference of all MQTT command codes: - -**Power Control** - -.. list-table:: - :header-rows: 1 - :widths: 15 40 45 - - * - Code - - Purpose - - Mode/Notes - * - 33554433 - - Power Off - - mode: "power-off" - * - 33554434 - - Power On - - mode: "power-on" - -**DHW Control** - -.. list-table:: - :header-rows: 1 - :widths: 15 40 45 - - * - Code - - Purpose - - Mode/Notes - * - 33554437 - - DHW Mode Change - - mode: "dhw-mode", param: [mode_id] or [5, days] for vacation - * - 33554464 - - DHW Temperature - - mode: "dhw-temperature", param: [temp] (20°F offset) - -**Anti-Legionella Control** - -.. list-table:: - :header-rows: 1 - :widths: 15 40 45 - - * - Code - - Purpose - - Mode/Notes - * - 33554471 - - Disable Anti-Legionella - - mode: "anti-leg-off", param: [] - * - 33554472 - - Enable Anti-Legionella - - mode: "anti-leg-on", param: [period_days] (1-30) - -**TOU Control** - -.. list-table:: - :header-rows: 1 - :widths: 15 40 45 - - * - Code - - Purpose - - Mode/Notes - * - 33554439 - - Configure TOU Schedule - - Topic: /ctrl/tou/rd, full schedule configuration - * - 33554475 - - Disable TOU - - mode: "tou-off", quick toggle without changing schedule - * - 33554476 - - Enable TOU - - mode: "tou-on", quick toggle without changing schedule - -**Reservation Management** - -.. list-table:: - :header-rows: 1 - :widths: 15 40 45 - - * - Code - - Purpose - - Mode/Notes - * - 16777226 - - Manage Reservations - - Topic: /ctrl/rsv/rd, schedule temperature/mode changes - -**Status Requests** - -.. list-table:: - :header-rows: 1 - :widths: 15 40 45 - - * - Code - - Purpose - - Mode/Notes - * - 16777217 - - Device Information - - Topic: /st/did, returns feature data - * - 16777219 - - Device Status - - Topic: /st, returns current status - * - 16777225 - - Energy Usage Query - - Topic: /st/energy-usage-daily-query/rd - * - 16777227 - - Software Download Info - - Topic: /st/dl-sw-info - * - 16777218 - - End Connection - - Topic: /st/end - -**Status Response Indicators** - -.. list-table:: - :header-rows: 1 - :widths: 15 40 45 - - * - Code - - Purpose - - Mode/Notes - * - 67108883 - - Standard Status Type - - Appears in response status.command field - * - 67108892 - - Extended Status Type - - Appears in response status.command field - -**Command Code Format** - -Command codes follow a pattern based on their category: - -* ``0x01......`` (16777216+) - Request/Query commands -* ``0x02......`` (33554432+) - Control commands -* ``0x04......`` (67108864+) - Status response type indicators diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 0000000..9f9e459 --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,331 @@ +============= +Configuration +============= + +This guide covers configuring the nwp500-python library for your +environment. + +Credentials +=========== + +The library requires your Navien Smart Control credentials (email and +password used in the Navilink mobile app). + +Environment Variables (Recommended) +------------------------------------ + +Store credentials in environment variables for security: + +**Linux/macOS:** + +.. code-block:: bash + + export NAVIEN_EMAIL="your-email@example.com" + export NAVIEN_PASSWORD="your-password" + +**Windows (PowerShell):** + +.. code-block:: powershell + + $env:NAVIEN_EMAIL="your-email@example.com" + $env:NAVIEN_PASSWORD="your-password" + +**Windows (Command Prompt):** + +.. code-block:: bat + + set NAVIEN_EMAIL=your-email@example.com + set NAVIEN_PASSWORD=your-password + +Then in your code: + +.. code-block:: python + + import os + from nwp500 import NavienAuthClient + + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + + async with NavienAuthClient(email, password) as auth: + # ... + +Configuration File +------------------ + +Create a config file (keep this private!): + +.. code-block:: ini + + # config.ini + [navien] + email = your-email@example.com + password = your-password + +Load it in your code: + +.. code-block:: python + + import configparser + from nwp500 import NavienAuthClient + + config = configparser.ConfigParser() + config.read('config.ini') + + email = config['navien']['email'] + password = config['navien']['password'] + + async with NavienAuthClient(email, password) as auth: + # ... + +.. warning:: + Never commit configuration files with credentials to version control! + Add ``config.ini`` to your ``.gitignore`` file. + +Direct in Code (Not Recommended) +--------------------------------- + +Only for testing: + +.. code-block:: python + + from nwp500 import NavienAuthClient + + async with NavienAuthClient( + "your-email@example.com", + "your-password" + ) as auth: + # ... + +Authentication Options +====================== + +Timeout Settings +---------------- + +Configure request timeouts: + +.. code-block:: python + + from nwp500 import NavienAuthClient + + # Increase timeout for slow connections + async with NavienAuthClient( + email, + password, + timeout=60 # seconds + ) as auth: + # ... + +Custom Base URL +--------------- + +Use a different API endpoint (for testing or proxies): + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient + + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient( + auth, + base_url="https://custom.api.url/api/v2.1" + ) + +MQTT Configuration +================== + +The MQTT client supports various configuration options through +``MqttConnectionConfig``: + +Basic Configuration +------------------- + +.. code-block:: python + + from nwp500 import NavienMqttClient, MqttConnectionConfig + from nwp500.mqtt_utils import MqttConnectionConfig + + config = MqttConnectionConfig( + client_id="my-custom-client", # or None for auto-generated + clean_session=True, + keep_alive_secs=1200 + ) + + mqtt = NavienMqttClient(auth, config=config) + +Reconnection Settings +--------------------- + +Configure automatic reconnection behavior: + +.. code-block:: python + + config = MqttConnectionConfig( + auto_reconnect=True, + max_reconnect_attempts=15, + initial_reconnect_delay=1.0, # seconds + max_reconnect_delay=120.0, # seconds + reconnect_backoff_multiplier=2.0 + ) + +Command Queue Settings +---------------------- + +Configure command queueing when disconnected: + +.. code-block:: python + + config = MqttConnectionConfig( + enable_command_queue=True, + max_queued_commands=100 + ) + +Complete Example +---------------- + +.. code-block:: python + + from nwp500 import NavienMqttClient + from nwp500.mqtt_utils import MqttConnectionConfig + + config = MqttConnectionConfig( + # Connection + endpoint="a1t30mldyslmuq-ats.iot.us-east-1.amazonaws.com", + region="us-east-1", + client_id="my-app-client", + clean_session=True, + keep_alive_secs=1200, + + # Reconnection + auto_reconnect=True, + max_reconnect_attempts=10, + initial_reconnect_delay=1.0, + max_reconnect_delay=120.0, + reconnect_backoff_multiplier=2.0, + + # Command queue + enable_command_queue=True, + max_queued_commands=100 + ) + + mqtt = NavienMqttClient(auth, config=config) + +Logging Configuration +===================== + +The library uses Python's standard logging module: + +Basic Logging +------------- + +.. code-block:: python + + import logging + + # Enable all library logs + logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + +Selective Logging +----------------- + +.. code-block:: python + + import logging + + # Only log from nwp500 library + nwp_logger = logging.getLogger('nwp500') + nwp_logger.setLevel(logging.INFO) + + # Only log MQTT messages + mqtt_logger = logging.getLogger('nwp500.mqtt_client') + mqtt_logger.setLevel(logging.DEBUG) + +Log to File +----------- + +.. code-block:: python + + import logging + + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('navien.log'), + logging.StreamHandler() + ] + ) + +Best Practices +============== + +1. **Never hardcode credentials** - Use environment variables or config + files +2. **Use async context managers** - Ensures proper cleanup +3. **Enable logging** - Helps debug issues +4. **Handle exceptions** - Network errors are common +5. **Rate limit API calls** - Use MQTT for real-time updates +6. **Secure config files** - Set proper file permissions (chmod 600) + +Example: Production Configuration +================================== + +.. code-block:: python + + import os + import logging + from nwp500 import NavienAuthClient, NavienMqttClient + from nwp500.mqtt_utils import MqttConnectionConfig + + # Configure logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/var/log/navien.log'), + logging.StreamHandler() + ] + ) + + # Get credentials from environment + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + + if not email or not password: + raise ValueError( + "NAVIEN_EMAIL and NAVIEN_PASSWORD must be set" + ) + + # Configure MQTT with reconnection + mqtt_config = MqttConnectionConfig( + auto_reconnect=True, + max_reconnect_attempts=15, + enable_command_queue=True + ) + + async def main(): + try: + async with NavienAuthClient( + email, + password, + timeout=30 + ) as auth: + mqtt = NavienMqttClient(auth, config=mqtt_config) + await mqtt.connect() + # ... your application code ... + await mqtt.disconnect() + except Exception as e: + logging.error(f"Application error: {e}", exc_info=True) + raise + +Next Steps +========== + +* :doc:`quickstart` - Build your first application +* :doc:`python_api/auth_client` - Authentication details +* :doc:`python_api/mqtt_client` - MQTT client configuration +* :doc:`guides/auto_recovery` - Automatic reconnection guide diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index e582053..0000000 --- a/docs/contributing.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CONTRIBUTING.rst diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst new file mode 100644 index 0000000..ac7b6bc --- /dev/null +++ b/docs/development/contributing.rst @@ -0,0 +1 @@ +.. include:: ../../CONTRIBUTING.rst diff --git a/docs/DEVELOPMENT.rst b/docs/development/history.rst similarity index 96% rename from docs/DEVELOPMENT.rst rename to docs/development/history.rst index 43922e0..ae4f1b1 100644 --- a/docs/DEVELOPMENT.rst +++ b/docs/development/history.rst @@ -87,8 +87,8 @@ compatibility - Automatic credential handling from authentication API - Session ID generation for connection tracking **Key Files:** - ``src/nwp500/mqtt_client.py`` - MQTT client -implementation - :doc:`MQTT_CLIENT` - Complete documentation - -:doc:`MQTT_MESSAGES` - Message format reference +implementation - :doc:`../python_api/mqtt_client` - Complete documentation - +:doc:`../protocol/mqtt_protocol` - Message format reference Device Status & Feature Callbacks (October 7, 2025) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -268,7 +268,7 @@ Complete event-driven architecture for device state changes: - ``src/nwp500/mqtt_client.py`` - MQTT integration with event emitter - ``examples/event_emitter_demo.py`` - Comprehensive demonstration - ``tests/test_events.py`` - Unit tests (19 tests) -- :doc:`EVENT_EMITTER` - Feature documentation +- :doc:`../python_api/events` - Feature documentation **Thread Safety Implementation:** @@ -337,8 +337,8 @@ References ---------- - `OpenAPI Specification `__ - API specification -- :doc:`MQTT_MESSAGES` - MQTT message reference -- :doc:`DEVICE_STATUS_FIELDS` - Device status fields -- :doc:`AUTHENTICATION` - Authentication guide -- :doc:`API_CLIENT` - API client guide -- :doc:`MQTT_CLIENT` - MQTT client guide +- :doc:`../protocol/mqtt_protocol` - MQTT message reference +- :doc:`../protocol/device_status` - Device status fields +- :doc:`../python_api/auth_client` - Authentication guide +- :doc:`../python_api/api_client` - API client guide +- :doc:`../python_api/mqtt_client` - MQTT client guide diff --git a/docs/AUTO_RECOVERY.rst b/docs/guides/auto_recovery.rst similarity index 99% rename from docs/AUTO_RECOVERY.rst rename to docs/guides/auto_recovery.rst index 5b10a57..b560b61 100644 --- a/docs/AUTO_RECOVERY.rst +++ b/docs/guides/auto_recovery.rst @@ -1,6 +1,6 @@ -======================================== +=============================================== Automatic Reconnection After Connection Failure -======================================== +=============================================== This guide explains how to automatically recover from permanent MQTT connection failures (after max reconnection attempts are exhausted). diff --git a/docs/COMMAND_QUEUE.rst b/docs/guides/command_queue.rst similarity index 97% rename from docs/COMMAND_QUEUE.rst rename to docs/guides/command_queue.rst index e095aff..c0ec12e 100644 --- a/docs/COMMAND_QUEUE.rst +++ b/docs/guides/command_queue.rst @@ -300,9 +300,9 @@ Technical Notes See Also ======== -- :doc:`MQTT_CLIENT` - MQTT client documentation -- :doc:`EVENT_EMITTER` - Event emitter documentation -- :doc:`AUTHENTICATION` - Authentication and tokens +- :doc:`../python_api/mqtt_client` - MQTT client documentation +- :doc:`../python_api/events` - Event emitter documentation +- :doc:`../python_api/auth_client` - Authentication and tokens Example Code ============ diff --git a/docs/ENERGY_MONITORING.rst b/docs/guides/energy_monitoring.rst similarity index 98% rename from docs/ENERGY_MONITORING.rst rename to docs/guides/energy_monitoring.rst index d309026..c10c026 100644 --- a/docs/ENERGY_MONITORING.rst +++ b/docs/guides/energy_monitoring.rst @@ -334,6 +334,6 @@ Notes See Also -------- -- :doc:`DEVICE_STATUS_FIELDS` - Complete list of all status fields -- :doc:`MQTT_CLIENT` - How to connect and subscribe to device updates -- :doc:`MQTT_MESSAGES` - Message format reference +- :doc:`../protocol/device_status` - Complete list of all status fields +- :doc:`../python_api/mqtt_client` - How to connect and subscribe to device updates +- :doc:`../protocol/mqtt_protocol` - Message format reference diff --git a/docs/guides/event_system.rst b/docs/guides/event_system.rst new file mode 100644 index 0000000..7bf3df4 --- /dev/null +++ b/docs/guides/event_system.rst @@ -0,0 +1,513 @@ +======================== +Event-Driven Programming +======================== + +This guide demonstrates how to build event-driven applications using the +nwp500 library's event system. + +Overview +======== + +The event system allows you to: + +* React to device state changes in real-time +* Build responsive, reactive applications +* Separate concerns (monitoring, logging, alerting) +* Handle multiple devices with a unified interface + +Benefits +-------- + +**Compared to polling:** + +* Lower latency - react immediately to changes +* More efficient - no wasted requests +* Cleaner code - declarative callbacks vs loops +* Better scalability - handle multiple devices easily + +**Use cases:** + +* Home automation triggers +* Alert systems +* Data logging and analytics +* UI updates +* Integration with other systems + +Basic Usage +=========== + +Simple Event Handler +-------------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + import asyncio + + async def main(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Define event handler + def on_status_update(status): + print(f"Temperature: {status.dhwTemperature}°F") + print(f"Power: {status.currentInstPower}W") + + # Subscribe to status updates + await mqtt.subscribe_device_status(device, on_status_update) + await mqtt.request_device_status(device) + + # Monitor for 5 minutes + await asyncio.sleep(300) + await mqtt.disconnect() + + asyncio.run(main()) + +Advanced Patterns +================= + +Pattern 1: State Tracking +-------------------------- + +Track state changes and react only when values change significantly. + +.. code-block:: python + + class DeviceMonitor: + def __init__(self, device, mqtt): + self.device = device + self.mqtt = mqtt + self.last_temp = None + self.last_power = None + + async def start(self): + await self.mqtt.subscribe_device_status( + self.device, + self.on_status + ) + await self.mqtt.request_device_status(self.device) + + def on_status(self, status): + # Temperature changed by more than 2°F + if self.last_temp is None or abs(status.dhwTemperature - self.last_temp) >= 2: + print(f"Temperature changed: {self.last_temp}°F → {status.dhwTemperature}°F") + self.last_temp = status.dhwTemperature + + # Power changed by more than 100W + if self.last_power is None or abs(status.currentInstPower - self.last_power) >= 100: + print(f"Power changed: {self.last_power}W → {status.currentInstPower}W") + self.last_power = status.currentInstPower + + # Usage + async def main(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + monitor = DeviceMonitor(device, mqtt) + await monitor.start() + + await asyncio.sleep(3600) # Monitor for 1 hour + +Pattern 2: Multi-Device Monitoring +----------------------------------- + +Monitor multiple devices with individual callbacks. + +.. code-block:: python + + class MultiDeviceMonitor: + def __init__(self, mqtt): + self.mqtt = mqtt + self.devices = {} + + async def add_device(self, device): + device_id = device.device_info.mac_address + + # Create device-specific callback + def callback(status): + self.on_device_status(device_id, status) + + # Subscribe + await self.mqtt.subscribe_device_status(device, callback) + await self.mqtt.request_device_status(device) + + self.devices[device_id] = { + 'device': device, + 'callback': callback, + 'last_status': None + } + + def on_device_status(self, device_id, status): + device_data = self.devices[device_id] + device_name = device_data['device'].device_info.device_name + + print(f"[{device_name}]") + print(f" Temperature: {status.dhwTemperature}°F") + print(f" Power: {status.currentInstPower}W") + print() + + device_data['last_status'] = status + + # Usage + async def main(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + devices = await api.list_devices() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + monitor = MultiDeviceMonitor(mqtt) + + # Add all devices + for device in devices: + await monitor.add_device(device) + + # Monitor indefinitely + while True: + await asyncio.sleep(60) + +Pattern 3: Alert System +------------------------ + +Build an alert system that triggers on specific conditions. + +.. code-block:: python + + from datetime import datetime + from typing import Callable, List + + class AlertRule: + def __init__(self, name: str, condition: Callable, action: Callable): + self.name = name + self.condition = condition + self.action = action + + def check(self, status): + if self.condition(status): + self.action(status) + + class AlertSystem: + def __init__(self, device, mqtt): + self.device = device + self.mqtt = mqtt + self.rules: List[AlertRule] = [] + + def add_rule(self, rule: AlertRule): + self.rules.append(rule) + + async def start(self): + await self.mqtt.subscribe_device_status( + self.device, + self.on_status + ) + await self.mqtt.start_periodic_requests( + self.device, + period_seconds=60 + ) + + def on_status(self, status): + for rule in self.rules: + rule.check(status) + + # Define alert actions + def send_email(subject, body): + print(f"EMAIL: {subject}\n{body}") + # Implement email sending + + def send_sms(message): + print(f"SMS: {message}") + # Implement SMS sending + + def log_alert(message): + timestamp = datetime.now().isoformat() + print(f"[{timestamp}] ALERT: {message}") + + # Usage + async def main(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + alerts = AlertSystem(device, mqtt) + + # Define alert rules + alerts.add_rule(AlertRule( + name="Low Temperature", + condition=lambda s: s.dhwTemperature < 110, + action=lambda s: send_email( + "Low Water Temperature", + f"Temperature dropped to {s.dhwTemperature}°F" + ) + )) + + alerts.add_rule(AlertRule( + name="High Power", + condition=lambda s: s.currentInstPower > 2000, + action=lambda s: log_alert( + f"High power usage: {s.currentInstPower}W" + ) + )) + + alerts.add_rule(AlertRule( + name="Error Detected", + condition=lambda s: s.errorCode != 0, + action=lambda s: send_sms( + f"Device error: {s.errorCode}" + ) + )) + + await alerts.start() + + # Monitor indefinitely + while True: + await asyncio.sleep(3600) + +Pattern 4: Data Logger +----------------------- + +Log device data to a database or file. + +.. code-block:: python + + import sqlite3 + from datetime import datetime + + class DataLogger: + def __init__(self, device, mqtt, db_path="navien_data.db"): + self.device = device + self.mqtt = mqtt + self.db_path = db_path + self.setup_database() + + def setup_database(self): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS status_log ( + timestamp TEXT, + device_mac TEXT, + temperature REAL, + target_temp REAL, + power REAL, + mode TEXT, + operation_mode TEXT, + error_code INTEGER + ) + """) + conn.commit() + conn.close() + + async def start(self): + await self.mqtt.subscribe_device_status( + self.device, + self.log_status + ) + await self.mqtt.start_periodic_requests( + self.device, + period_seconds=300 # Log every 5 minutes + ) + + def log_status(self, status): + timestamp = datetime.now().isoformat() + device_mac = self.device.device_info.mac_address + + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO status_log VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + timestamp, + device_mac, + status.dhwTemperature, + status.dhwTemperatureSetting, + status.currentInstPower, + status.dhwOperationSetting.name, + status.operationMode.name, + status.errorCode + )) + conn.commit() + conn.close() + + print(f"[{timestamp}] Logged status for {device_mac}") + + # Usage + async def main(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + logger = DataLogger(device, mqtt) + await logger.start() + + # Log indefinitely + while True: + await asyncio.sleep(3600) + +Pattern 5: Home Automation Integration +--------------------------------------- + +Integrate with Home Assistant, OpenHAB, or custom systems. + +.. code-block:: python + + import aiohttp + + class HomeAssistantBridge: + def __init__(self, device, mqtt, ha_url, ha_token): + self.device = device + self.mqtt = mqtt + self.ha_url = ha_url + self.ha_token = ha_token + + async def start(self): + await self.mqtt.subscribe_device_status( + self.device, + self.publish_to_ha + ) + await self.mqtt.start_periodic_requests( + self.device, + period_seconds=30 + ) + + async def publish_to_ha(self, status): + """Publish device status to Home Assistant MQTT.""" + device_mac = self.device.device_info.mac_address + + # Prepare state data + state_data = { + 'temperature': status.dhwTemperature, + 'target_temperature': status.dhwTemperatureSetting, + 'power': status.currentInstPower, + 'mode': status.dhwOperationSetting.name, + 'state': status.operationMode.name, + 'error': status.errorCode + } + + # Publish to HA + async with aiohttp.ClientSession() as session: + headers = { + 'Authorization': f'Bearer {self.ha_token}', + 'Content-Type': 'application/json' + } + + url = f"{self.ha_url}/api/states/sensor.navien_{device_mac}" + + async with session.post(url, headers=headers, json={ + 'state': status.dhwTemperature, + 'attributes': state_data + }) as resp: + if resp.status == 200: + print(f"Published to Home Assistant") + else: + print(f"HA publish failed: {resp.status}") + + # Usage + async def main(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + bridge = HomeAssistantBridge( + device, + mqtt, + ha_url="http://homeassistant.local:8123", + ha_token="your_long_lived_token" + ) + + await bridge.start() + + # Run indefinitely + while True: + await asyncio.sleep(3600) + +Best Practices +============== + +1. **Keep handlers lightweight:** + + .. code-block:: python + + # ✓ Fast handler + def on_status(status): + asyncio.create_task(process_status(status)) + + # ✗ Slow handler (blocks event loop) + def on_status(status): + time.sleep(5) # BAD + process_status(status) + +2. **Handle errors in callbacks:** + + .. code-block:: python + + def safe_handler(status): + try: + process_status(status) + except Exception as e: + print(f"Handler error: {e}") + # Don't let errors crash the event loop + +3. **Unsubscribe when done:** + + .. code-block:: python + + # Track callback references + callback = lambda s: print(s.dhwTemperature) + + await mqtt.subscribe_device_status(device, callback) + + # Later, unsubscribe + # (if the MQTT client supports it) + +4. **Use async callbacks when possible:** + + .. code-block:: python + + async def async_handler(status): + # Can await async operations + await save_to_database(status) + await send_notification(status) + +5. **Batch updates to reduce overhead:** + + .. code-block:: python + + class BatchProcessor: + def __init__(self): + self.buffer = [] + + def on_status(self, status): + self.buffer.append(status) + + if len(self.buffer) >= 10: + self.flush() + + def flush(self): + # Process batch + save_batch_to_db(self.buffer) + self.buffer.clear() + +Related Documentation +===================== + +* :doc:`../python_api/events` - Event API reference +* :doc:`../python_api/mqtt_client` - MQTT client +* :doc:`../python_api/models` - Data models diff --git a/docs/guides/reservations.rst b/docs/guides/reservations.rst new file mode 100644 index 0000000..9c133b5 --- /dev/null +++ b/docs/guides/reservations.rst @@ -0,0 +1,687 @@ +===================== +Reservation Schedules +===================== + +Overview +======== + +Reservations (also called "scheduled programs") allow you to automatically +change your water heater's operating mode and temperature at specific times +of day. This is useful for: + +* **Morning preparation**: Switch to High Demand mode before your morning + shower +* **Energy optimization**: Use Energy Saver mode during the day when demand + is low +* **Weekend schedules**: Different settings for weekdays vs. weekends +* **Vacation mode**: Automatically enable vacation mode during extended + absences + +Reservations are stored on the device itself and execute locally, so they +continue to work even if your internet connection is lost. + +Quick Example +============= + +Here's a simple example that sets up a weekday morning reservation: + +.. code-block:: python + + import asyncio + from nwp500 import ( + NavienAuthClient, + NavienAPIClient, + NavienMqttClient + ) + + async def main(): + async with NavienAuthClient( + "email@example.com", + "password" + ) as auth: + # Get device + api = NavienAPIClient(auth) + device = await api.get_first_device() + + # Build reservation entry + weekday_morning = NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Monday", "Tuesday", "Wednesday", "Thursday", + "Friday"], + hour=6, + minute=30, + mode_id=4, # High Demand + param=120 # 140°F display (120 + 20) + ) + + # Send to device + mqtt = NavienMqttClient(auth) + await mqtt.connect() + await mqtt.update_reservations( + device, + [weekday_morning], + enabled=True + ) + await mqtt.disconnect() + + asyncio.run(main()) + +Reservation Entry Format +========================= + +Each reservation entry is a dictionary with the following fields: + +JSON Schema +----------- + +.. code-block:: json + + { + "enable": 1, + "week": 62, + "hour": 6, + "min": 30, + "mode": 4, + "param": 120 + } + +Field Descriptions +------------------ + +``enable`` (integer, required) + Enable flag for this reservation entry: + + * ``1`` - Enabled (reservation will execute) + * ``2`` - Disabled (reservation is stored but won't execute) + +``week`` (integer, required) + Bitfield representing days of the week when this reservation should run. + Each bit corresponds to a day: + + * Bit 0 (value 1): Sunday + * Bit 1 (value 2): Monday + * Bit 2 (value 4): Tuesday + * Bit 3 (value 8): Wednesday + * Bit 4 (value 16): Thursday + * Bit 5 (value 32): Friday + * Bit 6 (value 64): Saturday + + **Examples:** + + * Weekdays only: ``62`` (binary: 0111110 = Mon+Tue+Wed+Thu+Fri) + * Weekends only: ``65`` (binary: 1000001 = Sun+Sat) + * Every day: ``127`` (binary: 1111111 = all days) + * Monday only: ``2`` (binary: 0000010) + +``hour`` (integer, required) + Hour when reservation should execute (24-hour format, 0-23). + +``min`` (integer, required) + Minute when reservation should execute (0-59). + +``mode`` (integer, required) + DHW operation mode to switch to. Valid mode IDs: + + * ``1`` - Heat Pump Only + * ``2`` - Electric Heater Only + * ``3`` - Energy Saver (Eco Mode) + * ``4`` - High Demand + * ``5`` - Vacation Mode + * ``6`` - Power Off + +``param`` (integer, required) + Mode-specific parameter value. For temperature modes (1-4), this is the + target water temperature with a **20°F offset**: + + * Display temperature = ``param + 20`` + * Message value = Display temperature - 20 + + **Temperature Examples:** + + * 120°F display → ``param = 100`` + * 130°F display → ``param = 110`` + * 140°F display → ``param = 120`` + * 150°F display → ``param = 130`` + + For non-temperature modes (Vacation, Power Off), the param value is + typically ignored but should be set to a valid temperature offset + (e.g., ``100``) for consistency. + +Helper Functions +================ + +The library provides helper functions to make building reservations easier. + +Building Reservation Entries +----------------------------- + +Use ``build_reservation_entry()`` to create properly formatted entries: + +.. code-block:: python + + from nwp500 import NavienAPIClient + + # Weekday morning - High Demand mode at 140°F + entry = NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + hour=6, + minute=30, + mode_id=4, # High Demand + param=120 # 140°F (120 + 20) + ) + # Returns: {'enable': 1, 'week': 62, 'hour': 6, 'min': 30, + # 'mode': 4, 'param': 120} + + # Weekend - Energy Saver mode at 120°F + entry2 = NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Saturday", "Sunday"], + hour=8, + minute=0, + mode_id=3, # Energy Saver + param=100 # 120°F (100 + 20) + ) + + # You can also use day indices (0=Sunday, 6=Saturday) + entry3 = NavienAPIClient.build_reservation_entry( + enabled=True, + days=[1, 2, 3, 4, 5], # Monday-Friday + hour=18, + minute=0, + mode_id=1, # Heat Pump Only + param=110 # 130°F (110 + 20) + ) + +Encoding Week Bitfields +------------------------ + +To manually encode days into a bitfield: + +.. code-block:: python + + from nwp500.encoding import encode_week_bitfield + + # From day names + weekdays = encode_week_bitfield( + ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + ) + # Returns: 62 + + # From day indices (0-6, Sunday=0) + weekends = encode_week_bitfield([0, 6]) + # Returns: 65 (Sunday + Saturday) + + # Mixed case and whitespace are handled + days = encode_week_bitfield(["monday", " Tuesday ", "WEDNESDAY"]) + # Returns: 14 + +Decoding Week Bitfields +------------------------ + +To decode a bitfield back to day names: + +.. code-block:: python + + from nwp500.encoding import decode_week_bitfield + + days = decode_week_bitfield(62) + # Returns: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] + + days = decode_week_bitfield(127) + # Returns: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', + # 'Friday', 'Saturday'] + +Managing Reservations +====================== + +Updating Reservations +--------------------- + +Send a new reservation schedule to the device: + +.. code-block:: python + + async def update_schedule(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + # Build multiple reservation entries + reservations = [ + # Weekday morning: High Demand at 140°F + NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Monday", "Tuesday", "Wednesday", "Thursday", + "Friday"], + hour=6, + minute=30, + mode_id=4, + param=120 + ), + # Weekday evening: Energy Saver at 130°F + NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Monday", "Tuesday", "Wednesday", "Thursday", + "Friday"], + hour=18, + minute=0, + mode_id=3, + param=110 + ), + # Weekend: Heat Pump Only at 120°F + NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Saturday", "Sunday"], + hour=8, + minute=0, + mode_id=1, + param=100 + ), + ] + + # Send to device + mqtt = NavienMqttClient(auth) + await mqtt.connect() + await mqtt.update_reservations( + device, + reservations, + enabled=True # Enable reservation system + ) + await mqtt.disconnect() + +Reading Current Reservations +----------------------------- + +Request the current reservation schedule from the device: + +.. code-block:: python + + import asyncio + from typing import Any + + async def read_schedule(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Subscribe to reservation responses + response_topic = ( + f"cmd/{device.device_info.device_type}/" + f"{mqtt.config.client_id}/res/rsv/rd" + ) + + def on_reservation_response( + topic: str, + message: dict[str, Any] + ) -> None: + response = message.get("response", {}) + use = response.get("reservationUse", 0) + entries = response.get("reservation", []) + + print(f"Reservation System: " + f"{'Enabled' if use == 1 else 'Disabled'}") + print(f"Number of entries: {len(entries)}") + + for idx, entry in enumerate(entries, 1): + days = NavienAPIClient.decode_week_bitfield( + entry.get("week", 0) + ) + hour = entry.get("hour", 0) + minute = entry.get("min", 0) + mode = entry.get("mode", 0) + display_temp = entry.get("param", 0) + 20 + + print(f"\nEntry {idx}:") + print(f" Time: {hour:02d}:{minute:02d}") + print(f" Days: {', '.join(days)}") + print(f" Mode: {mode}") + print(f" Temp: {display_temp}°F") + + await mqtt.subscribe(response_topic, on_reservation_response) + + # Request current schedule + await mqtt.request_reservations(device) + + # Wait for response + await asyncio.sleep(5) + await mqtt.disconnect() + +Disabling Reservations +----------------------- + +To disable the reservation system while keeping entries stored: + +.. code-block:: python + + async def disable_reservations(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Keep existing entries but disable execution + await mqtt.update_reservations( + device, + [], # Empty list keeps existing entries + enabled=False # Disable reservation system + ) + + await mqtt.disconnect() + +Clearing All Reservations +-------------------------- + +To completely clear the reservation schedule: + +.. code-block:: python + + async def clear_reservations(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Send empty list with disabled flag + await mqtt.update_reservations( + device, + [], + enabled=False + ) + + await mqtt.disconnect() + +Common Patterns +=============== + +Weekday vs. Weekend Schedules +------------------------------ + +Different settings for work days and weekends: + +.. code-block:: python + + reservations = [ + # Weekday morning: early start, high demand + NavienAPIClient.build_reservation_entry( + enabled=True, + days=[1, 2, 3, 4, 5], # Mon-Fri + hour=5, + minute=30, + mode_id=4, # High Demand + param=120 # 140°F + ), + # Weekend morning: later start, energy saver + NavienAPIClient.build_reservation_entry( + enabled=True, + days=[0, 6], # Sun, Sat + hour=8, + minute=0, + mode_id=3, # Energy Saver + param=110 # 130°F + ), + ] + +Energy Optimization Schedule +----------------------------- + +Minimize energy use during peak hours: + +.. code-block:: python + + reservations = [ + # Morning prep: 6:00 AM - High Demand for showers + NavienAPIClient.build_reservation_entry( + enabled=True, + days=[1, 2, 3, 4, 5], + hour=6, + minute=0, + mode_id=4, + param=120 + ), + # Day: 9:00 AM - Switch to Energy Saver + NavienAPIClient.build_reservation_entry( + enabled=True, + days=[1, 2, 3, 4, 5], + hour=9, + minute=0, + mode_id=3, + param=100 + ), + # Evening: 5:00 PM - Heat Pump Only (before peak pricing) + NavienAPIClient.build_reservation_entry( + enabled=True, + days=[1, 2, 3, 4, 5], + hour=17, + minute=0, + mode_id=1, + param=110 + ), + # Night: 10:00 PM - Back to Energy Saver + NavienAPIClient.build_reservation_entry( + enabled=True, + days=[1, 2, 3, 4, 5], + hour=22, + minute=0, + mode_id=3, + param=100 + ), + ] + +Vacation Mode Automation +------------------------- + +Automatically enable vacation mode during a trip: + +.. code-block:: python + + # Enable vacation mode at start of trip + start_vacation = NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Friday"], # Leaving Friday evening + hour=20, + minute=0, + mode_id=5, # Vacation Mode + param=100 # Temperature doesn't matter for vacation mode + ) + + # Return to normal operation when you get back + end_vacation = NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Sunday"], # Returning Sunday afternoon + hour=14, + minute=0, + mode_id=3, # Energy Saver + param=110 # 130°F + ) + + reservations = [start_vacation, end_vacation] + +Important Notes +=============== + +Temperature Offset +------------------ + +The ``param`` field uses a **20°F offset** from the display temperature: + +* If you want the display to show 140°F, use ``param=120`` +* If you see ``param=100`` in a response, it means 120°F display +* This offset applies to all temperature-based modes (Heat Pump, Electric, + Energy Saver, High Demand) + +Device Limits +------------- + +* The device can store a limited number of reservation entries (typically + around 10-20) +* Entries are stored in order and execute based on time and day matching +* If multiple entries match the same time, the last one sent takes + precedence +* Reservations execute in the device's local time zone + +Execution Timing +---------------- + +* Reservations execute at the exact minute specified +* The device checks for matching reservations every minute +* If the device is powered off, reservations will not execute (use mode 6 + in a reservation to power off) +* Reservations persist through power cycles and internet outages + +Complete Example +================ + +Full working example with error handling and response monitoring: + +.. code-block:: python + + #!/usr/bin/env python3 + """Complete reservation management example.""" + + import asyncio + import os + import sys + from typing import Any + + from nwp500 import ( + NavienAPIClient, + NavienAuthClient, + NavienMqttClient + ) + + + async def main() -> None: + # Get credentials + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + + if not email or not password: + print("Error: Set NAVIEN_EMAIL and NAVIEN_PASSWORD") + sys.exit(1) + + async with NavienAuthClient(email, password) as auth: + # Get device + api = NavienAPIClient(auth) + device = await api.get_first_device() + if not device: + print("No devices found") + return + + print(f"Managing reservations for: " + f"{device.device_info.device_name}") + + # Build comprehensive schedule + reservations = [ + # Weekday morning + NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Monday", "Tuesday", "Wednesday", "Thursday", + "Friday"], + hour=6, + minute=30, + mode_id=4, # High Demand + param=120 # 140°F + ), + # Weekday day + NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Monday", "Tuesday", "Wednesday", "Thursday", + "Friday"], + hour=9, + minute=0, + mode_id=3, # Energy Saver + param=100 # 120°F + ), + # Weekend morning + NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Saturday", "Sunday"], + hour=8, + minute=0, + mode_id=3, # Energy Saver + param=110 # 130°F + ), + ] + + # Connect to MQTT + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Set up response handler + response_topic = ( + f"cmd/{device.device_info.device_type}/" + f"{mqtt.config.client_id}/res/rsv/rd" + ) + + response_received = asyncio.Event() + + def on_response(topic: str, message: dict[str, Any]) -> None: + response = message.get("response", {}) + use = response.get("reservationUse", 0) + entries = response.get("reservation", []) + + print(f"\nReservation System: " + f"{'Enabled' if use == 1 else 'Disabled'}") + print(f"Active entries: {len(entries)}\n") + + for idx, entry in enumerate(entries, 1): + days = NavienAPIClient.decode_week_bitfield( + entry["week"] + ) + print(f"Entry {idx}: {entry['hour']:02d}:" + f"{entry['min']:02d} - Mode {entry['mode']} - " + f"{entry['param'] + 20}°F - " + f"{', '.join(days)}") + + response_received.set() + + await mqtt.subscribe(response_topic, on_response) + + # Send new schedule + print("\nUpdating reservation schedule...") + await mqtt.update_reservations( + device, + reservations, + enabled=True + ) + print("Update sent") + + # Request confirmation + print("\nRequesting current schedule...") + await mqtt.request_reservations(device) + + # Wait for response + try: + await asyncio.wait_for( + response_received.wait(), + timeout=10.0 + ) + except asyncio.TimeoutError: + print("Warning: No response received within 10 seconds") + + await mqtt.disconnect() + print("\nDone") + + + if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nCancelled") + +See Also +======== + +* :doc:`/guides/time_of_use` - Time-of-Use pricing optimization +* :doc:`/python_api/mqtt_client` - MQTT client API reference +* :doc:`/protocol/mqtt_protocol` - MQTT protocol details +* :doc:`/python_api/api_client` - API client reference (includes + ``build_reservation_entry()``) diff --git a/docs/TIME_OF_USE.rst b/docs/guides/time_of_use.rst similarity index 77% rename from docs/TIME_OF_USE.rst rename to docs/guides/time_of_use.rst index c1b4065..0616975 100644 --- a/docs/TIME_OF_USE.rst +++ b/docs/guides/time_of_use.rst @@ -1,11 +1,10 @@ -========================== Time of Use (TOU) Pricing ========================== The Navien NWP500 supports Time of Use (TOU) pricing schedules, allowing the water heater to optimize heating based on electricity rates that vary throughout the day. The Navien mobile app integrates with the OpenEI (Open Energy Information) API to retrieve utility rate information. Overview -======== +-------- Time of Use pricing enables: @@ -18,19 +17,19 @@ Time of Use pricing enables: The system uses utility rate data from OpenEI to automatically configure optimal heating schedules based on your location and utility provider. OpenEI API Integration -====================== +---------------------- The Navien mobile app queries the OpenEI Utility Rates API to retrieve current electricity rate information for the user's location. This allows the app to present available rate plans and configure TOU schedules automatically. API Endpoint ------------ +~~~~~~~~~~~~ .. code-block:: text GET https://api.openei.org/utility_rates Query Parameters ---------------- +~~~~~~~~~~~~~~~~ The following parameters are used to query utility rates: @@ -70,14 +69,14 @@ The following parameters are used to query utility rates: - Maximum number of results (``100``) Example Request --------------- +~~~~~~~~~~~~~~~ .. code-block:: text GET https://api.openei.org/utility_rates?version=7&format=json&api_key=YOUR_API_KEY&detail=full&address=94903§or=Residential&orderby=startdate&direction=desc&limit=100 Response Format --------------- +~~~~~~~~~~~~~~~ The API returns a JSON response with an array of utility rate plans: @@ -122,7 +121,7 @@ The API returns a JSON response with an array of utility rate plans: } Key Response Fields -^^^^^^^^^^^^^^^^^^ +""""""""""""""""""" .. list-table:: :widths: 25 15 60 @@ -160,7 +159,7 @@ Key Response Fields - Units for fixed charges (e.g., ``$/month``) Rate Structure -------------- +~~~~~~~~~~~~~~ The ``energyratestructure`` field contains tiered pricing: @@ -170,7 +169,7 @@ The ``energyratestructure`` field contains tiered pricing: * ``max`` field indicates the upper limit for that tier (optional) Hour-by-Hour Schedules ---------------------- +~~~~~~~~~~~~~~~~~~~~~~ The ``energyweekdayschedule`` and ``energyweekendschedule`` arrays map rate periods: @@ -180,12 +179,12 @@ The ``energyweekdayschedule`` and ``energyweekendschedule`` arrays map rate peri * ``0`` typically represents off-peak, ``1`` represents on-peak TOU API Methods -============== +--------------- The library provides methods for working with TOU information through both REST API and MQTT. REST API: Get TOU Info ---------------------- +~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -223,7 +222,7 @@ Retrieves stored TOU configuration from the Navien cloud API. schedule: List[TOUSchedule] # TOU schedule periods MQTT: Configure TOU Schedule ----------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -244,7 +243,7 @@ Configures the TOU schedule directly on the device via MQTT. * ``enabled``: Whether to enable TOU scheduling (default: ``True``) MQTT: Enable/Disable TOU ------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -261,7 +260,7 @@ Enables or disables TOU operation without changing the schedule. * ``enabled``: ``True`` to enable TOU, ``False`` to disable MQTT: Request TOU Settings --------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -284,15 +283,15 @@ The device will respond on the topic: cmd/{deviceType}/{deviceId}/res/tou/rd Building TOU Periods -=================== +-------------------- Helper Methods -------------- +~~~~~~~~~~~~~~ The ``NavienAPIClient`` class provides helper methods for building TOU period configurations: build_tou_period() -^^^^^^^^^^^^^^^^^ +"""""""""""""""""" .. code-block:: python @@ -328,7 +327,7 @@ Creates a TOU period configuration dictionary. Dictionary with encoded TOU period data ready for MQTT transmission. encode_price() -^^^^^^^^^^^^^ +"""""""""""""" .. code-block:: python @@ -346,7 +345,7 @@ Encodes a floating-point price into an integer for transmission. # Returns: 45000 decode_price() -^^^^^^^^^^^^^ +"""""""""""""" .. code-block:: python @@ -364,7 +363,7 @@ Decodes an integer price back to floating-point. # Returns: 0.45 encode_week_bitfield() -^^^^^^^^^^^^^^^^^^^^^ +"""""""""""""""""""""" .. code-block:: python @@ -394,7 +393,7 @@ Encodes a list of day names into a bitfield. # Returns: 0b0111110 = 62 decode_week_bitfield() -^^^^^^^^^^^^^^^^^^^^^ +"""""""""""""""""""""" .. code-block:: python @@ -412,7 +411,7 @@ Decodes a bitfield back into a list of day names. # Returns: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] Usage Examples -============= +============== Example 1: Simple TOU Schedule ------------------------------ @@ -486,7 +485,7 @@ Configure two rate periods - off-peak and peak pricing: asyncio.run(configure_simple_tou()) Example 2: Complex Seasonal Schedule ------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Configure different rates for summer and winter: @@ -566,7 +565,7 @@ Configure different rates for summer and winter: asyncio.run(configure_seasonal_tou()) Example 3: Retrieve Current TOU Settings ----------------------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Query the device for its current TOU configuration: @@ -622,7 +621,7 @@ Query the device for its current TOU configuration: asyncio.run(check_tou_settings()) Example 4: Toggle TOU On/Off ----------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Enable or disable TOU operation: @@ -648,11 +647,173 @@ Enable or disable TOU operation: # Disable TOU asyncio.run(toggle_tou(False)) +Example 5: Retrieve Schedule from OpenEI API +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This example demonstrates the complete workflow of retrieving utility rate +data from the OpenEI API and configuring it on your device: + +.. code-block:: python + + import asyncio + import aiohttp + from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient + + OPENEI_API_URL = "https://api.openei.org/utility_rates" + OPENEI_API_KEY = "DEMO_KEY" # Get your own key at openei.org + + async def fetch_openei_rates(zip_code: str, api_key: str): + """Fetch utility rates from OpenEI API.""" + params = { + "version": 7, + "format": "json", + "api_key": api_key, + "detail": "full", + "address": zip_code, + "sector": "Residential", + "orderby": "startdate", + "direction": "desc", + "limit": 100, + } + + async with aiohttp.ClientSession() as session: + async with session.get(OPENEI_API_URL, params=params) as response: + response.raise_for_status() + return await response.json() + + def select_tou_rate_plan(rate_data): + """Select first approved residential TOU plan.""" + for plan in rate_data.get("items", []): + if ( + plan.get("approved") + and plan.get("sector") == "Residential" + and "energyweekdayschedule" in plan + and "energyratestructure" in plan + ): + return plan + return None + + def convert_openei_to_tou_periods(rate_plan): + """Convert OpenEI rate structure to Navien TOU periods.""" + weekday_schedule = rate_plan["energyweekdayschedule"][0] + rate_structure = rate_plan["energyratestructure"][0] + + # Map period indices to rates + period_rates = {} + for idx, tier in enumerate(rate_structure): + period_rates[idx] = tier.get("rate", 0.0) + + # Find continuous time blocks + periods = [] + current_period = None + start_hour = 0 + + for hour in range(24): + period_idx = weekday_schedule[hour] + + if period_idx != current_period: + if current_period is not None: + # Save previous period + periods.append({ + "start_hour": start_hour, + "end_hour": hour - 1, + "end_minute": 59, + "rate": period_rates.get(current_period, 0.0), + }) + current_period = period_idx + start_hour = hour + + # Last period + periods.append({ + "start_hour": start_hour, + "end_hour": 23, + "end_minute": 59, + "rate": period_rates.get(current_period, 0.0), + }) + + # Convert to TOU format + weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + return [ + NavienAPIClient.build_tou_period( + season_months=range(1, 13), + week_days=weekdays, + start_hour=p["start_hour"], + start_minute=0, + end_hour=p["end_hour"], + end_minute=p["end_minute"], + price_min=p["rate"], + price_max=p["rate"], + decimal_point=5, + ) + for p in periods + ] + + async def configure_openei_schedule(): + """Main function to retrieve and configure TOU from OpenEI.""" + zip_code = "94103" # San Francisco example + + # Fetch and parse OpenEI data + rate_data = await fetch_openei_rates(zip_code, OPENEI_API_KEY) + rate_plan = select_tou_rate_plan(rate_data) + + if not rate_plan: + print("No suitable TOU rate plan found") + return + + print(f"Using plan: {rate_plan['name']}") + print(f"Utility: {rate_plan['utility']}") + + tou_periods = convert_openei_to_tou_periods(rate_plan) + + # Configure on device + async with NavienAuthClient("user@example.com", "password") as auth: + api_client = NavienAPIClient(auth_client=auth) + device = await api_client.get_first_device() + + mqtt_client = NavienMqttClient(auth) + await mqtt_client.connect() + + # Get controller serial (see Example 1 for full code) + # ... obtain controller_serial ... + + # Configure the schedule + await mqtt_client.configure_tou_schedule( + device=device, + controller_serial_number=controller_serial, + periods=tou_periods, + enabled=True, + ) + + print(f"Configured {len(tou_periods)} TOU periods from OpenEI") + await mqtt_client.disconnect() + + asyncio.run(configure_openei_schedule()) + +**Key Points:** + +* The OpenEI API requires a free API key (register at openei.org) +* The ``DEMO_KEY`` is rate-limited and suitable for testing only +* Rate structures vary by utility - this example handles simple TOU plans +* Complex tiered rates may require additional logic to flatten into periods +* The example uses weekday schedules; extend for weekends as needed +* Set ``ZIP_CODE`` environment variable to search your location + +**Required Dependencies:** + +.. code-block:: bash + + pip install aiohttp + +**Complete Working Example:** + +See ``examples/tou_openei_example.py`` for a fully working implementation +with error handling, weekend support, and detailed console output. + MQTT Message Format -================== +------------------- TOU Control Topic ----------------- +~~~~~~~~~~~~~~~~~ To configure TOU settings, publish to: @@ -688,7 +849,7 @@ Message payload: } Field Descriptions -^^^^^^^^^^^^^^^^^ +"""""""""""""""""" .. list-table:: :widths: 25 15 60 @@ -729,7 +890,7 @@ Field Descriptions - Number of decimal places in price encoding TOU Response Topic ------------------ +~~~~~~~~~~~~~~~~~~ The device responds on: @@ -740,7 +901,7 @@ The device responds on: Response payload matches the control payload format. TOU Status in Device State --------------------------- +~~~~~~~~~~~~~~~~~~~~~~~~~~~ The device status includes TOU-related fields: @@ -754,10 +915,10 @@ The device status includes TOU-related fields: * ``touStatus``: ``1`` if TOU scheduling is active, ``0`` if inactive * ``touOverrideStatus``: ``1`` if user has temporarily overridden TOU schedule -See :doc:`DEVICE_STATUS_FIELDS` for more details. +See :doc:`../protocol/device_status` for more details. Best Practices -============= +-------------- 1. **Obtain controller serial number first** @@ -788,7 +949,7 @@ Best Practices Use ``asyncio.wait_for()`` with appropriate timeouts when waiting for device responses. Limitations -========== +----------- * Maximum 16 TOU periods per configuration * Time resolution limited to minutes (no seconds) @@ -797,18 +958,19 @@ Limitations * No support for variable rate structures (e.g., tiered rates) - only flat rate per period Further Reading -============== +--------------- -* :doc:`API_CLIENT` - API client documentation and ``get_tou_info()`` method -* :doc:`MQTT_CLIENT` - MQTT client and TOU configuration methods -* :doc:`MQTT_MESSAGES` - MQTT message formats including TOU commands -* :doc:`DEVICE_STATUS_FIELDS` - Device status fields including ``touStatus`` +* :doc:`../python_api/api_client` - API client documentation and ``get_tou_info()`` method +* :doc:`../python_api/mqtt_client` - MQTT client and TOU configuration methods +* :doc:`../protocol/mqtt_protocol` - MQTT message formats including TOU commands +* :doc:`../protocol/device_status` - Device status fields including ``touStatus`` * `OpenEI Utility Rates API `__ - Official OpenEI API documentation * `OpenEI IURDB `__ - Interactive Utility Rate Database Related Examples -=============== +---------------- -* ``examples/tou_schedule_example.py`` - Complete working example of TOU configuration +* ``examples/tou_schedule_example.py`` - Complete working example of manual TOU configuration +* ``examples/tou_openei_example.py`` - Retrieve TOU schedules from OpenEI API and configure device For questions or issues related to TOU functionality, please refer to the project repository. diff --git a/docs/index.rst b/docs/index.rst index bd47726..d925185 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,345 +2,147 @@ nwp500-python ============= -Python client library for Navien NWP500 water heaters. +Python client library for Navien NWP500 heat pump water heaters. -Features +.. image:: https://img.shields.io/pypi/v/nwp500-python.svg + :target: https://pypi.org/project/nwp500-python/ + :alt: PyPI version + +.. image:: https://img.shields.io/pypi/pyversions/nwp500-python.svg + :target: https://pypi.org/project/nwp500-python/ + :alt: Python versions + +Overview ======== -* **REST API Client** - Full implementation of Navien Smart Control API +This library provides a complete Python interface to Navien NWP500 heat +pump water heaters through the Navien Smart Control cloud platform. It +supports both REST API and real-time MQTT communication. + +**Key Features:** + +* **REST API Client** - Complete implementation of Navien Smart Control + API * **MQTT Client** - Real-time device communication via AWS IoT Core -* **Authentication** - JWT-based authentication with automatic token refresh -* **Type Safety** - Comprehensive data models for all API responses +* **Authentication** - JWT-based auth with automatic token refresh +* **Type Safety** - Comprehensive type-annotated data models +* **Event System** - Subscribe to device state changes with callbacks * **Energy Monitoring** - Track power consumption and usage statistics +* **Time-of-Use (TOU)** - Optimize for variable electricity pricing +* **Async/Await** - Fully asynchronous, non-blocking operations -Quick Start Guide -================= - -Get started with the nwp500 Python library. +Quick Start +=========== Installation ------------ .. code-block:: bash - pip install nwp500 - -Or install from source: + pip install nwp500-python -.. code-block:: bash - - git clone https://github.com/eman/nwp500-python.git - cd nwp500-python - pip install -e . - -Prerequisites +Basic Example ------------- -- Python 3.9+ -- Navilink Smart Control account -- At least one Navien NWP500 device registered to your account - -Basic Usage ------------ - -1. Authentication -^^^^^^^^^^^^^^^^^ - .. code-block:: python import asyncio - from nwp500 import NavienAuthClient - - async def authenticate(): - async with NavienAuthClient("email@example.com", "password") as client: - # Already authenticated! - print(f"Access Token: {client.current_tokens.access_token[:20]}...") - print(f"Logged in as: {client.user_email}") - - asyncio.run(authenticate()) - -2. List Your Devices -^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient - - async def list_devices(): - async with NavienAuthClient("email@example.com", "password") as auth_client: - - api_client = NavienAPIClient(auth_client=auth_client) - devices = await api_client.list_devices() - - for device in devices: - print(f"Device: {device.device_info.device_name}") - print(f" MAC: {device.device_info.mac_address}") - print(f" Type: {device.device_info.device_type}") - - asyncio.run(list_devices()) - -3. Monitor Device Status (Real-time) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient - async def monitor_device(): - # Authenticate once - async with NavienAuthClient("email@example.com", "password") as auth_client: - - # Get devices using API client - api_client = NavienAPIClient(auth_client=auth_client) - device = await api_client.get_first_device() - - # Connect to MQTT - mqtt = NavienMqttClient(auth_client) + async def main(): + # Authenticate (credentials from env vars or direct) + async with NavienAuthClient( + "email@example.com", + "password" + ) as auth: + + # Get device list via REST API + api = NavienAPIClient(auth) + device = await api.get_first_device() + print(f"Device: {device.device_info.device_name}") + + # Connect to MQTT for real-time control + mqtt = NavienMqttClient(auth) await mqtt.connect() - # Define status callback + # Monitor device status def on_status(status): - print(f"\nDevice Status Update:") - print(f" Temperature: {status.dhwTemperature}°F") - print(f" Target: {status.dhwTemperatureSetting}°F") - print(f" Power: {status.currentInstPower}W") - print(f" Energy: {status.availableEnergyCapacity}%") + print(f"Temp: {status.dhwTemperature}°F") + print(f"Power: {status.currentInstPower}W") - # Subscribe to status updates await mqtt.subscribe_device_status(device, on_status) - - # Request initial status await mqtt.request_device_status(device) - # Monitor for 60 seconds - await asyncio.sleep(60) - await mqtt.disconnect() - - asyncio.run(monitor_device()) - -4. Control Your Device -^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient - - async def control_device(): - # Authenticate and connect - async with NavienAuthClient("email@example.com", "password") as auth_client: - - # Get device using API client - api_client = NavienAPIClient(auth_client=auth_client) - device = await api_client.get_first_device() - - mqtt = NavienMqttClient(auth_client) - await mqtt.connect() - - # Turn on the device + # Control device await mqtt.set_power(device, power_on=True) - print("Device powered on") - - # Set to Energy Saver mode - await mqtt.set_dhw_mode(device, mode_id=4) - print("Set to Energy Saver mode") - - # Set target temperature to 120°F await mqtt.set_dhw_temperature(device, temperature=120) - print("Temperature set to 120°F") - await asyncio.sleep(2) + await asyncio.sleep(30) await mqtt.disconnect() - asyncio.run(control_device()) - -Operation Modes ---------------- + asyncio.run(main()) -The NWP500 supports four DHW operation modes: +Documentation Index +=================== -.. list-table:: - :header-rows: 1 - :widths: 10 20 70 - - * - Mode ID - - Name - - Description - * - 1 - - Heat Pump Only - - Use heat pump exclusively (most efficient) - * - 2 - - Electric Only - - Use electric heating elements only - * - 3 - - Energy Saver - - Balanced mode (heat pump + electric as needed) - * - 4 - - High Demand - - Maximum heating (all components as needed) - -.. note:: - Additional modes may appear in device status: - - - Mode 0: Standby (device in idle state) - - Mode 6: Power Off (device is powered off) - -Configuration with Environment Variables ----------------------------------------- - -Store credentials securely using environment variables: - -.. code-block:: bash - - export NAVIEN_EMAIL="email@example.com" - export NAVIEN_PASSWORD="your_password" - -Then in your code: - -.. code-block:: python - - import os - from nwp500 import NavienAuthClient, NavienAPIClient - - email = os.getenv("NAVIEN_EMAIL") - password = os.getenv("NAVIEN_PASSWORD") - - async with NavienAuthClient(email, password) as auth_client: - api_client = NavienAPIClient(auth_client=auth_client) - devices = await api_client.list_devices() - -Complete Example ----------------- - -Here's a complete example that demonstrates all major features: - -.. code-block:: python - - import asyncio - import os - from nwp500 import ( - NavienAuthClient, - NavienAPIClient, - NavienMqttClient, - DeviceStatus - ) - - async def main(): - # Get credentials from environment - email = os.getenv("NAVIEN_EMAIL") - password = os.getenv("NAVIEN_PASSWORD") - - if not email or not password: - print("Please set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables") - return - - print("Authenticating...") - async with NavienAuthClient(email, password) as auth_client: - print(f"Logged in as: {auth_client.user_email}") - - # Get device list - api_client = NavienAPIClient(auth_client=auth_client) - devices = await api_client.list_devices() - - device = devices[0] - - print(f"Connected to device: {device.device_info.device_name}") - print(f"MAC Address: {device.device_info.mac_address}") - - # Connect MQTT - mqtt_client = NavienMqttClient(auth_client) - await mqtt_client.connect() - print(f"MQTT Connected: {mqtt_client.client_id}") - - # Status monitoring - update_count = 0 - - def on_status(status: DeviceStatus): - nonlocal update_count - update_count += 1 - - print(f"\n--- Status Update #{update_count} ---") - print(f"Water Temperature: {status.dhwTemperature}°F " - f"(Target: {status.dhwTemperatureSetting}°F)") - print(f"Power Consumption: {status.currentInstPower}W") - print(f"Energy Capacity: {status.availableEnergyCapacity}%") - - # Show active components - active = [] - if status.compUse: - active.append("Heat Pump") - if status.heatUpperUse: - active.append("Upper Heater") - if status.heatLowerUse: - active.append("Lower Heater") - - if active: - print(f"Active Components: {', '.join(active)}") - else: - print("Active Components: None (Standby)") - - # Subscribe - await mqtt_client.subscribe_device_status(device, on_status) - - # Request status - await mqtt_client.request_device_status(device) - - # Monitor for 30 seconds - print("\nMonitoring device for 30 seconds...") - await asyncio.sleep(30) - - # Cleanup - await mqtt_client.disconnect() - print("\nDisconnected") +.. toctree:: + :maxdepth: 1 + :caption: Getting Started - if __name__ == "__main__": - asyncio.run(main()) + quickstart + installation + configuration +.. toctree:: + :maxdepth: 2 + :caption: Python API Reference -Documentation -============= + python_api/auth_client + python_api/api_client + python_api/mqtt_client + python_api/models + python_api/constants + python_api/events + python_api/exceptions + python_api/cli .. toctree:: :maxdepth: 2 - :caption: Getting Started + :caption: Complete Module Reference - Overview - Authentication - REST API Client - MQTT Client + api/modules .. toctree:: :maxdepth: 2 - :caption: User Guides + :caption: Protocol Reference - Command Queue - Event Emitter - Energy Monitoring - Time of Use (TOU) Pricing - Auto-Recovery Quick Reference - Auto-Recovery Complete Guide + protocol/rest_api + protocol/mqtt_protocol + protocol/device_status + protocol/device_features + protocol/error_codes + protocol/firmware_tracking .. toctree:: - :maxdepth: 2 - :caption: API Reference + :maxdepth: 1 + :caption: User Guides - API Reference (OpenAPI) - Device Status Fields - Device Feature Fields - Error Codes - MQTT Messages - Firmware Tracking + guides/reservations + guides/energy_monitoring + guides/time_of_use + guides/event_system + guides/command_queue + guides/auto_recovery .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Development - Development History - Contributing - License - Authors - Changelog - Module Reference - + development/contributing + development/history + changelog + license + authors Indices and tables ================== @@ -348,19 +150,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - -.. _toctree: https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html -.. _reStructuredText: https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html -.. _references: https://www.sphinx-doc.org/en/stable/markup/inline.html -.. _Python domain syntax: https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html#the-python-domain -.. _Sphinx: https://www.sphinx-doc.org/ -.. _Python: https://docs.python.org/ -.. _Numpy: https://numpy.org/doc/stable -.. _SciPy: https://docs.scipy.org/doc/scipy/reference/ -.. _matplotlib: https://matplotlib.org/contents.html# -.. _Pandas: https://pandas.pydata.org/pandas-docs/stable -.. _Scikit-Learn: https://scikit-learn.org/stable -.. _autodoc: https://www.sphinx-doc.org/en/master/ext/autodoc.html -.. _Google style: https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings -.. _NumPy style: https://numpydoc.readthedocs.io/en/latest/format.html -.. _classical style: https://www.sphinx-doc.org/en/master/domains.html#info-field-lists diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..b9ca36a --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,153 @@ +============== +Installation +============== + +Requirements +============ + +* Python 3.9 or higher +* pip (Python package installer) +* Navien Smart Control account + +Installing from PyPI +==================== + +The easiest way to install nwp500-python: + +.. code-block:: bash + + pip install nwp500-python + +This will install the library and all required dependencies. + +Installing from Source +====================== + +For development or to get the latest features: + +.. code-block:: bash + + git clone https://github.com/eman/nwp500-python.git + cd nwp500-python + pip install -e . + +Development Installation +======================== + +To install with development dependencies (testing, linting, docs): + +.. code-block:: bash + + git clone https://github.com/eman/nwp500-python.git + cd nwp500-python + pip install -e ".[dev]" + +Dependencies +============ + +Core Dependencies +----------------- + +The library requires: + +* ``aiohttp>=3.8.0`` - Async HTTP client for REST API +* ``awsiotsdk>=1.20.0`` - AWS IoT SDK for MQTT +* ``pydantic>=2.0.0`` - Data validation and models + +Optional Dependencies +--------------------- + +For development: + +* ``pytest>=7.0.0`` - Testing framework +* ``pytest-asyncio>=0.21.0`` - Async test support +* ``pytest-cov>=4.0.0`` - Coverage reporting +* ``ruff>=0.1.0`` - Fast Python linter +* ``mypy>=1.0.0`` - Static type checking +* ``sphinx>=5.0.0`` - Documentation generation + +Verification +============ + +Verify the installation: + +.. code-block:: python + + import nwp500 + print(nwp500.__version__) + +Or test with a simple script: + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient + import asyncio + + async def test(): + async with NavienAuthClient("email@test.com", "pass") as auth: + api = NavienAPIClient(auth) + # This will fail with bad credentials, but proves import works + try: + await api.list_devices() + except Exception as e: + print(f"Library loaded successfully: {type(e).__name__}") + + asyncio.run(test()) + +Troubleshooting +=============== + +ImportError: No module named 'nwp500' +-------------------------------------- + +Make sure you installed the package: + +.. code-block:: bash + + pip install nwp500-python + +If using a virtual environment, ensure it's activated. + +SSL/TLS Errors +-------------- + +If you get SSL certificate errors: + +.. code-block:: bash + + # macOS + /Applications/Python\ 3.x/Install\ Certificates.command + + # Linux (update certificates) + sudo apt-get update && sudo apt-get install ca-certificates + +AWS IoT Connection Issues +-------------------------- + +The MQTT client requires the AWS IoT SDK: + +.. code-block:: bash + + pip install awsiotsdk>=1.20.0 + +Upgrading +========= + +To upgrade to the latest version: + +.. code-block:: bash + + pip install --upgrade nwp500-python + +To upgrade to a specific version: + +.. code-block:: bash + + pip install nwp500-python==X.Y.Z + +Next Steps +========== + +* :doc:`quickstart` - Get started with your first script +* :doc:`configuration` - Configure credentials and options +* :doc:`python_api/auth_client` - Learn about authentication diff --git a/docs/DEVICE_FEATURE_FIELDS.rst b/docs/protocol/device_features.rst similarity index 97% rename from docs/DEVICE_FEATURE_FIELDS.rst rename to docs/protocol/device_features.rst index ca82b8f..49b7ebd 100644 --- a/docs/DEVICE_FEATURE_FIELDS.rst +++ b/docs/protocol/device_features.rst @@ -360,7 +360,7 @@ Usage Example See Also -------- -* :doc:`DEVICE_STATUS_FIELDS` - Real-time device status field reference -* :doc:`MQTT_CLIENT` - MQTT client usage guide for device communication -* :doc:`API_CLIENT` - REST API client for device management -* :doc:`ERROR_CODES` - Complete error code reference for diagnostics \ No newline at end of file +* :doc:`device_status` - Real-time device status field reference +* :doc:`../python_api/mqtt_client` - MQTT client usage guide for device communication +* :doc:`../python_api/api_client` - REST API client for device management +* :doc:`error_codes` - Complete error code reference for diagnostics \ No newline at end of file diff --git a/docs/DEVICE_STATUS_FIELDS.rst b/docs/protocol/device_status.rst similarity index 99% rename from docs/DEVICE_STATUS_FIELDS.rst rename to docs/protocol/device_status.rst index b3c5767..22228fc 100644 --- a/docs/DEVICE_STATUS_FIELDS.rst +++ b/docs/protocol/device_status.rst @@ -758,6 +758,6 @@ Technical Notes See Also -------- -* :doc:`ERROR_CODES` - Complete error code reference with diagnostics -* :doc:`ENERGY_MONITORING` - Energy consumption tracking -* :doc:`MQTT_MESSAGES` - Status message format details +* :doc:`error_codes` - Complete error code reference with diagnostics +* :doc:`../guides/energy_monitoring` - Energy consumption tracking +* :doc:`mqtt_protocol` - Status message format details diff --git a/docs/ERROR_CODES.rst b/docs/protocol/error_codes.rst similarity index 99% rename from docs/ERROR_CODES.rst rename to docs/protocol/error_codes.rst index c367fa5..5237602 100644 --- a/docs/ERROR_CODES.rst +++ b/docs/protocol/error_codes.rst @@ -340,6 +340,6 @@ Contact technical support at 1-800-519-8794 when: See Also -------- -* :doc:`DEVICE_STATUS_FIELDS` - Status field descriptions -* :doc:`MQTT_MESSAGES` - Error reporting via MQTT +* :doc:`device_status` - Status field descriptions +* :doc:`mqtt_protocol` - Error reporting via MQTT * Installation Manual - Complete technical specifications diff --git a/docs/FIRMWARE_TRACKING.rst b/docs/protocol/firmware_tracking.rst similarity index 100% rename from docs/FIRMWARE_TRACKING.rst rename to docs/protocol/firmware_tracking.rst diff --git a/docs/protocol/mqtt_protocol.rst b/docs/protocol/mqtt_protocol.rst new file mode 100644 index 0000000..7020351 --- /dev/null +++ b/docs/protocol/mqtt_protocol.rst @@ -0,0 +1,475 @@ +====================== +MQTT Protocol +====================== + +This document describes the MQTT protocol used for real-time communication +with Navien NWP500 devices via AWS IoT Core. + +.. note:: + Most users should use the Python :doc:`../python_api/mqtt_client` rather than + implementing the protocol directly. This documentation is for + understanding the underlying protocol or implementing clients in other + languages. + +Overview +======== + +**Protocol:** MQTT 3.1.1 over WebSockets +**Broker:** AWS IoT Core +**Authentication:** AWS SigV4 with temporary credentials +**Message Format:** JSON + +Topic Structure +=============== + +Topics follow a hierarchical structure: + +Command Topics +-------------- + +.. code-block:: text + + cmd/{deviceType}/{deviceId}/ctrl # Control commands + cmd/{deviceType}/{deviceId}/st # Status requests + cmd/{deviceType}/{clientId}/res/{type} # Responses + +Event Topics +------------ + +.. code-block:: text + + evt/{deviceType}/{deviceId}/app-connection # App connection signal + +**Variables:** + +* ``{deviceType}`` - Device type code (52 for NWP500) +* ``{deviceId}`` - Device MAC address (without colons) +* ``{clientId}`` - MQTT client ID +* ``{type}`` - Response type (status, info, energy-usage, etc.) + +Message Structure +================= + +All MQTT messages are JSON with this structure: + +.. code-block:: json + + { + "clientID": "client-12345", + "sessionID": "session-67890", + "requestTopic": "cmd/52/04786332fca0/ctrl", + "responseTopic": "cmd/52/client-12345/res/status/rd", + "protocolVersion": 2, + "request": { + "command": 33554438, + "deviceType": 52, + "macAddress": "04786332fca0", + "additionalValue": "...", + "mode": "dhw-temperature", + "param": [120], + "paramStr": "" + } + } + +**Fields:** + +* ``clientID`` - MQTT client identifier +* ``sessionID`` - Session identifier for tracking +* ``requestTopic`` - Topic where command was sent +* ``responseTopic`` - Topic to subscribe for responses +* ``protocolVersion`` - Protocol version (always 2) +* ``request`` - Command payload (see below) + +Request Object +============== + +.. code-block:: json + + { + "command": 33554438, + "deviceType": 52, + "macAddress": "04786332fca0", + "additionalValue": "...", + "mode": "dhw-temperature", + "param": [120], + "paramStr": "" + } + +**Fields:** + +* ``command`` (int) - Command code (see Command Codes below) +* ``deviceType`` (int) - Device type (52 for NWP500) +* ``macAddress`` (str) - Device MAC address +* ``additionalValue`` (str) - Additional device identifier +* ``mode`` (str, optional) - Operation mode for control commands +* ``param`` (array, optional) - Command parameters +* ``paramStr`` (str) - Parameter string +* ``month`` (array, optional) - Months for energy queries +* ``year`` (int, optional) - Year for energy queries + +Command Codes +============= + +Status and Info Requests +------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 40 20 40 + + * - Command + - Code + - Description + * - Device Status Request + - 16777221 + - Request current device status + * - Device Info Request + - 16777222 + - Request device features/capabilities + * - Reservation Read + - 16777222 + - Read reservation schedule + * - Energy Usage Query + - 33554435 + - Query energy usage data + +Control Commands +---------------- + +.. list-table:: + :header-rows: 1 + :widths: 40 20 40 + + * - Command + - Code + - Description + * - Power On + - 33554434 + - Turn device on + * - Power Off + - 33554433 + - Turn device off + * - Set DHW Mode + - 33554437 + - Change operation mode + * - Set DHW Temperature + - 33554438 + - Set target temperature + * - Enable Anti-Legionella + - 33554472 + - Enable anti-Legionella cycle + * - Disable Anti-Legionella + - 33554471 + - Disable anti-Legionella + * - Update Reservations + - 16777226 + - Update reservation schedule + * - Configure TOU + - 33554439 + - Configure TOU schedule + * - Enable TOU + - 33554476 + - Enable TOU optimization + * - Disable TOU + - 33554475 + - Disable TOU optimization + +Control Command Details +======================= + +Power Control +------------- + +**Power On:** + +.. code-block:: json + + { + "command": 33554434, + "mode": "power-on", + "param": [], + "paramStr": "" + } + +**Power Off:** + +.. code-block:: json + + { + "command": 33554433, + "mode": "power-off", + "param": [], + "paramStr": "" + } + +DHW Mode +-------- + +.. code-block:: json + + { + "command": 33554437, + "mode": "dhw-mode", + "param": [3], + "paramStr": "" + } + +**Mode Values:** + +* 1 = Heat Pump Only +* 2 = Electric Only +* 3 = Energy Saver +* 4 = High Demand +* 5 = Vacation (requires second param: days) + +**Vacation Example:** + +.. code-block:: json + + { + "command": 33554437, + "mode": "dhw-mode", + "param": [5, 7], + "paramStr": "" + } + +DHW Temperature +--------------- + +.. code-block:: json + + { + "command": 33554438, + "mode": "dhw-temperature", + "param": [120], + "paramStr": "" + } + +.. important:: + Temperature is 20°F less than display value. For 140°F display, + send 120°F. + +Anti-Legionella +--------------- + +**Enable (7-day cycle):** + +.. code-block:: json + + { + "command": 33554472, + "mode": "anti-legionella-setting", + "param": [2, 7], + "paramStr": "" + } + +**Disable:** + +.. code-block:: json + + { + "command": 33554471, + "mode": "anti-legionella-setting", + "param": [1], + "paramStr": "" + } + +Energy Usage Query +------------------ + +.. code-block:: json + + { + "command": 33554435, + "mode": "energy-usage-daily-query", + "param": [], + "paramStr": "", + "year": 2024, + "month": [10, 11, 12] + } + +Response Messages +================= + +Status Response +--------------- + +.. code-block:: json + + { + "clientID": "client-12345", + "sessionID": "session-67890", + "requestTopic": "...", + "responseTopic": "...", + "response": { + "command": 16777221, + "deviceType": 52, + "macAddress": "...", + "status": { + "dhwTemperature": 120, + "dhwTemperatureSetting": 120, + "currentInstPower": 450, + "operationMode": 64, + "dhwOperationSetting": 3, + "operationBusy": 2, + "compUse": 2, + "heatUpperUse": 1, + "errorCode": 0, + ... + } + } + } + +**Field Conversions:** + +* Boolean fields: 1=false, 2=true +* Temperature fields: Add 20 to get display value +* Enum fields: Map integers to enum values + +See :doc:`device_status` for complete field reference. + +Feature/Info Response +--------------------- + +.. code-block:: json + + { + "response": { + "feature": { + "controllerSerialNumber": "ABC123", + "controllerSwVersion": 184614912, + "dhwTemperatureMin": 75, + "dhwTemperatureMax": 130, + "energyUsageUse": 1, + ... + } + } + } + +See :doc:`device_features` for complete field reference. + +Energy Usage Response +--------------------- + +.. code-block:: json + + { + "response": { + "typeOfUsage": "daily", + "year": 2024, + "data": [ + { + "heUsage": 1200, + "hpUsage": 3500, + "heTime": 2, + "hpTime": 8 + } + ], + "total": { + "heUsage": 1200, + "hpUsage": 3500 + } + } + } + +Connection Flow +=============== + +1. **Authenticate** + + Obtain AWS credentials from REST API sign-in. + +2. **Connect MQTT** + + Connect to AWS IoT endpoint using WebSocket with AWS SigV4 auth. + +3. **Signal App Connection** + + Publish to ``evt/52/{deviceId}/app-connection``: + + .. code-block:: json + + { + "clientID": "client-12345", + "sessionID": "session-67890", + "event": "app-connection" + } + +4. **Subscribe to Responses** + + Subscribe to ``cmd/52/{clientId}/res/#`` + +5. **Send Commands / Requests** + + Publish commands to appropriate control/status topics. + +6. **Receive Responses** + + Process responses via subscribed topics. + +Example: Request Status +======================= + +**1. Subscribe:** + +.. code-block:: text + + Topic: cmd/52/my-client-id/res/status/rd + QoS: 1 + +**2. Publish Request:** + +.. code-block:: text + + Topic: cmd/52/04786332fca0/st/rd + QoS: 1 + Payload: + +.. code-block:: json + + { + "clientID": "my-client-id", + "sessionID": "my-session-id", + "requestTopic": "cmd/52/04786332fca0/st/rd", + "responseTopic": "cmd/52/my-client-id/res/status/rd", + "protocolVersion": 2, + "request": { + "command": 16777221, + "deviceType": 52, + "macAddress": "04786332fca0", + "additionalValue": "...", + "mode": "", + "param": [], + "paramStr": "" + } + } + +**3. Receive Response:** + +Response arrives on subscribed topic with device status. + +Python Implementation +===================== + +See :doc:`../python_api/mqtt_client` for the Python client that implements +this protocol. + +**Quick Example:** + +.. code-block:: python + + from nwp500 import NavienMqttClient + + # Client handles all protocol details + mqtt = NavienMqttClient(auth) + await mqtt.connect() + await mqtt.subscribe_device_status(device, callback) + await mqtt.request_device_status(device) + +Related Documentation +===================== + +* :doc:`../python_api/mqtt_client` - Python MQTT client +* :doc:`device_status` - Device status fields +* :doc:`device_features` - Device feature fields +* :doc:`error_codes` - Error codes diff --git a/docs/protocol/rest_api.rst b/docs/protocol/rest_api.rst new file mode 100644 index 0000000..23ecca1 --- /dev/null +++ b/docs/protocol/rest_api.rst @@ -0,0 +1,424 @@ +==================== +REST API Protocol +==================== + +This document describes the Navien Smart Control REST API protocol based +on the OpenAPI 3.1 specification. + +Base URL +======== + +.. code-block:: + + https://nlus.naviensmartcontrol.com/api/v2.1 + +All endpoints are relative to this base URL. + +Authentication +============== + +The API uses JWT (JSON Web Tokens) for authentication with a +**non-standard header format**: + +.. important:: + **Non-Standard Authorization Header** + + * Header name: **lowercase** ``authorization`` (not ``Authorization``) + * Header value: **raw token** (no ``Bearer`` prefix) + + Example: ``{"authorization": "eyJraWQi..."}`` + + This differs from standard OAuth2/JWT authentication! + +Authentication Flow +------------------- + +1. **Sign In** - POST credentials to ``/user/sign-in`` +2. **Receive Tokens** - Get ``idToken``, ``accessToken``, ``refreshToken`` +3. **Use Token** - Include ``accessToken`` in ``authorization`` header +4. **Refresh** - POST ``refreshToken`` to ``/auth/refresh`` before expiry + +Token Lifetimes +--------------- + +* **Access Token**: 3600 seconds (1 hour) +* **Refresh Token**: Used to obtain new access tokens +* **AWS Credentials**: Included with tokens for MQTT access + +Endpoints +========= + +Authentication Endpoints +------------------------ + +POST /user/sign-in +^^^^^^^^^^^^^^^^^^ + +Authenticate user and obtain tokens. + +**Request Body:** + +.. code-block:: json + + { + "userId": "user@example.com", + "password": "your_password" + } + +**Response (200 OK):** + +.. code-block:: json + + { + "code": 200, + "msg": "SUCCESS", + "data": { + "userInfo": { + "userType": "O", + "userFirstName": "John", + "userLastName": "Doe", + "userStatus": "NORMAL", + "userSeq": 36283 + }, + "token": { + "idToken": "eyJraWQ...", + "accessToken": "eyJraWQ...", + "refreshToken": "eyJraWQ...", + "authenticationExpiresIn": 3600, + "accessKeyId": "ASIA...", + "secretKey": "abc123...", + "sessionToken": "IQoJ...", + "authorizationExpiresIn": 3600 + }, + "legal": [] + } + } + +**Error Response (401 Unauthorized):** + +.. code-block:: json + + { + "code": 401, + "msg": "Invalid credentials" + } + +POST /auth/refresh +^^^^^^^^^^^^^^^^^^ + +Refresh access token using refresh token. + +**Request Body:** + +.. code-block:: json + + { + "refreshToken": "eyJraWQ..." + } + +**Response (200 OK):** + +.. code-block:: json + + { + "code": 200, + "msg": "SUCCESS", + "data": { + "idToken": "eyJraWQ...", + "accessToken": "eyJraWQ...", + "refreshToken": "eyJraWQ...", + "authenticationExpiresIn": 3600, + "accessKeyId": "ASIA...", + "secretKey": "abc123...", + "sessionToken": "IQoJ...", + "authorizationExpiresIn": 3600 + } + } + +Device Management Endpoints +---------------------------- + +POST /device/list +^^^^^^^^^^^^^^^^^ + +List all devices registered to the user. + +**Authentication Required:** Yes + +**Request Body:** + +.. code-block:: json + + { + "userId": "user@example.com", + "offset": 0, + "count": 20 + } + +**Parameters:** + +* ``userId`` (string, required) - User email address +* ``offset`` (integer) - Pagination offset (default: 0) +* ``count`` (integer) - Number of devices to return (default: 20, max: + 20) + +**Response (200 OK):** + +.. code-block:: json + + { + "code": 200, + "msg": "SUCCESS", + "data": [ + { + "deviceInfo": { + "homeSeq": 12345, + "macAddress": "04786332fca0", + "additionalValue": "...", + "deviceType": 52, + "deviceName": "Water Heater", + "connected": 2, + "installType": "indoor" + }, + "location": { + "state": "CA", + "city": "San Francisco", + "address": "123 Main St", + "latitude": 37.7749, + "longitude": -122.4194, + "altitude": 16.0 + } + } + ] + } + +POST /device/info +^^^^^^^^^^^^^^^^^ + +Get detailed information about a specific device. + +**Authentication Required:** Yes + +**Request Body:** + +.. code-block:: json + + { + "macAddress": "04786332fca0", + "additionalValue": "...", + "userId": "user@example.com" + } + +**Response:** Same as device object in ``/device/list`` + +POST /device/firmware/info +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Get firmware information for a device. + +**Authentication Required:** Yes + +**Request Body:** + +.. code-block:: json + + { + "macAddress": "04786332fca0", + "additionalValue": "...", + "userId": "user@example.com" + } + +**Response (200 OK):** + +.. code-block:: json + + { + "code": 200, + "msg": "SUCCESS", + "data": { + "firmwares": [ + { + "macAddress": "04786332fca0", + "additionalValue": "...", + "deviceType": 52, + "curSwCode": 1, + "curVersion": 184614912, + "downloadedVersion": null, + "deviceGroup": "NWP500" + } + ] + } + } + +GET /device/tou +^^^^^^^^^^^^^^^ + +Get Time-of-Use (TOU) information for a device. + +**Authentication Required:** Yes + +**Query Parameters:** + +* ``macAddress`` (string, required) - Device MAC address +* ``additionalValue`` (string, required) - Additional device identifier +* ``controllerId`` (string, required) - Controller ID +* ``userId`` (string, required) - User email +* ``userType`` (string) - User type (default: "O") + +**Response (200 OK):** + +.. code-block:: json + + { + "code": 200, + "msg": "SUCCESS", + "data": { + "registerPath": "...", + "sourceType": "...", + "touInfo": { + "controllerId": "...", + "manufactureId": "...", + "name": "Pacific Gas & Electric", + "utility": "PG&E", + "zipCode": 94102, + "schedule": [ + { + "season": 448, + "interval": [ + { + "week": 62, + "startHour": 9, + "startMinute": 0, + "endHour": 17, + "endMinute": 0, + "priceMin": 10, + "priceMax": 25, + "decimalPoint": 2 + } + ] + } + ] + } + } + } + +POST /app/update-push-token +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Update push notification token (optional). + +**Authentication Required:** Yes + +**Request Body:** + +.. code-block:: json + + { + "userId": "user@example.com", + "pushToken": "...", + "modelName": "Python Client", + "appVersion": "1.0.0", + "os": "Python", + "osVersion": "3.9+" + } + +**Response (200 OK):** + +.. code-block:: json + + { + "code": 200, + "msg": "SUCCESS" + } + +Error Responses +=============== + +All error responses follow this format: + +.. code-block:: json + + { + "code": , + "msg": "", + "data": null + } + +Common Error Codes +------------------ + +.. list-table:: + :header-rows: 1 + :widths: 10 20 70 + + * - Code + - Meaning + - Description + * - 200 + - Success + - Request completed successfully + * - 400 + - Bad Request + - Invalid request parameters + * - 401 + - Unauthorized + - Invalid or expired authentication token + * - 403 + - Forbidden + - User lacks permission for this resource + * - 404 + - Not Found + - Resource not found + * - 500 + - Server Error + - Internal server error + +Rate Limiting +============= + +The API does not currently publish specific rate limits. Best practices: + +* Avoid polling endpoints more frequently than once per minute +* Use MQTT for real-time updates instead of polling REST API +* Implement exponential backoff for failed requests +* Cache responses when appropriate + +Data Models +=========== + +See :doc:`../python_api/models` for complete Python data model documentation. + +Example Usage +============= + +Using curl +---------- + +Sign in: + +.. code-block:: bash + + curl -X POST https://nlus.naviensmartcontrol.com/api/v2.1/user/sign-in \ + -H "Content-Type: application/json" \ + -d '{"userId":"user@example.com","password":"your_password"}' + +List devices (with token): + +.. code-block:: bash + + curl -X POST https://nlus.naviensmartcontrol.com/api/v2.1/device/list \ + -H "Content-Type: application/json" \ + -H "authorization: YOUR_ACCESS_TOKEN" \ + -d '{"userId":"user@example.com","offset":0,"count":20}' + +Using Python +------------ + +See :doc:`../python_api/api_client` for the Python client documentation. + +Related Documentation +===================== + +* :doc:`mqtt_protocol` - MQTT protocol for real-time communication +* :doc:`../python_api/auth_client` - Python authentication client +* :doc:`../python_api/api_client` - Python REST API client diff --git a/docs/python_api/api_client.rst b/docs/python_api/api_client.rst new file mode 100644 index 0000000..68d5b69 --- /dev/null +++ b/docs/python_api/api_client.rst @@ -0,0 +1,529 @@ +========== +API Client +========== + +The ``NavienAPIClient`` provides REST API access to Navien Smart Control for device +discovery, configuration queries, and management operations. + +.. important:: + Use the API client for **device discovery and configuration**. + Use :doc:`mqtt_client` for **real-time monitoring and control**. + +Overview +======== + +The API client provides: + +* Device discovery and listing +* Device information queries +* Firmware version checking +* Time-of-Use (TOU) schedule queries +* Push notification management + +All methods are async and require an authenticated :doc:`auth_client`. + +Quick Start +=========== + +Basic Usage +----------- + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient + import asyncio + + async def main(): + async with NavienAuthClient("email@example.com", "password") as auth: + api = NavienAPIClient(auth) + + # List all devices + devices = await api.list_devices() + for device in devices: + info = device.device_info + print(f"{info.device_name}") + print(f" MAC: {info.mac_address}") + print(f" Status: {'Online' if info.connected == 2 else 'Offline'}") + + asyncio.run(main()) + +API Reference +============= + +NavienAPIClient +--------------- + +.. py:class:: NavienAPIClient(auth_client, base_url=API_BASE_URL, session=None) + + REST API client for Navien Smart Control. + + :param auth_client: Authenticated NavienAuthClient instance + :type auth_client: NavienAuthClient + :param base_url: API base URL + :type base_url: str + :param session: Optional aiohttp session + :type session: aiohttp.ClientSession or None + :raises ValueError: If auth_client not authenticated + + **Example:** + + .. code-block:: python + + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + # Ready to use + +Device Methods +-------------- + +list_devices() +^^^^^^^^^^^^^^ + +.. py:method:: list_devices(offset=0, count=20) + + List all devices registered to your account. + + :param offset: Pagination offset (default: 0) + :type offset: int + :param count: Number of devices to return, max 20 (default: 20) + :type count: int + :return: List of Device objects + :rtype: list[Device] + :raises APIError: If request fails + :raises AuthenticationError: If not authenticated + + **Example:** + + .. code-block:: python + + # Get all devices (up to 20) + devices = await api.list_devices() + + # Pagination + first_batch = await api.list_devices(offset=0, count=10) + second_batch = await api.list_devices(offset=10, count=10) + + # Process devices + for device in devices: + info = device.device_info + loc = device.location + + print(f"{info.device_name}") + print(f" MAC: {info.mac_address}") + print(f" Type: {info.device_type}") + print(f" Connected: {info.connected == 2}") + + if loc.city: + print(f" Location: {loc.city}, {loc.state}") + +get_first_device() +^^^^^^^^^^^^^^^^^^ + +.. py:method:: get_first_device() + + Get the first device from your account (convenience method). + + :return: First device or None if no devices + :rtype: Device or None + + **Example:** + + .. code-block:: python + + device = await api.get_first_device() + if device: + print(f"Using device: {device.device_info.device_name}") + else: + print("No devices found") + +get_device_info() +^^^^^^^^^^^^^^^^^ + +.. py:method:: get_device_info(mac_address, additional_value="") + + Get detailed information about a specific device. + + :param mac_address: Device MAC address (without colons) + :type mac_address: str + :param additional_value: Additional device identifier + :type additional_value: str + :return: Device object with full information + :rtype: Device + :raises APIError: If device not found + + **Example:** + + .. code-block:: python + + # Get specific device + device = await api.get_device_info("04786332fca0") + + print(f"Device: {device.device_info.device_name}") + print(f"Model: {device.device_info.device_type}") + print(f"Location: {device.location.city}") + +Firmware Methods +---------------- + +get_firmware_info() +^^^^^^^^^^^^^^^^^^^ + +.. py:method:: get_firmware_info(mac_address=None, additional_value="") + + Get firmware version information for devices. + + :param mac_address: Specific device MAC or None for all devices + :type mac_address: str or None + :param additional_value: Additional device identifier + :type additional_value: str + :return: List of firmware information objects + :rtype: list[FirmwareInfo] + + **Example:** + + .. code-block:: python + + # Get firmware for all devices + firmware_list = await api.get_firmware_info() + + for fw in firmware_list: + print(f"Device: {fw.mac_address}") + print(f" Current version: {fw.cur_version}") + print(f" Current code: {fw.cur_sw_code}") + + if fw.downloaded_version: + print(f" ⚠️ Update available: {fw.downloaded_version}") + print(f" Download code: {fw.downloaded_sw_code}") + else: + print(f" ✓ Up to date") + + # Get firmware for specific device + fw_info = await api.get_firmware_info(mac_address="04786332fca0") + +Time-of-Use Methods +------------------- + +get_tou_info() +^^^^^^^^^^^^^^ + +.. py:method:: get_tou_info(mac_address, additional_value, controller_id) + + Get Time-of-Use pricing schedule for a device. + + :param mac_address: Device MAC address + :type mac_address: str + :param additional_value: Additional device identifier + :type additional_value: str + :param controller_id: Controller serial number + :type controller_id: str + :return: TOU information + :rtype: TOUInfo + + **Example:** + + .. code-block:: python + + # Get controller ID from device + device = await api.get_first_device() + + # Query TOU settings (need controller ID from MQTT) + tou = await api.get_tou_info( + mac_address=device.device_info.mac_address, + additional_value=device.device_info.additional_value, + controller_id="ABC123456" # From device feature + ) + + print(f"Utility: {tou.utility}") + print(f"Schedule: {tou.name}") + print(f"ZIP: {tou.zip_code}") + + for schedule in tou.schedule: + print(f"Season months: {schedule.season}") + for interval in schedule.intervals: + print(f" {interval}") + +Push Notification Methods +-------------------------- + +update_push_token() +^^^^^^^^^^^^^^^^^^^ + +.. py:method:: update_push_token(device_token, device_type="ios") + + Update push notification token. + + :param device_token: Firebase/APNs device token + :type device_token: str + :param device_type: Device type ("ios" or "android") + :type device_type: str + + **Example:** + + .. code-block:: python + + # Register for push notifications + await api.update_push_token( + device_token="your_firebase_token", + device_type="android" + ) + +Properties +---------- + +is_authenticated +^^^^^^^^^^^^^^^^ + +.. py:attribute:: is_authenticated + + Check if client is authenticated. + + :type: bool + + **Example:** + + .. code-block:: python + + if api.is_authenticated: + devices = await api.list_devices() + +user_email +^^^^^^^^^^ + +.. py:attribute:: user_email + + Get authenticated user's email. + + :type: str or None + + **Example:** + + .. code-block:: python + + print(f"API client for: {api.user_email}") + +Examples +======== + +Example 1: Device Discovery and Report +--------------------------------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient + + async def device_report(): + async with NavienAuthClient() as auth: + api = NavienAPIClient(auth) + + devices = await api.list_devices() + print(f"Found {len(devices)} device(s)\n") + print("DEVICE REPORT") + print("=" * 60) + + for i, device in enumerate(devices, 1): + info = device.device_info + loc = device.location + + status = "🟢 Online" if info.connected == 2 else "🔴 Offline" + + print(f"\n{i}. {info.device_name}") + print(f" Status: {status}") + print(f" MAC: {info.mac_address}") + print(f" Type: {info.device_type}") + + if loc.city: + print(f" Location: {loc.city}, {loc.state}") + print(f" Coordinates: {loc.latitude}, {loc.longitude}") + + asyncio.run(device_report()) + +Example 2: Firmware Check +-------------------------- + +.. code-block:: python + + async def check_firmware(): + async with NavienAuthClient() as auth: + api = NavienAPIClient(auth) + + firmware_list = await api.get_firmware_info() + + print("FIRMWARE STATUS") + print("=" * 60) + + updates_available = 0 + + for fw in firmware_list: + print(f"\nDevice: {fw.mac_address}") + print(f" Current: {fw.cur_version} (code: {fw.cur_sw_code})") + + if fw.downloaded_version: + print(f" ⚠️ UPDATE AVAILABLE") + print(f" Version: {fw.downloaded_version}") + print(f" Code: {fw.downloaded_sw_code}") + updates_available += 1 + else: + print(f" ✓ Up to date") + + if updates_available: + print(f"\n{updates_available} device(s) have updates available") + else: + print("\nAll devices are up to date") + + asyncio.run(check_firmware()) + +Example 3: Multi-Device Management +----------------------------------- + +.. code-block:: python + + async def manage_devices(): + async with NavienAuthClient() as auth: + api = NavienAPIClient(auth) + + # Get all devices + devices = await api.list_devices() + firmware_list = await api.get_firmware_info() + + # Create firmware lookup + firmware_map = {fw.mac_address: fw for fw in firmware_list} + + # Process each device + for device in devices: + info = device.device_info + fw = firmware_map.get(info.mac_address) + + print(f"\n{info.device_name}") + print(f" MAC: {info.mac_address}") + print(f" Status: {'Online' if info.connected == 2 else 'Offline'}") + + if fw: + print(f" Firmware: {fw.cur_version}") + if fw.downloaded_version: + print(f" Update: {fw.downloaded_version} available") + + asyncio.run(manage_devices()) + +Example 4: Pagination for Many Devices +--------------------------------------- + +.. code-block:: python + + async def get_all_devices(): + async with NavienAuthClient() as auth: + api = NavienAPIClient(auth) + + all_devices = [] + offset = 0 + batch_size = 20 + + while True: + batch = await api.list_devices(offset=offset, count=batch_size) + + if not batch: + break + + all_devices.extend(batch) + print(f"Loaded {len(batch)} devices (total: {len(all_devices)})") + + if len(batch) < batch_size: + break + + offset += batch_size + + return all_devices + + asyncio.run(get_all_devices()) + +Error Handling +============== + +.. code-block:: python + + from nwp500 import APIError, AuthenticationError + + async def safe_api_calls(): + try: + async with NavienAuthClient() as auth: + api = NavienAPIClient(auth) + devices = await api.list_devices() + return devices + + except AuthenticationError as e: + print(f"Auth failed: {e.message}") + if e.status_code == 401: + print("Invalid credentials") + return None + + except APIError as e: + print(f"API error: {e.message}") + print(f"Code: {e.code}") + + if e.code == 404: + print("Resource not found") + elif e.code >= 500: + print("Server error - try again later") + + return None + +Best Practices +============== + +1. **Use API client for discovery, MQTT for monitoring:** + + .. code-block:: python + + # ✓ Correct usage + async with NavienAuthClient() as auth: + # API: Discover devices + api = NavienAPIClient(auth) + device = await api.get_first_device() + + # MQTT: Monitor and control + mqtt = NavienMqttClient(auth) + await mqtt.connect() + await mqtt.subscribe_device_status(device, on_status) + +2. **Cache device list:** + + .. code-block:: python + + # Get once + devices = await api.list_devices() + + # Reuse for multiple operations + for device in devices: + await process_device(device) + +3. **Check firmware regularly:** + + .. code-block:: python + + # Check daily + while True: + fw_list = await api.get_firmware_info() + check_for_updates(fw_list) + await asyncio.sleep(86400) # 24 hours + +4. **Handle pagination:** + + .. code-block:: python + + all_devices = [] + offset = 0 + + while True: + batch = await api.list_devices(offset=offset, count=20) + if not batch: + break + all_devices.extend(batch) + offset += 20 + +Related Documentation +===================== + +* :doc:`auth_client` - Authentication client +* :doc:`mqtt_client` - MQTT client for monitoring/control +* :doc:`models` - Data models (Device, FirmwareInfo, etc.) +* :doc:`exceptions` - Exception handling +* :doc:`../protocol/rest_api` - REST API protocol details diff --git a/docs/python_api/auth_client.rst b/docs/python_api/auth_client.rst new file mode 100644 index 0000000..ffc7417 --- /dev/null +++ b/docs/python_api/auth_client.rst @@ -0,0 +1,497 @@ +====================== +Authentication Client +====================== + +The ``NavienAuthClient`` handles all authentication with the Navien Smart Control API, +including sign-in, token management, and automatic token refresh. + +Overview +======== + +The authentication client: + +* Signs in with email and password +* Manages JWT tokens (ID, access, refresh) +* Provides AWS credentials for MQTT +* Automatically refreshes expired tokens +* Works as async context manager + +Quick Start +=========== + +Basic Authentication +-------------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient + import asyncio + + async def main(): + # Use as context manager (recommended) + async with NavienAuthClient("email@example.com", "password") as auth: + print(f"Authenticated as: {auth.user_email}") + print(f"User: {auth.current_user.full_name}") + + # auth is ready to use with API and MQTT clients + # Tokens are automatically refreshed + + asyncio.run(main()) + +Environment Variables +--------------------- + +.. code-block:: python + + import os + + # Set credentials in environment + os.environ['NAVIEN_EMAIL'] = 'your@email.com' + os.environ['NAVIEN_PASSWORD'] = 'your_password' + + # Create without parameters + async with NavienAuthClient() as auth: + # Credentials loaded from environment + print(f"Logged in as: {auth.user_email}") + +API Reference +============= + +NavienAuthClient +---------------- + +.. py:class:: NavienAuthClient(email=None, password=None, base_url=API_BASE_URL) + + JWT-based authentication client for Navien Smart Control API. + + :param email: User email (or set NAVIEN_EMAIL env var) + :type email: str or None + :param password: User password (or set NAVIEN_PASSWORD env var) + :type password: str or None + :param base_url: API base URL + :type base_url: str + + **Example:** + + .. code-block:: python + + # With parameters + auth = NavienAuthClient("email@example.com", "password") + + # From environment variables + auth = NavienAuthClient() + + # Always use as context manager + async with auth: + # Authenticated + pass + +Authentication Methods +---------------------- + +sign_in() +^^^^^^^^^ + +.. py:method:: sign_in(email=None, password=None) + + Sign in to Navien Smart Control API. + + :param email: User email (uses constructor value if None) + :type email: str or None + :param password: User password (uses constructor value if None) + :type password: str or None + :return: Authentication response with user info and tokens + :rtype: AuthenticationResponse + :raises InvalidCredentialsError: If email/password incorrect + :raises AuthenticationError: If sign-in fails + + **Example:** + + .. code-block:: python + + auth = NavienAuthClient() + + try: + response = await auth.sign_in("email@example.com", "password") + print(f"Signed in as: {response.user_info.full_name}") + print(f"Tokens expire in: {response.tokens.time_until_expiry}") + except InvalidCredentialsError: + print("Wrong email or password") + +refresh_token() +^^^^^^^^^^^^^^^ + +.. py:method:: refresh_token(refresh_token) + + Refresh access token using refresh token. + + :param refresh_token: Refresh token from previous sign-in + :type refresh_token: str + :return: New auth tokens + :rtype: AuthTokens + :raises TokenRefreshError: If refresh fails + + .. note:: + This is usually called automatically by ``ensure_valid_token()``. + You rarely need to call it manually. + + **Example:** + + .. code-block:: python + + try: + new_tokens = await auth.refresh_token(old_refresh_token) + print(f"Token refreshed, expires: {new_tokens.expires_at}") + except TokenRefreshError: + print("Refresh failed - need to sign in again") + +ensure_valid_token() +^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: ensure_valid_token() + + Ensure access token is valid, refreshing if needed. + + :return: Current valid tokens or None if not authenticated + :rtype: AuthTokens or None + + **Example:** + + .. code-block:: python + + # This is called automatically by API/MQTT clients + tokens = await auth.ensure_valid_token() + if tokens: + print(f"Valid until: {tokens.expires_at}") + +Token and Session Management +----------------------------- + +close() +^^^^^^^ + +.. py:method:: close() + + Close the HTTP session. + + .. note:: + Called automatically when using context manager. + + **Example:** + + .. code-block:: python + + auth = NavienAuthClient(email, password) + try: + await auth.sign_in() + # ... operations ... + finally: + await auth.close() + +get_auth_headers() +^^^^^^^^^^^^^^^^^^ + +.. py:method:: get_auth_headers() + + Get HTTP headers for authenticated requests. + + :return: Headers dictionary with Authorization bearer token + :rtype: dict[str, str] + + **Example:** + + .. code-block:: python + + headers = auth.get_auth_headers() + # {'Authorization': 'Bearer eyJ0eXAiOiJKV1...'} + + # Used internally by API client + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as resp: + data = await resp.json() + +Properties +---------- + +is_authenticated +^^^^^^^^^^^^^^^^ + +.. py:attribute:: is_authenticated + + Check if currently authenticated. + + :type: bool + + **Example:** + + .. code-block:: python + + if auth.is_authenticated: + print("Ready to make API calls") + else: + await auth.sign_in(email, password) + +current_user +^^^^^^^^^^^^ + +.. py:attribute:: current_user + + Get current user information. + + :type: UserInfo or None + + **Example:** + + .. code-block:: python + + if auth.current_user: + print(f"Name: {auth.current_user.full_name}") + print(f"Type: {auth.current_user.user_type}") + print(f"Status: {auth.current_user.user_status}") + +current_tokens +^^^^^^^^^^^^^^ + +.. py:attribute:: current_tokens + + Get current authentication tokens. + + :type: AuthTokens or None + + **Example:** + + .. code-block:: python + + if auth.current_tokens: + tokens = auth.current_tokens + print(f"Expires: {tokens.expires_at}") + print(f"Time left: {tokens.time_until_expiry}") + + if tokens.is_expired: + await auth.ensure_valid_token() + +user_email +^^^^^^^^^^ + +.. py:attribute:: user_email + + Get authenticated user's email. + + :type: str or None + + **Example:** + + .. code-block:: python + + print(f"Logged in as: {auth.user_email}") + +Data Models +=========== + +UserInfo +-------- + +.. py:class:: UserInfo + + User information from authentication. + + :param user_first_name: First name + :param user_last_name: Last name + :param user_type: User type + :param user_status: Account status + + **Properties:** + + * ``full_name`` - Full name (first + last) + +AuthTokens +---------- + +.. py:class:: AuthTokens + + Authentication tokens and AWS credentials. + + :param id_token: JWT ID token + :param access_token: JWT access token + :param refresh_token: Refresh token + :param authentication_expires_in: Expiry in seconds + :param access_key_id: AWS access key (for MQTT) + :param secret_key: AWS secret key (for MQTT) + :param session_token: AWS session token (for MQTT) + + **Properties:** + + * ``expires_at`` - Expiration timestamp + * ``is_expired`` - Check if expired + * ``time_until_expiry`` - Time remaining + * ``bearer_token`` - Formatted bearer token + +AuthenticationResponse +---------------------- + +.. py:class:: AuthenticationResponse + + Complete sign-in response. + + :param user_info: User information + :param tokens: Authentication tokens + +Examples +======== + +Example 1: Basic Authentication +-------------------------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient + + async def basic_auth(): + async with NavienAuthClient("email@example.com", "password") as auth: + print(f"Authenticated: {auth.is_authenticated}") + print(f"User: {auth.current_user.full_name}") + print(f"Email: {auth.user_email}") + + tokens = auth.current_tokens + print(f"Token expires: {tokens.expires_at}") + print(f"Time remaining: {tokens.time_until_expiry}") + +Example 2: Environment Variables +--------------------------------- + +.. code-block:: python + + import os + from nwp500 import NavienAuthClient + + os.environ['NAVIEN_EMAIL'] = 'your@email.com' + os.environ['NAVIEN_PASSWORD'] = 'your_password' + + async def env_auth(): + async with NavienAuthClient() as auth: + print(f"Logged in as: {auth.user_email}") + +Example 3: Manual Token Management +----------------------------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient, InvalidCredentialsError + + async def manual_auth(): + auth = NavienAuthClient() + + try: + # Sign in + response = await auth.sign_in("email@example.com", "password") + print(f"Signed in: {response.user_info.full_name}") + + # Check token status + if auth.current_tokens.is_expired: + print("Token expired, refreshing...") + await auth.ensure_valid_token() + + # Use for API calls + headers = auth.get_auth_headers() + + except InvalidCredentialsError: + print("Invalid credentials") + finally: + await auth.close() + +Example 4: Long-Running Application +------------------------------------ + +.. code-block:: python + + from nwp500 import NavienAuthClient + + async def long_running(): + async with NavienAuthClient(email, password) as auth: + while True: + # Token is automatically refreshed + await auth.ensure_valid_token() + + # Do work + await perform_operations(auth) + + # Sleep + await asyncio.sleep(3600) + +Error Handling +============== + +.. code-block:: python + + from nwp500 import ( + InvalidCredentialsError, + TokenExpiredError, + TokenRefreshError, + AuthenticationError + ) + + async def handle_auth_errors(): + try: + async with NavienAuthClient(email, password) as auth: + # Operations + pass + + except InvalidCredentialsError: + print("Wrong email or password") + + except TokenExpiredError: + print("Token expired and refresh failed") + + except TokenRefreshError: + print("Could not refresh token - sign in again") + + except AuthenticationError as e: + print(f"Auth error: {e.message}") + +Best Practices +============== + +1. **Always use context manager:** + + .. code-block:: python + + # ✓ Correct + async with NavienAuthClient(email, password) as auth: + # operations + + # ✗ Wrong + auth = NavienAuthClient(email, password) + await auth.sign_in() + # ... forgot to call auth.close() + +2. **Use environment variables for credentials:** + + .. code-block:: python + + # Don't hardcode credentials + async with NavienAuthClient() as auth: + # Loaded from NAVIEN_EMAIL and NAVIEN_PASSWORD + pass + +3. **Share auth client:** + + .. code-block:: python + + async with NavienAuthClient(email, password) as auth: + # Use same auth for both clients + api = NavienAPIClient(auth) + mqtt = NavienMqttClient(auth) + +4. **Let automatic refresh work:** + + .. code-block:: python + + # Don't manually check/refresh + # The client does it automatically + +Related Documentation +===================== + +* :doc:`api_client` - REST API client +* :doc:`mqtt_client` - MQTT client +* :doc:`exceptions` - Exception handling diff --git a/docs/python_api/cli.rst b/docs/python_api/cli.rst new file mode 100644 index 0000000..baeb070 --- /dev/null +++ b/docs/python_api/cli.rst @@ -0,0 +1,654 @@ +====================== +Command Line Interface +====================== + +The ``nwp500`` CLI provides a command-line interface for monitoring and +controlling Navien water heaters without writing Python code. + +.. code-block:: bash + + # Python module + python3 -m nwp500.cli [options] + + # Or if installed + navien-cli [options] + +Overview +======== + +The CLI supports: + +* **Real-time monitoring** - Continuous device status updates +* **Device status** - One-time status queries +* **Power control** - Turn device on/off +* **Mode control** - Change operation mode (Heat Pump, Electric, etc.) +* **Temperature control** - Set target temperature +* **Energy queries** - Get historical energy usage +* **Reservations** - View and update schedule +* **Time-of-Use** - Configure TOU settings +* **Device information** - Firmware, features, capabilities + +Authentication +============== + +The CLI supports multiple authentication methods: + +Environment Variables (Recommended) +------------------------------------ + +.. code-block:: bash + + export NAVIEN_EMAIL="your@email.com" + export NAVIEN_PASSWORD="your_password" + + python3 -m nwp500.cli --status + +Command Line Arguments +---------------------- + +.. code-block:: bash + + python3 -m nwp500.cli \ + --email "your@email.com" \ + --password "your_password" \ + --status + +Token Caching +------------- + +The CLI automatically caches authentication tokens in ``~/.navien_tokens.json`` +to avoid repeated sign-ins. Tokens are refreshed automatically when expired. + +Global Options +============== + +.. option:: --email EMAIL + + Navien account email. Overrides ``NAVIEN_EMAIL`` environment variable. + +.. option:: --password PASSWORD + + Navien account password. Overrides ``NAVIEN_PASSWORD`` environment variable. + +.. option:: --version + + Show version information and exit. + +.. option:: -v, --verbose + + Enable debug logging output. + +Commands +======== + +Monitoring Commands +------------------- + +monitor (default) +^^^^^^^^^^^^^^^^^ + +Real-time continuous monitoring of device status. + +.. code-block:: bash + + # Monitor with JSON output (default) + python3 -m nwp500.cli + + # Monitor with formatted text output + python3 -m nwp500.cli --output text + + # Monitor with compact output + python3 -m nwp500.cli --output compact + +**Options:** + +.. option:: --output FORMAT + + Output format: ``json``, ``text``, or ``compact`` (default: ``json``) + +**Example Output (text format):** + +.. code-block:: text + + [12:34:56] Navien Water Heater Status + ═══════════════════════════════════════ + Temperature: 138.0°F (Target: 140.0°F) + Power: 1250W + Mode: ENERGY_SAVER + State: HEAT_PUMP + Energy: 85.5% + + Components: + ✓ Heat Pump Running + ✗ Upper Heater + ✗ Lower Heater + + [12:35:01] Temperature changed: 139.0°F + +--status +^^^^^^^^ + +Get current device status (one-time query). + +.. code-block:: bash + + python3 -m nwp500.cli --status + +**Output:** Complete device status with temperatures, power, mode, and +component states. + +--status-raw +^^^^^^^^^^^^ + +Get raw device status without conversions. + +.. code-block:: bash + + python3 -m nwp500.cli --status-raw + +**Output:** Raw JSON status data as received from device (no temperature +conversions or formatting). + +Device Information Commands +--------------------------- + +--device-info +^^^^^^^^^^^^^ + +Get comprehensive device information. + +.. code-block:: bash + + python3 -m nwp500.cli --device-info + +**Output:** Device name, MAC address, connection status, firmware versions, +and location. + +--device-feature +^^^^^^^^^^^^^^^^ + +Get device features and capabilities. + +.. code-block:: bash + + python3 -m nwp500.cli --device-feature + +**Output:** Supported features, temperature limits, firmware versions, serial +number. + +**Example Output:** + +.. code-block:: text + + Device Features: + Serial Number: ABC123456789 + Controller FW: 184614912 + WiFi FW: 34013184 + + Temperature Range: 100°F - 150°F + + Supported Features: + ✓ Energy Monitoring + ✓ Anti-Legionella + ✓ Reservations + ✓ Heat Pump Mode + ✓ Electric Mode + ✓ Energy Saver Mode + ✓ High Demand Mode + +--get-controller-serial +^^^^^^^^^^^^^^^^^^^^^^^ + +Get controller serial number (required for TOU commands). + +.. code-block:: bash + + python3 -m nwp500.cli --get-controller-serial + +**Output:** Controller serial number. + +Control Commands +---------------- + +--power-on +^^^^^^^^^^ + +Turn device on. + +.. code-block:: bash + + python3 -m nwp500.cli --power-on + + # Get status after power on + python3 -m nwp500.cli --power-on --status + +--power-off +^^^^^^^^^^^ + +Turn device off. + +.. code-block:: bash + + python3 -m nwp500.cli --power-off + + # Get status after power off + python3 -m nwp500.cli --power-off --status + +--set-mode MODE +^^^^^^^^^^^^^^^ + +Change operation mode. + +.. code-block:: bash + + # Heat Pump Only (most efficient) + python3 -m nwp500.cli --set-mode heat-pump + + # Electric Only (fastest recovery) + python3 -m nwp500.cli --set-mode electric + + # Energy Saver (recommended, balanced) + python3 -m nwp500.cli --set-mode energy-saver + + # High Demand (maximum capacity) + python3 -m nwp500.cli --set-mode high-demand + + # Vacation mode for 7 days + python3 -m nwp500.cli --set-mode vacation --vacation-days 7 + + # Get status after mode change + python3 -m nwp500.cli --set-mode energy-saver --status + +**Available Modes:** + +* ``heat-pump`` - Heat pump only (1) +* ``electric`` - Electric only (2) +* ``energy-saver`` - Energy Saver/Hybrid (3) **recommended** +* ``high-demand`` - High Demand (4) +* ``vacation`` - Vacation mode (5) - requires ``--vacation-days`` + +**Options:** + +.. option:: --vacation-days DAYS + + Number of vacation days (required when ``--set-mode vacation``). + +--set-dhw-temp TEMPERATURE +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Set target DHW temperature. + +.. code-block:: bash + + # Set to 140°F + python3 -m nwp500.cli --set-dhw-temp 140 + + # Set to 130°F and get status + python3 -m nwp500.cli --set-dhw-temp 130 --status + +.. important:: + Temperature is specified as **display value** (what you see on the device). + The CLI automatically converts to message value (display - 20°F). + +Energy Commands +--------------- + +--get-energy +^^^^^^^^^^^^ + +Query historical energy usage data. + +.. code-block:: bash + + # Get current month + python3 -m nwp500.cli --get-energy \ + --energy-year 2024 \ + --energy-months "10" + + # Get multiple months + python3 -m nwp500.cli --get-energy \ + --energy-year 2024 \ + --energy-months "8,9,10" + + # Get full year + python3 -m nwp500.cli --get-energy \ + --energy-year 2024 \ + --energy-months "1,2,3,4,5,6,7,8,9,10,11,12" + +**Options:** + +.. option:: --energy-year YEAR + + Year to query (e.g., 2024). + +.. option:: --energy-months MONTHS + + Comma-separated list of months (1-12). + +**Example Output:** + +.. code-block:: text + + Energy Usage Report + ═══════════════════ + + Total Usage: 1,234,567 Wh (1,234.6 kWh) + Heat Pump: 75.5% (932,098 Wh, 245 hours) + Electric: 24.5% (302,469 Wh, 67 hours) + + Daily Breakdown - October 2024: + Day 1: 42,345 Wh (HP: 32,100 Wh, HE: 10,245 Wh) + Day 2: 38,921 Wh (HP: 30,450 Wh, HE: 8,471 Wh) + Day 3: 45,678 Wh (HP: 35,200 Wh, HE: 10,478 Wh) + ... + +Reservation Commands +-------------------- + +--get-reservations +^^^^^^^^^^^^^^^^^^ + +Get current reservation schedule. + +.. code-block:: bash + + python3 -m nwp500.cli --get-reservations + +**Output:** Current reservation schedule configuration. + +--set-reservations FILE +^^^^^^^^^^^^^^^^^^^^^^^ + +Update reservation schedule from JSON file. + +.. code-block:: bash + + python3 -m nwp500.cli --set-reservations schedule.json \ + --reservations-enabled + +**Options:** + +.. option:: --reservations-enabled + + Enable reservation schedule (use ``--reservations-disabled`` to disable). + +.. option:: --reservations-disabled + + Disable reservation schedule. + +**JSON Format:** + +.. code-block:: json + + [ + { + "startHour": 6, + "startMinute": 0, + "endHour": 22, + "endMinute": 0, + "weekDays": [1, 1, 1, 1, 1, 0, 0], + "temperature": 120 + }, + { + "startHour": 8, + "startMinute": 0, + "endHour": 20, + "endMinute": 0, + "weekDays": [0, 0, 0, 0, 0, 1, 1], + "temperature": 130 + } + ] + +Time-of-Use Commands +-------------------- + +--get-tou +^^^^^^^^^ + +Get Time-of-Use configuration (requires controller serial). + +.. code-block:: bash + + # First get controller serial + python3 -m nwp500.cli --get-controller-serial + # Output: ABC123456789 + + # Then query TOU (done automatically by CLI) + python3 -m nwp500.cli --get-tou + +**Output:** TOU utility, schedule name, ZIP code, and pricing intervals. + +--set-tou-enabled STATE +^^^^^^^^^^^^^^^^^^^^^^^ + +Enable or disable TOU optimization. + +.. code-block:: bash + + # Enable TOU + python3 -m nwp500.cli --set-tou-enabled on + + # Disable TOU + python3 -m nwp500.cli --set-tou-enabled off + + # Get status after change + python3 -m nwp500.cli --set-tou-enabled on --status + +Complete Examples +================= + +Example 1: Quick Status Check +------------------------------ + +.. code-block:: bash + + #!/bin/bash + export NAVIEN_EMAIL="your@email.com" + export NAVIEN_PASSWORD="your_password" + + python3 -m nwp500.cli --status + +Example 2: Change Mode and Verify +---------------------------------- + +.. code-block:: bash + + #!/bin/bash + + # Set to Energy Saver and check status + python3 -m nwp500.cli \ + --set-mode energy-saver \ + --status + +Example 3: Morning Boost Script +-------------------------------- + +.. code-block:: bash + + #!/bin/bash + # Boost temperature in the morning + + python3 -m nwp500.cli \ + --set-mode high-demand \ + --set-dhw-temp 150 \ + --status + + echo "Morning boost activated!" + +Example 4: Energy Report +------------------------- + +.. code-block:: bash + + #!/bin/bash + # Get last 3 months energy usage + + YEAR=$(date +%Y) + M1=$(date +%-m) + M2=$((M1 - 1)) + M3=$((M1 - 2)) + + python3 -m nwp500.cli --get-energy \ + --energy-year $YEAR \ + --energy-months "$M3,$M2,$M1" \ + > energy_report.txt + + echo "Energy report saved to energy_report.txt" + +Example 5: Vacation Mode Setup +------------------------------- + +.. code-block:: bash + + #!/bin/bash + # Set vacation mode for 14 days + + python3 -m nwp500.cli \ + --set-mode vacation \ + --vacation-days 14 \ + --status + + echo "Vacation mode set for 14 days" + +Example 6: Continuous Monitoring +--------------------------------- + +.. code-block:: bash + + #!/bin/bash + # Monitor device with formatted output + + python3 -m nwp500.cli --output text + +Example 7: Cron Job for Daily Status +------------------------------------- + +.. code-block:: bash + + # Add to crontab: crontab -e + # Run daily at 6 AM + 0 6 * * * /usr/bin/python3 -m nwp500.cli --status >> /var/log/navien_daily.log 2>&1 + +Example 8: Temperature Alert Script +------------------------------------ + +.. code-block:: bash + + #!/bin/bash + # Check temperature and alert if too low + + STATUS=$(python3 -m nwp500.cli --status 2>&1) + TEMP=$(echo "$STATUS" | grep -oP 'dhwTemperature.*?\K\d+') + + if [ "$TEMP" -lt 120 ]; then + echo "WARNING: Water temperature is $TEMP°F (below 120°F)" + # Send notification, email, etc. + fi + +Troubleshooting +=============== + +Authentication Errors +--------------------- + +.. code-block:: bash + + # Check if credentials are set + echo $NAVIEN_EMAIL + echo $NAVIEN_PASSWORD + + # Try with explicit credentials + python3 -m nwp500.cli \ + --email "your@email.com" \ + --password "your_password" \ + --status + + # Clear cached tokens + rm ~/.navien_tokens.json + +Connection Issues +----------------- + +.. code-block:: bash + + # Enable debug logging + python3 -m nwp500.cli --verbose --status + +No Devices Found +---------------- + +.. code-block:: bash + + # Verify account has devices registered + python3 -m nwp500.cli --device-info + +Command Not Found +----------------- + +.. code-block:: bash + + # Use full Python module path + python3 -m nwp500.cli --help + + # Or install package + pip install -e . + +Best Practices +============== + +1. **Use environment variables for credentials:** + + .. code-block:: bash + + # In ~/.bashrc or ~/.zshrc + export NAVIEN_EMAIL="your@email.com" + export NAVIEN_PASSWORD="your_password" + +2. **Create shell aliases:** + + .. code-block:: bash + + # In ~/.bashrc or ~/.zshrc + alias navien='python3 -m nwp500.cli' + alias navien-status='navien --status' + alias navien-monitor='navien --output text' + +3. **Use scripts for common operations:** + + .. code-block:: bash + + # morning_boost.sh + #!/bin/bash + python3 -m nwp500.cli --set-mode high-demand --set-dhw-temp 150 + + # vacation.sh + #!/bin/bash + python3 -m nwp500.cli --set-mode vacation --vacation-days ${1:-7} + +4. **Combine commands efficiently:** + + .. code-block:: bash + + # Make change and verify in one command + python3 -m nwp500.cli --set-mode energy-saver --status + +5. **Use cron for automation:** + + .. code-block:: bash + + # Morning boost: 6 AM + 0 6 * * * python3 -m nwp500.cli --set-mode high-demand + + # Night economy: 10 PM + 0 22 * * * python3 -m nwp500.cli --set-mode heat-pump + + # Daily status report: 6 PM + 0 18 * * * python3 -m nwp500.cli --status >> ~/navien_log.txt + +Related Documentation +===================== + +* :doc:`auth_client` - Python authentication API +* :doc:`api_client` - Python REST API +* :doc:`mqtt_client` - Python MQTT API +* :doc:`models` - Data models diff --git a/docs/python_api/constants.rst b/docs/python_api/constants.rst new file mode 100644 index 0000000..a28fff2 --- /dev/null +++ b/docs/python_api/constants.rst @@ -0,0 +1,374 @@ +========= +Constants +========= + +The ``nwp500.constants`` module defines MQTT command codes and protocol constants. + +Command Codes +============= + +CommandCode +----------- + +MQTT command codes for device communication. + +.. py:class:: CommandCode(IntEnum) + + All MQTT commands use these numeric codes. Commands fall into two categories: + + * **Query commands** (16777xxx) - Request information + * **Control commands** (33554xxx) - Change settings + +Query Commands +^^^^^^^^^^^^^^ + +.. py:attribute:: DEVICE_INFO_REQUEST = 16777217 + + Request device feature information and capabilities. + + **Response:** DeviceFeature object with firmware versions, serial number, + temperature limits, and supported features. + + **Example:** + + .. code-block:: python + + await mqtt.request_device_info(device) + +.. py:attribute:: STATUS_REQUEST = 16777219 + + Request current device status. + + **Response:** DeviceStatus object with 100+ fields including temperatures, + power consumption, operation mode, and component states. + + **Example:** + + .. code-block:: python + + await mqtt.request_device_status(device) + +.. py:attribute:: RESERVATION_READ = 16777222 + + Read current reservation schedule. + + **Response:** Reservation schedule configuration. + + **Example:** + + .. code-block:: python + + await mqtt.request_reservations(device) + +.. py:attribute:: ENERGY_USAGE_QUERY = 16777225 + + Query historical energy usage data. + + **Request Parameters:** + * year (int) - Year to query + * months (list[int]) - List of months (1-12) + + **Response:** EnergyUsageResponse with daily breakdown of heat pump and + electric heater consumption. + + **Example:** + + .. code-block:: python + + await mqtt.request_energy_usage(device, 2024, [10, 11]) + +.. py:attribute:: RESERVATION_MANAGEMENT = 16777226 + + Update reservation schedule. + + **Request Parameters:** + * enabled (bool) - Enable/disable schedule + * reservations (list[dict]) - Reservation entries + + **Example:** + + .. code-block:: python + + await mqtt.update_reservations(device, True, reservations) + +Power Control Commands +^^^^^^^^^^^^^^^^^^^^^^ + +.. py:attribute:: POWER_OFF = 33554433 + + Turn device off. + + **Example:** + + .. code-block:: python + + await mqtt.set_power(device, power_on=False) + +.. py:attribute:: POWER_ON = 33554434 + + Turn device on. + + **Example:** + + .. code-block:: python + + await mqtt.set_power(device, power_on=True) + +DHW Control Commands +^^^^^^^^^^^^^^^^^^^^ + +.. py:attribute:: DHW_MODE = 33554437 + + Change DHW operation mode. + + **Request Parameters:** + * mode_id (int) - Mode: 1=Heat Pump, 2=Electric, 3=Energy Saver, + 4=High Demand, 5=Vacation + * vacation_days (int, optional) - Days for vacation mode + + **Example:** + + .. code-block:: python + + from nwp500 import DhwOperationSetting + + # Energy Saver mode + await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + + # Vacation mode for 7 days + await mqtt.set_dhw_mode( + device, + DhwOperationSetting.VACATION.value, + vacation_days=7 + ) + +.. py:attribute:: DHW_TEMPERATURE = 33554464 + + Set DHW target temperature. + + **Request Parameters:** + * temperature (int) - Temperature in °F (message value, not display) + + .. important:: + Message value is 20°F less than display value. + Display 140°F = Message 120°F + + **Example:** + + .. code-block:: python + + # For 140°F display, send 120°F message + await mqtt.set_dhw_temperature(device, 120) + + # Or use convenience method + await mqtt.set_dhw_temperature_display(device, 140) + +Anti-Legionella Commands +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:attribute:: ANTI_LEGIONELLA_DISABLE = 33554471 + + Disable anti-Legionella protection cycle. + + **Example:** + + .. code-block:: python + + await mqtt.disable_anti_legionella(device) + +.. py:attribute:: ANTI_LEGIONELLA_ENABLE = 33554472 + + Enable anti-Legionella protection cycle. + + **Request Parameters:** + * period_days (int) - Cycle period (typically 7 or 14 days) + + **Example:** + + .. code-block:: python + + # Enable weekly cycle + await mqtt.enable_anti_legionella(device, period_days=7) + +Time-of-Use Commands +^^^^^^^^^^^^^^^^^^^^ + +.. py:attribute:: TOU_SETTINGS = 33554439 + + Configure TOU schedule. + + **Example:** + + .. code-block:: python + + await mqtt.configure_tou_schedule(device, schedule_data) + +.. py:attribute:: TOU_DISABLE = 33554475 + + Disable TOU optimization. + + **Example:** + + .. code-block:: python + + await mqtt.set_tou_enabled(device, False) + +.. py:attribute:: TOU_ENABLE = 33554476 + + Enable TOU optimization. + + **Example:** + + .. code-block:: python + + await mqtt.set_tou_enabled(device, True) + +Usage Examples +============== + +Using Command Codes Directly +----------------------------- + +.. code-block:: python + + from nwp500.constants import CommandCode + from nwp500.models import MqttRequest, MqttCommand + + # Build custom request + request = MqttRequest( + command=CommandCode.STATUS_REQUEST, + deviceType=52, + macAddress="04786332fca0", + additionalValue="", + param=[], + paramStr="" + ) + + command = MqttCommand( + clientID=mqtt.client_id, + sessionID=mqtt.session_id, + requestTopic=f"cmd/52/04786332fca0/ctrl", + responseTopic=f"cmd/52/04786332fca0/st", + request=request, + protocolVersion=2 + ) + + # Publish + await mqtt.publish(topic, command) + +Checking Command Types +---------------------- + +.. code-block:: python + + from nwp500.constants import CommandCode + + def is_query_command(cmd_code: int) -> bool: + """Check if command is a query (not control).""" + return 16777000 <= cmd_code < 16778000 + + def is_control_command(cmd_code: int) -> bool: + """Check if command is a control operation.""" + return 33554000 <= cmd_code < 33555000 + + # Usage + if is_query_command(CommandCode.STATUS_REQUEST): + print("This is a query command") + + if is_control_command(CommandCode.POWER_ON): + print("This is a control command") + +Backward Compatibility +====================== + +Legacy constant names are supported for backward compatibility: + +.. code-block:: python + + # Old names (still work) + CMD_STATUS_REQUEST = CommandCode.STATUS_REQUEST + CMD_DEVICE_INFO_REQUEST = CommandCode.DEVICE_INFO_REQUEST + CMD_POWER_ON = CommandCode.POWER_ON + CMD_POWER_OFF = CommandCode.POWER_OFF + # ... etc + + # Prefer new enum-based names + from nwp500.constants import CommandCode + CommandCode.STATUS_REQUEST + +Firmware Version Constants +=========================== + +Latest Known Firmware +--------------------- + +The library tracks known firmware versions for compatibility: + +.. code-block:: python + + from nwp500.constants import LATEST_KNOWN_FIRMWARE + + # Latest observed versions + { + "controllerSwVersion": 184614912, + "panelSwVersion": 0, + "wifiSwVersion": 34013184 + } + +Firmware Field Changes +---------------------- + +Some fields were introduced in specific firmware versions: + +.. code-block:: python + + from nwp500.constants import KNOWN_FIRMWARE_FIELD_CHANGES + + # Example: heatMinOpTemperature field + { + "heatMinOpTemperature": { + "introduced_in": "Controller: 184614912, WiFi: 34013184", + "description": "Minimum operating temperature for heating element", + "conversion": "raw + 20" + } + } + +Best Practices +============== + +1. **Use enums instead of magic numbers:** + + .. code-block:: python + + # ✓ Clear and type-safe + from nwp500.constants import CommandCode + request.command = CommandCode.STATUS_REQUEST + + # ✗ Magic number + request.command = 16777219 + +2. **Let the client handle command building:** + + .. code-block:: python + + # ✓ Preferred - client handles command codes + await mqtt.request_device_status(device) + + # ✗ Manual - only for advanced use cases + await mqtt.publish(topic, build_command(CommandCode.STATUS_REQUEST)) + +3. **Check command types for logging/debugging:** + + .. code-block:: python + + def log_command(cmd_code: int): + cmd_name = CommandCode(cmd_code).name + cmd_type = "Query" if cmd_code < 33554000 else "Control" + print(f"{cmd_type} command: {cmd_name} ({cmd_code})") + +Related Documentation +===================== + +* :doc:`models` - Data models and enums +* :doc:`mqtt_client` - MQTT client using these commands +* :doc:`../protocol/mqtt_protocol` - MQTT protocol details diff --git a/docs/python_api/events.rst b/docs/python_api/events.rst new file mode 100644 index 0000000..30ecfe6 --- /dev/null +++ b/docs/python_api/events.rst @@ -0,0 +1,358 @@ +============ +Event System +============ + +The ``nwp500.events`` module provides an event-driven architecture for +reacting to device state changes, errors, and system events. + +Overview +======== + +The MQTT client uses an EventEmitter pattern that allows you to: + +* Subscribe to specific events with callback functions +* React to device state changes in real-time +* Handle connection events (interruption, resumption) +* Monitor errors and diagnostics +* Build reactive, event-driven applications + +All events are emitted asynchronously and callbacks are invoked with +relevant data. + +EventEmitter +============ + +Base class for event-driven components. + +.. py:class:: EventEmitter + + Provides event subscription and emission capabilities. + + **Methods:** + + .. py:method:: on(event, callback) + + Register a callback for an event. + + :param event: Event name + :type event: str + :param callback: Function to call when event fires + :type callback: Callable + + .. py:method:: off(event, callback=None) + + Unregister callback(s) for an event. + + :param event: Event name + :type event: str + :param callback: Specific callback to remove, or None for all + :type callback: Callable or None + + .. py:method:: emit(event, *args, **kwargs) + + Emit an event to all registered callbacks. + + :param event: Event name + :type event: str + :param args: Positional arguments for callbacks + :param kwargs: Keyword arguments for callbacks + +MQTT Client Events +================== + +The :doc:`mqtt_client` emits the following events: + +Connection Events +----------------- + +connection_interrupted +^^^^^^^^^^^^^^^^^^^^^^ + +Emitted when MQTT connection is lost. + +**Callback signature:** + +.. code-block:: python + + def on_interrupted(error): + """ + :param error: Error that caused interruption + :type error: Exception + """ + +**Example:** + +.. code-block:: python + + def handle_disconnect(error): + print(f"Connection lost: {error}") + # Save state, notify user, etc. + + mqtt.on('connection_interrupted', handle_disconnect) + +connection_resumed +^^^^^^^^^^^^^^^^^^ + +Emitted when MQTT connection is restored. + +**Callback signature:** + +.. code-block:: python + + def on_resumed(return_code, session_present): + """ + :param return_code: MQTT return code + :type return_code: int + :param session_present: Whether session was resumed + :type session_present: bool + """ + +**Example:** + +.. code-block:: python + + def handle_reconnect(return_code, session_present): + print("Connection restored") + # Re-request status, resume operations + await mqtt.request_device_status(device) + + mqtt.on('connection_resumed', handle_reconnect) + +Device Events +------------- + +status_received +^^^^^^^^^^^^^^^ + +Emitted when device status update is received. + +**Callback signature:** + +.. code-block:: python + + def on_status(status): + """ + :param status: Device status object + :type status: DeviceStatus + """ + +**Example:** + +.. code-block:: python + + def handle_status(status): + print(f"Temperature: {status.dhwTemperature}°F") + print(f"Power: {status.currentInstPower}W") + + mqtt.on('status_received', handle_status) + +feature_received +^^^^^^^^^^^^^^^^ + +Emitted when device feature/info update is received. + +**Callback signature:** + +.. code-block:: python + + def on_feature(feature): + """ + :param feature: Device feature object + :type feature: DeviceFeature + """ + +temperature_changed +^^^^^^^^^^^^^^^^^^^ + +Emitted when water temperature changes significantly. + +**Callback signature:** + +.. code-block:: python + + def on_temp_change(old_temp, new_temp): + """ + :param old_temp: Previous temperature + :type old_temp: float + :param new_temp: Current temperature + :type new_temp: float + """ + +mode_changed +^^^^^^^^^^^^ + +Emitted when operation mode changes. + +**Callback signature:** + +.. code-block:: python + + def on_mode_change(old_mode, new_mode): + """ + :param old_mode: Previous mode + :type old_mode: DhwOperationSetting + :param new_mode: Current mode + :type new_mode: DhwOperationSetting + """ + +error_detected +^^^^^^^^^^^^^^ + +Emitted when device reports an error code. + +**Callback signature:** + +.. code-block:: python + + def on_error(error_code, sub_error_code): + """ + :param error_code: Main error code + :type error_code: int + :param sub_error_code: Sub-error code + :type sub_error_code: int + """ + +Examples +======== + +Example 1: Basic Event Handling +-------------------------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienMqttClient + + async def main(): + async with NavienAuthClient(email, password) as auth: + mqtt = NavienMqttClient(auth) + + # Register event handlers + mqtt.on('status_received', lambda s: print(f"Temp: {s.dhwTemperature}°F")) + mqtt.on('error_detected', lambda e, se: print(f"Error: {e}")) + + await mqtt.connect() + # Events will be emitted automatically + await asyncio.sleep(300) + +Example 2: Connection Monitoring +--------------------------------- + +.. code-block:: python + + async def monitor_connection(): + async with NavienAuthClient(email, password) as auth: + mqtt = NavienMqttClient(auth) + + def on_disconnected(error): + print(f"Lost connection: {error}") + # Alert user, save state + + def on_reconnected(rc, session): + print("Connection restored!") + # Resume operations + + mqtt.on('connection_interrupted', on_disconnected) + mqtt.on('connection_resumed', on_reconnected) + + await mqtt.connect() + await asyncio.sleep(86400) # Monitor for 24h + +Example 3: Temperature Alerts +------------------------------ + +.. code-block:: python + + async def temperature_alerts(): + async with NavienAuthClient(email, password) as auth: + mqtt = NavienMqttClient(auth) + + def check_temp(status): + if status.dhwTemperature < 110: + print("⚠️ WARNING: Temperature below 110°F") + send_alert("Low water temperature") + + if status.dhwTemperature > 145: + print("⚠️ WARNING: Temperature above 145°F") + send_alert("High water temperature") + + mqtt.on('status_received', check_temp) + + await mqtt.connect() + await mqtt.subscribe_device_status(device, lambda s: None) + await mqtt.start_periodic_requests(device, period_seconds=60) + + await asyncio.sleep(86400) + +Example 4: Multiple Event Handlers +----------------------------------- + +.. code-block:: python + + async def multi_handler(): + async with NavienAuthClient(email, password) as auth: + mqtt = NavienMqttClient(auth) + + # Log all status updates + mqtt.on('status_received', lambda s: log_status(s)) + + # Track temperature + mqtt.on('temperature_changed', lambda old, new: + print(f"Temp: {old}°F → {new}°F")) + + # Monitor mode changes + mqtt.on('mode_changed', lambda old, new: + print(f"Mode: {old.name} → {new.name}")) + + # Alert on errors + mqtt.on('error_detected', lambda e, se: + send_alert(f"Error: {e}:{se}")) + + await mqtt.connect() + # All handlers will be called automatically + +Best Practices +============== + +1. **Register handlers before connecting:** + + .. code-block:: python + + # ✓ Register first + mqtt.on('status_received', handler) + await mqtt.connect() + + # ✗ May miss early events + await mqtt.connect() + mqtt.on('status_received', handler) + +2. **Use lambda for simple handlers:** + + .. code-block:: python + + mqtt.on('status_received', lambda s: print(f"{s.dhwTemperature}°F")) + +3. **Use named functions for complex handlers:** + + .. code-block:: python + + def complex_handler(status): + # Complex logic + process_status(status) + update_database(status) + check_alerts(status) + + mqtt.on('status_received', complex_handler) + +4. **Clean up handlers when done:** + + .. code-block:: python + + mqtt.off('status_received', handler) # Remove specific + mqtt.off('status_received') # Remove all + +Related Documentation +===================== + +* :doc:`mqtt_client` - MQTT client with events +* :doc:`models` - Data models passed to event handlers +* :doc:`exceptions` - Exception handling diff --git a/docs/python_api/exceptions.rst b/docs/python_api/exceptions.rst new file mode 100644 index 0000000..1a3c295 --- /dev/null +++ b/docs/python_api/exceptions.rst @@ -0,0 +1,429 @@ +========== +Exceptions +========== + +Exception classes for error handling in the nwp500 library. + +Overview +======== + +The library provides specific exception types for different error +scenarios: + +* **Authentication errors** - Sign-in, token refresh failures +* **API errors** - REST API request failures +* **MQTT errors** - Connection and communication issues + +All exceptions inherit from Python's base ``Exception`` class and +provide additional context through attributes. + +Authentication Exceptions +========================= + +AuthenticationError +------------------- + +Base exception for all authentication-related errors. + +.. py:class:: AuthenticationError(message, status_code=None, response=None) + + Base class for authentication failures. + + :param message: Error description + :type message: str + :param status_code: HTTP status code if available + :type status_code: int or None + :param response: Complete API response dictionary + :type response: dict or None + + **Attributes:** + + * ``message`` (str) - Error message + * ``status_code`` (int or None) - HTTP status code + * ``response`` (dict or None) - Full API response + + **Example:** + + .. code-block:: python + + from nwp500 import AuthenticationError + + try: + async with NavienAuthClient(email, password) as auth: + # Operations + pass + except AuthenticationError as e: + print(f"Auth failed: {e.message}") + if e.status_code: + print(f"Status code: {e.status_code}") + if e.response: + print(f"Response: {e.response}") + +InvalidCredentialsError +----------------------- + +Raised when email/password combination is incorrect. + +.. py:class:: InvalidCredentialsError + + Subclass of :py:class:`AuthenticationError`. + + Raised during ``sign_in()`` when credentials are rejected. + + **Example:** + + .. code-block:: python + + from nwp500 import InvalidCredentialsError + + try: + await auth.sign_in("wrong@email.com", "wrong_password") + except InvalidCredentialsError: + print("Invalid email or password") + # Prompt user to re-enter credentials + +TokenExpiredError +----------------- + +Raised when an authentication token has expired. + +.. py:class:: TokenExpiredError + + Subclass of :py:class:`AuthenticationError`. + + Usually raised when token refresh fails and re-authentication is + required. + + **Example:** + + .. code-block:: python + + from nwp500 import TokenExpiredError + + try: + await api.list_devices() + except TokenExpiredError: + print("Token expired - please sign in again") + # Re-authenticate + +TokenRefreshError +----------------- + +Raised when automatic token refresh fails. + +.. py:class:: TokenRefreshError + + Subclass of :py:class:`AuthenticationError`. + + Occurs when refresh token is invalid or expired, requiring new + sign-in. + + **Example:** + + .. code-block:: python + + from nwp500 import TokenRefreshError + + try: + await auth.ensure_valid_token() + except TokenRefreshError: + print("Cannot refresh token - signing in again") + await auth.sign_in(email, password) + +API Exceptions +============== + +APIError +-------- + +Raised when REST API returns an error response. + +.. py:class:: APIError(message, code=None, response=None) + + Exception for REST API failures. + + :param message: Error description + :type message: str + :param code: HTTP or API error code + :type code: int or None + :param response: Complete API response dictionary + :type response: dict or None + + **Attributes:** + + * ``message`` (str) - Error message + * ``code`` (int or None) - HTTP/API error code + * ``response`` (dict or None) - Full API response + + **Common HTTP codes:** + + * 400 - Bad request (invalid parameters) + * 401 - Unauthorized (authentication failed) + * 404 - Not found (device or resource missing) + * 429 - Rate limited (too many requests) + * 500 - Server error (Navien API issue) + * 503 - Service unavailable (API down) + + **Example:** + + .. code-block:: python + + from nwp500 import APIError + + try: + device = await api.get_device_info("invalid_mac") + except APIError as e: + print(f"API error: {e.message}") + print(f"Code: {e.code}") + + if e.code == 404: + print("Device not found") + elif e.code == 401: + print("Authentication failed") + elif e.code >= 500: + print("Server error - try again later") + +MQTT Exceptions +=============== + +MQTT-related errors typically manifest as Python exceptions from the +underlying ``awscrt`` and ``awsiot`` libraries. + +Common MQTT Errors +------------------ + +**Connection Failures:** + +* ``ConnectionError`` - Failed to connect to AWS IoT Core +* ``TimeoutError`` - Connection attempt timed out +* ``ssl.SSLError`` - TLS/SSL handshake failed + +**Authentication Failures:** + +* ``Exception`` with "unauthorized" - Invalid AWS credentials +* ``Exception`` with "forbidden" - AWS policy denies access + +**Network Errors:** + +* ``OSError`` - Network interface issues +* ``socket.error`` - Socket-level errors + +Example MQTT Error Handling +---------------------------- + +.. code-block:: python + + from nwp500 import NavienMqttClient + import asyncio + + async def safe_mqtt_connect(): + mqtt = NavienMqttClient(auth) + + try: + await mqtt.connect() + print("Connected successfully") + + except ConnectionError as e: + print(f"Connection failed: {e}") + # Check network, credentials + + except TimeoutError: + print("Connection timed out") + # Retry with longer timeout + + except Exception as e: + print(f"Unexpected error: {e}") + # Log for debugging + +Error Handling Patterns +======================= + +Pattern 1: Specific Exception Handling +--------------------------------------- + +.. code-block:: python + + from nwp500 import ( + NavienAuthClient, + InvalidCredentialsError, + TokenExpiredError, + APIError + ) + + async def robust_operation(): + try: + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + devices = await api.list_devices() + return devices + + except InvalidCredentialsError: + print("Invalid credentials") + # Re-prompt user + + except TokenExpiredError: + print("Token expired") + # Force re-authentication + + except APIError as e: + if e.code == 429: + print("Rate limited - waiting...") + await asyncio.sleep(60) + # Retry + else: + print(f"API error: {e.message}") + + except Exception as e: + print(f"Unexpected error: {e}") + # Log and notify + +Pattern 2: Base Exception Handling +----------------------------------- + +.. code-block:: python + + from nwp500 import AuthenticationError, APIError + + async def simple_handling(): + try: + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + return await api.list_devices() + + except AuthenticationError as e: + # Handles all auth errors + print(f"Authentication failed: {e.message}") + return None + + except APIError as e: + # Handles all API errors + print(f"API request failed: {e.message}") + return None + +Pattern 3: Retry Logic +----------------------- + +.. code-block:: python + + from nwp500 import APIError + import asyncio + + async def retry_on_failure(max_retries=3): + for attempt in range(max_retries): + try: + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + return await api.list_devices() + + except APIError as e: + if e.code >= 500: + # Server error - retry + print(f"Attempt {attempt + 1} failed: {e.message}") + if attempt < max_retries - 1: + await asyncio.sleep(2 ** attempt) # Exponential backoff + else: + raise # Give up after max retries + else: + # Client error - don't retry + raise + +Pattern 4: Graceful Degradation +-------------------------------- + +.. code-block:: python + + from nwp500 import APIError, AuthenticationError + + async def with_fallback(): + try: + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + devices = await api.list_devices() + return devices + + except AuthenticationError: + print("Cannot authenticate - using cached data") + return load_cached_devices() + + except APIError: + print("API unavailable - using cached data") + return load_cached_devices() + +Best Practices +============== + +1. **Catch specific exceptions first:** + + .. code-block:: python + + try: + await auth.sign_in(email, password) + except InvalidCredentialsError: + # Handle specifically + pass + except AuthenticationError: + # Handle generally + pass + except Exception: + # Handle anything else + pass + +2. **Use exception attributes:** + + .. code-block:: python + + try: + await api.list_devices() + except APIError as e: + # Use error details + log.error(f"API error: {e.message}") + log.error(f"Code: {e.code}") + log.debug(f"Response: {e.response}") + +3. **Implement retry logic for transient errors:** + + .. code-block:: python + + async def with_retry(func, max_attempts=3): + for i in range(max_attempts): + try: + return await func() + except APIError as e: + if e.code >= 500 and i < max_attempts - 1: + await asyncio.sleep(2 ** i) + else: + raise + +4. **Always cleanup resources:** + + .. code-block:: python + + mqtt = NavienMqttClient(auth) + try: + await mqtt.connect() + # Operations + except Exception as e: + print(f"Error: {e}") + finally: + await mqtt.disconnect() + +5. **Log for debugging:** + + .. code-block:: python + + import logging + + try: + await api.list_devices() + except APIError as e: + logging.error(f"API error: {e.message}", extra={ + 'code': e.code, + 'response': e.response + }) + +Related Documentation +===================== + +* :doc:`auth_client` - Authentication client +* :doc:`api_client` - REST API client +* :doc:`mqtt_client` - MQTT client diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst new file mode 100644 index 0000000..eb790c4 --- /dev/null +++ b/docs/python_api/models.rst @@ -0,0 +1,718 @@ +=========== +Data Models +=========== + +The ``nwp500.models`` module provides type-safe data models for all Navien +device data, including device information, status, features, and energy usage. + +Overview +======== + +All models are **immutable dataclasses** with: + +* Type annotations for all fields +* Automatic validation +* JSON serialization support +* Enum types for categorical values +* Automatic unit conversions + +Enumerations +============ + +DhwOperationSetting +------------------- + +DHW (Domestic Hot Water) operation modes - the user's configured heating +preference. + +.. py:class:: DhwOperationSetting(Enum) + + **Values:** + + * ``HEAT_PUMP = 1`` - Heat Pump Only + - Most efficient mode + - Uses only heat pump (no electric heaters) + - Slowest recovery time + - Lowest operating cost + - Best for normal daily use + + * ``ELECTRIC = 2`` - Electric Only + - Fast recovery mode + - Uses only electric resistance heaters + - Fastest recovery time + - Highest operating cost + - Use for high-demand situations + + * ``ENERGY_SAVER = 3`` - Energy Saver (Hybrid) + - **Recommended for most users** + - Balanced efficiency and performance + - Uses heat pump primarily, electric when needed + - Good recovery time + - Moderate operating cost + + * ``HIGH_DEMAND = 4`` - High Demand + - Maximum heating capacity + - Uses both heat pump and electric heaters + - Fast recovery with continuous demand + - Higher operating cost + - Best for large families or frequent use + + * ``VACATION = 5`` - Vacation Mode + - Low-power standby mode + - Maintains minimum temperature + - Prevents freezing + - Lowest energy consumption + - Requires vacation_days parameter + + **Example:** + + .. code-block:: python + + from nwp500 import DhwOperationSetting, NavienMqttClient + + # Set to Energy Saver (recommended) + await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + + # Set to Heat Pump Only (most efficient) + await mqtt.set_dhw_mode(device, DhwOperationSetting.HEAT_PUMP.value) + + # Set vacation mode for 7 days + await mqtt.set_dhw_mode( + device, + DhwOperationSetting.VACATION.value, + vacation_days=7 + ) + + # Check current mode from status + def on_status(status): + if status.dhwOperationSetting == DhwOperationSetting.ENERGY_SAVER: + print("Running in Energy Saver mode") + +CurrentOperationMode +-------------------- + +Current real-time operational state - what the device is doing **right now**. + +.. py:class:: CurrentOperationMode(Enum) + + Unlike ``DhwOperationSetting`` (user preference), this reflects the actual + real-time operation and changes dynamically. + + **Values:** + + * ``IDLE = 0`` - Device is idle, not heating + * ``HEAT_PUMP = 1`` - Heat pump actively running + * ``ELECTRIC_HEATER = 2`` - Electric heater actively running + * ``HEAT_PUMP_AND_HEATER = 3`` - Both heat pump and electric running + + **Example:** + + .. code-block:: python + + from nwp500 import CurrentOperationMode + + def on_status(status): + mode = status.operationMode + + if mode == CurrentOperationMode.IDLE: + print("Device idle") + elif mode == CurrentOperationMode.HEAT_PUMP: + print(f"Heat pump running at {status.currentInstPower}W") + elif mode == CurrentOperationMode.ELECTRIC_HEATER: + print(f"Electric heater at {status.currentInstPower}W") + elif mode == CurrentOperationMode.HEAT_PUMP_AND_HEATER: + print(f"Both running at {status.currentInstPower}W") + +TemperatureUnit +--------------- + +Temperature scale enumeration. + +.. py:class:: TemperatureUnit(Enum) + + **Values:** + + * ``CELSIUS = 1`` - Celsius (°C) + * ``FAHRENHEIT = 2`` - Fahrenheit (°F) + + **Example:** + + .. code-block:: python + + def on_status(status): + if status.temperatureType == TemperatureUnit.FAHRENHEIT: + print(f"Temperature: {status.dhwTemperature}°F") + else: + print(f"Temperature: {status.dhwTemperature}°C") + +Device Models +============= + +Device +------ + +Complete device representation with info and location. + +.. py:class:: Device + + **Fields:** + + * ``device_info`` (DeviceInfo) - Device identification and status + * ``location`` (Location) - Physical location information + + **Example:** + + .. code-block:: python + + device = await api.get_first_device() + + # Access device info + info = device.device_info + print(f"Name: {info.device_name}") + print(f"MAC: {info.mac_address}") + print(f"Type: {info.device_type}") + print(f"Connected: {info.connected == 2}") + + # Access location + loc = device.location + if loc.city: + print(f"Location: {loc.city}, {loc.state}") + print(f"Coords: {loc.latitude}, {loc.longitude}") + +DeviceInfo +---------- + +Device identification and connection information. + +.. py:class:: DeviceInfo + + **Fields:** + + * ``home_seq`` (int) - Home sequence number + * ``mac_address`` (str) - MAC address (without colons) + * ``additional_value`` (str) - Additional identifier + * ``device_type`` (int) - Device type code (52 for NWP500) + * ``device_name`` (str) - User-assigned device name + * ``connected`` (int) - Connection status (2 = online, 0 = offline) + * ``install_type`` (str, optional) - Installation type + + **Example:** + + .. code-block:: python + + info = device.device_info + + print(f"Device: {info.device_name}") + print(f"MAC: {info.mac_address}") + print(f"Type: {info.device_type}") + + if info.connected == 2: + print("Status: Online ✓") + else: + print("Status: Offline ✗") + +Location +-------- + +Physical location information for a device. + +.. py:class:: Location + + **Fields:** + + * ``state`` (str, optional) - State/province + * ``city`` (str, optional) - City name + * ``address`` (str, optional) - Street address + * ``latitude`` (float, optional) - GPS latitude + * ``longitude`` (float, optional) - GPS longitude + * ``altitude`` (float, optional) - Altitude in meters + + **Example:** + + .. code-block:: python + + loc = device.location + + if loc.city and loc.state: + print(f"Location: {loc.city}, {loc.state}") + + if loc.latitude and loc.longitude: + print(f"GPS: {loc.latitude}, {loc.longitude}") + +FirmwareInfo +------------ + +Firmware version information. + +.. py:class:: FirmwareInfo + + **Fields:** + + * ``mac_address`` (str) - Device MAC address + * ``additional_value`` (str) - Additional identifier + * ``device_type`` (int) - Device type code + * ``cur_sw_code`` (int) - Current software code + * ``cur_version`` (int) - Current version number + * ``downloaded_version`` (int, optional) - Downloaded update version + * ``device_group`` (str, optional) - Device group + + **Example:** + + .. code-block:: python + + fw_list = await api.get_firmware_info() + + for fw in fw_list: + print(f"Device: {fw.mac_address}") + print(f" Current: {fw.cur_version} (code: {fw.cur_sw_code})") + + if fw.downloaded_version: + print(f" ⚠️ Update available: {fw.downloaded_version}") + else: + print(f" ✓ Up to date") + +Status Models +============= + +DeviceStatus +------------ + +Complete real-time device status with 100+ fields. + +.. py:class:: DeviceStatus + + **Key Temperature Fields:** + + * ``dhwTemperature`` (float) - Current water temperature (°F or °C) + * ``dhwTemperatureSetting`` (float) - Target temperature setting + * ``dhwTargetTemperatureSetting`` (float) - Target with offsets applied + * ``tankUpperTemperature`` (float) - Upper tank sensor + * ``tankLowerTemperature`` (float) - Lower tank sensor + * ``currentInletTemperature`` (float) - Cold water inlet temperature + * ``outsideTemperature`` (float) - Outdoor temperature + * ``ambientTemperature`` (float) - Ambient air temperature + + .. note:: + Temperature display values are 20°F higher than message values. + Display: 140°F = Message: 120°F + + **Key Power/Energy Fields:** + + * ``currentInstPower`` (float) - Current power consumption (Watts) + * ``totalEnergyCapacity`` (float) - Total energy capacity (%) + * ``availableEnergyCapacity`` (float) - Available energy (%) + * ``dhwChargePer`` (float) - DHW charge percentage + + **Operation Mode Fields:** + + * ``operationMode`` (CurrentOperationMode) - Current operational state + * ``dhwOperationSetting`` (DhwOperationSetting) - User's mode preference + * ``temperatureType`` (TemperatureUnit) - Temperature unit + + **Boolean Status Fields:** + + * ``operationBusy`` (bool) - Device actively heating water + * ``dhwUse`` (bool) - Water being used (short-term detection) + * ``dhwUseSustained`` (bool) - Water being used (sustained) + * ``compUse`` (bool) - Compressor/heat pump running + * ``heatUpperUse`` (bool) - Upper electric heater active + * ``heatLowerUse`` (bool) - Lower electric heater active + * ``evaFanUse`` (bool) - Evaporator fan running + * ``antiLegionellaUse`` (bool) - Anti-Legionella enabled + * ``antiLegionellaOperationBusy`` (bool) - Anti-Legionella cycle active + * ``programReservationUse`` (bool) - Reservation schedule enabled + * ``freezeProtectionUse`` (bool) - Freeze protection enabled + + **Error/Diagnostic Fields:** + + * ``errorCode`` (int) - Error code (0 = no error) + * ``subErrorCode`` (int) - Sub-error code + * ``smartDiagnostic`` (int) - Smart diagnostic status + * ``faultStatus1`` (int) - Fault status flags + * ``faultStatus2`` (int) - Additional fault flags + + **Network/Communication:** + + * ``wifiRssi`` (int) - WiFi signal strength (dBm) + + **Vacation/Schedule:** + + * ``vacationDaySetting`` (int) - Vacation days configured + * ``vacationDayElapsed`` (int) - Vacation days elapsed + * ``antiLegionellaPeriod`` (int) - Anti-Legionella cycle period + + **Time-of-Use (TOU):** + + * ``touStatus`` (int) - TOU status + * ``touOverrideStatus`` (int) - TOU override status + + **Heat Pump Detailed Status:** + + * ``targetFanRpm`` (int) - Target fan RPM + * ``currentFanRpm`` (int) - Current fan RPM + * ``fanPwm`` (int) - Fan PWM duty cycle + * ``mixingRate`` (float) - Mixing valve rate + * ``eevStep`` (int) - Electronic expansion valve position + * ``dischargeTemperature`` (float) - Compressor discharge temp + * ``suctionTemperature`` (float) - Compressor suction temp + * ``evaporatorTemperature`` (float) - Evaporator temperature + * ``targetSuperHeat`` (float) - Target superheat + * ``currentSuperHeat`` (float) - Current superheat + + **Example:** + + .. code-block:: python + + def on_status(status): + # Temperature monitoring + print(f"Water: {status.dhwTemperature}°F") + print(f"Target: {status.dhwTemperatureSetting}°F") + print(f"Upper Tank: {status.tankUpperTemperature}°F") + print(f"Lower Tank: {status.tankLowerTemperature}°F") + + # Power consumption + print(f"Power: {status.currentInstPower}W") + print(f"Energy: {status.availableEnergyCapacity}%") + + # Operation mode + print(f"Mode: {status.dhwOperationSetting.name}") + print(f"State: {status.operationMode.name}") + + # Active heating + if status.operationBusy: + print("Heating water:") + if status.compUse: + print(" - Heat pump running") + if status.heatUpperUse: + print(" - Upper heater active") + if status.heatLowerUse: + print(" - Lower heater active") + + # Water usage detection + if status.dhwUse: + print("Water usage detected (short-term)") + if status.dhwUseSustained: + print("Water usage detected (sustained)") + + # Errors + if status.errorCode != 0: + print(f"ERROR: {status.errorCode}") + if status.subErrorCode != 0: + print(f" Sub-error: {status.subErrorCode}") + +DeviceFeature +------------- + +Device capabilities, features, and firmware information. + +.. py:class:: DeviceFeature + + **Firmware Version Fields:** + + * ``controllerSwVersion`` (int) - Controller firmware version + * ``panelSwVersion`` (int) - Panel firmware version + * ``wifiSwVersion`` (int) - WiFi module firmware version + * ``controllerSwCode`` (int) - Controller software code + * ``panelSwCode`` (int) - Panel software code + * ``wifiSwCode`` (int) - WiFi software code + * ``controllerSerialNumber`` (str) - Controller serial number + + **Device Configuration:** + + * ``countryCode`` (int) - Country code + * ``modelTypeCode`` (int) - Model type + * ``controlTypeCode`` (int) - Control type + * ``volumeCode`` (int) - Tank volume code + * ``tempFormulaType`` (int) - Temperature formula type + * ``temperatureType`` (TemperatureUnit) - Temperature unit + + **Temperature Limits:** + + * ``dhwTemperatureMin`` (int) - Minimum DHW temperature + * ``dhwTemperatureMax`` (int) - Maximum DHW temperature + * ``freezeProtectionTempMin`` (int) - Min freeze protection temp + * ``freezeProtectionTempMax`` (int) - Max freeze protection temp + + **Feature Flags (all int, 0=disabled, 1=enabled):** + + * ``powerUse`` - Power control supported + * ``dhwUse`` - DHW functionality + * ``dhwTemperatureSettingUse`` - Temperature control + * ``energyUsageUse`` - Energy monitoring supported + * ``antiLegionellaSettingUse`` - Anti-Legionella supported + * ``programReservationUse`` - Reservation scheduling supported + * ``freezeProtectionUse`` - Freeze protection available + * ``heatpumpUse`` - Heat pump mode available + * ``electricUse`` - Electric mode available + * ``energySaverUse`` - Energy Saver mode available + * ``highDemandUse`` - High Demand mode available + * ``smartDiagnosticUse`` - Smart diagnostics available + * ``wifiRssiUse`` - WiFi signal strength available + * ``holidayUse`` - Holiday/vacation mode + * ``mixingValueUse`` - Mixing valve + * ``drSettingUse`` - Demand response + * ``dhwRefillUse`` - DHW refill + * ``ecoUse`` - Eco mode + + **Example:** + + .. code-block:: python + + def on_feature(feature): + print(f"Serial: {feature.controllerSerialNumber}") + print(f"Firmware: {feature.controllerSwVersion}") + print(f"WiFi: {feature.wifiSwVersion}") + + print(f"\nTemperature Range:") + print(f" Min: {feature.dhwTemperatureMin}°F") + print(f" Max: {feature.dhwTemperatureMax}°F") + + print(f"\nSupported Features:") + if feature.energyUsageUse: + print(" ✓ Energy monitoring") + if feature.antiLegionellaSettingUse: + print(" ✓ Anti-Legionella") + if feature.programReservationUse: + print(" ✓ Reservations") + if feature.heatpumpUse: + print(" ✓ Heat pump mode") + if feature.electricUse: + print(" ✓ Electric mode") + if feature.energySaverUse: + print(" ✓ Energy Saver mode") + if feature.highDemandUse: + print(" ✓ High Demand mode") + +Energy Models +============= + +EnergyUsageResponse +------------------- + +Complete energy usage response with daily breakdown. + +.. py:class:: EnergyUsageResponse + + **Fields:** + + * ``deviceType`` (int) - Device type + * ``macAddress`` (str) - Device MAC + * ``additionalValue`` (str) - Additional identifier + * ``typeOfUsage`` (int) - Usage type code + * ``total`` (EnergyUsageTotal) - Total usage summary + * ``usage`` (list[MonthlyEnergyData]) - Monthly data with daily breakdown + + **Example:** + + .. code-block:: python + + def on_energy(energy): + # Overall totals + total = energy.total + print(f"Total Usage: {total.total_usage} Wh") + print(f"Heat Pump: {total.heat_pump_percentage:.1f}%") + print(f"Electric: {total.heat_element_percentage:.1f}%") + + # Monthly data + for month_data in energy.usage: + print(f"\n{month_data.year}-{month_data.month:02d}:") + + # Daily breakdown + for day_num, day in enumerate(month_data.data, 1): + if day.total_usage > 0: + print(f" Day {day_num}: {day.total_usage} Wh") + print(f" HP: {day.hpUsage} Wh ({day.hpTime}h)") + print(f" HE: {day.heUsage} Wh ({day.heTime}h)") + +EnergyUsageTotal +---------------- + +Summary totals for energy usage. + +.. py:class:: EnergyUsageTotal + + **Fields:** + + * ``heUsage`` (int) - Total heat element usage (Wh) + * ``hpUsage`` (int) - Total heat pump usage (Wh) + * ``heTime`` (int) - Total heat element time (hours) + * ``hpTime`` (int) - Total heat pump time (hours) + + **Computed Properties:** + + * ``total_usage`` (int) - heUsage + hpUsage + * ``heat_pump_percentage`` (float) - (hpUsage / total) × 100 + * ``heat_element_percentage`` (float) - (heUsage / total) × 100 + +MonthlyEnergyData +----------------- + +Energy data for one month with daily breakdown. + +.. py:class:: MonthlyEnergyData + + **Fields:** + + * ``year`` (int) - Year + * ``month`` (int) - Month (1-12) + * ``data`` (list[EnergyUsageData]) - Daily data (index 0 = day 1) + +EnergyUsageData +--------------- + +Energy data for a single day. + +.. py:class:: EnergyUsageData + + **Fields:** + + * ``heUsage`` (int) - Heat element usage (Wh) + * ``hpUsage`` (int) - Heat pump usage (Wh) + * ``heTime`` (int) - Heat element time (hours) + * ``hpTime`` (int) - Heat pump time (hours) + + **Computed Properties:** + + * ``total_usage`` (int) - heUsage + hpUsage + +Time-of-Use Models +================== + +TOUInfo +------- + +Time-of-Use pricing schedule information. + +.. py:class:: TOUInfo + + **Fields:** + + * ``register_path`` (str) - Registration path + * ``source_type`` (str) - Source type + * ``controller_id`` (str) - Controller ID + * ``manufacture_id`` (str) - Manufacturer ID + * ``name`` (str) - Schedule name + * ``utility`` (str) - Utility provider name + * ``zip_code`` (int) - ZIP code + * ``schedule`` (list[TOUSchedule]) - Seasonal schedules + + **Example:** + + .. code-block:: python + + tou = await api.get_tou_info(mac, additional_value, controller_id) + + print(f"Utility: {tou.utility}") + print(f"Schedule: {tou.name}") + print(f"ZIP: {tou.zip_code}") + + for season in tou.schedule: + print(f"\nSeason {season.season}:") + for interval in season.intervals: + print(f" {interval}") + +TOUSchedule +----------- + +Seasonal TOU schedule. + +.. py:class:: TOUSchedule + + **Fields:** + + * ``season`` (int) - Season identifier/months + * ``intervals`` (list[dict]) - Time intervals with pricing tiers + +MQTT Models +=========== + +MqttCommand +----------- + +Complete MQTT command message. + +.. py:class:: MqttCommand + + **Fields:** + + * ``clientID`` (str) - MQTT client ID + * ``sessionID`` (str) - Session ID + * ``requestTopic`` (str) - Request topic + * ``responseTopic`` (str) - Response topic + * ``request`` (MqttRequest) - Request payload + * ``protocolVersion`` (int) - Protocol version (default: 2) + +MqttRequest +----------- + +MQTT request payload. + +.. py:class:: MqttRequest + + **Fields:** + + * ``command`` (int) - Command code (see CommandCode) + * ``deviceType`` (int) - Device type + * ``macAddress`` (str) - Device MAC + * ``additionalValue`` (str) - Additional identifier + * ``mode`` (str, optional) - Mode parameter + * ``param`` (list[int | float]) - Numeric parameters + * ``paramStr`` (str) - String parameters + * ``month`` (list[int], optional) - Month list for energy queries + * ``year`` (int, optional) - Year for energy queries + +Best Practices +============== + +1. **Use enums for type safety:** + + .. code-block:: python + + # ✓ Type-safe + from nwp500 import DhwOperationSetting + await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + + # ✗ Magic numbers + await mqtt.set_dhw_mode(device, 3) + +2. **Check feature support:** + + .. code-block:: python + + def on_feature(feature): + if feature.energyUsageUse: + # Device supports energy monitoring + await mqtt.request_energy_usage(device, year, months) + +3. **Handle temperature conversions:** + + .. code-block:: python + + # Display temperature is 20°F higher than message value + display_temp = 140 + message_value = display_temp - 20 # 120 + + # Or use convenience method + await mqtt.set_dhw_temperature_display(device, 140) + +4. **Monitor operation state:** + + .. code-block:: python + + def on_status(status): + # User's mode preference + user_mode = status.dhwOperationSetting + + # Current real-time state + current_state = status.operationMode + + # These can differ! + # User sets ENERGY_SAVER, device might be in HEAT_PUMP state + +Related Documentation +===================== + +* :doc:`auth_client` - Authentication +* :doc:`api_client` - REST API +* :doc:`mqtt_client` - MQTT client +* :doc:`constants` - Command codes and constants diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst new file mode 100644 index 0000000..1beca42 --- /dev/null +++ b/docs/python_api/mqtt_client.rst @@ -0,0 +1,1084 @@ +============ +MQTT Client +============ + +The ``NavienMqttClient`` is the **primary interface** for real-time communication +with Navien devices. Use this for monitoring status and sending control commands. + +.. important:: + **MQTT is the main way to interact with your Navien device.** Use the REST API + only for device discovery. MQTT provides real-time updates, lower latency, + bidirectional communication, and event-driven architecture. + +Overview +======== + +The MQTT client provides: + +* **Real-Time Monitoring** - Subscribe to device status updates as they happen +* **Device Control** - Send commands (power, temperature, mode) +* **Event System** - React to state changes with callbacks +* **Auto-Reconnection** - Automatic recovery from network issues with exponential backoff +* **Command Queueing** - Commands queued when offline, sent automatically on reconnect +* **Type-Safe** - Returns strongly-typed data models (DeviceStatus, DeviceFeature) +* **Periodic Requests** - Automatic periodic status/info requests +* **Energy Monitoring** - Query and subscribe to energy usage data + +All operations are fully asynchronous and non-blocking. + +Quick Start +=========== + +Basic Monitoring +---------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + import asyncio + + async def main(): + async with NavienAuthClient("email@example.com", "password") as auth: + # Get device via API + api = NavienAPIClient(auth) + device = await api.get_first_device() + + # Connect MQTT + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Subscribe to status updates + def on_status(status): + print(f"Water Temp: {status.dhwTemperature}°F") + print(f"Target: {status.dhwTemperatureSetting}°F") + print(f"Power: {status.currentInstPower}W") + print(f"Mode: {status.dhwOperationSetting.name}") + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) + + # Monitor for 60 seconds + await asyncio.sleep(60) + await mqtt.disconnect() + + asyncio.run(main()) + +Device Control +-------------- + +.. code-block:: python + + async def control_device(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Control operations + await mqtt.set_power(device, power_on=True) + await mqtt.set_dhw_mode(device, mode_id=3) # Energy Saver + await mqtt.set_dhw_temperature_display(device, 140) + + await mqtt.disconnect() + + asyncio.run(control_device()) + +API Reference +============= + +NavienMqttClient +---------------- + +.. py:class:: NavienMqttClient(auth_client, config=None, on_connection_interrupted=None, on_connection_resumed=None) + + MQTT client for real-time device communication via AWS IoT Core. + + :param auth_client: Authenticated NavienAuthClient instance + :type auth_client: NavienAuthClient + :param config: Connection configuration (optional) + :type config: MqttConnectionConfig or None + :param on_connection_interrupted: Callback for connection loss + :type on_connection_interrupted: Callable or None + :param on_connection_resumed: Callback for connection restoration + :type on_connection_resumed: Callable or None + :raises ValueError: If auth_client not authenticated or missing AWS credentials + + **Example:** + + .. code-block:: python + + from nwp500 import NavienMqttClient + from nwp500.mqtt_utils import MqttConnectionConfig + + # Default configuration + mqtt = NavienMqttClient(auth) + + # Custom configuration + config = MqttConnectionConfig( + auto_reconnect=True, + max_reconnect_attempts=15, + enable_command_queue=True, + max_queued_commands=100 + ) + mqtt = NavienMqttClient(auth, config=config) + + # With connection callbacks + def on_interrupted(error): + print(f"Connection lost: {error}") + + def on_resumed(return_code, session_present): + print("Connection restored!") + + mqtt = NavienMqttClient( + auth, + on_connection_interrupted=on_interrupted, + on_connection_resumed=on_resumed + ) + +Connection Methods +------------------ + +connect() +^^^^^^^^^ + +.. py:method:: connect() + + Connect to AWS IoT Core MQTT broker. + + :return: True if connection successful + :rtype: bool + :raises Exception: If connection fails + + **Example:** + + .. code-block:: python + + mqtt = NavienMqttClient(auth) + + try: + connected = await mqtt.connect() + if connected: + print(f"Connected! Client ID: {mqtt.client_id}") + else: + print("Connection failed") + except Exception as e: + print(f"Error connecting: {e}") + +disconnect() +^^^^^^^^^^^^ + +.. py:method:: disconnect() + + Disconnect from MQTT broker and cleanup all resources. + + Stops all periodic tasks, unsubscribes from topics, and closes connection. + + **Example:** + + .. code-block:: python + + try: + # ... operations ... + finally: + await mqtt.disconnect() + +Monitoring Methods +------------------ + +subscribe_device_status() +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: subscribe_device_status(device, callback) + + Subscribe to device status updates with automatic parsing. + + The callback receives DeviceStatus objects with 100+ fields including temperature, + power consumption, operation mode, and component states. + + :param device: Device object + :type device: Device + :param callback: Function receiving DeviceStatus objects + :type callback: Callable[[DeviceStatus], None] + :return: Subscription packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + def on_status(status): + """Called every time device status updates.""" + print(f"Temperature: {status.dhwTemperature}°F") + print(f"Target: {status.dhwTemperatureSetting}°F") + print(f"Mode: {status.dhwOperationSetting.name}") + print(f"Power: {status.currentInstPower}W") + print(f"Energy: {status.availableEnergyCapacity}%") + + # Check if actively heating + if status.operationBusy: + print("Device is heating water") + if status.compUse: + print(" - Heat pump running") + if status.heatUpperUse: + print(" - Upper heater active") + if status.heatLowerUse: + print(" - Lower heater active") + + # Check water usage + if status.dhwUse: + print("Water is being used (short-term)") + if status.dhwUseSustained: + print("Water is being used (sustained)") + + # Check for errors + if status.errorCode != 0: + print(f"ERROR: {status.errorCode}") + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) + +request_device_status() +^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_device_status(device) + + Request current device status. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Subscribe first to receive response + await mqtt.subscribe_device_status(device, on_status) + + # Then request + await mqtt.request_device_status(device) + + # Can request periodically + while monitoring: + await mqtt.request_device_status(device) + await asyncio.sleep(30) # Every 30 seconds + +subscribe_device_feature() +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: subscribe_device_feature(device, callback) + + Subscribe to device feature/capability/info updates. + + The callback receives DeviceFeature objects containing serial number, + firmware version, temperature limits, and supported features. + + :param device: Device object + :type device: Device + :param callback: Function receiving DeviceFeature objects + :type callback: Callable[[DeviceFeature], None] + :return: Subscription packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + def on_feature(feature): + """Called when device features/info received.""" + print(f"Serial: {feature.controllerSerialNumber}") + print(f"Firmware: {feature.controllerSwVersion}") + print(f"Temp Range: {feature.dhwTemperatureMin}°F - " + f"{feature.dhwTemperatureMax}°F") + + # Check capabilities + if feature.energyUsageUse: + print("Energy monitoring: Supported") + if feature.antiLegionellaSettingUse: + print("Anti-Legionella: Supported") + if feature.reservationUse: + print("Reservations: Supported") + + await mqtt.subscribe_device_feature(device, on_feature) + await mqtt.request_device_info(device) + +request_device_info() +^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_device_info(device) + + Request device features and capabilities. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + await mqtt.subscribe_device_feature(device, on_feature) + await mqtt.request_device_info(device) + +subscribe_device() +^^^^^^^^^^^^^^^^^^ + +.. py:method:: subscribe_device(device, callback) + + Subscribe to all messages from a device (low-level). + + This subscribes to both control and status topics, providing raw message access. + For most use cases, use subscribe_device_status() or subscribe_device_feature() instead. + + :param device: Device object + :type device: Device + :param callback: Function receiving (topic, message) tuples + :type callback: Callable[[str, dict], None] + :return: List of subscription packet IDs + :rtype: list[int] + + **Example:** + + .. code-block:: python + + def on_message(topic, message): + """Receive all messages from device.""" + print(f"Topic: {topic}") + print(f"Message: {message}") + + if 'response' in message: + response = message['response'] + if 'status' in response: + # Device status update + status_data = response['status'] + elif 'feature' in response: + # Device feature info + feature_data = response['feature'] + + await mqtt.subscribe_device(device, on_message) + +Control Methods +--------------- + +set_power() +^^^^^^^^^^^ + +.. py:method:: set_power(device, power_on) + + Turn device on or off. + + :param device: Device object + :type device: Device + :param power_on: True to turn on, False to turn off + :type power_on: bool + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Turn on + await mqtt.set_power(device, power_on=True) + print("Device powered ON") + + # Turn off + await mqtt.set_power(device, power_on=False) + print("Device powered OFF") + +set_dhw_mode() +^^^^^^^^^^^^^^ + +.. py:method:: set_dhw_mode(device, mode_id, vacation_days=None) + + Set DHW (Domestic Hot Water) operation mode. + + :param device: Device object + :type device: Device + :param mode_id: Mode ID (1-5) + :type mode_id: int + :param vacation_days: Number of days for vacation mode (required if mode_id=5) + :type vacation_days: int or None + :return: Publish packet ID + :rtype: int + + **Operation Modes:** + + * 1 = Heat Pump Only - Most efficient, uses only heat pump + * 2 = Electric Only - Fast recovery, uses only electric heaters + * 3 = Energy Saver - Balanced, recommended for most users + * 4 = High Demand - Maximum heating capacity + * 5 = Vacation - Low power mode for extended absence + + **Example:** + + .. code-block:: python + + from nwp500 import DhwOperationSetting + + # Set to Heat Pump Only (most efficient) + await mqtt.set_dhw_mode(device, DhwOperationSetting.HEAT_PUMP.value) + + # Set to Energy Saver (balanced, recommended) + await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + # or just: + await mqtt.set_dhw_mode(device, 3) + + # Set to High Demand (maximum heating) + await mqtt.set_dhw_mode(device, DhwOperationSetting.HIGH_DEMAND.value) + + # Set vacation mode for 7 days + await mqtt.set_dhw_mode( + device, + DhwOperationSetting.VACATION.value, + vacation_days=7 + ) + +set_dhw_temperature() +^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: set_dhw_temperature(device, temperature) + + Set target DHW temperature using MESSAGE value (20°F less than display). + + :param device: Device object + :type device: Device + :param temperature: Temperature in °F (message value, NOT display value) + :type temperature: int + :return: Publish packet ID + :rtype: int + + .. important:: + The message value is 20°F LESS than the display value. + For a target display temperature of 140°F, send 120°F. + Use ``set_dhw_temperature_display()`` to use display values directly. + + **Example:** + + .. code-block:: python + + # For 140°F display, send 120°F message value + await mqtt.set_dhw_temperature(device, temperature=120) + +set_dhw_temperature_display() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: set_dhw_temperature_display(device, display_temperature) + + Set target DHW temperature using DISPLAY value (convenience method). + + Automatically converts display value to message value by subtracting 20°F. + + :param device: Device object + :type device: Device + :param display_temperature: Temperature as shown on display/app (°F) + :type display_temperature: int + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Set display temperature to 140°F + # (automatically sends 120°F message value) + await mqtt.set_dhw_temperature_display(device, 140) + + # Common temperatures + await mqtt.set_dhw_temperature_display(device, 120) # Standard + await mqtt.set_dhw_temperature_display(device, 130) # Medium + await mqtt.set_dhw_temperature_display(device, 140) # Hot + await mqtt.set_dhw_temperature_display(device, 150) # Maximum + +enable_anti_legionella() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: enable_anti_legionella(device, period_days) + + Enable anti-Legionella protection cycle. + + :param device: Device object + :type device: Device + :param period_days: Cycle period in days (typically 7 or 14) + :type period_days: int + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Enable weekly anti-Legionella cycle + await mqtt.enable_anti_legionella(device, period_days=7) + + # Enable bi-weekly cycle + await mqtt.enable_anti_legionella(device, period_days=14) + +disable_anti_legionella() +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: disable_anti_legionella(device) + + Disable anti-Legionella protection. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + await mqtt.disable_anti_legionella(device) + +Energy Monitoring Methods +-------------------------- + +request_energy_usage() +^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_energy_usage(device, year, months) + + Request daily energy usage data for specified period. + + :param device: Device object + :type device: Device + :param year: Year to query (e.g., 2024) + :type year: int + :param months: List of months to query (1-12) + :type months: list[int] + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Subscribe first + await mqtt.subscribe_energy_usage(device, on_energy) + + # Request current month + from datetime import datetime + now = datetime.now() + await mqtt.request_energy_usage(device, now.year, [now.month]) + + # Request multiple months + await mqtt.request_energy_usage(device, 2024, [8, 9, 10]) + + # Request full year + await mqtt.request_energy_usage(device, 2024, list(range(1, 13))) + +subscribe_energy_usage() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: subscribe_energy_usage(device, callback) + + Subscribe to energy usage query responses. + + :param device: Device object + :type device: Device + :param callback: Function receiving EnergyUsageResponse objects + :type callback: Callable[[EnergyUsageResponse], None] + :return: Subscription packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + def on_energy(energy): + """Process energy usage data.""" + print(f"Total Usage: {energy.total.total_usage} Wh") + print(f"Heat Pump: {energy.total.heat_pump_percentage:.1f}%") + print(f"Electric: {energy.total.heat_element_percentage:.1f}%") + + print("\nDaily Breakdown:") + for day_data in energy.data: + print(f" Date: Day {len(energy.data)}") + print(f" Total: {day_data.total_usage} Wh") + print(f" HP: {day_data.hpUsage} Wh ({day_data.hpTime}h)") + print(f" HE: {day_data.heUsage} Wh ({day_data.heTime}h)") + + await mqtt.subscribe_energy_usage(device, on_energy) + await mqtt.request_energy_usage(device, year=2024, months=[10]) + +Reservation Methods +------------------- + +update_reservations() +^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: update_reservations(device, enabled, reservations) + + Update device reservation schedule. + + :param device: Device object + :type device: Device + :param enabled: Enable/disable reservation schedule + :type enabled: bool + :param reservations: List of reservation objects + :type reservations: list[dict] + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Define reservations + reservations = [ + { + "startHour": 6, + "startMinute": 0, + "endHour": 22, + "endMinute": 0, + "weekDays": [1, 1, 1, 1, 1, 0, 0], # Mon-Fri + "temperature": 120 + }, + { + "startHour": 8, + "startMinute": 0, + "endHour": 20, + "endMinute": 0, + "weekDays": [0, 0, 0, 0, 0, 1, 1], # Sat-Sun + "temperature": 130 + } + ] + + # Update schedule + await mqtt.update_reservations(device, True, reservations) + +request_reservations() +^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_reservations(device) + + Request current reservation schedule. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + +Time-of-Use Methods +------------------- + +set_tou_enabled() +^^^^^^^^^^^^^^^^^ + +.. py:method:: set_tou_enabled(device, enabled) + + Enable or disable Time-of-Use optimization. + + :param device: Device object + :type device: Device + :param enabled: True to enable, False to disable + :type enabled: bool + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Enable TOU + await mqtt.set_tou_enabled(device, True) + + # Disable TOU + await mqtt.set_tou_enabled(device, False) + +Periodic Request Methods +------------------------ + +start_periodic_requests() +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: start_periodic_requests(device, request_type=DEVICE_STATUS, period_seconds=300.0) + + Start automatic periodic status or info requests. + + :param device: Device object + :type device: Device + :param request_type: Type of request (DEVICE_STATUS or DEVICE_INFO) + :type request_type: PeriodicRequestType + :param period_seconds: Interval in seconds (default: 300 = 5 minutes) + :type period_seconds: float + + **Example:** + + .. code-block:: python + + from nwp500.mqtt_utils import PeriodicRequestType + + # Subscribe first + await mqtt.subscribe_device_status(device, on_status) + + # Start periodic status requests every 60 seconds + await mqtt.start_periodic_requests( + device, + PeriodicRequestType.DEVICE_STATUS, + period_seconds=60 + ) + + # Monitor for extended period + await asyncio.sleep(3600) # 1 hour + + # Stop when done + await mqtt.stop_periodic_requests( + device, + PeriodicRequestType.DEVICE_STATUS + ) + +stop_periodic_requests() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: stop_periodic_requests(device, request_type) + + Stop periodic requests for a device. + + :param device: Device object + :type device: Device + :param request_type: Type of request to stop + :type request_type: PeriodicRequestType + +stop_all_periodic_tasks() +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: stop_all_periodic_tasks(device) + + Stop all periodic tasks for a device. + + :param device: Device object + :type device: Device + +Utility Methods +--------------- + +signal_app_connection() +^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: signal_app_connection(device) + + Signal that an application has connected (recommended at startup). + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + await mqtt.connect() + await mqtt.signal_app_connection(device) + +subscribe(), unsubscribe(), publish() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Low-level MQTT operations (advanced use only). + +Properties +---------- + +is_connected +^^^^^^^^^^^^ + +.. py:attribute:: is_connected + + Check if currently connected to MQTT broker. + + :type: bool + + **Example:** + + .. code-block:: python + + if mqtt.is_connected: + await mqtt.set_power(device, True) + else: + print("Not connected") + +client_id +^^^^^^^^^ + +.. py:attribute:: client_id + + Get MQTT client ID. + + :type: str + +session_id +^^^^^^^^^^ + +.. py:attribute:: session_id + + Get current session ID. + + :type: str + +queued_commands_count +^^^^^^^^^^^^^^^^^^^^^ + +.. py:attribute:: queued_commands_count + + Get number of queued commands (when offline). + + :type: int + + **Example:** + + .. code-block:: python + + count = mqtt.queued_commands_count + if count > 0: + print(f"{count} commands queued (will send on reconnect)") + +reconnect_attempts +^^^^^^^^^^^^^^^^^^ + +.. py:attribute:: reconnect_attempts + + Get current reconnection attempt count. + + :type: int + +is_reconnecting +^^^^^^^^^^^^^^^ + +.. py:attribute:: is_reconnecting + + Check if currently attempting to reconnect. + + :type: bool + +Examples +======== + +Example 1: Complete Monitoring Application +------------------------------------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + from datetime import datetime + import asyncio + + async def monitor_device(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Track state + last_temp = None + last_power = None + + def on_status(status): + nonlocal last_temp, last_power + now = datetime.now().strftime("%H:%M:%S") + + # Temperature changed + if last_temp != status.dhwTemperature: + print(f"[{now}] Temperature: {status.dhwTemperature}°F " + f"(Target: {status.dhwTemperatureSetting}°F)") + last_temp = status.dhwTemperature + + # Power changed + if last_power != status.currentInstPower: + print(f"[{now}] Power: {status.currentInstPower}W") + last_power = status.currentInstPower + + # Heating state + if status.operationBusy: + components = [] + if status.compUse: + components.append("HP") + if status.heatUpperUse: + components.append("Upper") + if status.heatLowerUse: + components.append("Lower") + print(f"[{now}] Heating: {', '.join(components)}") + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) + + # Monitor indefinitely + try: + while True: + await asyncio.sleep(3600) + except KeyboardInterrupt: + print("Stopping...") + finally: + await mqtt.disconnect() + + asyncio.run(monitor_device()) + +Example 2: Automatic Temperature Control +----------------------------------------- + +.. code-block:: python + + async def auto_temperature_control(): + \"\"\"Adjust temperature based on usage patterns.\"\"\" + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Track water usage + last_use_time = None + + def on_status(status): + nonlocal last_use_time + + # Water is being used + if status.dhwUse or status.dhwUseSustained: + last_use_time = datetime.now() + + # If temp dropped below 130°F, boost to high demand + if status.dhwTemperature < 130: + asyncio.create_task( + mqtt.set_dhw_mode(device, 4) # High Demand + ) + + # No use for 2 hours, go to energy saver + elif last_use_time: + idle_time = (datetime.now() - last_use_time).seconds + if idle_time > 7200: # 2 hours + asyncio.create_task( + mqtt.set_dhw_mode(device, 3) # Energy Saver + ) + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.start_periodic_requests(device, period_seconds=60) + + # Run for extended period + await asyncio.sleep(86400) # 24 hours + await mqtt.disconnect() + + asyncio.run(auto_temperature_control()) + +Example 3: Multi-Device Monitoring +----------------------------------- + +.. code-block:: python + + async def monitor_multiple_devices(): + \"\"\"Monitor multiple devices simultaneously.\"\"\" + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + devices = await api.list_devices() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Create callback for each device + def create_callback(device_name): + def callback(status): + print(f"[{device_name}] {status.dhwTemperature}°F, " + f"{status.currentInstPower}W, " + f"{status.dhwOperationSetting.name}") + return callback + + # Subscribe to all devices + for device in devices: + callback = create_callback(device.device_info.device_name) + await mqtt.subscribe_device_status(device, callback) + await mqtt.request_device_status(device) + + # Monitor + await asyncio.sleep(3600) + await mqtt.disconnect() + + asyncio.run(monitor_multiple_devices()) + +Best Practices +============== + +1. **Always subscribe before requesting:** + + .. code-block:: python + + # ✓ Correct order + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) + + # ✗ Wrong - response will be missed + await mqtt.request_device_status(device) + await mqtt.subscribe_device_status(device, on_status) + +2. **Use context managers:** + + .. code-block:: python + + async with NavienAuthClient(email, password) as auth: + mqtt = NavienMqttClient(auth) + try: + await mqtt.connect() + # ... operations ... + finally: + await mqtt.disconnect() + +3. **Handle connection events:** + + .. code-block:: python + + def on_interrupted(error): + print(f"Connection lost: {error}") + # Save state, notify user, etc. + + def on_resumed(return_code, session_present): + print("Connection restored") + # Re-request status, etc. + + mqtt = NavienMqttClient( + auth, + on_connection_interrupted=on_interrupted, + on_connection_resumed=on_resumed + ) + +4. **Use periodic requests for long-running monitoring:** + + .. code-block:: python + + # Instead of manual loop + await mqtt.subscribe_device_status(device, on_status) + await mqtt.start_periodic_requests(device, period_seconds=300) + + # Monitor as long as needed + await asyncio.sleep(86400) # 24 hours + + await mqtt.stop_periodic_requests(device) + +5. **Check connection status:** + + .. code-block:: python + + if mqtt.is_connected: + await mqtt.set_power(device, True) + else: + print("Not connected - reconnecting...") + await mqtt.connect() + +Related Documentation +===================== + +* :doc:`auth_client` - Authentication client +* :doc:`api_client` - REST API client +* :doc:`models` - Data models (DeviceStatus, DeviceFeature, etc.) +* :doc:`events` - Event system +* :doc:`exceptions` - Exception handling +* :doc:`../protocol/mqtt_protocol` - MQTT protocol details +* :doc:`../guides/energy_monitoring` - Energy monitoring guide +* :doc:`../guides/command_queue` - Command queueing guide +* :doc:`../guides/auto_recovery` - Auto-reconnection guide diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 0000000..6af7704 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,278 @@ +========== +Quickstart +========== + +This guide will get you up and running with the nwp500-python library +in just a few minutes. + +Prerequisites +============= + +* Python 3.9 or higher +* Navien Smart Control account (via Navilink mobile app) +* At least one Navien NWP500 device registered to your account +* Valid email and password for your Navien account + +Installation +============ + +Install the library using pip: + +.. code-block:: bash + + pip install nwp500-python + +Or install from source: + +.. code-block:: bash + + git clone https://github.com/eman/nwp500-python.git + cd nwp500-python + pip install -e . + +Your First Script +================= + +1. Authentication +----------------- + +Authentication is the first step. The library uses your Navien Smart +Control credentials to obtain JWT tokens and AWS IoT credentials. + +.. code-block:: python + + import asyncio + from nwp500 import NavienAuthClient + + async def authenticate(): + async with NavienAuthClient( + "your-email@example.com", + "your-password" + ) as auth: + print(f"Logged in as: {auth.user_email}") + print(f"User: {auth.current_user.full_name}") + + asyncio.run(authenticate()) + +.. note:: + The ``async with`` context manager automatically handles sign-in + when you enter the context and cleanup when you exit. + +2. List Your Devices +--------------------- + +Use the REST API client to list devices registered to your account: + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient + + async def list_devices(): + async with NavienAuthClient( + "your-email@example.com", + "your-password" + ) as auth: + + api = NavienAPIClient(auth) + devices = await api.list_devices() + + for device in devices: + print(f"Device: {device.device_info.device_name}") + print(f" MAC: {device.device_info.mac_address}") + print(f" Type: {device.device_info.device_type}") + print(f" Location: {device.location.city}, " + f"{device.location.state}") + + asyncio.run(list_devices()) + +3. Monitor Device Status (Real-time) +------------------------------------- + +Connect to MQTT for real-time device monitoring: + +.. code-block:: python + + from nwp500 import ( + NavienAuthClient, + NavienAPIClient, + NavienMqttClient + ) + + async def monitor_device(): + async with NavienAuthClient( + "your-email@example.com", + "your-password" + ) as auth: + + # Get first device + api = NavienAPIClient(auth) + device = await api.get_first_device() + + if not device: + print("No devices found") + return + + # Connect MQTT + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Define status callback + def on_status(status): + print(f"\nDevice Status:") + print(f" Water Temp: {status.dhwTemperature}°F") + print(f" Target: {status.dhwTemperatureSetting}°F") + print(f" Power: {status.currentInstPower}W") + print(f" Mode: {status.dhwOperationSetting.name}") + + # Subscribe and request status + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) + + # Monitor for 60 seconds + print("Monitoring device...") + await asyncio.sleep(60) + + await mqtt.disconnect() + + asyncio.run(monitor_device()) + +4. Control Your Device +---------------------- + +Send control commands to change device settings: + +.. code-block:: python + + from nwp500 import ( + NavienAuthClient, + NavienAPIClient, + NavienMqttClient, + DhwOperationSetting + ) + + async def control_device(): + async with NavienAuthClient( + "your-email@example.com", + "your-password" + ) as auth: + + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Turn on the device + await mqtt.set_power(device, power_on=True) + print("✓ Device powered on") + + # Set to Energy Saver mode + await mqtt.set_dhw_mode( + device, + mode_id=DhwOperationSetting.ENERGY_SAVER.value + ) + print("✓ Set to Energy Saver mode") + + # Set temperature to 120°F + await mqtt.set_dhw_temperature(device, temperature=120) + print("✓ Temperature set to 120°F") + + await asyncio.sleep(2) + await mqtt.disconnect() + + asyncio.run(control_device()) + +Operation Modes +=============== + +The NWP500 supports several DHW (Domestic Hot Water) operation modes: + +.. list-table:: + :header-rows: 1 + :widths: 15 20 65 + + * - Mode ID + - Name + - Description + * - 1 + - Heat Pump Only + - Most efficient; uses only heat pump (slowest recovery) + * - 2 + - Electric Only + - Fastest recovery; uses only electric elements (highest cost) + * - 3 + - Energy Saver + - Balanced efficiency and recovery (recommended default) + * - 4 + - High Demand + - Maximum heating capacity; uses all components as needed + * - 5 + - Vacation + - Suspends heating to save energy during extended absence + * - 6 + - Power Off + - Device is powered off (read-only status) + +Using Environment Variables +============================ + +Store credentials securely using environment variables: + +.. code-block:: bash + + export NAVIEN_EMAIL="your-email@example.com" + export NAVIEN_PASSWORD="your-password" + +Then in your code: + +.. code-block:: python + + import os + from nwp500 import NavienAuthClient, NavienAPIClient + + async def main(): + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + + if not email or not password: + raise ValueError( + "Set NAVIEN_EMAIL and NAVIEN_PASSWORD " + "environment variables" + ) + + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + devices = await api.list_devices() + # ... + +Next Steps +========== + +Now that you have the basics, explore these topics: + +* :doc:`python_api/auth_client` - Deep dive into authentication +* :doc:`python_api/mqtt_client` - Complete MQTT client documentation +* :doc:`guides/energy_monitoring` - Track energy usage +* :doc:`guides/time_of_use` - Optimize for TOU pricing +* :doc:`guides/event_system` - Use the event-driven architecture + +Common Issues +============= + +**Authentication Failed** + Verify your email and password are correct. You can test them in the + Navilink mobile app first. + +**No Devices Found** + Ensure your device is registered to your account in the Navilink app + and is online. + +**Connection Timeout** + Check your network connection. The library needs internet access to + reach the Navien cloud platform. + +**Import Errors** + Make sure you installed the library: ``pip install nwp500-python`` + +For more help, see the :doc:`development/contributing` guide or file an +issue on GitHub. diff --git a/docs/readme.rst b/docs/readme.rst deleted file mode 100644 index 81995ef..0000000 --- a/docs/readme.rst +++ /dev/null @@ -1,2 +0,0 @@ -.. _readme: -.. include:: ../README.rst diff --git a/examples/README.md b/examples/README.md index 16ecc8a..7a0fc82 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,6 +17,8 @@ Or install the required dependencies: pip install aiohttp>=3.8.0 awsiotsdk>=1.21.0 ``` +**Note:** The `tou_openei_example.py` requires `aiohttp` which is included in the library's dependencies. If you're running examples without installing the library, make sure to install aiohttp separately. + ## Authentication All examples use the `NavienAuthClient` which requires credentials passed to the constructor. Authentication happens automatically when entering the async context. @@ -63,6 +65,52 @@ python authenticate.py - `test_mqtt_connection.py` - MQTT connection testing - `test_mqtt_messaging.py` - MQTT message handling +### Device Control Examples + +- `power_control_example.py` - Turn device on/off +- `set_dhw_temperature_example.py` - Set water temperature +- `set_mode_example.py` - Change operation mode +- `anti_legionella_example.py` - Configure anti-legionella settings + +### Time of Use (TOU) Examples + +- `tou_schedule_example.py` - Manually configure TOU pricing schedule +- `tou_openei_example.py` - Retrieve TOU schedule from OpenEI API and configure device + +**TOU OpenEI Example Usage:** + +This example fetches real utility rate data from the OpenEI API and configures it on your device: + +```bash +export NAVIEN_EMAIL='your_email@example.com' +export NAVIEN_PASSWORD='your_password' +export ZIP_CODE='94103' # Your ZIP code +export OPENEI_API_KEY='your_openei_api_key' # Optional, defaults to DEMO_KEY + +python tou_openei_example.py +``` + +**Getting an OpenEI API Key:** +1. Visit https://openei.org/services/api/signup/ +2. Create a free account +3. Get your API key from the dashboard +4. The DEMO_KEY works for testing but has rate limits + +**What the OpenEI Example Does:** +1. Queries the OpenEI Utility Rates API for your location +2. Finds an approved residential TOU rate plan +3. Parses the rate structure and time schedules +4. Converts to Navien TOU period format +5. Configures the schedule on your device via MQTT + +### Scheduling Examples + +- `reservation_schedule_example.py` - Configure heating reservations/schedules + +### Energy Monitoring Examples + +- `energy_usage_example.py` - Monitor real-time energy consumption + ### Common Pattern All examples follow this pattern: diff --git a/examples/tou_openei_example.py b/examples/tou_openei_example.py new file mode 100755 index 0000000..7d993dd --- /dev/null +++ b/examples/tou_openei_example.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +""" +Example: Retrieve TOU schedule from OpenEI API and configure device. + +This example demonstrates how to: +1. Query the OpenEI Utility Rates API for electricity rate plans +2. Parse the rate structure from the API response +3. Convert OpenEI rate schedules into TOU periods +4. Configure the TOU schedule on a Navien device via MQTT +""" + +import asyncio +import os +import sys +from typing import Any + +import aiohttp + +from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient + +# OpenEI API configuration +OPENEI_API_URL = "https://api.openei.org/utility_rates" +OPENEI_API_VERSION = 7 + +# You can get a free API key from https://openei.org/services/api/signup/ +# For testing purposes, you can use the demo key (rate limited) +OPENEI_API_KEY = "DEMO_KEY" + + +async def fetch_openei_rates( + zip_code: str, api_key: str = OPENEI_API_KEY +) -> dict[str, Any]: + """ + Fetch utility rate information from OpenEI API. + + Args: + zip_code: ZIP code to search for rates + api_key: OpenEI API key (default: DEMO_KEY) + + Returns: + Dictionary containing rate plan data from OpenEI + + Raises: + aiohttp.ClientError: If the API request fails + """ + params = { + "version": OPENEI_API_VERSION, + "format": "json", + "api_key": api_key, + "detail": "full", + "address": zip_code, + "sector": "Residential", + "orderby": "startdate", + "direction": "desc", + "limit": 100, + } + + async with aiohttp.ClientSession() as session: + async with session.get(OPENEI_API_URL, params=params) as response: + response.raise_for_status() + data = await response.json() + return data + + +def select_rate_plan(rate_data: dict[str, Any]) -> dict[str, Any] | None: + """ + Select a suitable rate plan from OpenEI response. + + This example selects the first approved residential rate plan + with time-of-use pricing (has energyweekdayschedule). + + Args: + rate_data: Response data from OpenEI API + + Returns: + Selected rate plan dictionary, or None if no suitable plan found + """ + items = rate_data.get("items", []) + + for plan in items: + # Look for approved residential plans with TOU schedules + if ( + plan.get("approved") + and plan.get("sector") == "Residential" + and "energyweekdayschedule" in plan + and "energyratestructure" in plan + ): + return plan + + return None + + +def convert_openei_to_tou_periods( + rate_plan: dict[str, Any], +) -> list[dict[str, Any]]: + """ + Convert OpenEI rate plan to TOU period format for Navien device. + + This is a simplified conversion that handles basic TOU schedules. + More complex rate structures (e.g., tiered rates, demand charges) + may require additional logic. + + Args: + rate_plan: Rate plan data from OpenEI + + Returns: + List of TOU period dictionaries ready for device configuration + """ + weekday_schedule = rate_plan.get("energyweekdayschedule", [[]]) + # Note: weekend_schedule available but not used in this simplified example + # weekend_schedule = rate_plan.get("energyweekendschedule", [[]]) + rate_structure = rate_plan.get("energyratestructure", [[]]) + + # For simplicity, we'll use the first month's schedule + # A production implementation would handle all 12 months + if not weekday_schedule or not weekday_schedule[0]: + print("Warning: No weekday schedule found in rate plan") + return [] + + hourly_schedule = weekday_schedule[0] # 24-hour array + + # Extract unique rate periods from the hourly schedule + # Build a map of period_index -> rate + period_to_rate = {} + for month_idx, month_tiers in enumerate(rate_structure): + if month_tiers: + for tier_idx, tier in enumerate(month_tiers): + if tier_idx not in period_to_rate: + period_to_rate[tier_idx] = tier.get("rate", 0.0) + + # Find continuous time blocks with the same rate period + periods = [] + current_period = None + start_hour = 0 + + for hour in range(24): + period_idx = hourly_schedule[hour] + + if current_period is None: + # Start of first period + current_period = period_idx + start_hour = hour + elif period_idx != current_period: + # Period changed, save previous period + rate = period_to_rate.get(current_period, 0.0) + periods.append( + { + "start_hour": start_hour, + "end_hour": hour - 1, + "end_minute": 59, + "rate": rate, + } + ) + + # Start new period + current_period = period_idx + start_hour = hour + + # Don't forget the last period + if current_period is not None: + rate = period_to_rate.get(current_period, 0.0) + periods.append( + { + "start_hour": start_hour, + "end_hour": 23, + "end_minute": 59, + "rate": rate, + } + ) + + # Convert to TOU period format + tou_periods = [] + weekdays = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + ] + + for period in periods: + tou_period = NavienAPIClient.build_tou_period( + season_months=range(1, 13), # All months + week_days=weekdays, + start_hour=period["start_hour"], + start_minute=0, + end_hour=period["end_hour"], + end_minute=period["end_minute"], + price_min=period["rate"], + price_max=period["rate"], + decimal_point=5, + ) + tou_periods.append(tou_period) + + return tou_periods + + +async def _wait_for_controller_serial(mqtt_client: NavienMqttClient, device) -> str: + """Get controller serial number from device.""" + loop = asyncio.get_running_loop() + feature_future: asyncio.Future = loop.create_future() + + def capture_feature(feature) -> None: + if not feature_future.done(): + feature_future.set_result(feature) + + await mqtt_client.subscribe_device_feature(device, capture_feature) + await mqtt_client.request_device_info(device) + feature = await asyncio.wait_for(feature_future, timeout=15) + return feature.controllerSerialNumber + + +async def main() -> None: + # Check for required environment variables + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + zip_code = os.getenv("ZIP_CODE", "94103") # Default to SF + + if not email or not password: + print("Error: Set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables") + sys.exit(1) + + # Optional: Use custom OpenEI API key + api_key = os.getenv("OPENEI_API_KEY", OPENEI_API_KEY) + + print(f"Fetching utility rates for ZIP code: {zip_code}") + print("(This may take a few seconds...)") + + # Step 1: Fetch rate data from OpenEI + try: + rate_data = await fetch_openei_rates(zip_code, api_key) + except Exception as e: + print(f"Error fetching OpenEI data: {e}") + sys.exit(1) + + # Step 2: Select a suitable rate plan + rate_plan = select_rate_plan(rate_data) + if not rate_plan: + print("No suitable TOU rate plan found for this location") + sys.exit(1) + + print("\nSelected rate plan:") + print(f" Utility: {rate_plan.get('utility')}") + print(f" Name: {rate_plan.get('name')}") + print(f" EIA ID: {rate_plan.get('eiaid')}") + + # Step 3: Convert rate plan to TOU periods + tou_periods = convert_openei_to_tou_periods(rate_plan) + + if not tou_periods: + print("Could not convert rate plan to TOU periods") + sys.exit(1) + + print(f"\nConverted to {len(tou_periods)} TOU periods:") + for i, period in enumerate(tou_periods, 1): + # Decode for display + days = NavienAPIClient.decode_week_bitfield(period["week"]) + price = NavienAPIClient.decode_price(period["priceMin"], period["decimalPoint"]) + print( + f" {i}. {period['startHour']:02d}:{period['startMinute']:02d}" + f"-{period['endHour']:02d}:{period['endMinute']:02d} " + f"@ ${price:.5f}/kWh ({', '.join(days[:2])}...)" + ) + + # Step 4: Connect to Navien device + print("\nConnecting to Navien device...") + async with NavienAuthClient(email, password) as auth_client: + api_client = NavienAPIClient(auth_client=auth_client) + device = await api_client.get_first_device() + + if not device: + print("No devices found for this account") + return + + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + + print("Getting controller serial number...") + try: + controller_serial = await _wait_for_controller_serial(mqtt_client, device) + except asyncio.TimeoutError: + print("Timed out waiting for device info") + await mqtt_client.disconnect() + return + + # Step 5: Configure TOU schedule on device + print("\nConfiguring TOU schedule on device...") + + response_topic = ( + f"cmd/{device.device_info.device_type}/" + f"{mqtt_client.config.client_id}/res/tou/rd" + ) + + def on_tou_response(topic: str, message: dict[str, Any]) -> None: + response = message.get("response", {}) + enabled = response.get("reservationUse") + print("\nDevice confirmed TOU schedule configured") + print(f" Enabled: {enabled == 2}") + print(f" Periods: {len(response.get('reservation', []))}") + + await mqtt_client.subscribe(response_topic, on_tou_response) + + await mqtt_client.configure_tou_schedule( + device=device, + controller_serial_number=controller_serial, + periods=tou_periods, + enabled=True, + ) + + print("Waiting for device confirmation...") + await asyncio.sleep(5) + + await mqtt_client.disconnect() + print("\nTOU schedule from OpenEI successfully configured!") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nCancelled by user") diff --git a/pyproject.toml b/pyproject.toml index fb6d382..fac5689 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ version_scheme = "no-guess-dev" [tool.ruff] # Ruff configuration for code formatting and linting -line-length = 100 +line-length = 80 target-version = "py39" # Exclude directories diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index cf58ed4..a67b25e 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -1,3 +1,9 @@ +"""Navien NWP500 water heater control library. + +This package provides Python bindings for Navien Smart Control API and MQTT +communication for NWP500 heat pump water heaters. +""" + from importlib.metadata import ( PackageNotFoundError, version, diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index 69c1cab..abb5fa2 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -21,7 +21,13 @@ class APIError(Exception): - """Raised when API returns an error response.""" + """Raised when API returns an error response. + + Attributes: + message: Error message describing the failure + code: HTTP or API error code + response: Complete API response dictionary + """ def __init__( self, @@ -29,6 +35,13 @@ def __init__( code: Optional[int] = None, response: Optional[dict[str, Any]] = None, ): + """Initialize API error. + + Args: + message: Error message describing the failure + code: HTTP or API error code + response: Complete API response dictionary + """ self.message = message self.code = code self.response = response @@ -61,10 +74,12 @@ def __init__( Initialize Navien API client. Args: - auth_client: Authenticated NavienAuthClient instance. Must already be + auth_client: Authenticated NavienAuthClient instance. Must already + be authenticated via sign_in(). base_url: Base URL for the API - session: Optional aiohttp session (uses auth_client's session if not provided) + session: Optional aiohttp session (uses auth_client's session if not + provided) Raises: ValueError: If auth_client is not authenticated @@ -80,7 +95,9 @@ def __init__( self._session: aiohttp.ClientSession = session or auth_client._session # type: ignore[assignment] if self._session is None: raise ValueError("auth_client must have an active session") - self._owned_session = False # Never own session when auth_client is provided + self._owned_session = ( + False # Never own session when auth_client is provided + ) self._owned_auth = False # Never own auth_client async def __aenter__(self) -> "NavienAPIClient": @@ -115,7 +132,9 @@ async def _make_request( AuthenticationError: If not authenticated """ if not self._auth_client or not self._auth_client.is_authenticated: - raise AuthenticationError("Must authenticate before making API calls") + raise AuthenticationError( + "Must authenticate before making API calls" + ) # Ensure token is valid await self._auth_client.ensure_valid_token() @@ -154,7 +173,9 @@ async def _make_request( # Device Management Endpoints - async def list_devices(self, offset: int = 0, count: int = 20) -> list[Device]: + async def list_devices( + self, offset: int = 0, count: int = 20 + ) -> list[Device]: """ List all devices associated with the user. @@ -188,7 +209,9 @@ async def list_devices(self, offset: int = 0, count: int = 20) -> list[Device]: _logger.info(f"Retrieved {len(devices)} device(s)") return devices - async def get_device_info(self, mac_address: str, additional_value: str = "") -> Device: + async def get_device_info( + self, mac_address: str, additional_value: str = "" + ) -> Device: """ Get detailed information about a specific device. @@ -219,7 +242,9 @@ async def get_device_info(self, mac_address: str, additional_value: str = "") -> data = response.get("data", {}) device = Device.from_dict(data) - _logger.info(f"Retrieved info for device: {device.device_info.device_name}") + _logger.info( + f"Retrieved info for device: {device.device_info.device_name}" + ) return device async def get_firmware_info( diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index c6b8e13..ebb72fc 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -70,12 +70,16 @@ class AuthTokens: # Calculated fields issued_at: datetime = field(default_factory=datetime.now) - _expires_at: datetime = field(default=datetime.now(), init=False, repr=False) + _expires_at: datetime = field( + default=datetime.now(), init=False, repr=False + ) def __post_init__(self) -> None: """Cache the expiration timestamp after initialization.""" # Pre-calculate and cache the expiration time - self._expires_at = self.issued_at + timedelta(seconds=self.authentication_expires_in) + self._expires_at = self.issued_at + timedelta( + seconds=self.authentication_expires_in + ) @classmethod def from_dict(cls, data: dict[str, Any]) -> "AuthTokens": @@ -104,7 +108,10 @@ def is_expired(self) -> bool: @property def time_until_expiry(self) -> timedelta: - """Get the time remaining until token expiration (uses cached expiration time).""" + """Get time remaining until token expiration. + + Uses cached expiration time for efficiency. + """ return self._expires_at - datetime.now() @property @@ -124,7 +131,9 @@ class AuthenticationResponse: message: str = "SUCCESS" @classmethod - def from_dict(cls, response_data: dict[str, Any]) -> "AuthenticationResponse": + def from_dict( + cls, response_data: dict[str, Any] + ) -> "AuthenticationResponse": """Create AuthenticationResponse from API response.""" code = response_data.get("code", 200) message = response_data.get("msg", "SUCCESS") @@ -144,7 +153,13 @@ def from_dict(cls, response_data: dict[str, Any]) -> "AuthenticationResponse": class AuthenticationError(Exception): - """Base exception for authentication errors.""" + """Base exception for authentication errors. + + Attributes: + message: Error message describing the failure + status_code: HTTP status code + response: Complete API response dictionary + """ def __init__( self, @@ -152,6 +167,13 @@ def __init__( status_code: Optional[int] = None, response: Optional[dict[str, Any]] = None, ): + """Initialize authentication error. + + Args: + message: Error message describing the failure + status_code: HTTP status code + response: Complete API response dictionary + """ self.message = message self.status_code = status_code self.response = response @@ -186,10 +208,12 @@ class NavienAuthClient: - Session management - AWS credentials (if provided by API) - Authentication is performed automatically when entering the async context manager. + Authentication is performed automatically when entering the async context + manager. Example: - >>> async with NavienAuthClient(user_id="user@example.com", password="password") as client: + >>> async with NavienAuthClient(user_id="user@example.com", + password="password") as client: ... print(f"Welcome {client.current_user.full_name}") ... print(f"Access token: {client.current_tokens.access_token}") ... @@ -198,7 +222,8 @@ class NavienAuthClient: ... ... # Refresh when needed ... if client.current_tokens.is_expired: - ... new_tokens = await client.refresh_token(client.current_tokens.refresh_token) + ... new_tokens = await + client.refresh_token(client.current_tokens.refresh_token) """ def __init__( @@ -257,7 +282,9 @@ async def _ensure_session(self) -> None: self._session = aiohttp.ClientSession(timeout=self.timeout) self._owned_session = True - async def sign_in(self, user_id: str, password: str) -> AuthenticationResponse: + async def sign_in( + self, user_id: str, password: str + ) -> AuthenticationResponse: """ Authenticate user and obtain tokens. @@ -292,7 +319,11 @@ async def sign_in(self, user_id: str, password: str) -> AuthenticationResponse: if code != 200 or not response.ok: _logger.error(f"Sign-in failed: {code} - {msg}") - if code == 401 or "invalid" in msg.lower() or "unauthorized" in msg.lower(): + if ( + code == 401 + or "invalid" in msg.lower() + or "unauthorized" in msg.lower() + ): raise InvalidCredentialsError( f"Invalid credentials: {msg}", status_code=code, @@ -310,9 +341,13 @@ async def sign_in(self, user_id: str, password: str) -> AuthenticationResponse: self._user_email = user_id # Store the email for later use _logger.info( - f"Successfully authenticated user: {auth_response.user_info.full_name}" + "Successfully authenticated user: %s", + auth_response.user_info.full_name, + ) + _logger.debug( + "Token expires in: %s", + auth_response.tokens.time_until_expiry, ) - _logger.debug(f"Token expires in: {auth_response.tokens.time_until_expiry}") return auth_response @@ -370,7 +405,9 @@ async def refresh_token(self, refresh_token: str) -> AuthTokens: self._auth_response.tokens = new_tokens _logger.info("Successfully refreshed access token") - _logger.debug(f"New token expires in: {new_tokens.time_until_expiry}") + _logger.debug( + f"New token expires in: {new_tokens.time_until_expiry}" + ) return new_tokens @@ -397,7 +434,9 @@ async def ensure_valid_token(self) -> Optional[AuthTokens]: if self._auth_response.tokens.is_expired: _logger.info("Token expired, refreshing...") - return await self.refresh_token(self._auth_response.tokens.refresh_token) + return await self.refresh_token( + self._auth_response.tokens.refresh_token + ) return self._auth_response.tokens @@ -436,7 +475,8 @@ def get_auth_headers(self) -> dict[str, str]: Note: Based on HAR analysis of actual API traffic, the authorization - header uses the raw token without 'Bearer ' prefix (lowercase 'authorization'). + header uses the raw token without 'Bearer ' prefix (lowercase + 'authorization'). This is different from standard Bearer token authentication. """ headers = { @@ -444,8 +484,10 @@ def get_auth_headers(self) -> dict[str, str]: "Content-Type": "application/json", } - # IMPORTANT: Use lowercase 'authorization' and raw token (no 'Bearer ' prefix) - # This matches the actual API behavior from HAR analysis in working implementation + # IMPORTANT: Use lowercase 'authorization' and raw token (no 'Bearer ' + # prefix) + # This matches the actual API behavior from HAR analysis in working + # implementation if self._auth_response and self._auth_response.tokens.access_token: headers["authorization"] = self._auth_response.tokens.access_token @@ -456,8 +498,10 @@ def get_auth_headers(self) -> dict[str, str]: async def authenticate(user_id: str, password: str) -> AuthenticationResponse: - """ - Convenience function to authenticate and get tokens. + """Authenticate user and obtain tokens. + + This is a convenience function that creates a temporary auth client, + authenticates, and returns the response. Args: user_id: User email address @@ -472,13 +516,17 @@ async def authenticate(user_id: str, password: str) -> AuthenticationResponse: """ async with NavienAuthClient(user_id, password) as client: if client._auth_response is None: - raise AuthenticationError("Authentication failed: no response received") + raise AuthenticationError( + "Authentication failed: no response received" + ) return client._auth_response async def refresh_access_token(refresh_token: str) -> AuthTokens: - """ - Convenience function to refresh an access token. + """Refresh an access token using a refresh token. + + This is a convenience function that creates a temporary session to + perform the token refresh operation without requiring full authentication. Args: refresh_token: The refresh token diff --git a/src/nwp500/cli.py b/src/nwp500/cli.py index 98fa591..0f5e64f 100644 --- a/src/nwp500/cli.py +++ b/src/nwp500/cli.py @@ -1,5 +1,4 @@ -""" -Navien Water Heater Control Script - Backward Compatibility Wrapper +"""Navien Water Heater Control Script - Backward Compatibility Wrapper. This module maintains backward compatibility by importing from the new modular cli package structure. diff --git a/src/nwp500/cli/__init__.py b/src/nwp500/cli/__init__.py index e5f99fe..10d4bc8 100644 --- a/src/nwp500/cli/__init__.py +++ b/src/nwp500/cli/__init__.py @@ -17,7 +17,11 @@ handle_update_reservations_request, ) from .monitoring import handle_monitoring -from .output_formatters import format_json_output, print_json, write_status_to_csv +from .output_formatters import ( + format_json_output, + print_json, + write_status_to_csv, +) from .token_storage import load_tokens, save_tokens __all__ = [ diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index fe8ab10..394ea70 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -1,5 +1,4 @@ -""" -Navien Water Heater Control Script - Main Entry Point +"""Navien Water Heater Control Script - Main Entry Point. This module provides the command-line interface to monitor and control Navien water heaters using the nwp500-python library. @@ -13,7 +12,11 @@ from typing import Optional from nwp500 import NavienAPIClient, NavienAuthClient, __version__ -from nwp500.auth import AuthenticationResponse, InvalidCredentialsError, UserInfo +from nwp500.auth import ( + AuthenticationResponse, + InvalidCredentialsError, + UserInfo, +) from .commands import ( handle_device_feature_request, @@ -40,7 +43,9 @@ _logger = logging.getLogger(__name__) -async def get_authenticated_client(args: argparse.Namespace) -> Optional[NavienAuthClient]: +async def get_authenticated_client( + args: argparse.Namespace, +) -> Optional[NavienAuthClient]: """ Get an authenticated NavienAuthClient using cached tokens or credentials. @@ -74,7 +79,10 @@ async def get_authenticated_client(args: argparse.Namespace) -> Optional[NavienA ) return auth_client - _logger.info("Cached tokens are invalid, expired, or incomplete. Re-authenticating...") + _logger.info( + "Cached tokens are invalid, expired, or incomplete. " + "Re-authenticating..." + ) # Fallback to email/password email = args.email or os.getenv("NAVIEN_EMAIL") password = args.password or os.getenv("NAVIEN_PASSWORD") @@ -96,7 +104,9 @@ async def get_authenticated_client(args: argparse.Namespace) -> Optional[NavienA _logger.error("Invalid email or password.") return None except Exception as e: - _logger.error(f"An unexpected error occurred during authentication: {e}") + _logger.error( + f"An unexpected error occurred during authentication: {e}" + ) return None @@ -159,16 +169,23 @@ async def async_main(args: argparse.Namespace) -> int: await asyncio.sleep(2) await handle_status_request(mqtt, device) elif args.set_dhw_temp: - await handle_set_dhw_temp_request(mqtt, device, args.set_dhw_temp) + await handle_set_dhw_temp_request( + mqtt, device, args.set_dhw_temp + ) if args.status: - _logger.info("Getting updated status after temperature change...") + _logger.info( + "Getting updated status after temperature change..." + ) await asyncio.sleep(2) await handle_status_request(mqtt, device) elif args.get_reservations: await handle_get_reservations_request(mqtt, device) elif args.set_reservations: await handle_update_reservations_request( - mqtt, device, args.set_reservations, args.reservations_enabled + mqtt, + device, + args.set_reservations, + args.reservations_enabled, ) elif args.get_tou: await handle_get_tou_request(mqtt, device, api_client) @@ -181,19 +198,27 @@ async def async_main(args: argparse.Namespace) -> int: await handle_status_request(mqtt, device) elif args.get_energy: if not args.energy_year or not args.energy_months: - _logger.error("--energy-year and --energy-months are required for --get-energy") + _logger.error( + "--energy-year and --energy-months are required " + "for --get-energy" + ) return 1 try: - months = [int(m.strip()) for m in args.energy_months.split(",")] + months = [ + int(m.strip()) for m in args.energy_months.split(",") + ] if not all(1 <= m <= 12 for m in months): _logger.error("Months must be between 1 and 12") return 1 except ValueError: _logger.error( - "Invalid month format. Use comma-separated numbers (e.g., '9' or '8,9,10')" + "Invalid month format. Use comma-separated numbers " + "(e.g., '9' or '8,9,10')" ) return 1 - await handle_get_energy_request(mqtt, device, args.energy_year, months) + await handle_get_energy_request( + mqtt, device, args.energy_year, months + ) elif args.status_raw: await handle_status_raw_request(mqtt, device) elif args.status: @@ -221,7 +246,9 @@ async def async_main(args: argparse.Namespace) -> int: def parse_args(args: list[str]) -> argparse.Namespace: """Parse command line parameters.""" - parser = argparse.ArgumentParser(description="Navien Water Heater Control Script") + parser = argparse.ArgumentParser( + description="Navien Water Heater Control Script" + ) parser.add_argument( "--version", action="version", @@ -242,7 +269,8 @@ def parse_args(args: list[str]) -> argparse.Namespace: parser.add_argument( "--status", action="store_true", - help="Fetch and print the current device status. Can be combined with control commands.", + help="Fetch and print the current device status. " + "Can be combined with control commands.", ) parser.add_argument( "--status-raw", @@ -256,12 +284,14 @@ def parse_args(args: list[str]) -> argparse.Namespace: group.add_argument( "--device-info", action="store_true", - help="Fetch and print comprehensive device information via MQTT, then exit.", + help="Fetch and print comprehensive device information via MQTT, " + "then exit.", ) group.add_argument( "--device-feature", action="store_true", - help="Fetch and print device feature and capability information via MQTT, then exit.", + help="Fetch and print device feature and capability information " + "via MQTT, then exit.", ) group.add_argument( "--get-controller-serial", @@ -274,7 +304,8 @@ def parse_args(args: list[str]) -> argparse.Namespace: type=str, metavar="MODE", help="Set operation mode and display response. " - "Options: heat-pump, electric, energy-saver, high-demand, vacation, standby", + "Options: heat-pump, electric, energy-saver, high-demand, " + "vacation, standby", ) group.add_argument( "--set-dhw-temp", @@ -296,20 +327,22 @@ def parse_args(args: list[str]) -> argparse.Namespace: group.add_argument( "--get-reservations", action="store_true", - help="Fetch and print current reservation schedule from device via MQTT, then exit.", + help="Fetch and print current reservation schedule from device " + "via MQTT, then exit.", ) group.add_argument( "--set-reservations", type=str, metavar="JSON", - help="Update reservation schedule with JSON array of reservation objects. " - "Use --reservations-enabled to control if schedule is active.", + help="Update reservation schedule with JSON array of reservation " + "objects. Use --reservations-enabled to control if schedule is " + "active.", ) group.add_argument( "--get-tou", action="store_true", - help="Fetch and print Time-of-Use settings from the REST API, then exit. " - "Controller serial number is automatically retrieved.", + help="Fetch and print Time-of-Use settings from the REST API, " + "then exit. Controller serial number is automatically retrieved.", ) group.add_argument( "--set-tou-enabled", @@ -321,15 +354,16 @@ def parse_args(args: list[str]) -> argparse.Namespace: group.add_argument( "--get-energy", action="store_true", - help="Request energy usage data for specified year and months via MQTT, then exit. " - "Requires --energy-year and --energy-months options.", + help="Request energy usage data for specified year and months " + "via MQTT, then exit. Requires --energy-year and --energy-months " + "options.", ) group.add_argument( "--monitor", action="store_true", default=True, # Default action - help="Run indefinitely, polling for status every 30 seconds and logging to a CSV file. " - "(default)", + help="Run indefinitely, polling for status every 30 seconds and " + "logging to a CSV file. (default)", ) # Additional options for new commands @@ -337,7 +371,8 @@ def parse_args(args: list[str]) -> argparse.Namespace: "--reservations-enabled", action="store_true", default=True, - help="When used with --set-reservations, enable the reservation schedule. (default: True)", + help="When used with --set-reservations, enable the reservation " + "schedule. (default: True)", ) parser.add_argument( "--tou-serial", @@ -348,20 +383,22 @@ def parse_args(args: list[str]) -> argparse.Namespace: parser.add_argument( "--energy-year", type=int, - help="Year for energy usage query (e.g., 2025). Required with --get-energy.", + help="Year for energy usage query (e.g., 2025). " + "Required with --get-energy.", ) parser.add_argument( "--energy-months", type=str, - help="Comma-separated list of months (1-12) for energy usage query " - "(e.g., '9' or '8,9,10'). Required with --get-energy.", + help="Comma-separated list of months (1-12) for energy usage " + "query (e.g., '9' or '8,9,10'). Required with --get-energy.", ) parser.add_argument( "-o", "--output", type=str, default="nwp500_status.csv", - help="Output CSV file name for monitoring. (default: nwp500_status.csv)", + help="Output CSV file name for monitoring. " + "(default: nwp500_status.csv)", ) # Logging @@ -385,7 +422,11 @@ def parse_args(args: list[str]) -> argparse.Namespace: def setup_logging(loglevel: int) -> None: - """Setup basic logging.""" + """Configure basic logging for the application. + + Args: + loglevel: Logging level (e.g., logging.DEBUG, logging.INFO) + """ logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" logging.basicConfig( level=loglevel or logging.WARNING, @@ -396,12 +437,19 @@ def setup_logging(loglevel: int) -> None: def main(args_list: list[str]) -> None: - """Wrapper for the asynchronous main function.""" + """Run the asynchronous main function with argument parsing. + + Args: + args_list: Command-line arguments to parse + """ args = parse_args(args_list) # Validate that --status and --status-raw are not used together if args.status and args.status_raw: - print("Error: --status and --status-raw cannot be used together.", file=sys.stderr) + print( + "Error: --status and --status-raw cannot be used together.", + file=sys.stderr, + ) return # Set default log level for libraries diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py index ec4c291..ea0f9e7 100644 --- a/src/nwp500/cli/commands.py +++ b/src/nwp500/cli/commands.py @@ -16,8 +16,7 @@ async def get_controller_serial_number( mqtt: NavienMqttClient, device: Device, timeout: float = 10.0 ) -> Optional[str]: - """ - Helper function to retrieve controller serial number from device. + """Retrieve controller serial number from device. Args: mqtt: MQTT client instance @@ -52,7 +51,11 @@ async def handle_status_request(mqtt: NavienMqttClient, device: Device) -> None: def on_status(status: DeviceStatus) -> None: if not future.done(): - print(json.dumps(asdict(status), indent=2, default=_json_default_serializer)) + print( + json.dumps( + asdict(status), indent=2, default=_json_default_serializer + ) + ) future.set_result(None) await mqtt.subscribe_device_status(device, on_status) @@ -65,7 +68,9 @@ def on_status(status: DeviceStatus) -> None: _logger.error("Timed out waiting for device status response.") -async def handle_status_raw_request(mqtt: NavienMqttClient, device: Device) -> None: +async def handle_status_raw_request( + mqtt: NavienMqttClient, device: Device +) -> None: """Request device status once and print raw MQTT data (no conversions).""" future = asyncio.get_running_loop().create_future() @@ -83,7 +88,13 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: ) future.set_result(None) elif "status" in message: - print(json.dumps(message["status"], indent=2, default=_json_default_serializer)) + print( + json.dumps( + message["status"], + indent=2, + default=_json_default_serializer, + ) + ) future.set_result(None) # Subscribe to all device messages @@ -98,7 +109,9 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: _logger.error("Timed out waiting for device status response.") -async def handle_device_info_request(mqtt: NavienMqttClient, device: Device) -> None: +async def handle_device_info_request( + mqtt: NavienMqttClient, device: Device +) -> None: """ Request comprehensive device information via MQTT and print it. @@ -110,7 +123,11 @@ async def handle_device_info_request(mqtt: NavienMqttClient, device: Device) -> def on_device_info(info: Any) -> None: if not future.done(): - print(json.dumps(asdict(info), indent=2, default=_json_default_serializer)) + print( + json.dumps( + asdict(info), indent=2, default=_json_default_serializer + ) + ) future.set_result(None) await mqtt.subscribe_device_feature(device, on_device_info) @@ -123,7 +140,19 @@ def on_device_info(info: Any) -> None: _logger.error("Timed out waiting for device info response.") -async def handle_get_controller_serial_request(mqtt: NavienMqttClient, device: Device) -> None: +async def handle_device_feature_request( + mqtt: NavienMqttClient, device: Device +) -> None: + """Request device feature and capability information via MQTT. + + Alias for handle_device_info_request. Both fetch the same data. + """ + await handle_device_info_request(mqtt, device) + + +async def handle_get_controller_serial_request( + mqtt: NavienMqttClient, device: Device +) -> None: """Request and display just the controller serial number.""" serial_number = await get_controller_serial_number(mqtt, device) if serial_number: @@ -132,7 +161,9 @@ async def handle_get_controller_serial_request(mqtt: NavienMqttClient, device: D _logger.error("Failed to retrieve controller serial number.") -async def handle_set_mode_request(mqtt: NavienMqttClient, device: Device, mode_name: str) -> None: +async def handle_set_mode_request( + mqtt: NavienMqttClient, device: Device, mode_name: str +) -> None: """ Set device operation mode and display the response. @@ -178,7 +209,9 @@ def on_status_response(status: DeviceStatus) -> None: await mqtt.subscribe_device_status(device, on_status_response) try: - _logger.info(f"Setting operation mode to '{mode_name}' (mode ID: {mode_id})...") + _logger.info( + f"Setting operation mode to '{mode_name}' (mode ID: {mode_id})..." + ) # Send the mode change command await mqtt.set_dhw_mode(device, mode_id) @@ -189,10 +222,21 @@ def on_status_response(status: DeviceStatus) -> None: if responses: status = responses[0] - print(json.dumps(asdict(status), indent=2, default=_json_default_serializer)) - _logger.info(f"Mode change successful. New mode: {status.operationMode.name}") + print( + json.dumps( + asdict(status), + indent=2, + default=_json_default_serializer, + ) + ) + _logger.info( + f"Mode change successful. New mode: " + f"{status.operationMode.name}" + ) else: - _logger.warning("Mode command sent but no status response received") + _logger.warning( + "Mode command sent but no status response received" + ) except asyncio.TimeoutError: _logger.error("Timed out waiting for mode change confirmation") @@ -215,7 +259,10 @@ async def handle_set_dhw_temp_request( # Validate temperature range # Based on MQTT client documentation: display range approximately 115-150°F if temperature < 115 or temperature > 150: - _logger.error(f"Temperature {temperature}°F is out of range. Valid range: 115-150°F") + _logger.error( + f"Temperature {temperature}°F is out of range. " + f"Valid range: 115-150°F" + ) return # Set up callback to capture status response after temperature change @@ -243,22 +290,34 @@ def on_status_response(status: DeviceStatus) -> None: if responses: status = responses[0] - print(json.dumps(asdict(status), indent=2, default=_json_default_serializer)) + print( + json.dumps( + asdict(status), + indent=2, + default=_json_default_serializer, + ) + ) _logger.info( f"Temperature change successful. New target: " f"{status.dhwTargetTemperatureSetting}°F" ) else: - _logger.warning("Temperature command sent but no status response received") + _logger.warning( + "Temperature command sent but no status response received" + ) except asyncio.TimeoutError: - _logger.error("Timed out waiting for temperature change confirmation") + _logger.error( + "Timed out waiting for temperature change confirmation" + ) except Exception as e: _logger.error(f"Error setting temperature: {e}") -async def handle_power_request(mqtt: NavienMqttClient, device: Device, power_on: bool) -> None: +async def handle_power_request( + mqtt: NavienMqttClient, device: Device, power_on: bool +) -> None: """ Set device power state and display the response. @@ -300,8 +359,12 @@ def on_power_change_response(status: DeviceStatus) -> None: "dhwOperationSetting": status.dhwOperationSetting.name, "dhwTemperature": f"{status.dhwTemperature}°F", "dhwChargePer": f"{status.dhwChargePer}%", - "tankUpperTemperature": f"{status.tankUpperTemperature:.1f}°F", - "tankLowerTemperature": f"{status.tankLowerTemperature:.1f}°F", + "tankUpperTemperature": ( + f"{status.tankUpperTemperature:.1f}°F" + ), + "tankLowerTemperature": ( + f"{status.tankLowerTemperature:.1f}°F" + ), }, }, indent=2, @@ -315,7 +378,9 @@ def on_power_change_response(status: DeviceStatus) -> None: _logger.error(f"Error turning device {action}: {e}") -async def handle_get_reservations_request(mqtt: NavienMqttClient, device: Device) -> None: +async def handle_get_reservations_request( + mqtt: NavienMqttClient, device: Device +) -> None: """Request current reservation schedule from the device.""" future = asyncio.get_running_loop().create_future() @@ -323,7 +388,10 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: # Device responses have "response" field with actual data if not future.done() and "response" in message: # Decode and format the reservation data for human readability - from nwp500.encoding import decode_reservation_hex, decode_week_bitfield + from nwp500.encoding import ( + decode_reservation_hex, + decode_week_bitfield, + ) response = message.get("response", {}) reservation_use = response.get("reservationUse", 0) @@ -334,7 +402,9 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: reservations = decode_reservation_hex(reservation_hex) else: # Already structured (shouldn't happen but handle it) - reservations = reservation_hex if isinstance(reservation_hex, list) else [] + reservations = ( + reservation_hex if isinstance(reservation_hex, list) else [] + ) # Format for display output = { @@ -346,14 +416,17 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: for idx, entry in enumerate(reservations, start=1): week_days = decode_week_bitfield(entry.get("week", 0)) param_value = entry.get("param", 0) - # Temperature is encoded as (display - 20), so display = param + 20 + # Temperature is encoded as (display - 20), so display = param + + # 20 display_temp = param_value + 20 formatted_entry = { "number": idx, "enabled": entry.get("enable") == 1, "days": week_days, - "time": f"{entry.get('hour', 0):02d}:{entry.get('min', 0):02d}", + "time": ( + f"{entry.get('hour', 0):02d}:{entry.get('min', 0):02d}" + ), "mode": entry.get("mode"), "temperatureF": display_temp, "raw": entry, @@ -361,7 +434,9 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: output["reservations"].append(formatted_entry) # Print formatted output - print(json.dumps(output, indent=2, default=_json_default_serializer)) + print( + json.dumps(output, indent=2, default=_json_default_serializer) + ) future.set_result(None) # Subscribe to all device-type messages to catch the response @@ -380,7 +455,10 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: async def handle_update_reservations_request( - mqtt: NavienMqttClient, device: Device, reservations_json: str, enabled: bool + mqtt: NavienMqttClient, + device: Device, + reservations_json: str, + enabled: bool, ) -> None: """Update reservation schedule on the device.""" try: @@ -397,7 +475,9 @@ async def handle_update_reservations_request( def raw_callback(topic: str, message: dict[str, Any]) -> None: # Only process response messages, not request echoes if not future.done() and "response" in message: - print(json.dumps(message, indent=2, default=_json_default_serializer)) + print( + json.dumps(message, indent=2, default=_json_default_serializer) + ) future.set_result(None) # Subscribe to client-specific response topic pattern @@ -416,7 +496,9 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: _logger.error("Timed out waiting for reservation update response.") -async def handle_get_tou_request(mqtt: NavienMqttClient, device: Device, api_client: Any) -> None: +async def handle_get_tou_request( + mqtt: NavienMqttClient, device: Device, api_client: Any +) -> None: """Request Time-of-Use settings from the REST API.""" try: # Get controller serial number via MQTT @@ -451,7 +533,10 @@ async def handle_get_tou_request(mqtt: NavienMqttClient, device: Device, api_cli "utility": tou_info.utility, "zipCode": tou_info.zip_code, "schedule": [ - {"season": schedule.season, "interval": schedule.intervals} + { + "season": schedule.season, + "interval": schedule.intervals, + } for schedule in tou_info.schedule ], }, @@ -487,7 +572,13 @@ def on_status_response(status: DeviceStatus) -> None: await asyncio.wait_for(future, timeout=10) if responses: status = responses[0] - print(json.dumps(asdict(status), indent=2, default=_json_default_serializer)) + print( + json.dumps( + asdict(status), + indent=2, + default=_json_default_serializer, + ) + ) _logger.info(f"TOU {action} successful.") else: _logger.warning("TOU command sent but no response received") @@ -506,7 +597,9 @@ async def handle_get_energy_request( def raw_callback(topic: str, message: dict[str, Any]) -> None: if not future.done(): - print(json.dumps(message, indent=2, default=_json_default_serializer)) + print( + json.dumps(message, indent=2, default=_json_default_serializer) + ) future.set_result(None) # Subscribe to energy usage response (uses default device topic) diff --git a/src/nwp500/cli/monitoring.py b/src/nwp500/cli/monitoring.py index 9537a4a..5f48fde 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -10,7 +10,9 @@ _logger = logging.getLogger(__name__) -async def handle_monitoring(mqtt: NavienMqttClient, device: Device, output_file: str) -> None: +async def handle_monitoring( + mqtt: NavienMqttClient, device: Device, output_file: str +) -> None: """ Start periodic monitoring and write status to CSV. @@ -22,7 +24,9 @@ async def handle_monitoring(mqtt: NavienMqttClient, device: Device, output_file: This function runs indefinitely, polling the device every 30 seconds and writing status updates to a CSV file. """ - _logger.info(f"Starting periodic monitoring. Writing updates to {output_file}") + _logger.info( + f"Starting periodic monitoring. Writing updates to {output_file}" + ) _logger.info("Press Ctrl+C to stop.") def on_status_update(status: DeviceStatus) -> None: diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index b92c7b0..4e4c5cc 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -15,8 +15,7 @@ def _json_default_serializer(obj: Any) -> Any: - """ - Custom JSON serializer for objects not serializable by default json code. + """Serialize objects not serializable by default json code. Args: obj: Object to serialize diff --git a/src/nwp500/cli/token_storage.py b/src/nwp500/cli/token_storage.py index 77ee12d..4204819 100644 --- a/src/nwp500/cli/token_storage.py +++ b/src/nwp500/cli/token_storage.py @@ -29,7 +29,9 @@ def save_tokens(tokens: AuthTokens, email: str) -> None: "id_token": tokens.id_token, "access_token": tokens.access_token, "refresh_token": tokens.refresh_token, - "authentication_expires_in": tokens.authentication_expires_in, + "authentication_expires_in": ( + tokens.authentication_expires_in + ), "issued_at": tokens.issued_at.isoformat(), # AWS Credentials "access_key_id": tokens.access_key_id, @@ -74,5 +76,7 @@ def load_tokens() -> tuple[Optional[AuthTokens], Optional[str]]: _logger.info(f"Tokens loaded from {TOKEN_FILE} for user {email}") return tokens, email except (OSError, json.JSONDecodeError, KeyError) as e: - _logger.error(f"Failed to load or parse tokens, will re-authenticate: {e}") + _logger.error( + f"Failed to load or parse tokens, will re-authenticate: {e}" + ) return None, None diff --git a/src/nwp500/config.py b/src/nwp500/config.py index 0aba5eb..d9c092f 100644 --- a/src/nwp500/config.py +++ b/src/nwp500/config.py @@ -1,6 +1,4 @@ -""" -Configuration for the Navien API client. -""" +"""Configuration for the Navien API client.""" API_BASE_URL = "https://nlus.naviensmartcontrol.com/api/v2.1" SIGN_IN_ENDPOINT = "/user/sign-in" diff --git a/src/nwp500/constants.py b/src/nwp500/constants.py index c8001ea..686f853 100644 --- a/src/nwp500/constants.py +++ b/src/nwp500/constants.py @@ -1,6 +1,4 @@ -""" -This module defines constants for the Navien API. -""" +"""Constants and command codes for Navien device communication.""" from enum import IntEnum @@ -82,9 +80,11 @@ class CommandCode(IntEnum): # mismatches. # Known Firmware Versions and Field Changes -# Track firmware versions where new fields were introduced to help with debugging +# Track firmware versions where new fields were introduced to help with +# debugging KNOWN_FIRMWARE_FIELD_CHANGES = { - # Format: "field_name": {"introduced_in": "version", "description": "what it does"} + # Format: "field_name": {"introduced_in": "version", "description": "what it + # does"} "heatMinOpTemperature": { "introduced_in": "Controller: 184614912, WiFi: 34013184", "description": "Minimum operating temperature for heating element", diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index dbe76a3..71c8d77 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -22,7 +22,9 @@ ] # Pre-computed lookup tables for performance -WEEKDAY_NAME_TO_BIT = {name.lower(): 1 << idx for idx, name in enumerate(WEEKDAY_ORDER)} +WEEKDAY_NAME_TO_BIT = { + name.lower(): 1 << idx for idx, name in enumerate(WEEKDAY_ORDER) +} MONTH_TO_BIT = {month: 1 << (month - 1) for month in range(1, 13)} @@ -36,10 +38,12 @@ def encode_week_bitfield(days: Iterable[Union[str, int]]) -> int: Convert a collection of day names or indices into a reservation bitfield. Args: - days: Collection of weekday names (case-insensitive) or indices (0-6 or 1-7) + days: Collection of weekday names (case-insensitive) or indices (0-6 or + 1-7) Returns: - Integer bitfield where each bit represents a day (Sunday=bit 0, Monday=bit 1, etc.) + Integer bitfield where each bit represents a day (Sunday=bit 0, + Monday=bit 1, etc.) Raises: ValueError: If day name is invalid or index is out of range @@ -90,7 +94,8 @@ def decode_week_bitfield(bitfield: int) -> list[str]: ['Monday', 'Wednesday', 'Friday'] >>> decode_week_bitfield(127) # All days - ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', + 'Saturday'] >>> decode_week_bitfield(65) ['Sunday', 'Saturday'] @@ -168,7 +173,8 @@ def encode_price(value: Real, decimal_point: int) -> int: """ Encode a price into the integer representation expected by the device. - The device stores prices as integers with a separate decimal point indicator. + The device stores prices as integers with a separate decimal point + indicator. For example, $12.34 with decimal_point=2 is stored as 1234. Args: @@ -252,7 +258,8 @@ def decode_reservation_hex(hex_string: str) -> list[dict[str, int]]: Examples: >>> decode_reservation_hex("013e061e0478") - [{'enable': 1, 'week': 62, 'hour': 6, 'minute': 30, 'mode': 4, 'param': 120}] + [{'enable': 1, 'week': 62, 'hour': 6, 'minute': 30, 'mode': 4, 'param': + 120}] """ data = bytes.fromhex(hex_string) reservations = [] @@ -358,8 +365,9 @@ def build_tou_period( price_max: Union[int, Real], decimal_point: int, ) -> dict[str, int]: - """ - Build a TOU (Time of Use) period entry consistent with MQTT command requirements. + """Build a TOU (Time of Use) period entry. + + Consistent with MQTT command requirements. Args: season_months: Collection of month numbers (1-12) for this period @@ -381,7 +389,8 @@ def build_tou_period( Examples: >>> build_tou_period( ... season_months=[6, 7, 8], - ... week_days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + ... week_days=["Monday", "Tuesday", "Wednesday", "Thursday", + "Friday"], ... start_hour=9, ... start_minute=0, ... end_hour=17, @@ -400,7 +409,10 @@ def build_tou_period( if not 0 <= value <= upper: raise ValueError(f"{label} must be between 0 and {upper}") - for label, value in (("start_minute", start_minute), ("end_minute", end_minute)): + for label, value in ( + ("start_minute", start_minute), + ("end_minute", end_minute), + ): if not 0 <= value <= 59: raise ValueError(f"{label} must be between 0 and 59") @@ -409,15 +421,16 @@ def build_tou_period( season_bitfield = encode_season_bitfield(season_months) # Encode prices if they're Real numbers (not already encoded) - if isinstance(price_min, Real) and not isinstance(price_min, int): - encoded_min = encode_price(price_min, decimal_point) - else: + # Note: int is a subclass of Real, so we check int first + if isinstance(price_min, int): encoded_min = int(price_min) + else: # isinstance(price_min, Real) + encoded_min = encode_price(price_min, decimal_point) - if isinstance(price_max, Real) and not isinstance(price_max, int): - encoded_max = encode_price(price_max, decimal_point) - else: + if isinstance(price_max, int): encoded_max = int(price_max) + else: # isinstance(price_max, Real) + encoded_max = encode_price(price_max, decimal_point) return { "season": season_bitfield, diff --git a/src/nwp500/events.py b/src/nwp500/events.py index 4047d9e..11cdbc1 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -93,13 +93,19 @@ async def save_to_db(temp: float): emitter.on('temperature_changed', save_to_db, priority=100) """ - listener = EventListener(callback=callback, once=False, priority=priority) + listener = EventListener( + callback=callback, once=False, priority=priority + ) self._listeners[event].append(listener) # Sort by priority (highest first) - self._listeners[event].sort(key=lambda listener: listener.priority, reverse=True) + self._listeners[event].sort( + key=lambda listener: listener.priority, reverse=True + ) - _logger.debug(f"Registered listener for '{event}' event (priority: {priority})") + _logger.debug( + f"Registered listener for '{event}' event (priority: {priority})" + ) def once( self, @@ -122,22 +128,34 @@ def once( emitter.once('device_ready', initialize_device) # Will only be called once, then auto-removed """ - listener = EventListener(callback=callback, once=True, priority=priority) + listener = EventListener( + callback=callback, once=True, priority=priority + ) self._listeners[event].append(listener) - self._once_callbacks.add((event, callback)) # Track (event, callback) for O(1) lookup + self._once_callbacks.add( + (event, callback) + ) # Track (event, callback) for O(1) lookup # Sort by priority (highest first) - self._listeners[event].sort(key=lambda listener: listener.priority, reverse=True) - - _logger.debug(f"Registered one-time listener for '{event}' event (priority: {priority})") - - def off(self, event: str, callback: Optional[Callable[..., Any]] = None) -> int: + self._listeners[event].sort( + key=lambda listener: listener.priority, reverse=True + ) + + _logger.debug( + f"Registered one-time listener for '{event}' event " + f"(priority: {priority})" + ) + + def off( + self, event: str, callback: Optional[Callable[..., Any]] = None + ) -> int: """ Remove event listener(s). Args: event: Event name - callback: Specific callback to remove, or None to remove all for event + callback: Specific callback to remove, or None to remove all for + event Returns: Number of listeners removed @@ -160,13 +178,17 @@ def off(self, event: str, callback: Optional[Callable[..., Any]] = None) -> int: for listener in self._listeners[event]: self._once_callbacks.discard((event, listener.callback)) del self._listeners[event] - _logger.debug(f"Removed all {count} listener(s) for '{event}' event") + _logger.debug( + f"Removed all {count} listener(s) for '{event}' event" + ) return count # Remove specific callback original_count = len(self._listeners[event]) self._listeners[event] = [ - listener for listener in self._listeners[event] if listener.callback != callback + listener + for listener in self._listeners[event] + if listener.callback != callback ] removed_count = original_count - len(self._listeners[event]) @@ -179,7 +201,9 @@ def off(self, event: str, callback: Optional[Callable[..., Any]] = None) -> int: del self._listeners[event] if removed_count > 0: - _logger.debug(f"Removed {removed_count} listener(s) for '{event}' event") + _logger.debug( + f"Removed {removed_count} listener(s) for '{event}' event" + ) return removed_count @@ -317,7 +341,9 @@ def remove_all_listeners(self, event: Optional[str] = None) -> int: """ if event is None: # Remove all listeners for all events - count = sum(len(listeners) for listeners in self._listeners.values()) + count = sum( + len(listeners) for listeners in self._listeners.values() + ) self._listeners.clear() self._once_callbacks.clear() _logger.debug(f"Removed all {count} listener(s) for all events") @@ -352,7 +378,9 @@ async def wait_for( # Wait for specific condition old_temp, new_temp = await emitter.wait_for('temperature_changed') """ - future: asyncio.Future[tuple[tuple[Any, ...], dict[str, Any]]] = asyncio.Future() + future: asyncio.Future[tuple[tuple[Any, ...], dict[str, Any]]] = ( + asyncio.Future() + ) def handler(*args: Any, **kwargs: Any) -> None: if not future.done(): @@ -364,7 +392,9 @@ def handler(*args: Any, **kwargs: Any) -> None: try: if timeout is not None: - args_tuple, kwargs_dict = await asyncio.wait_for(future, timeout=timeout) + args_tuple, kwargs_dict = await asyncio.wait_for( + future, timeout=timeout + ) else: args_tuple, kwargs_dict = await future diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 22ce813..84cfff9 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -1,4 +1,5 @@ -""" +"""Data models for Navien NWP500 water heater communication. + This module defines data classes for representing data structures used in the Navien NWP500 water heater communication protocol. @@ -36,7 +37,9 @@ def meta(**kwargs: Any) -> dict[str, Any]: return kwargs -def apply_field_conversions(cls: type[Any], data: dict[str, Any]) -> dict[str, Any]: +def apply_field_conversions( + cls: type[Any], data: dict[str, Any] +) -> dict[str, Any]: """ Apply conversions to data based on field metadata. @@ -98,7 +101,9 @@ def apply_field_conversions(cls: type[Any], data: dict[str, Any]) -> dict[str, A "Unknown %s value: %s. Defaulting to %s.", field_name, value, - default_value.name if hasattr(default_value, "name") else default_value, + default_value.name + if hasattr(default_value, "name") + else default_value, ) converted_data[field_name] = default_value else: @@ -127,17 +132,29 @@ def _decicelsius_to_fahrenheit(raw_value: float) -> float: class DhwOperationSetting(Enum): - """Enumeration for DHW operation setting modes (user-configured preferences). + """DHW operation setting modes (user-configured heating preferences). - This enum represents the user's configured mode preference - what heating mode - the device should use when it needs to heat water. These values appear in the + This enum represents the user's configured mode preference - what heating + mode + the device should use when it needs to heat water. These values appear in + the dhwOperationSetting field and are set via user commands. These modes balance energy efficiency and recovery time based on user needs: - Higher efficiency = longer recovery time, lower operating costs - Lower efficiency = faster recovery time, higher operating costs - Values are based on the MQTT protocol dhw-mode command parameter. + Values are based on the MQTT protocol dhw-mode command parameter as + documented + in MQTT_MESSAGES.rst. + + Attributes: + HEAT_PUMP: Heat Pump Only - most efficient, slowest recovery + ELECTRIC: Electric Only - least efficient, fastest recovery + ENERGY_SAVER: Hybrid: Efficiency - balanced, good default + HIGH_DEMAND: Hybrid: Boost - maximum heating capacity + VACATION: Vacation mode - suspends heating to save energy + POWER_OFF: Device powered off - appears when device is turned off """ HEAT_PUMP = 1 # Heat Pump Only - most efficient, slowest recovery @@ -149,7 +166,7 @@ class DhwOperationSetting(Enum): class CurrentOperationMode(Enum): - """Enumeration for current operation mode (real-time operational state). + """Current operation mode (real-time operational state). This enum represents the device's current actual operational state - what the device is doing RIGHT NOW. These values appear in the operationMode @@ -158,7 +175,14 @@ class CurrentOperationMode(Enum): Unlike DhwOperationSetting (user preference), this reflects real-time operation and changes dynamically as the device starts/stops heating. - Values are based on device status responses in MQTT messages. + Values are based on device status responses in MQTT messages as documented + in DEVICE_STATUS_FIELDS.rst. + + Attributes: + STANDBY: Device is idle, not actively heating + HEAT_PUMP_MODE: Heat pump is actively running to heat water + HYBRID_EFFICIENCY_MODE: Device actively heating in Energy Saver mode + HYBRID_BOOST_MODE: Device actively heating in High Demand mode """ STANDBY = 0 # Device is idle, not actively heating @@ -168,7 +192,12 @@ class CurrentOperationMode(Enum): class TemperatureUnit(Enum): - """Enumeration for temperature units.""" + """Temperature unit enumeration. + + Attributes: + CELSIUS: Celsius temperature scale (°C) + FAHRENHEIT: Fahrenheit temperature scale (°F) + """ CELSIUS = 1 FAHRENHEIT = 2 @@ -176,7 +205,20 @@ class TemperatureUnit(Enum): @dataclass class DeviceInfo: - """Device information from API.""" + """Device information from API. + + Contains basic device identification and network status information + retrieved from the Navien Smart Control REST API. + + Attributes: + home_seq: Home sequence identifier + mac_address: Device MAC address (unique identifier) + additional_value: Additional device identifier value + device_type: Device type code (52 for NWP500) + device_name: User-assigned device name + connected: Connection status (1=offline, 2=online) + install_type: Installation type (optional) + """ home_seq: int mac_address: str @@ -202,7 +244,18 @@ def from_dict(cls, data: dict[str, Any]) -> "DeviceInfo": @dataclass class Location: - """Location information for a device.""" + """Location information for a device. + + Contains geographic and address information for a Navien device. + + Attributes: + state: State or province + city: City name + address: Street address + latitude: GPS latitude coordinate + longitude: GPS longitude coordinate + altitude: Altitude/elevation + """ state: Optional[str] = None city: Optional[str] = None @@ -226,7 +279,15 @@ def from_dict(cls, data: dict[str, Any]) -> "Location": @dataclass class Device: - """Complete device information including location.""" + """Complete device information including location. + + Represents a complete Navien device with both identification/status + information and geographic location data. + + Attributes: + device_info: Device identification and status + location: Geographic location information + """ device_info: DeviceInfo location: Location @@ -245,7 +306,20 @@ def from_dict(cls, data: dict[str, Any]) -> "Device": @dataclass class FirmwareInfo: - """Firmware information for a device.""" + """Firmware information for a device. + + Contains version and update information for device firmware. + See FIRMWARE_TRACKING.rst for details on firmware version tracking. + + Attributes: + mac_address: Device MAC address + additional_value: Additional device identifier + device_type: Device type code + cur_sw_code: Current software code + cur_version: Current firmware version + downloaded_version: Downloaded firmware version (if available) + device_group: Device group identifier (optional) + """ mac_address: str additional_value: str @@ -271,7 +345,15 @@ def from_dict(cls, data: dict[str, Any]) -> "FirmwareInfo": @dataclass class TOUSchedule: - """Time of Use schedule information.""" + """Time of Use schedule information. + + Represents a Time-of-Use (TOU) pricing schedule for energy optimization. + See TIME_OF_USE.rst for detailed information about TOU configuration. + + Attributes: + season: Season bitfield (months when schedule applies) + intervals: List of time intervals with pricing information + """ season: int intervals: list[dict[str, Any]] @@ -279,12 +361,29 @@ class TOUSchedule: @classmethod def from_dict(cls, data: dict[str, Any]) -> "TOUSchedule": """Create TOUSchedule from API response dictionary.""" - return cls(season=data.get("season", 0), intervals=data.get("interval", [])) + return cls( + season=data.get("season", 0), intervals=data.get("interval", []) + ) @dataclass class TOUInfo: - """Time of Use information.""" + """Time of Use information. + + Contains complete Time-of-Use (TOU) configuration including utility + information and pricing schedules. See TIME_OF_USE.rst for details + on configuring TOU optimization. + + Attributes: + register_path: Registration path + source_type: Source type identifier + controller_id: Controller identifier + manufacture_id: Manufacturer identifier + name: TOU schedule name + utility: Utility company name + zip_code: ZIP code for utility area + schedule: List of TOU schedules by season + """ register_path: str source_type: str @@ -385,7 +484,9 @@ class DeviceStatus: conOvrSensorUse: bool = field(metadata=meta(conversion="device_bool")) wtrOvrSensorUse: bool = field(metadata=meta(conversion="device_bool")) antiLegionellaUse: bool = field(metadata=meta(conversion="device_bool")) - antiLegionellaOperationBusy: bool = field(metadata=meta(conversion="device_bool")) + antiLegionellaOperationBusy: bool = field( + metadata=meta(conversion="device_bool") + ) errorBuzzerUse: bool = field(metadata=meta(conversion="device_bool")) currentHeatUse: bool = field(metadata=meta(conversion="device_bool")) heatUpperUse: bool = field(metadata=meta(conversion="device_bool")) @@ -398,8 +499,12 @@ class DeviceStatus: # Temperature fields with offset (raw + 20) dhwTemperature: float = field(metadata=meta(conversion="add_20")) dhwTemperatureSetting: float = field(metadata=meta(conversion="add_20")) - dhwTargetTemperatureSetting: float = field(metadata=meta(conversion="add_20")) - freezeProtectionTemperature: float = field(metadata=meta(conversion="add_20")) + dhwTargetTemperatureSetting: float = field( + metadata=meta(conversion="add_20") + ) + freezeProtectionTemperature: float = field( + metadata=meta(conversion="add_20") + ) dhwTemperature2: float = field(metadata=meta(conversion="add_20")) hpUpperOnTempSetting: float = field(metadata=meta(conversion="add_20")) hpUpperOffTempSetting: float = field(metadata=meta(conversion="add_20")) @@ -428,14 +533,28 @@ class DeviceStatus: recircDhwFlowRate: float = field(metadata=meta(conversion="div_10")) # Temperature fields with decicelsius to Fahrenheit conversion - tankUpperTemperature: float = field(metadata=meta(conversion="decicelsius_to_f")) - tankLowerTemperature: float = field(metadata=meta(conversion="decicelsius_to_f")) - dischargeTemperature: float = field(metadata=meta(conversion="decicelsius_to_f")) - suctionTemperature: float = field(metadata=meta(conversion="decicelsius_to_f")) - evaporatorTemperature: float = field(metadata=meta(conversion="decicelsius_to_f")) - ambientTemperature: float = field(metadata=meta(conversion="decicelsius_to_f")) + tankUpperTemperature: float = field( + metadata=meta(conversion="decicelsius_to_f") + ) + tankLowerTemperature: float = field( + metadata=meta(conversion="decicelsius_to_f") + ) + dischargeTemperature: float = field( + metadata=meta(conversion="decicelsius_to_f") + ) + suctionTemperature: float = field( + metadata=meta(conversion="decicelsius_to_f") + ) + evaporatorTemperature: float = field( + metadata=meta(conversion="decicelsius_to_f") + ) + ambientTemperature: float = field( + metadata=meta(conversion="decicelsius_to_f") + ) targetSuperHeat: float = field(metadata=meta(conversion="decicelsius_to_f")) - currentSuperHeat: float = field(metadata=meta(conversion="decicelsius_to_f")) + currentSuperHeat: float = field( + metadata=meta(conversion="decicelsius_to_f") + ) # Enum fields with default fallbacks operationMode: CurrentOperationMode = field( @@ -454,18 +573,24 @@ class DeviceStatus: ) temperatureType: TemperatureUnit = field( metadata=meta( - conversion="enum", enum_class=TemperatureUnit, default_value=TemperatureUnit.FAHRENHEIT + conversion="enum", + enum_class=TemperatureUnit, + default_value=TemperatureUnit.FAHRENHEIT, ) ) @classmethod def from_dict(cls, data: dict[str, Any]) -> "DeviceStatus": - """ - Creates a DeviceStatus object from a raw dictionary, applying - conversions based on field metadata. + """Create a DeviceStatus object from a raw dictionary. + + Applies conversions based on field metadata, eliminating duplicate + field lists and making the code more maintainable. + + Args: + data: Raw status dictionary from MQTT or API response - The conversion logic is now driven by field metadata, eliminating - duplicate field lists and making the code more maintainable. + Returns: + DeviceStatus object with all conversions applied """ # Copy data to avoid modifying the original dictionary converted_data = data.copy() @@ -487,28 +612,35 @@ def from_dict(cls, data: dict[str, Any]) -> "DeviceStatus": unknown_fields = set(converted_data.keys()) - valid_fields if unknown_fields: # Check if any unknown fields are documented in constants - known_firmware_fields = set(constants.KNOWN_FIRMWARE_FIELD_CHANGES.keys()) + known_firmware_fields = set( + constants.KNOWN_FIRMWARE_FIELD_CHANGES.keys() + ) known_new_fields = unknown_fields & known_firmware_fields truly_unknown = unknown_fields - known_firmware_fields if known_new_fields: _logger.info( "Ignoring known new fields from recent firmware: %s. " - "These fields are documented but not yet implemented in DeviceStatus. " - "Please report this with your firmware version to help us track field changes.", + "These fields are documented but not yet implemented " + "in DeviceStatus. Please report this with your " + "firmware version to help us track field changes.", known_new_fields, ) if truly_unknown: _logger.warning( "Discovered new unknown fields from device status: %s. " - "This may indicate a firmware update. Please report this issue with your " - "device firmware version (controllerSwVersion, panelSwVersion, wifiSwVersion) " - "so we can update the library. See constants.KNOWN_FIRMWARE_FIELD_CHANGES.", + "This may indicate a firmware update. Please report " + "this issue with your device firmware version " + "(controllerSwVersion, panelSwVersion, wifiSwVersion) " + "so we can update the library. See " + "constants.KNOWN_FIRMWARE_FIELD_CHANGES.", truly_unknown, ) - converted_data = {k: v for k, v in converted_data.items() if k in valid_fields} + converted_data = { + k: v for k, v in converted_data.items() if k in valid_fields + } return cls(**converted_data) @@ -567,15 +699,23 @@ class DeviceFeature: # Enum field with default fallback temperatureType: TemperatureUnit = field( metadata=meta( - conversion="enum", enum_class=TemperatureUnit, default_value=TemperatureUnit.FAHRENHEIT + conversion="enum", + enum_class=TemperatureUnit, + default_value=TemperatureUnit.FAHRENHEIT, ) ) @classmethod def from_dict(cls, data: dict[str, Any]) -> "DeviceFeature": - """ - Creates a DeviceFeature object from a raw dictionary, applying - conversions based on field metadata. + """Create a DeviceFeature object from a raw dictionary. + + Applies conversions based on field metadata. + + Args: + data: Raw feature dictionary from MQTT or API response + + Returns: + DeviceFeature object with all conversions applied """ # Copy data to avoid modifying the original dictionary converted_data = data.copy() @@ -591,20 +731,38 @@ def from_dict(cls, data: dict[str, Any]) -> "DeviceFeature": if unknown_fields: _logger.info( "Ignoring unknown fields from device feature: %s. " - "This may indicate new device capabilities from a firmware update.", + "This may indicate new device capabilities from a " + "firmware update.", unknown_fields, ) - converted_data = {k: v for k, v in converted_data.items() if k in valid_fields} + converted_data = { + k: v for k, v in converted_data.items() if k in valid_fields + } return cls(**converted_data) @dataclass class MqttRequest: - """ - Represents the 'request' object within an MQTT command payload. - - This is a flexible structure that can accommodate various commands. + """MQTT command request payload. + + Represents the 'request' object within an MQTT command payload. This is a + flexible structure that accommodates various command types including status + requests, control commands, and queries. + + See MQTT_MESSAGES.rst for detailed documentation of all command types + and their required fields. + + Attributes: + command: Command code (from CommandCode enum) + deviceType: Device type code (52 for NWP500) + macAddress: Device MAC address + additionalValue: Additional device identifier + mode: Operation mode for control commands + param: Parameter list for control commands + paramStr: Parameter string for control commands + month: Month list for energy usage queries + year: Year for energy usage queries """ command: int @@ -622,9 +780,18 @@ class MqttRequest: @dataclass class MqttCommand: - """ - Represents the overall structure of an MQTT command message sent to a - Navien device. + """Represents an MQTT command message sent to a Navien device. + + This class structures the complete MQTT message including routing + information (topics), session tracking, and the actual command request. + + Attributes: + clientID: MQTT client identifier + sessionID: Session identifier for tracking requests/responses + requestTopic: MQTT topic to publish the command to + responseTopic: MQTT topic to subscribe for responses + request: The actual command request payload + protocolVersion: MQTT protocol version (default: 2) """ clientID: str @@ -637,11 +804,17 @@ class MqttCommand: @dataclass class EnergyUsageData: - """ - Represents daily or monthly energy usage data for a single day/month. + """Daily or monthly energy usage data for a single period. This data shows the energy consumption and operating time for both - the heat pump and electric heating elements. + the heat pump and electric heating elements. See ENERGY_MONITORING.rst + for details on querying and interpreting energy usage data. + + Attributes: + heUsage: Heat Element usage in Watt-hours (Wh) + hpUsage: Heat Pump usage in Watt-hours (Wh) + heTime: Heat Element operating time in hours + hpTime: Heat Pump operating time in hours """ heUsage: int # Heat Element usage in Watt-hours (Wh) @@ -651,12 +824,20 @@ class EnergyUsageData: @property def total_usage(self) -> int: - """Total energy usage (heat element + heat pump) in Wh.""" + """Calculate total energy usage. + + Returns: + Total energy usage (heat element + heat pump) in Watt-hours + """ return self.heUsage + self.hpUsage @property def total_time(self) -> int: - """Total operating time (heat element + heat pump) in hours.""" + """Calculate total operating time. + + Returns: + Total operating time (heat element + heat pump) in hours + """ return self.heTime + self.hpTime @@ -695,7 +876,8 @@ def from_dict(cls, data: dict[str, Any]) -> "MonthlyEnergyData": # Convert list of dictionaries to EnergyUsageData objects if "data" in converted_data: converted_data["data"] = [ - EnergyUsageData(**day_data) for day_data in converted_data["data"] + EnergyUsageData(**day_data) + for day_data in converted_data["data"] ] return cls(**converted_data) @@ -703,8 +885,11 @@ def from_dict(cls, data: dict[str, Any]) -> "MonthlyEnergyData": @dataclass class EnergyUsageTotal: - """ - Represents total energy usage across the queried period. + """Represents total energy usage across the queried period. + + Attributes: + heUsage: Total Heat Element usage in Watt-hours (Wh) + hpUsage: Total Heat Pump usage in Watt-hours (Wh) """ heUsage: int # Total Heat Element usage in Watt-hours (Wh) @@ -753,7 +938,9 @@ class EnergyUsageResponse: total: EnergyUsageTotal usage: list[MonthlyEnergyData] - def get_month_data(self, year: int, month: int) -> Optional[MonthlyEnergyData]: + def get_month_data( + self, year: int, month: int + ) -> Optional[MonthlyEnergyData]: """ Get energy usage data for a specific month. @@ -776,12 +963,15 @@ def from_dict(cls, data: dict[str, Any]) -> "EnergyUsageResponse": # Convert total to EnergyUsageTotal if "total" in converted_data: - converted_data["total"] = EnergyUsageTotal(**converted_data["total"]) + converted_data["total"] = EnergyUsageTotal( + **converted_data["total"] + ) # Convert usage list to MonthlyEnergyData objects if "usage" in converted_data: converted_data["usage"] = [ - MonthlyEnergyData.from_dict(month_data) for month_data in converted_data["usage"] + MonthlyEnergyData.from_dict(month_data) + for month_data in converted_data["usage"] ] return cls(**converted_data) diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt_client.py index b1331b6..4b85050 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt_client.py @@ -106,8 +106,10 @@ class NavienMqttClient(EventEmitter): - error_detected: Error code detected (error_code, status) - error_cleared: Error code cleared (error_code) - connection_interrupted: Connection lost (error) - - connection_resumed: Connection restored (return_code, session_present) - - reconnection_failed: Reconnection permanently failed after max attempts (attempt_count) + - connection_resumed: Connection restored (return_code, + session_present) + - reconnection_failed: Reconnection permanently failed after max + attempts (attempt_count) """ def __init__( @@ -127,12 +129,13 @@ def __init__( on_connection_resumed: Callback for connection resumption Raises: - ValueError: If auth client is not authenticated or AWS credentials are not available + ValueError: If auth client is not authenticated or AWS + credentials are not available """ if not auth_client.is_authenticated: raise ValueError( - "Authentication client must be authenticated before creating MQTT client. " - "Call auth_client.sign_in() first." + "Authentication client must be authenticated before " + "creating MQTT client. Call auth_client.sign_in() first." ) if not auth_client.current_tokens: @@ -177,7 +180,9 @@ def __init__( self._on_connection_interrupted = on_connection_interrupted self._on_connection_resumed = on_connection_resumed - _logger.info(f"Initialized MQTT client with ID: {self.config.client_id}") + _logger.info( + f"Initialized MQTT client with ID: {self.config.client_id}" + ) def _schedule_coroutine(self, coro: Any) -> None: """ @@ -219,15 +224,20 @@ def _on_connection_interrupted_internal(self, error: Exception) -> None: if self._reconnection_handler and self.config.auto_reconnect: self._reconnection_handler.on_connection_interrupted(error) - def _on_connection_resumed_internal(self, return_code: Any, session_present: Any) -> None: + def _on_connection_resumed_internal( + self, return_code: Any, session_present: Any + ) -> None: """Internal handler for connection resumption.""" _logger.info( - f"Connection resumed: return_code={return_code}, session_present={session_present}" + f"Connection resumed: return_code={return_code}, " + f"session_present={session_present}" ) self._connected = True # Emit event - self._schedule_coroutine(self.emit("connection_resumed", return_code, session_present)) + self._schedule_coroutine( + self.emit("connection_resumed", return_code, session_present) + ) # Call user callback if self._on_connection_resumed: @@ -235,7 +245,9 @@ def _on_connection_resumed_internal(self, return_code: Any, session_present: Any # Delegate to reconnection handler to reset state if self._reconnection_handler: - self._reconnection_handler.on_connection_resumed(return_code, session_present) + self._reconnection_handler.on_connection_resumed( + return_code, session_present + ) # Send any queued commands if self.config.enable_command_queue and self._command_queue: @@ -258,59 +270,13 @@ async def _start_reconnect_task(self) -> None: a coroutine that's scheduled via _schedule_coroutine. """ if not self._reconnect_task or self._reconnect_task.done(): - self._reconnect_task = asyncio.create_task(self._reconnect_with_backoff()) - - async def _reconnect_with_backoff(self) -> None: - """ - Attempt to reconnect with exponential backoff. - - This method is called automatically when connection is interrupted - if auto_reconnect is enabled. - """ - while ( - not self._connected - and not self._manual_disconnect - and self._reconnect_attempts < self.config.max_reconnect_attempts - ): - self._reconnect_attempts += 1 - - # Calculate delay with exponential backoff - delay = min( - self.config.initial_reconnect_delay - * (self.config.reconnect_backoff_multiplier ** (self._reconnect_attempts - 1)), - self.config.max_reconnect_delay, - ) - - _logger.info( - "Reconnection attempt %d/%d in %.1f seconds...", - self._reconnect_attempts, - self.config.max_reconnect_attempts, - delay, + # This method is no longer used - reconnection is handled by + # MqttReconnectionHandler + _logger.warning( + "_start_reconnect_task called but reconnection is now " + "handled by MqttReconnectionHandler" ) - try: - await asyncio.sleep(delay) - - # AWS IoT SDK will handle the actual reconnection automatically - # We just need to wait and monitor the connection state - _logger.debug("Waiting for AWS IoT SDK automatic reconnection...") - - except asyncio.CancelledError: - _logger.info("Reconnection task cancelled") - break - except Exception as e: - _logger.error(f"Error during reconnection attempt: {e}") - - if self._reconnect_attempts >= self.config.max_reconnect_attempts: - _logger.error( - f"Failed to reconnect after {self.config.max_reconnect_attempts} attempts. " - "Manual reconnection required." - ) - # Stop all periodic tasks to reduce log noise - await self._stop_all_periodic_tasks() - # Emit event so users can take action - self._schedule_coroutine(self.emit("reconnection_failed", self._reconnect_attempts)) - async def connect(self) -> bool: """ Establish connection to AWS IoT Core. @@ -379,7 +345,8 @@ async def connect(self) -> bool: ) # Initialize periodic request manager - # Note: These will be implemented later when we delegate device control methods + # Note: These will be implemented later when we + # delegate device control methods self._periodic_manager = MqttPeriodicRequestManager( is_connected_func=lambda: self._connected, request_device_info_func=self._device_controller.request_device_info, @@ -440,7 +407,9 @@ async def disconnect(self) -> None: _logger.error(f"Error during disconnect: {e}") raise - def _on_message_received(self, topic: str, payload: bytes, **kwargs: Any) -> None: + def _on_message_received( + self, topic: str, payload: bytes, **kwargs: Any + ) -> None: """Internal callback for received messages.""" try: # Parse JSON payload and delegate to subscription manager @@ -448,7 +417,8 @@ def _on_message_received(self, topic: str, payload: bytes, **kwargs: Any) -> Non # Call registered handlers via subscription manager if self._subscription_manager: - # The subscription manager will handle matching and calling handlers + # The subscription manager will handle matching + # and calling handlers pass # Subscription manager handles this internally except json.JSONDecodeError as e: @@ -477,7 +447,10 @@ def _topic_matches_pattern(self, topic: str, pattern: str) -> bool: return False # Check parts before # with + wildcard support for i in range(hash_idx): - if pattern_parts[i] != "+" and topic_parts[i] != pattern_parts[i]: + if ( + pattern_parts[i] != "+" + and topic_parts[i] != pattern_parts[i] + ): return False return True @@ -561,7 +534,9 @@ async def publish( """ if not self._connected: if self.config.enable_command_queue: - _logger.debug(f"Not connected, queuing command to topic: {topic}") + _logger.debug( + f"Not connected, queuing command to topic: {topic}" + ) self._command_queue.enqueue(topic, payload, qos) return 0 # Return 0 to indicate command was queued else: @@ -575,22 +550,27 @@ async def publish( return await self._connection_manager.publish(topic, payload, qos) except Exception as e: # Handle clean session cancellation gracefully - # Check exception type and name attribute for proper error identification + # Check exception type and name attribute for proper + # error identification if ( isinstance(e, AwsCrtError) and e.name == "AWS_ERROR_MQTT_CANCELLED_FOR_CLEAN_SESSION" ): _logger.warning( - "Publish cancelled due to clean session. This is expected during reconnection." + "Publish cancelled due to clean session. This is " + "expected during reconnection." ) # Queue the command if queue is enabled if self.config.enable_command_queue: - _logger.debug("Queuing command due to clean session cancellation") + _logger.debug( + "Queuing command due to clean session cancellation" + ) self._command_queue.enqueue(topic, payload, qos) return 0 # Return 0 to indicate command was queued # Otherwise, raise an error so the caller can handle the failure raise RuntimeError( - "Publish cancelled due to clean session and command queue is disabled" + "Publish cancelled due to clean session and " + "command queue is disabled" ) # Note: redact_topic is already used elsewhere in the file @@ -616,7 +596,9 @@ async def subscribe_device( raise RuntimeError("Not connected to MQTT broker") # Delegate to subscription manager - return await self._subscription_manager.subscribe_device(device, callback) + return await self._subscription_manager.subscribe_device( + device, callback + ) async def subscribe_device_status( self, device: Device, callback: Callable[[DeviceStatus], None] @@ -661,16 +643,23 @@ async def subscribe_device_status( >>> >>> # State change events >>> mqtt_client.on('heating_started', lambda s: print("Heating ON")) - >>> mqtt_client.on('heating_stopped', lambda s: print("Heating OFF")) + >>> mqtt_client.on( + ... 'heating_stopped', lambda s: print("Heating OFF") + ... ) >>> >>> # Subscribe to start receiving events - >>> await mqtt_client.subscribe_device_status(device, lambda s: None) + >>> await mqtt_client.subscribe_device_status( + ... device, lambda s: None + ... ) """ if not self._connected or not self._subscription_manager: raise RuntimeError("Not connected to MQTT broker") - # Delegate to subscription manager (it handles state change detection and events) - return await self._subscription_manager.subscribe_device_status(device, callback) + # Delegate to subscription manager (it handles state change + # detection and events) + return await self._subscription_manager.subscribe_device_status( + device, callback + ) async def subscribe_device_feature( self, device: Device, callback: Callable[[DeviceFeature], None] @@ -699,19 +688,29 @@ async def subscribe_device_feature( >>> def on_feature(feature: DeviceFeature): ... print(f"Serial: {feature.controllerSerialNumber}") ... print(f"FW Version: {feature.controllerSwVersion}") - ... print(f"Temp Range: {feature.dhwTemperatureMin}-{feature.dhwTemperatureMax}°F") + ... print( + ... f"Temp Range: {feature.dhwTemperatureMin}-" + ... f"{feature.dhwTemperatureMax}°F" + ... ) >>> >>> await mqtt_client.subscribe_device_feature(device, on_feature) >>> # Or use event emitter - >>> mqtt_client.on('feature_received', lambda f: print(f"FW: {f.controllerSwVersion}")) - >>> await mqtt_client.subscribe_device_feature(device, lambda f: None) + >>> mqtt_client.on( + ... 'feature_received', + ... lambda f: print(f"FW: {f.controllerSwVersion}") + ... ) + >>> await mqtt_client.subscribe_device_feature( + ... device, lambda f: None + ... ) """ if not self._connected or not self._subscription_manager: raise RuntimeError("Not connected to MQTT broker") # Delegate to subscription manager - return await self._subscription_manager.subscribe_device_feature(device, callback) + return await self._subscription_manager.subscribe_device_feature( + device, callback + ) async def request_device_status(self, device: Device) -> int: """ @@ -792,16 +791,22 @@ async def set_dhw_mode( if not self._connected or not self._device_controller: raise RuntimeError("Not connected to MQTT broker") - return await self._device_controller.set_dhw_mode(device, mode_id, vacation_days) + return await self._device_controller.set_dhw_mode( + device, mode_id, vacation_days + ) - async def enable_anti_legionella(self, device: Device, period_days: int) -> int: + async def enable_anti_legionella( + self, device: Device, period_days: int + ) -> int: """Enable Anti-Legionella disinfection with a 1-30 day cycle. - This command has been confirmed through HAR analysis of the official Navien app. + This command has been confirmed through HAR analysis of the + official Navien app. When sent, the device responds with antiLegionellaUse=2 (enabled) and antiLegionellaPeriod set to the specified value. - See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" for the authoritative + See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" for the + authoritative command code (33554472) and expected payload format: {"mode": "anti-leg-on", "param": [], "paramStr": ""} @@ -818,18 +823,23 @@ async def enable_anti_legionella(self, device: Device, period_days: int) -> int: if not self._connected or not self._device_controller: raise RuntimeError("Not connected to MQTT broker") - return await self._device_controller.enable_anti_legionella(device, period_days) + return await self._device_controller.enable_anti_legionella( + device, period_days + ) async def disable_anti_legionella(self, device: Device) -> int: """Disable the Anti-Legionella disinfection cycle. - This command has been confirmed through HAR analysis of the official Navien app. + This command has been confirmed through HAR analysis of the + official Navien app. When sent, the device responds with antiLegionellaUse=1 (disabled) while antiLegionellaPeriod retains its previous value. - The correct command code is 33554471 (not 33554473 as previously assumed). + The correct command code is 33554471 (not 33554473 as + previously assumed). - See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" section for details. + See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" section + for details. Returns: The message ID of the published command @@ -839,7 +849,9 @@ async def disable_anti_legionella(self, device: Device) -> int: return await self._device_controller.disable_anti_legionella(device) - async def set_dhw_temperature(self, device: Device, temperature: int) -> int: + async def set_dhw_temperature( + self, device: Device, temperature: int + ) -> int: """ Set DHW target temperature. @@ -853,7 +865,8 @@ async def set_dhw_temperature(self, device: Device, temperature: int) -> int: Args: device: Device object - temperature: Target temperature in Fahrenheit (message value, NOT display value) + temperature: Target temperature in Fahrenheit (message + value, NOT display value) Returns: Publish packet ID @@ -865,18 +878,25 @@ async def set_dhw_temperature(self, device: Device, temperature: int) -> int: if not self._connected or not self._device_controller: raise RuntimeError("Not connected to MQTT broker") - return await self._device_controller.set_dhw_temperature(device, temperature) + return await self._device_controller.set_dhw_temperature( + device, temperature + ) - async def set_dhw_temperature_display(self, device: Device, display_temperature: int) -> int: + async def set_dhw_temperature_display( + self, device: Device, display_temperature: int + ) -> int: """ - Set DHW target temperature using the DISPLAY value (what you see on device/app). + Set DHW target temperature using the DISPLAY value (what you + see on device/app). - This is a convenience method that automatically converts display temperature + This is a convenience method that automatically converts + display temperature to the message value by subtracting 20 degrees. Args: device: Device object - display_temperature: Target temperature as shown on display/app (Fahrenheit) + display_temperature: Target temperature as shown on + display/app (Fahrenheit) Returns: Publish packet ID @@ -936,16 +956,21 @@ async def request_tou_settings( if not self._connected or not self._device_controller: raise RuntimeError("Not connected to MQTT broker") - return await self._device_controller.request_tou_settings(device, controller_serial_number) + return await self._device_controller.request_tou_settings( + device, controller_serial_number + ) async def set_tou_enabled(self, device: Device, enabled: bool) -> int: - """Quickly toggle Time-of-Use functionality without modifying the schedule.""" + """Quickly toggle Time-of-Use functionality without + modifying the schedule.""" if not self._connected or not self._device_controller: raise RuntimeError("Not connected to MQTT broker") return await self._device_controller.set_tou_enabled(device, enabled) - async def request_energy_usage(self, device: Device, year: int, months: list[int]) -> int: + async def request_energy_usage( + self, device: Device, year: int, months: list[int] + ) -> int: """ Request daily energy usage data for specified month(s). @@ -981,7 +1006,9 @@ async def request_energy_usage(self, device: Device, year: int, months: list[int if not self._connected or not self._device_controller: raise RuntimeError("Not connected to MQTT broker") - return await self._device_controller.request_energy_usage(device, year, months) + return await self._device_controller.request_energy_usage( + device, year, months + ) async def subscribe_energy_usage( self, @@ -996,7 +1023,8 @@ async def subscribe_energy_usage( Args: device: Device object - callback: Callback function that receives EnergyUsageResponse objects + callback: Callback function that receives + EnergyUsageResponse objects Returns: Subscription packet ID @@ -1004,17 +1032,27 @@ async def subscribe_energy_usage( Example: >>> def on_energy_usage(energy: EnergyUsageResponse): ... print(f"Total Usage: {energy.total.total_usage} Wh") - ... print(f"Heat Pump: {energy.total.heat_pump_percentage:.1f}%") - ... print(f"Electric: {energy.total.heat_element_percentage:.1f}%") + ... print( + ... f"Heat Pump: " + ... f"{energy.total.heat_pump_percentage:.1f}%" + ... ) + ... print( + ... f"Electric: " + ... f"{energy.total.heat_element_percentage:.1f}%" + ... ) >>> - >>> await mqtt_client.subscribe_energy_usage(device, on_energy_usage) + >>> await mqtt_client.subscribe_energy_usage( + ... device, on_energy_usage + ... ) >>> await mqtt_client.request_energy_usage(device, 2025, [9]) """ if not self._connected or not self._subscription_manager: raise RuntimeError("Not connected to MQTT broker") # Delegate to subscription manager - return await self._subscription_manager.subscribe_energy_usage(device, callback) + return await self._subscription_manager.subscribe_energy_usage( + device, callback + ) async def signal_app_connection(self, device: Device) -> int: """ @@ -1040,13 +1078,15 @@ async def start_periodic_requests( """ Start sending periodic requests for device information or status. - This optional helper continuously sends requests at a specified interval. + This optional helper continuously sends requests at a + specified interval. It can be used to keep device information or status up-to-date. Args: device: Device object request_type: Type of request (DEVICE_INFO or DEVICE_STATUS) - period_seconds: Time between requests in seconds (default: 300 = 5 minutes) + period_seconds: Time between requests in seconds + (default: 300 = 5 minutes) Example: >>> # Start periodic status requests (default) @@ -1072,7 +1112,9 @@ async def start_periodic_requests( if not self._periodic_manager: raise RuntimeError("Periodic request manager not initialized") - await self._periodic_manager.start_periodic_requests(device, request_type, period_seconds) + await self._periodic_manager.start_periodic_requests( + device, request_type, period_seconds + ) async def stop_periodic_requests( self, @@ -1100,14 +1142,17 @@ async def stop_periodic_requests( if not self._periodic_manager: raise RuntimeError("Periodic request manager not initialized") - await self._periodic_manager.stop_periodic_requests(device, request_type) + await self._periodic_manager.stop_periodic_requests( + device, request_type + ) async def _stop_all_periodic_tasks(self) -> None: """ Stop all periodic tasks. This is called internally when reconnection fails permanently - to reduce log noise from tasks trying to send requests while disconnected. + to reduce log noise from tasks trying to send requests while + disconnected. """ # Delegate to public method with specific reason await self.stop_all_periodic_tasks(_reason="connection failure") @@ -1123,12 +1168,15 @@ async def start_periodic_device_info_requests( Args: device: Device object - period_seconds: Time between requests in seconds (default: 300 = 5 minutes) + period_seconds: Time between requests in seconds + (default: 300 = 5 minutes) """ if not self._periodic_manager: raise RuntimeError("Periodic request manager not initialized") - await self._periodic_manager.start_periodic_device_info_requests(device, period_seconds) + await self._periodic_manager.start_periodic_device_info_requests( + device, period_seconds + ) async def start_periodic_device_status_requests( self, device: Device, period_seconds: float = 300.0 @@ -1140,12 +1188,15 @@ async def start_periodic_device_status_requests( Args: device: Device object - period_seconds: Time between requests in seconds (default: 300 = 5 minutes) + period_seconds: Time between requests in seconds + (default: 300 = 5 minutes) """ if not self._periodic_manager: raise RuntimeError("Periodic request manager not initialized") - await self._periodic_manager.start_periodic_device_status_requests(device, period_seconds) + await self._periodic_manager.start_periodic_device_status_requests( + device, period_seconds + ) async def stop_periodic_device_info_requests(self, device: Device) -> None: """ @@ -1161,7 +1212,9 @@ async def stop_periodic_device_info_requests(self, device: Device) -> None: await self._periodic_manager.stop_periodic_device_info_requests(device) - async def stop_periodic_device_status_requests(self, device: Device) -> None: + async def stop_periodic_device_status_requests( + self, device: Device + ) -> None: """ Stop sending periodic device status requests for a device. @@ -1173,16 +1226,21 @@ async def stop_periodic_device_status_requests(self, device: Device) -> None: if not self._periodic_manager: raise RuntimeError("Periodic request manager not initialized") - await self._periodic_manager.stop_periodic_device_status_requests(device) + await self._periodic_manager.stop_periodic_device_status_requests( + device + ) - async def stop_all_periodic_tasks(self, _reason: Optional[str] = None) -> None: + async def stop_all_periodic_tasks( + self, _reason: Optional[str] = None + ) -> None: """ Stop all periodic request tasks. This is automatically called when disconnecting. Args: - _reason: Internal parameter for logging context (e.g., "connection failure") + _reason: Internal parameter for logging context + (e.g., "connection failure") Example: >>> await mqtt_client.stop_all_periodic_tasks() @@ -1261,4 +1319,3 @@ async def reset_reconnect(self) -> None: """ if self._reconnection_handler: self._reconnection_handler.reset() - await self._start_reconnect_task() diff --git a/src/nwp500/mqtt_command_queue.py b/src/nwp500/mqtt_command_queue.py index c938fae..9729359 100644 --- a/src/nwp500/mqtt_command_queue.py +++ b/src/nwp500/mqtt_command_queue.py @@ -50,7 +50,9 @@ def __init__(self, config: "MqttConnectionConfig"): maxsize=config.max_queued_commands ) - def enqueue(self, topic: str, payload: dict[str, Any], qos: mqtt.QoS) -> None: + def enqueue( + self, topic: str, payload: dict[str, Any], qos: mqtt.QoS + ) -> None: """ Add a command to the queue. @@ -64,12 +66,15 @@ def enqueue(self, topic: str, payload: dict[str, Any], qos: mqtt.QoS) -> None: """ if not self.config.enable_command_queue: _logger.warning( - f"Command queue disabled, dropping command to '{redact_topic(topic)}'. " - "Enable command queue in config to queue commands when disconnected." + f"Command queue disabled, dropping command to " + f"'{redact_topic(topic)}'. Enable command queue in " + f"config to queue commands when disconnected." ) return - command = QueuedCommand(topic=topic, payload=payload, qos=qos, timestamp=datetime.utcnow()) + command = QueuedCommand( + topic=topic, payload=payload, qos=qos, timestamp=datetime.utcnow() + ) # If queue is full, drop oldest command first if self._queue.full(): @@ -103,7 +108,8 @@ async def send_all( This is called automatically when connection is restored. Args: - publish_func: Async function to publish messages (topic, payload, qos) + publish_func: Async function to publish messages (topic, payload, + qos) is_connected_func: Function to check if currently connected Returns: @@ -141,7 +147,8 @@ async def send_all( except Exception as e: failed_count += 1 _logger.error( - f"Failed to send queued command to '{redact_topic(command.topic)}': {e}" + f"Failed to send queued command to " + f"'{redact_topic(command.topic)}': {e}" ) # Re-queue if there's room if not self._queue.full(): @@ -149,7 +156,9 @@ async def send_all( self._queue.put_nowait(command) _logger.warning("Re-queued failed command") except asyncio.QueueFull: - _logger.error("Failed to re-queue command - queue is full") + _logger.error( + "Failed to re-queue command - queue is full" + ) break # Stop processing on error to avoid cascade failures if sent_count > 0: diff --git a/src/nwp500/mqtt_connection.py b/src/nwp500/mqtt_connection.py index 38b9153..dc55d5b 100644 --- a/src/nwp500/mqtt_connection.py +++ b/src/nwp500/mqtt_connection.py @@ -1,7 +1,8 @@ """ MQTT connection management for Navien Smart Control. -This module handles establishing and maintaining the MQTT connection to AWS IoT Core, +This module handles establishing and maintaining the MQTT connection to AWS IoT +Core, including credential management and connection state tracking. """ @@ -52,11 +53,13 @@ def __init__( on_connection_resumed: Callback for connection resumption Raises: - ValueError: If auth client not authenticated or missing AWS credentials + ValueError: If auth client not authenticated or missing AWS + credentials """ if not auth_client.is_authenticated: raise ValueError( - "Authentication client must be authenticated before creating connection manager." + "Authentication client must be authenticated before " + "creating connection manager." ) if not auth_client.current_tokens: @@ -76,7 +79,9 @@ def __init__( self._on_connection_interrupted = on_connection_interrupted self._on_connection_resumed = on_connection_resumed - _logger.info(f"Initialized connection manager with client ID: {config.client_id}") + _logger.info( + f"Initialized connection manager with client ID: {config.client_id}" + ) async def connect(self) -> bool: """ @@ -103,9 +108,13 @@ async def connect(self) -> bool: try: # Build WebSocket MQTT connection with AWS credentials - # Run blocking operations in a thread to avoid blocking the event loop - # The AWS IoT SDK performs synchronous file I/O operations during connection setup - credentials_provider = await asyncio.to_thread(self._create_credentials_provider) + # Run blocking operations in a thread to avoid blocking the event + # loop + # The AWS IoT SDK performs synchronous file I/O operations during + # connection setup + credentials_provider = await asyncio.to_thread( + self._create_credentials_provider + ) self._connection = await asyncio.to_thread( mqtt_connection_builder.websockets_with_default_aws_signing, endpoint=self.config.endpoint, @@ -130,7 +139,8 @@ async def connect(self) -> bool: self._connected = True _logger.info( - f"Connected successfully: session_present={connect_result['session_present']}" + f"Connected successfully: " + f"session_present={connect_result['session_present']}" ) return True @@ -240,7 +250,9 @@ async def unsubscribe(self, topic: str) -> int: _logger.debug(f"Unsubscribing from topic: {topic}") # Convert concurrent.futures.Future to asyncio.Future and await - unsubscribe_future, packet_id = self._connection.unsubscribe(topic=topic) + unsubscribe_future, packet_id = self._connection.unsubscribe( + topic=topic + ) await asyncio.wrap_future(unsubscribe_future) _logger.info(f"Unsubscribed from '{topic}' with packet_id {packet_id}") @@ -298,5 +310,13 @@ def is_connected(self) -> bool: @property def connection(self) -> Optional[mqtt.Connection]: - """Get the underlying MQTT connection (for advanced usage).""" + """Get the underlying MQTT connection. + + Returns: + The MQTT connection object, or None if not connected + + Note: + This property is provided for advanced usage. Most operations + should use the higher-level methods provided by this class. + """ return self._connection diff --git a/src/nwp500/mqtt_device_control.py b/src/nwp500/mqtt_device_control.py index 70b72bd..2acd1d7 100644 --- a/src/nwp500/mqtt_device_control.py +++ b/src/nwp500/mqtt_device_control.py @@ -81,7 +81,8 @@ def _build_command( **kwargs, } - # Use navilink- prefix for device ID in topics (from reference implementation) + # Use navilink- prefix for device ID in topics (from reference + # implementation) device_topic = f"navilink-{device_id}" return { @@ -90,7 +91,9 @@ def _build_command( "protocolVersion": 2, "request": request, "requestTopic": f"cmd/{device_type}/{device_topic}", - "responseTopic": f"cmd/{device_type}/{device_topic}/{self._client_id}/res", + "responseTopic": ( + f"cmd/{device_type}/{device_topic}/{self._client_id}/res" + ), } async def request_device_status(self, device: Device) -> int: @@ -162,7 +165,9 @@ async def set_power(self, device: Device, power_on: bool) -> int: device_topic = f"navilink-{device_id}" topic = f"cmd/{device_type}/{device_topic}/ctrl" mode = "power-on" if power_on else "power-off" - command_code = CommandCode.POWER_ON if power_on else CommandCode.POWER_OFF + command_code = ( + CommandCode.POWER_ON if power_on else CommandCode.POWER_OFF + ) command = self._build_command( device_type=device_type, @@ -216,7 +221,9 @@ async def set_dhw_mode( param = [mode_id, vacation_days] else: if vacation_days is not None: - raise ValueError("vacation_days is only valid for vacation mode (mode 5)") + raise ValueError( + "vacation_days is only valid for vacation mode (mode 5)" + ) param = [mode_id] device_id = device.device_info.mac_address @@ -238,15 +245,19 @@ async def set_dhw_mode( return await self._publish(topic, command) - async def enable_anti_legionella(self, device: Device, period_days: int) -> int: + async def enable_anti_legionella( + self, device: Device, period_days: int + ) -> int: """ Enable Anti-Legionella disinfection with a 1-30 day cycle. - This command has been confirmed through HAR analysis of the official Navien app. + This command has been confirmed through HAR analysis of the official + Navien app. When sent, the device responds with antiLegionellaUse=2 (enabled) and antiLegionellaPeriod set to the specified value. - See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" for the authoritative + See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" for the + authoritative command code (33554472) and expected payload format: {"mode": "anti-leg-on", "param": [], "paramStr": ""} @@ -286,12 +297,15 @@ async def disable_anti_legionella(self, device: Device) -> int: """ Disable the Anti-Legionella disinfection cycle. - This command has been confirmed through HAR analysis of the official Navien app. + This command has been confirmed through HAR analysis of the official + Navien app. When sent, the device responds with antiLegionellaUse=1 (disabled) while antiLegionellaPeriod retains its previous value. - The correct command code is 33554471 (not 33554473 as previously assumed). - See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" section for details. + The correct command code is 33554471 (not 33554473 as previously + assumed). + See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" section for + details. Args: device: The device to control @@ -318,7 +332,9 @@ async def disable_anti_legionella(self, device: Device) -> int: return await self._publish(topic, command) - async def set_dhw_temperature(self, device: Device, temperature: int) -> int: + async def set_dhw_temperature( + self, device: Device, temperature: int + ) -> int: """ Set DHW target temperature. @@ -332,7 +348,8 @@ async def set_dhw_temperature(self, device: Device, temperature: int) -> int: Args: device: Device object - temperature: Target temperature in Fahrenheit (message value, NOT display value) + temperature: Target temperature in Fahrenheit (message value, NOT + display value) Returns: Publish packet ID @@ -360,16 +377,20 @@ async def set_dhw_temperature(self, device: Device, temperature: int) -> int: return await self._publish(topic, command) - async def set_dhw_temperature_display(self, device: Device, display_temperature: int) -> int: - """ - Set DHW target temperature using the DISPLAY value (what you see on device/app). + async def set_dhw_temperature_display( + self, device: Device, display_temperature: int + ) -> int: + """Set DHW target temperature using the DISPLAY value. - This is a convenience method that automatically converts display temperature - to the message value by subtracting 20 degrees. + Uses what you see on device/app. + + This is a convenience method that automatically converts display + temperature to the message value by subtracting 20 degrees. Args: device: Device object - display_temperature: Target temperature as shown on display/app (Fahrenheit) + display_temperature: Target temperature as shown on display/app + (Fahrenheit) Returns: Publish packet ID @@ -421,7 +442,9 @@ async def update_reservations( reservation=reservation_payload, ) command["requestTopic"] = topic - command["responseTopic"] = f"cmd/{device_type}/{self._client_id}/res/rsv/rd" + command["responseTopic"] = ( + f"cmd/{device_type}/{self._client_id}/res/rsv/rd" + ) return await self._publish(topic, command) @@ -448,7 +471,9 @@ async def request_reservations(self, device: Device) -> int: additional_value=additional_value, ) command["requestTopic"] = topic - command["responseTopic"] = f"cmd/{device_type}/{self._client_id}/res/rsv/rd" + command["responseTopic"] = ( + f"cmd/{device_type}/{self._client_id}/res/rsv/rd" + ) return await self._publish(topic, command) @@ -503,7 +528,9 @@ async def configure_tou_schedule( reservation=reservation_payload, ) command["requestTopic"] = topic - command["responseTopic"] = f"cmd/{device_type}/{self._client_id}/res/tou/rd" + command["responseTopic"] = ( + f"cmd/{device_type}/{self._client_id}/res/tou/rd" + ) return await self._publish(topic, command) @@ -542,7 +569,9 @@ async def request_tou_settings( controllerSerialNumber=controller_serial_number, ) command["requestTopic"] = topic - command["responseTopic"] = f"cmd/{device_type}/{self._client_id}/res/tou/rd" + command["responseTopic"] = ( + f"cmd/{device_type}/{self._client_id}/res/tou/rd" + ) return await self._publish(topic, command) @@ -563,7 +592,9 @@ async def set_tou_enabled(self, device: Device, enabled: bool) -> int: device_topic = f"navilink-{device_id}" topic = f"cmd/{device_type}/{device_topic}/ctrl" - command_code = CommandCode.TOU_ENABLE if enabled else CommandCode.TOU_DISABLE + command_code = ( + CommandCode.TOU_ENABLE if enabled else CommandCode.TOU_DISABLE + ) mode = "tou-on" if enabled else "tou-off" command = self._build_command( @@ -579,7 +610,9 @@ async def set_tou_enabled(self, device: Device, enabled: bool) -> int: return await self._publish(topic, command) - async def request_energy_usage(self, device: Device, year: int, months: list[int]) -> int: + async def request_energy_usage( + self, device: Device, year: int, months: list[int] + ) -> int: """ Request daily energy usage data for specified month(s). @@ -617,7 +650,9 @@ async def request_energy_usage(self, device: Device, year: int, months: list[int additional_value = device.device_info.additional_value device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/st/energy-usage-daily-query/rd" + topic = ( + f"cmd/{device_type}/{device_topic}/st/energy-usage-daily-query/rd" + ) command = self._build_command( device_type=device_type, diff --git a/src/nwp500/mqtt_periodic.py b/src/nwp500/mqtt_periodic.py index 5a54bad..c52958f 100644 --- a/src/nwp500/mqtt_periodic.py +++ b/src/nwp500/mqtt_periodic.py @@ -70,13 +70,15 @@ async def start_periodic_requests( """ Start sending periodic requests for device information or status. - This optional helper continuously sends requests at a specified interval. + This optional helper continuously sends requests at a specified + interval. It can be used to keep device information or status up-to-date. Args: device: Device object request_type: Type of request (DEVICE_INFO or DEVICE_STATUS) - period_seconds: Time between requests in seconds (default: 300 = 5 minutes) + period_seconds: Time between requests in seconds (default: 300 = 5 + minutes) Example: >>> # Start periodic status requests (default) @@ -100,20 +102,28 @@ async def start_periodic_requests( - All tasks automatically stop when client disconnects """ device_id = device.device_info.mac_address - # Do not log MAC address; use a generic placeholder to avoid leaking sensitive information + # Do not log MAC address; use a generic placeholder to avoid leaking + # sensitive information redacted_device_id = "DEVICE_ID_REDACTED" task_name = f"periodic_{request_type.value}_{device_id}" # Stop existing task for this device/type if any if task_name in self._periodic_tasks: - _logger.info(f"Stopping existing periodic {request_type.value} task") + _logger.info( + f"Stopping existing periodic {request_type.value} task" + ) await self.stop_periodic_requests(device, request_type) async def periodic_request() -> None: - """Internal coroutine for periodic requests.""" + """Execute periodic requests for device information or status. + + This coroutine runs continuously, sending requests at the configured + interval. It automatically skips requests when disconnected and + provides throttled logging to reduce noise. + """ _logger.info( - f"Started periodic {request_type.value} requests for {redacted_device_id} " - f"(every {period_seconds}s)" + f"Started periodic {request_type.value} requests for " + f"{redacted_device_id} (every {period_seconds}s)" ) # Track consecutive skips for throttled logging @@ -123,10 +133,15 @@ async def periodic_request() -> None: try: if not self._is_connected(): consecutive_skips += 1 - # Log warning only on first skip and then every 10th skip to reduce noise - if consecutive_skips == 1 or consecutive_skips % 10 == 0: + # Log warning only on first skip and then every 10th + # skip to reduce noise + if ( + consecutive_skips == 1 + or consecutive_skips % 10 == 0 + ): _logger.warning( - "Not connected, skipping %s request for %s (skipped %d time%s)", + "Not connected, skipping %s request for %s " + "(skipped %d time%s)", request_type.value, redacted_device_id, consecutive_skips, @@ -142,7 +157,8 @@ async def periodic_request() -> None: # Reset skip counter when connected if consecutive_skips > 0: _logger.info( - "Reconnected, resuming %s requests for %s (had skipped %d)", + "Reconnected, resuming %s requests for %s " + "(had skipped %d)", request_type.value, redacted_device_id, consecutive_skips, @@ -166,19 +182,24 @@ async def periodic_request() -> None: except asyncio.CancelledError: _logger.info( - f"Periodic {request_type.value} requests cancelled for {redacted_device_id}" + f"Periodic {request_type.value} requests cancelled " + f"for {redacted_device_id}" ) break except Exception as e: - # Handle clean session cancellation gracefully (expected during reconnection) - # Check exception type and name attribute for proper error identification + # Handle clean session cancellation gracefully (expected + # during reconnection) + # Check exception type and name attribute for proper error + # identification if ( isinstance(e, AwsCrtError) - and e.name == "AWS_ERROR_MQTT_CANCELLED_FOR_CLEAN_SESSION" + and e.name + == "AWS_ERROR_MQTT_CANCELLED_FOR_CLEAN_SESSION" ): _logger.debug( - "Periodic %s request cancelled due to clean session for %s. " - "This is expected during reconnection.", + "Periodic %s request cancelled due to clean " + "session for %s. This is expected during " + "reconnection.", request_type.value, redacted_device_id, ) @@ -198,8 +219,8 @@ async def periodic_request() -> None: self._periodic_tasks[task_name] = task _logger.info( - f"Started periodic {request_type.value} task for {redacted_device_id} " - f"with period {period_seconds}s" + f"Started periodic {request_type.value} task for " + f"{redacted_device_id} with period {period_seconds}s" ) async def stop_periodic_requests( @@ -256,14 +277,17 @@ async def stop_periodic_requests( + (f" (type={request_type.value})" if request_type else "") ) - async def stop_all_periodic_tasks(self, reason: Optional[str] = None) -> None: + async def stop_all_periodic_tasks( + self, reason: Optional[str] = None + ) -> None: """ Stop all periodic request tasks. This is automatically called when disconnecting. Args: - reason: Optional reason for logging context (e.g., "connection failure") + reason: Optional reason for logging context (e.g., "connection + failure") Example: >>> await manager.stop_all_periodic_tasks() @@ -281,7 +305,9 @@ async def stop_all_periodic_tasks(self, reason: Optional[str] = None) -> None: task.cancel() # Wait for all to complete - await asyncio.gather(*self._periodic_tasks.values(), return_exceptions=True) + await asyncio.gather( + *self._periodic_tasks.values(), return_exceptions=True + ) self._periodic_tasks.clear() _logger.info("All periodic tasks stopped") @@ -298,7 +324,8 @@ async def start_periodic_device_info_requests( Args: device: Device object - period_seconds: Time between requests in seconds (default: 300 = 5 minutes) + period_seconds: Time between requests in seconds (default: 300 = 5 + minutes) """ await self.start_periodic_requests( device=device, @@ -316,7 +343,8 @@ async def start_periodic_device_status_requests( Args: device: Device object - period_seconds: Time between requests in seconds (default: 300 = 5 minutes) + period_seconds: Time between requests in seconds (default: 300 = 5 + minutes) """ await self.start_periodic_requests( device=device, @@ -333,9 +361,13 @@ async def stop_periodic_device_info_requests(self, device: Device) -> None: Args: device: Device object """ - await self.stop_periodic_requests(device, PeriodicRequestType.DEVICE_INFO) + await self.stop_periodic_requests( + device, PeriodicRequestType.DEVICE_INFO + ) - async def stop_periodic_device_status_requests(self, device: Device) -> None: + async def stop_periodic_device_status_requests( + self, device: Device + ) -> None: """ Stop sending periodic device status requests for a device. @@ -344,4 +376,6 @@ async def stop_periodic_device_status_requests(self, device: Device) -> None: Args: device: Device object """ - await self.stop_periodic_requests(device, PeriodicRequestType.DEVICE_STATUS) + await self.stop_periodic_requests( + device, PeriodicRequestType.DEVICE_STATUS + ) diff --git a/src/nwp500/mqtt_reconnection.py b/src/nwp500/mqtt_reconnection.py index 2ea3ecf..1b7a976 100644 --- a/src/nwp500/mqtt_reconnection.py +++ b/src/nwp500/mqtt_reconnection.py @@ -40,7 +40,8 @@ def __init__( Args: config: MQTT connection configuration is_connected_func: Function to check if currently connected - schedule_coroutine_func: Function to schedule coroutines from any thread + schedule_coroutine_func: Function to schedule coroutines from any + thread """ self.config = config self._is_connected_func = is_connected_func @@ -87,7 +88,9 @@ def on_connection_interrupted(self, error: Exception) -> None: _logger.info("Starting automatic reconnection...") self._schedule_coroutine(self._start_reconnect_task()) - def on_connection_resumed(self, return_code: Any, session_present: Any) -> None: + def on_connection_resumed( + self, return_code: Any, session_present: Any + ) -> None: """ Handle connection resumption. @@ -96,9 +99,12 @@ def on_connection_resumed(self, return_code: Any, session_present: Any) -> None: session_present: Whether session was present """ _logger.info( - f"Connection resumed: return_code={return_code}, session_present={session_present}" + f"Connection resumed: return_code={return_code}, " + f"session_present={session_present}" + ) + self._reconnect_attempts = ( + 0 # Reset reconnection attempts on successful connection ) - self._reconnect_attempts = 0 # Reset reconnection attempts on successful connection # Cancel any pending reconnection task if self._reconnect_task and not self._reconnect_task.done(): @@ -113,7 +119,9 @@ async def _start_reconnect_task(self) -> None: a coroutine that's scheduled via _schedule_coroutine. """ if not self._reconnect_task or self._reconnect_task.done(): - self._reconnect_task = asyncio.create_task(self._reconnect_with_backoff()) + self._reconnect_task = asyncio.create_task( + self._reconnect_with_backoff() + ) async def _reconnect_with_backoff(self) -> None: """ @@ -132,7 +140,10 @@ async def _reconnect_with_backoff(self) -> None: # Calculate delay with exponential backoff delay = min( self.config.initial_reconnect_delay - * (self.config.reconnect_backoff_multiplier ** (self._reconnect_attempts - 1)), + * ( + self.config.reconnect_backoff_multiplier + ** (self._reconnect_attempts - 1) + ), self.config.max_reconnect_delay, ) @@ -148,7 +159,9 @@ async def _reconnect_with_backoff(self) -> None: # AWS IoT SDK will handle the actual reconnection automatically # We just need to wait and monitor the connection state - _logger.debug("Waiting for AWS IoT SDK automatic reconnection...") + _logger.debug( + "Waiting for AWS IoT SDK automatic reconnection..." + ) except asyncio.CancelledError: _logger.info("Reconnection task cancelled") @@ -158,7 +171,8 @@ async def _reconnect_with_backoff(self) -> None: if self._reconnect_attempts >= self.config.max_reconnect_attempts: _logger.error( - f"Failed to reconnect after {self.config.max_reconnect_attempts} attempts. " + f"Failed to reconnect after " + f"{self.config.max_reconnect_attempts} attempts. " "Manual reconnection required." ) @@ -173,7 +187,9 @@ async def cancel(self) -> None: @property def is_reconnecting(self) -> bool: """Check if currently attempting to reconnect.""" - return self._reconnect_task is not None and not self._reconnect_task.done() + return ( + self._reconnect_task is not None and not self._reconnect_task.done() + ) @property def attempt_count(self) -> int: @@ -183,3 +199,8 @@ def attempt_count(self) -> int: def reset_attempts(self) -> None: """Reset the reconnection attempt counter.""" self._reconnect_attempts = 0 + + def reset(self) -> None: + """Reset reconnection state and enable reconnection.""" + self._reconnect_attempts = 0 + self.enable() diff --git a/src/nwp500/mqtt_subscriptions.py b/src/nwp500/mqtt_subscriptions.py index 740edd5..484b851 100644 --- a/src/nwp500/mqtt_subscriptions.py +++ b/src/nwp500/mqtt_subscriptions.py @@ -60,7 +60,9 @@ def __init__( # Track subscriptions and handlers self._subscriptions: dict[str, mqtt.QoS] = {} - self._message_handlers: dict[str, list[Callable[[str, dict[str, Any]], None]]] = {} + self._message_handlers: dict[ + str, list[Callable[[str, dict[str, Any]], None]] + ] = {} # Track previous state for change detection self._previous_status: Optional[DeviceStatus] = None @@ -70,9 +72,10 @@ def subscriptions(self) -> dict[str, mqtt.QoS]: """Get current subscriptions.""" return self._subscriptions.copy() - def _on_message_received(self, topic: str, payload: bytes, **kwargs: Any) -> None: - """ - Internal callback for received messages. + def _on_message_received( + self, topic: str, payload: bytes, **kwargs: Any + ) -> None: + """Handle received MQTT messages. Parses JSON payload and routes to registered handlers. @@ -88,7 +91,10 @@ def _on_message_received(self, topic: str, payload: bytes, **kwargs: Any) -> Non # Call registered handlers that match this topic # Need to match against subscription patterns with wildcards - for subscription_pattern, handlers in self._message_handlers.items(): + for ( + subscription_pattern, + handlers, + ) in self._message_handlers.items(): if self._topic_matches_pattern(topic, subscription_pattern): for handler in handlers: try: @@ -117,9 +123,11 @@ def _topic_matches_pattern(self, topic: str, pattern: str) -> bool: True if topic matches pattern Examples: - >>> _topic_matches_pattern("cmd/52/device1/status", "cmd/52/+/status") + >>> _topic_matches_pattern("cmd/52/device1/status", + "cmd/52/+/status") True - >>> _topic_matches_pattern("cmd/52/device1/status/extra", "cmd/52/device1/#") + >>> _topic_matches_pattern("cmd/52/device1/status/extra", + "cmd/52/device1/#") True """ # Handle exact match @@ -141,7 +149,10 @@ def _topic_matches_pattern(self, topic: str, pattern: str) -> bool: return False # Check parts before # with + wildcard support for i in range(hash_idx): - if pattern_parts[i] != "+" and topic_parts[i] != pattern_parts[i]: + if ( + pattern_parts[i] != "+" + and topic_parts[i] != pattern_parts[i] + ): return False return True @@ -189,7 +200,8 @@ async def subscribe( subscribe_result = await asyncio.wrap_future(subscribe_future) _logger.info( - f"Subscribed to '{redact_topic(topic)}' with QoS {subscribe_result['qos']}" + f"Subscribed to '{redact_topic(topic)}' with QoS " + f"{subscribe_result['qos']}" ) # Store subscription and handler @@ -201,7 +213,9 @@ async def subscribe( return int(packet_id) except Exception as e: - _logger.error(f"Failed to subscribe to '{redact_topic(topic)}': {e}") + _logger.error( + f"Failed to subscribe to '{redact_topic(topic)}': {e}" + ) raise async def unsubscribe(self, topic: str) -> int: @@ -237,7 +251,9 @@ async def unsubscribe(self, topic: str) -> int: return int(packet_id) except Exception as e: - _logger.error(f"Failed to unsubscribe from '{redact_topic(topic)}': {e}") + _logger.error( + f"Failed to unsubscribe from '{redact_topic(topic)}': {e}" + ) raise async def subscribe_device( @@ -304,23 +320,27 @@ async def subscribe_device_status( >>> >>> # State change events >>> mqtt_client.on('heating_started', lambda s: print("Heating ON")) - >>> mqtt_client.on('heating_stopped', lambda s: print("Heating OFF")) + >>> mqtt_client.on('heating_stopped', lambda s: print("Heating + OFF")) >>> >>> # Subscribe to start receiving events - >>> await mqtt_client.subscribe_device_status(device, lambda s: None) + >>> await mqtt_client.subscribe_device_status(device, lambda s: + None) """ def status_message_handler(topic: str, message: dict[str, Any]) -> None: """Parse status messages and invoke user callback.""" try: # Log all messages received for debugging - _logger.debug(f"Status handler received message on topic: {topic}") + _logger.debug( + f"Status handler received message on topic: {topic}" + ) _logger.debug(f"Message keys: {list(message.keys())}") - # Check if message contains status data if "response" not in message: _logger.debug( - "Message does not contain 'response' key, skipping. Keys: %s", + "Message does not contain 'response' key, skipping. " + "Keys: %s", list(message.keys()), ) return @@ -330,21 +350,28 @@ def status_message_handler(topic: str, message: dict[str, Any]) -> None: if "status" not in response: _logger.debug( - "Response does not contain 'status' key, skipping. Keys: %s", + "Response does not contain 'status' key, skipping. " + "Keys: %s", list(response.keys()), ) return # Parse status into DeviceStatus object - _logger.info(f"Parsing device status message from topic: {topic}") + _logger.info( + f"Parsing device status message from topic: {topic}" + ) status_data = response["status"] device_status = DeviceStatus.from_dict(status_data) # Emit raw status event - self._schedule_coroutine(self._event_emitter.emit("status_received", device_status)) + self._schedule_coroutine( + self._event_emitter.emit("status_received", device_status) + ) # Detect and emit state changes - self._schedule_coroutine(self._detect_state_changes(device_status)) + self._schedule_coroutine( + self._detect_state_changes(device_status) + ) # Invoke user callback with parsed status _logger.info("Invoking user callback with parsed DeviceStatus") @@ -357,12 +384,18 @@ def status_message_handler(topic: str, message: dict[str, Any]) -> None: exc_info=True, ) except ValueError as e: - _logger.warning(f"Invalid value in status message: {e}", exc_info=True) + _logger.warning( + f"Invalid value in status message: {e}", exc_info=True + ) except Exception as e: - _logger.error(f"Error parsing device status: {e}", exc_info=True) + _logger.error( + f"Error parsing device status: {e}", exc_info=True + ) # Subscribe using the internal handler - return await self.subscribe_device(device=device, callback=status_message_handler) + return await self.subscribe_device( + device=device, callback=status_message_handler + ) async def _detect_state_changes(self, status: DeviceStatus) -> None: """ @@ -390,7 +423,8 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: status.dhwTemperature, ) _logger.debug( - f"Temperature changed: {prev.dhwTemperature}°F → {status.dhwTemperature}°F" + f"Temperature changed: {prev.dhwTemperature}°F → " + f"{status.dhwTemperature}°F" ) # Operation mode change @@ -400,7 +434,10 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: prev.operationMode, status.operationMode, ) - _logger.debug(f"Mode changed: {prev.operationMode} → {status.operationMode}") + _logger.debug( + f"Mode changed: {prev.operationMode} → " + f"{status.operationMode}" + ) # Power consumption change if status.currentInstPower != prev.currentInstPower: @@ -410,7 +447,8 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: status.currentInstPower, ) _logger.debug( - f"Power changed: {prev.currentInstPower}W → {status.currentInstPower}W" + f"Power changed: {prev.currentInstPower}W → " + f"{status.currentInstPower}W" ) # Heating started/stopped @@ -427,7 +465,9 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: # Error detection if status.errorCode and not prev.errorCode: - await self._event_emitter.emit("error_detected", status.errorCode, status) + await self._event_emitter.emit( + "error_detected", status.errorCode, status + ) _logger.info(f"Error detected: {status.errorCode}") if not status.errorCode and prev.errorCode: @@ -467,26 +507,34 @@ async def subscribe_device_feature( >>> def on_feature(feature: DeviceFeature): ... print(f"Serial: {feature.controllerSerialNumber}") ... print(f"FW Version: {feature.controllerSwVersion}") - ... print(f"Temp Range: {feature.dhwTemperatureMin}-{feature.dhwTemperatureMax}°F") + ... print(f"Temp Range: + {feature.dhwTemperatureMin}-{feature.dhwTemperatureMax}°F") >>> >>> await mqtt_client.subscribe_device_feature(device, on_feature) >>> # Or use event emitter - >>> mqtt_client.on('feature_received', lambda f: print(f"FW: {f.controllerSwVersion}")) - >>> await mqtt_client.subscribe_device_feature(device, lambda f: None) + >>> mqtt_client.on('feature_received', lambda f: print(f"FW: + {f.controllerSwVersion}")) + >>> await mqtt_client.subscribe_device_feature(device, lambda f: + None) """ - def feature_message_handler(topic: str, message: dict[str, Any]) -> None: + def feature_message_handler( + topic: str, message: dict[str, Any] + ) -> None: """Parse feature messages and invoke user callback.""" try: # Log all messages received for debugging - _logger.debug(f"Feature handler received message on topic: {topic}") + _logger.debug( + f"Feature handler received message on topic: {topic}" + ) _logger.debug(f"Message keys: {list(message.keys())}") # Check if message contains feature data if "response" not in message: _logger.debug( - "Message does not contain 'response' key, skipping. Keys: %s", + "Message does not contain 'response' key, " + "skipping. Keys: %s", list(message.keys()), ) return @@ -496,13 +544,16 @@ def feature_message_handler(topic: str, message: dict[str, Any]) -> None: if "feature" not in response: _logger.debug( - "Response does not contain 'feature' key, skipping. Keys: %s", + "Response does not contain 'feature' key, " + "skipping. Keys: %s", list(response.keys()), ) return # Parse feature into DeviceFeature object - _logger.info(f"Parsing device feature message from topic: {topic}") + _logger.info( + f"Parsing device feature message from topic: {topic}" + ) feature_data = response["feature"] device_feature = DeviceFeature.from_dict(feature_data) @@ -522,12 +573,18 @@ def feature_message_handler(topic: str, message: dict[str, Any]) -> None: exc_info=True, ) except ValueError as e: - _logger.warning(f"Invalid value in feature message: {e}", exc_info=True) + _logger.warning( + f"Invalid value in feature message: {e}", exc_info=True + ) except Exception as e: - _logger.error(f"Error parsing device feature: {e}", exc_info=True) + _logger.error( + f"Error parsing device feature: {e}", exc_info=True + ) # Subscribe using the internal handler - return await self.subscribe_device(device=device, callback=feature_message_handler) + return await self.subscribe_device( + device=device, callback=feature_message_handler + ) async def subscribe_energy_usage( self, @@ -542,7 +599,8 @@ async def subscribe_energy_usage( Args: device: Device object - callback: Callback function that receives EnergyUsageResponse objects + callback: Callback function that receives EnergyUsageResponse + objects Returns: Subscription packet ID @@ -550,24 +608,34 @@ async def subscribe_energy_usage( Example: >>> def on_energy_usage(energy: EnergyUsageResponse): ... print(f"Total Usage: {energy.total.total_usage} Wh") - ... print(f"Heat Pump: {energy.total.heat_pump_percentage:.1f}%") - ... print(f"Electric: {energy.total.heat_element_percentage:.1f}%") + ... print(f"Heat Pump: + {energy.total.heat_pump_percentage:.1f}%") + ... print(f"Electric: + {energy.total.heat_element_percentage:.1f}%") >>> - >>> await mqtt_client.subscribe_energy_usage(device, on_energy_usage) + >>> await mqtt_client.subscribe_energy_usage(device, + on_energy_usage) >>> await mqtt_client.request_energy_usage(device, 2025, [9]) """ - device_type = device.device_info.device_type def energy_message_handler(topic: str, message: dict[str, Any]) -> None: - """Internal handler to parse energy usage responses.""" + """Parse and route energy usage responses to user callback. + + Args: + topic: MQTT topic the message was received on + message: Parsed message dictionary + """ try: - _logger.debug("Energy handler received message on topic: %s", topic) + _logger.debug( + "Energy handler received message on topic: %s", topic + ) _logger.debug("Message keys: %s", list(message.keys())) if "response" not in message: _logger.debug( - "Message does not contain 'response' key, skipping. Keys: %s", + "Message does not contain 'response' key, " + "skipping. Keys: %s", list(message.keys()), ) return @@ -577,24 +645,38 @@ def energy_message_handler(topic: str, message: dict[str, Any]) -> None: if "typeOfUsage" not in response_data: _logger.debug( - "Response does not contain 'typeOfUsage' key, skipping. Keys: %s", + "Response does not contain 'typeOfUsage' key, " + "skipping. Keys: %s", list(response_data.keys()), ) return - _logger.info("Parsing energy usage response from topic: %s", topic) + _logger.info( + "Parsing energy usage response from topic: %s", topic + ) energy_response = EnergyUsageResponse.from_dict(response_data) - _logger.info("Invoking user callback with parsed EnergyUsageResponse") + _logger.info( + "Invoking user callback with parsed EnergyUsageResponse" + ) callback(energy_response) _logger.debug("User callback completed successfully") except KeyError as e: - _logger.warning("Failed to parse energy usage message - missing key: %s", e) + _logger.warning( + "Failed to parse energy usage message - missing key: %s", e + ) except Exception as e: - _logger.error("Error in energy usage message handler: %s", e, exc_info=True) + _logger.error( + "Error in energy usage message handler: %s", + e, + exc_info=True, + ) - response_topic = f"cmd/{device_type}/{self._client_id}/res/energy-usage-daily-query/rd" + response_topic = ( + f"cmd/{device_type}/{self._client_id}/res/" + f"energy-usage-daily-query/rd" + ) return await self.subscribe(response_topic, energy_message_handler) diff --git a/src/nwp500/mqtt_utils.py b/src/nwp500/mqtt_utils.py index e5d6558..97bd96f 100644 --- a/src/nwp500/mqtt_utils.py +++ b/src/nwp500/mqtt_utils.py @@ -116,17 +116,24 @@ def redact_topic(topic: str) -> str: Note: Uses pre-compiled regex patterns for better performance. """ - # Extra safety: catch any remaining hexadecimal sequences of typical MAC/device length - # (Handles without delimiters, with colons, with hyphens, uppercase/lowercase, etc.) + # Extra safety: catch any remaining hexadecimal sequences of typical + # MAC/device length + # (Handles without delimiters, with colons, with hyphens, + # uppercase/lowercase, etc.) for pattern in _MAC_PATTERNS: topic = pattern.sub("REDACTED", topic) - # Defensive: Generic cleanup for sequences of 12+ hex digits that look like MACs or IDs + # Defensive: Generic cleanup for sequences of 12+ hex digits that look like + # MACs or IDs topic = re.sub( r"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})", "REDACTED", topic ) # 01:23:45:67:89:ab - topic = re.sub(r"([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}", "REDACTED", topic) # 01-23-45-67-89-ab + topic = re.sub( + r"([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}", "REDACTED", topic + ) # 01-23-45-67-89-ab topic = re.sub(r"([0-9A-Fa-f]{12})", "REDACTED", topic) # 0123456789ab - topic = re.sub(r"(navilink-)[0-9A-Fa-f]{8,}", r"\1REDACTED", topic) # navilink-xxxxxxx + topic = re.sub( + r"(navilink-)[0-9A-Fa-f]{8,}", r"\1REDACTED", topic + ) # navilink-xxxxxxx return topic @@ -171,7 +178,9 @@ class MqttConnectionConfig: def __post_init__(self) -> None: """Generate client ID if not provided.""" if not self.client_id: - object.__setattr__(self, "client_id", f"navien-client-{uuid.uuid4().hex[:8]}") + object.__setattr__( + self, "client_id", f"navien-client-{uuid.uuid4().hex[:8]}" + ) @dataclass diff --git a/src/nwp500/utils.py b/src/nwp500/utils.py index 87c546c..22ed9e0 100644 --- a/src/nwp500/utils.py +++ b/src/nwp500/utils.py @@ -21,8 +21,7 @@ def log_performance(func: F) -> F: - """ - Decorator that logs execution time for async functions at DEBUG level. + """Log execution time for async functions at DEBUG level. This decorator measures the execution time of async functions and logs the duration when DEBUG logging is enabled. It's useful for identifying @@ -44,12 +43,16 @@ async def fetch_device_status(device_id: str) -> dict: # When called, logs: "fetch_device_status completed in 0.234s" Note: - - Only logs when DEBUG level is enabled to minimize overhead in production + - Only logs when DEBUG level is enabled to minimize overhead in + production - Uses time.perf_counter() for high-resolution timing - Preserves function metadata (name, docstring, etc.) """ if not asyncio.iscoroutinefunction(func): - raise TypeError(f"@log_performance can only be applied to async functions, got {func}") + raise TypeError( + "@log_performance can only be applied to async " + f"functions, got {func}" + ) @functools.wraps(func) async def wrapper(*args: Any, **kwargs: Any) -> Any: diff --git a/tests/test_command_queue.py b/tests/test_command_queue.py index 77454b4..0f5150e 100644 --- a/tests/test_command_queue.py +++ b/tests/test_command_queue.py @@ -16,7 +16,9 @@ def test_queued_command_dataclass(): qos = mqtt.QoS.AT_LEAST_ONCE timestamp = datetime.utcnow() - command = QueuedCommand(topic=topic, payload=payload, qos=qos, timestamp=timestamp) + command = QueuedCommand( + topic=topic, payload=payload, qos=qos, timestamp=timestamp + ) assert command.topic == topic assert command.payload == payload @@ -34,7 +36,9 @@ def test_mqtt_config_default_queue_settings(): def test_mqtt_config_custom_queue_settings(): """Test custom command queue configuration.""" - config = MqttConnectionConfig(enable_command_queue=False, max_queued_commands=50) + config = MqttConnectionConfig( + enable_command_queue=False, max_queued_commands=50 + ) assert config.enable_command_queue is False assert config.max_queued_commands == 50 diff --git a/tests/test_utils.py b/tests/test_utils.py index 2154284..1986721 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -111,7 +111,9 @@ async def sleep_func(): def test_log_performance_rejects_sync_functions(): """Test that decorator raises TypeError for non-async functions.""" - with pytest.raises(TypeError, match="can only be applied to async functions"): + with pytest.raises( + TypeError, match="can only be applied to async functions" + ): @log_performance def sync_func(): From 4aef740b83bd1903fa090a3d89fca8c4e8f9fe08 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 20 Oct 2025 23:09:02 -0700 Subject: [PATCH 2/6] Potential fix for code scanning alert no. 106: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/nwp500/mqtt_subscriptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nwp500/mqtt_subscriptions.py b/src/nwp500/mqtt_subscriptions.py index 484b851..e7d8426 100644 --- a/src/nwp500/mqtt_subscriptions.py +++ b/src/nwp500/mqtt_subscriptions.py @@ -200,7 +200,7 @@ async def subscribe( subscribe_result = await asyncio.wrap_future(subscribe_future) _logger.info( - f"Subscribed to '{redact_topic(topic)}' with QoS " + f"Subscription succeeded (topic redacted) with QoS " f"{subscribe_result['qos']}" ) From a13ce6adad5bbb08a2a7ea9aae0c5a1f81959b1e Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 20 Oct 2025 23:11:12 -0700 Subject: [PATCH 3/6] Potential fix for code scanning alert no. 107: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/nwp500/mqtt_utils.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/nwp500/mqtt_utils.py b/src/nwp500/mqtt_utils.py index 97bd96f..6827d85 100644 --- a/src/nwp500/mqtt_utils.py +++ b/src/nwp500/mqtt_utils.py @@ -116,24 +116,19 @@ def redact_topic(topic: str) -> str: Note: Uses pre-compiled regex patterns for better performance. """ - # Extra safety: catch any remaining hexadecimal sequences of typical - # MAC/device length - # (Handles without delimiters, with colons, with hyphens, - # uppercase/lowercase, etc.) + # Extra safety: catch any remaining hexadecimal or device-related sequences + # MAC/device length w/ possible delimiters, prefixes, or casing for pattern in _MAC_PATTERNS: topic = pattern.sub("REDACTED", topic) - # Defensive: Generic cleanup for sequences of 12+ hex digits that look like - # MACs or IDs - topic = re.sub( - r"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})", "REDACTED", topic - ) # 01:23:45:67:89:ab - topic = re.sub( - r"([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}", "REDACTED", topic - ) # 01-23-45-67-89-ab - topic = re.sub(r"([0-9A-Fa-f]{12})", "REDACTED", topic) # 0123456789ab - topic = re.sub( - r"(navilink-)[0-9A-Fa-f]{8,}", r"\1REDACTED", topic - ) # navilink-xxxxxxx + # Defensive: Cleanup for most common MAC and device ID patterns + topic = re.sub(r"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})", "REDACTED", topic) # 01:23:45:67:89:ab + topic = re.sub(r"([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}", "REDACTED", topic) # 01-23-45-67-89-ab + topic = re.sub(r"([0-9A-Fa-f]{12})", "REDACTED", topic) # 0123456789ab + topic = re.sub(r"(navilink-)[0-9A-Fa-f]{8,}", r"\1REDACTED", topic) # navilink-xxxxxxx + # Further defensive: catch anything that looks like a device ID (alphanumeric, 8+ chars) + topic = re.sub(r"(device[-_]?)?[0-9A-Fa-f]{8,}", "REDACTED", topic) + # Final fallback: catch any continuous hex/alphanumeric string longer than 8 chars (to cover variant IDs) + topic = re.sub(r"[0-9A-Fa-f]{8,}", "REDACTED", topic) return topic From 36a12b6789aeefdcb6865433de1e6f8d287fb815 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 20 Oct 2025 23:16:33 -0700 Subject: [PATCH 4/6] Update src/nwp500/mqtt_reconnection.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/nwp500/mqtt_reconnection.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/nwp500/mqtt_reconnection.py b/src/nwp500/mqtt_reconnection.py index 1b7a976..e6512f6 100644 --- a/src/nwp500/mqtt_reconnection.py +++ b/src/nwp500/mqtt_reconnection.py @@ -102,9 +102,8 @@ def on_connection_resumed( f"Connection resumed: return_code={return_code}, " f"session_present={session_present}" ) - self._reconnect_attempts = ( - 0 # Reset reconnection attempts on successful connection - ) + # Reset reconnection attempts on successful connection + self._reconnect_attempts = 0 # Cancel any pending reconnection task if self._reconnect_task and not self._reconnect_task.done(): From c0855fc082fc8dcff46a93eef5b14a0752868058 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 20 Oct 2025 23:20:30 -0700 Subject: [PATCH 5/6] Simplify int/Real type checking in encoding.py - Remove redundant int() cast (casting int to int is no-op) - Clarify comments to indicate float vs int handling - Fix unrelated line length issues in mqtt_utils.py --- src/nwp500/encoding.py | 9 ++++----- src/nwp500/mqtt_utils.py | 20 ++++++++++++++------ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 71c8d77..19da3cc 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -421,15 +421,14 @@ def build_tou_period( season_bitfield = encode_season_bitfield(season_months) # Encode prices if they're Real numbers (not already encoded) - # Note: int is a subclass of Real, so we check int first if isinstance(price_min, int): - encoded_min = int(price_min) - else: # isinstance(price_min, Real) + encoded_min = price_min + else: # Real (float) encoded_min = encode_price(price_min, decimal_point) if isinstance(price_max, int): - encoded_max = int(price_max) - else: # isinstance(price_max, Real) + encoded_max = price_max + else: # Real (float) encoded_max = encode_price(price_max, decimal_point) return { diff --git a/src/nwp500/mqtt_utils.py b/src/nwp500/mqtt_utils.py index 6827d85..72fdc45 100644 --- a/src/nwp500/mqtt_utils.py +++ b/src/nwp500/mqtt_utils.py @@ -121,13 +121,21 @@ def redact_topic(topic: str) -> str: for pattern in _MAC_PATTERNS: topic = pattern.sub("REDACTED", topic) # Defensive: Cleanup for most common MAC and device ID patterns - topic = re.sub(r"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})", "REDACTED", topic) # 01:23:45:67:89:ab - topic = re.sub(r"([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}", "REDACTED", topic) # 01-23-45-67-89-ab - topic = re.sub(r"([0-9A-Fa-f]{12})", "REDACTED", topic) # 0123456789ab - topic = re.sub(r"(navilink-)[0-9A-Fa-f]{8,}", r"\1REDACTED", topic) # navilink-xxxxxxx - # Further defensive: catch anything that looks like a device ID (alphanumeric, 8+ chars) + topic = re.sub( + r"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})", "REDACTED", topic + ) # 01:23:45:67:89:ab + topic = re.sub( + r"([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}", "REDACTED", topic + ) # 01-23-45-67-89-ab + topic = re.sub(r"([0-9A-Fa-f]{12})", "REDACTED", topic) # 0123456789ab + topic = re.sub( + r"(navilink-)[0-9A-Fa-f]{8,}", r"\1REDACTED", topic + ) # navilink-xxxxxxx + # Further defensive: catch anything that looks like a device ID + # (alphanumeric, 8+ chars) topic = re.sub(r"(device[-_]?)?[0-9A-Fa-f]{8,}", "REDACTED", topic) - # Final fallback: catch any continuous hex/alphanumeric string longer than 8 chars (to cover variant IDs) + # Final fallback: catch any continuous hex/alphanumeric string + # longer than 8 chars (to cover variant IDs) topic = re.sub(r"[0-9A-Fa-f]{8,}", "REDACTED", topic) return topic From d4dd3ec1dadabfd819713a1f49f4482901842667 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Mon, 20 Oct 2025 23:24:39 -0700 Subject: [PATCH 6/6] Address PR feedback: simplify price encoding logic and remove deprecated method - Simplify price encoding logic in build_tou_period() by checking if NOT int first (avoids redundant int-to-int casting and mypy unreachable code warnings) - Remove deprecated _start_reconnect_task() method from NavienMqttClient (reconnection is now fully handled by MqttReconnectionHandler) --- src/nwp500/encoding.py | 14 +++++++------- src/nwp500/mqtt_client.py | 15 --------------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 19da3cc..24abfb0 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -420,16 +420,16 @@ def build_tou_period( week_bitfield = encode_week_bitfield(week_days) season_bitfield = encode_season_bitfield(season_months) - # Encode prices if they're Real numbers (not already encoded) - if isinstance(price_min, int): - encoded_min = price_min - else: # Real (float) + # Encode prices if they're Real numbers (not already encoded integers) + if not isinstance(price_min, int): encoded_min = encode_price(price_min, decimal_point) + else: + encoded_min = price_min - if isinstance(price_max, int): - encoded_max = price_max - else: # Real (float) + if not isinstance(price_max, int): encoded_max = encode_price(price_max, decimal_point) + else: + encoded_max = price_max return { "season": season_bitfield, diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt_client.py index 4b85050..fa947bc 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt_client.py @@ -262,21 +262,6 @@ async def _send_queued_commands_internal(self) -> None: self._connection_manager.publish, lambda: self._connected ) - async def _start_reconnect_task(self) -> None: - """ - Start the reconnect task within the event loop. - - This is a helper method to create the reconnect task from within - a coroutine that's scheduled via _schedule_coroutine. - """ - if not self._reconnect_task or self._reconnect_task.done(): - # This method is no longer used - reconnection is handled by - # MqttReconnectionHandler - _logger.warning( - "_start_reconnect_task called but reconnection is now " - "handled by MqttReconnectionHandler" - ) - async def connect(self) -> bool: """ Establish connection to AWS IoT Core.