diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index eea8b80..d097d4d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -45,6 +45,15 @@ The version bump script: **Validation**: Run `make validate-version` to check for version-related mistakes before committing. +### Review Comments + +When working on pull requests, use the GitHub CLI to access review comments: +- **List review comments**: `gh pr review-comment list --repo=/` +- **Get PR details with reviews**: `gh pr view --repo=/` +- **Apply review feedback** before final submission + +This ensures you can address all feedback from code reviewers systematically. + ### Before Committing Changes Always run these checks before finalizing changes to ensure your code will pass CI: 1. **Linting**: `make ci-lint` - Ensures code style matches CI requirements diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9853ea..a383012 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.13' - name: Install tox run: | @@ -68,7 +68,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.13' - name: Install build dependencies run: | diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d29d7eb..6fc61f7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,47 @@ Changelog ========= +Version 7.2.0 (2025-12-23) +========================== + +Added +----- + +- **Dynamic Unit Extraction in CLI**: CLI output now dynamically extracts units from DeviceStatus model metadata + - New helper functions: ``_get_unit_suffix()`` and ``_add_numeric_item()`` + - Eliminates hardcoded units in output formatter + - Single source of truth: model metadata drives CLI display + +Fixed +----- + +- **Superheat Temperature Units**: Target and Current SuperHeat now correctly display in °F instead of °C + - Both fields use ``DeciCelsiusToF`` conversion, now properly reflected in CLI output + - Fields were displaying inconsistent units compared to all other temperature readings + +- **Missing CLI Output Units**: Multiple fields now display with proper units from model metadata + - ``current_dhw_flow_rate``: Now shows GPM unit + - ``total_energy_capacity``: Now shows Wh unit + - ``available_energy_capacity``: Now shows Wh unit + - ``dr_override_status``: Now shows hours unit + - ``vacation_day_setting``: Now shows days unit + - ``vacation_day_elapsed``: Now shows days unit + - ``anti_legionella_period``: Fixed to show days unit (was incorrectly h) + - ``wifi_rssi``: Now shows dBm unit + +- **Invalid MQTT Topic Filter**: Fixed ``reservations get`` command subscription topic + - Changed invalid topic pattern ``cmd/52/navilink-+/#`` to valid ``cmd/52/+/#`` + - AWS IoT Core MQTT does not support wildcards within topic segments + - Affected: ``handle_get_reservations_request()`` in commands.py + +Changed +------- + +- **CLI Output Formatter Refactoring**: Restructured ``print_device_status()`` to use dynamic unit extraction + - Reduced code duplication by ~400 lines + - Improved maintainability: field additions automatically get correct units + - No breaking changes to CLI output format or behavior + Version 7.1.0 (2025-12-22) ========================== diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ac99262..200a990 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -85,6 +85,69 @@ The project is organized around a modular architecture: Design principles include separation of concerns, clear public APIs, and extensibility for new device features. Contributors should review the `src/nwp500/` directory for module structure and refer to the documentation for details on each component. +Naming Conventions +------------------ + +Consistent naming conventions improve code readability and maintainability. Follow these patterns when adding new classes, methods, and exceptions. + +**Classes** + +- **Client classes**: Use ``NavienClient`` format for main client classes (e.g., ``NavienAuthClient``, ``NavienAPIClient``, ``NavienMqttClient``). This prefix clearly indicates these are the primary library clients. +- **Manager/Controller classes**: Use ```` format for classes managing specific functionality (e.g., ``MqttConnectionManager``, ``DeviceInfoCache``). Avoid Navien prefix for utility/internal classes. +- **Utility classes**: Use ```` or ``Utilities`` format for helper classes (e.g., ``MqttDiagnostics``, ``DeviceCapabilityChecker``). + +**Methods** + +Follow these patterns for consistent method naming: + +- **Getters**: Use ``get_()`` for single item retrieval (e.g., ``get_device_info()``, ``get_firmware_info()``) +- **Listers**: Use ``list_()`` for collection retrieval (e.g., ``list_devices()``) +- **Setters**: Use ``set_(value)`` for direct assignment or ``configure_()`` for complex configuration (e.g., ``set_power()``, ``set_dhw_temperature()``) +- **Actions**: Use ``_()`` format for operations that modify state (e.g., ``reset_filter()``, ``enable_anti_legionella()``) +- **Requesters**: Use ``request_()`` for async data fetching from devices (e.g., ``request_device_status()``, ``request_device_info()``) + +**Enums** + +- **Enum names**: Use descriptive names that indicate the device state or protocol value (e.g., ``OnOffFlag``, ``CurrentOperationMode``, ``HeatSource``). These names should reflect the Navien protocol directly. +- **Enum values**: Document device protocol mappings in code comments. For example: + + .. code-block:: python + + class OnOffFlag(IntEnum): + """Device on/off state per Navien protocol.""" + OFF = 0 # 0 = False per Navien protocol + ON = 1 # 1 = True per Navien protocol + +**Exceptions** + +Follow a clear exception hierarchy with consistent naming: + +- **Pattern**: ``Error`` (e.g., ``MqttConnectionError``, ``AuthenticationError``, ``DeviceCapabilityError``) +- **Grouping**: Group related errors under a base class that consumers can catch for broad error handling: + + - ``AuthenticationError`` - Base for all authentication failures (covers ``InvalidCredentialsError``, ``TokenExpiredError``, ``TokenRefreshError``) + - ``MqttError`` - Base for all MQTT operations (covers ``MqttConnectionError``, ``MqttNotConnectedError``, ``MqttPublishError``, etc.) + - ``ValidationError`` - Base for all validation failures (covers ``ParameterValidationError``, ``RangeValidationError``) + - ``DeviceError`` - Base for all device operations (covers ``DeviceNotFoundError``, ``DeviceOfflineError``, ``DeviceCapabilityError``, etc.) + +- **Example usage**: + + .. code-block:: python + + try: + await mqtt_client.control.set_temperature(device, 150) + except MqttNotConnectedError: + # Handle specific case: not connected + print("Connect to device first") + except MqttError: + # Handle other MQTT errors + print("MQTT operation failed") + except RangeValidationError as e: + print(f"Invalid {e.field}: must be {e.min_value}-{e.max_value}") + except ValidationError: + # Handle other validation errors + print("Invalid parameter") + Submit an issue --------------- diff --git a/README.rst b/README.rst index d61afc6..e297bfe 100644 --- a/README.rst +++ b/README.rst @@ -62,6 +62,34 @@ Basic Usage # Change operation mode await api_client.set_device_mode(device, "heat_pump") +For more detailed authentication information, see the `Authentication & Session Management `_ guide. + +MQTT Real-Time Monitoring +-------------------------- + +Monitor your device in real-time using MQTT: + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienMqttClient + + async with NavienAuthClient("your_email@example.com", "your_password") as auth_client: + # Create MQTT client + mqtt_client = NavienMqttClient(auth_client=auth_client) + await mqtt_client.connect() + + # Subscribe to device status updates + def on_status(status): + print(f"Temperature: {status.dhw_temperature}°F") + print(f"Mode: {status.operation_mode}") + + device = (await api_client.list_devices())[0] + await mqtt_client.subscribe_device_status(device, on_status) + + # Keep the connection alive + await mqtt_client.wait() + + Command Line Interface ====================== diff --git a/docs/guides/authentication.rst b/docs/guides/authentication.rst new file mode 100644 index 0000000..44a3cf5 --- /dev/null +++ b/docs/guides/authentication.rst @@ -0,0 +1,304 @@ +Authentication and Session Management +===================================== + +This guide explains how authentication works in nwp500-python and how to properly +manage sessions across different clients. + +Quick Start +----------- + +The simplest way to get started: + +.. code-block:: python + + import asyncio + from nwp500 import NavienAuthClient, NavienAPIClient + + async def main(): + # Create auth client and authenticate + async with NavienAuthClient("email@example.com", "password") as auth: + # Create API client using the auth session + api = NavienAPIClient(auth_client=auth) + + # Use the API client + devices = await api.list_devices() + print(f"Found {len(devices)} devices") + + asyncio.run(main()) + +How It Works +------------ + +Authentication Flow +~~~~~~~~~~~~~~~~~~~ + +1. **Create the auth client**: ``NavienAuthClient(email, password)`` + - Stores credentials in memory + - Does NOT authenticate yet + +2. **Enter the context manager**: ``async with auth_client:`` + - Creates an aiohttp session + - Authenticates with Navien API (using stored credentials) + - Tokens are obtained and stored + - Ready to use + +3. **Create other clients**: ``NavienAPIClient(auth_client=auth)`` + - Reuses the same session from auth client + - No need for separate authentication + - Can create multiple clients, all sharing the same session + +4. **Exit the context manager**: ``async with`` block ends + - Session is automatically closed + - All tokens are discarded + - Clients can no longer be used + +Session Management +~~~~~~~~~~~~~~~~~~ + +The auth client manages a single aiohttp session that is shared with all other +clients for efficiency. + +**Session lifecycle:** + +.. code-block:: python + + auth = NavienAuthClient(email, password) + # Session doesn't exist yet! + + async with auth: + # Session created here + api = NavienAPIClient(auth_client=auth) + mqtt = NavienMqttClient(auth_client=auth) + + # Both api and mqtt share the same session + devices = await api.list_devices() + await mqtt.connect() + + # Use clients... + + # Session is closed here! + # api and mqtt can no longer be used + + +Sharing Session Between Clients +-------------------------------- + +All clients (API and MQTT) can share the same session by using the same +auth client: + +.. code-block:: python + + async with NavienAuthClient(email, password) as auth: + # Single auth client for all clients + api = NavienAPIClient(auth_client=auth) + mqtt = NavienMqttClient(auth_client=auth) + + # Same session is used efficiently + devices = await api.list_devices() # Uses shared session + await mqtt.connect() # Uses shared session + + # Devices remain current even when using different clients + status = devices[0].status + + +Token Management +---------------- + +Automatic Token Refresh +~~~~~~~~~~~~~~~~~~~~~~~ + +Tokens are automatically refreshed when they expire: + +.. code-block:: python + + async with NavienAuthClient(email, password) as auth: + # Tokens obtained during __aenter__ + print(auth.current_tokens.access_token) + + # Tokens are automatically refreshed when making API calls + # No manual refresh needed in most cases + devices = await api.list_devices() + + +Checking Token Expiration +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can check if tokens are expired: + +.. code-block:: python + + async with NavienAuthClient(email, password) as auth: + tokens = auth.current_tokens + + # Check JWT token expiration + if tokens.is_expired: + print("JWT token has expired (already refreshed by library)") + + # Check AWS credentials expiration + if tokens.are_aws_credentials_expired: + print("AWS credentials have expired") + + +Restoring Previous Sessions +---------------------------- + +If you saved tokens from a previous session, you can restore them without +requiring another login: + +.. code-block:: python + + import json + from nwp500 import NavienAuthClient, AuthTokens + + # Save tokens from a previous session + async with NavienAuthClient(email, password) as auth: + tokens_data = auth.current_tokens.model_dump(mode="json") + with open("tokens.json", "w") as f: + json.dump(tokens_data, f) + + # Later: Restore from saved tokens + with open("tokens.json") as f: + saved_tokens = AuthTokens.model_validate_json(f.read()) + + # Authenticate using saved tokens (skips login if still valid) + async with NavienAuthClient(email, password, stored_tokens=saved_tokens) as auth: + api = NavienAPIClient(auth_client=auth) + devices = await api.list_devices() + + +Token Storage Best Practices +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Security Considerations:** + +1. **Never hardcode credentials**: Use environment variables or secure vaults +2. **Tokens have expiration**: Store with timestamp to check validity +3. **Refresh tokens are sensitive**: Protect like passwords +4. **Use HTTPS**: Always use secure connections +5. **Rotate tokens regularly**: Don't reuse the same tokens indefinitely + +**Example: Secure storage with expiration check** + +.. code-block:: python + + import json + from datetime import datetime + from nwp500 import NavienAuthClient, AuthTokens + + async def authenticate_with_cache(email: str, password: str, cache_file: str): + """Authenticate, using cached tokens if still valid.""" + + # Try to load cached tokens + try: + with open(cache_file) as f: + data = json.load(f) + cached_tokens = AuthTokens.model_validate(data["tokens"]) + cached_time = datetime.fromisoformat(data["cached_at"]) + + # Use cached tokens if less than 1 hour old + if (datetime.now() - cached_time).total_seconds() < 3600: + return NavienAuthClient( + email, + password, + stored_tokens=cached_tokens + ) + except (FileNotFoundError, json.JSONDecodeError, ValueError): + pass + + # Create new auth (triggers fresh login) + return NavienAuthClient(email, password) + + +Advanced: Custom Session +------------------------ + +If you need to use a custom aiohttp session: + +.. code-block:: python + + import aiohttp + from nwp500 import NavienAuthClient, NavienAPIClient + + # Create custom session with specific configuration + connector = aiohttp.TCPConnector(limit_per_host=5) + custom_session = aiohttp.ClientSession(connector=connector) + + try: + # Pass custom session to auth client + auth = NavienAuthClient( + email, + password, + session=custom_session + ) + + async with auth: + api = NavienAPIClient(auth_client=auth) + devices = await api.list_devices() + finally: + # Session management is YOUR responsibility + await custom_session.close() + + +Troubleshooting +--------------- + +"Session is closed" Error +~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Problem**: You get "Session is closed" when trying to use clients + +**Cause**: Exited the auth context manager before using clients + +**Solution**: Keep using clients inside the ``async with`` block + +.. code-block:: python + + # ❌ WRONG + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth_client=auth) + + # Session is closed here! + devices = await api.list_devices() # Error! + + # ✅ CORRECT + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth_client=auth) + devices = await api.list_devices() # Works! + + +"Authentication Failed" Error +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Problem**: Invalid credentials error during authentication + +**Cause**: Wrong email or password + +**Solution**: Check credentials and verify account exists + +.. code-block:: python + + from nwp500 import InvalidCredentialsError + + try: + async with NavienAuthClient(email, password) as auth: + ... + except InvalidCredentialsError: + print("Email or password is incorrect") + + +Token Refresh Failures +~~~~~~~~~~~~~~~~~~~~~~ + +**Problem**: Tokens can't be refreshed + +**Cause**: Refresh token expired or revoked + +**Solution**: Perform a fresh login (don't use stored_tokens) + +.. code-block:: python + + # Don't use stored tokens if refresh is failing + auth = NavienAuthClient(email, password) # No stored_tokens + async with auth: + # Fresh login will be performed + ... diff --git a/docs/guides/event_system.rst b/docs/guides/event_system.rst index a523a16..0a1a421 100644 --- a/docs/guides/event_system.rst +++ b/docs/guides/event_system.rst @@ -36,12 +36,39 @@ Benefits Basic Usage =========== +Discovering Available Events +----------------------------- + +The :class:`nwp500.mqtt_events.MqttClientEvents` class provides a complete registry +of all events with type-safe constants and full documentation: + +.. code-block:: python + + from nwp500 import MqttClientEvents + + # List all available events + for event_name in MqttClientEvents.get_all_events(): + print(f"- {event_name}") + + # Output: + # - CONNECTION_INTERRUPTED + # - CONNECTION_RESUMED + # - STATUS_RECEIVED + # - TEMPERATURE_CHANGED + # - MODE_CHANGED + # - POWER_CHANGED + # - HEATING_STARTED + # - HEATING_STOPPED + # - ERROR_DETECTED + # - ERROR_CLEARED + # - FEATURE_RECEIVED + Simple Event Handler -------------------- .. code-block:: python - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient, MqttClientEvents import asyncio async def main(): @@ -52,13 +79,13 @@ Simple Event Handler mqtt = NavienMqttClient(auth) await mqtt.connect() - # Define event handler + # Use type-safe event constants with IDE autocomplete def on_status_update(status): print(f"Temperature: {status.dhw_temperature}°F") print(f"Power: {status.current_inst_power}W") - # Subscribe to status updates - await mqtt.subscribe_device_status(device, on_status_update) + # Subscribe using event constants + mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_update) await mqtt.control.request_device_status(device) # Monitor for 5 minutes @@ -67,6 +94,37 @@ Simple Event Handler asyncio.run(main()) +Event Registry +-------------- + +The :class:`nwp500.mqtt_events.MqttClientEvents` class provides type-safe event +constants and programmatic discovery. This ensures your callbacks use valid event +names and enables IDE autocomplete: + +.. code-block:: python + + from nwp500 import MqttClientEvents, NavienMqttClient + + mqtt_client = NavienMqttClient(auth) + + # Type-safe constants with IDE autocomplete + mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, on_temp_change) + mqtt_client.on(MqttClientEvents.HEATING_STARTED, on_heating_start) + mqtt_client.on(MqttClientEvents.ERROR_DETECTED, on_error) + + # Programmatically discover all events + print("Available events:") + for event_name in MqttClientEvents.get_all_events(): + print(f" - {event_name}") + + # Get event string value if needed + event_value = MqttClientEvents.get_event_value("TEMPERATURE_CHANGED") + print(f"Event value: {event_value}") # Output: "temperature_changed" + +Each event has full type documentation. See +:class:`nwp500.mqtt_events` for complete details on event data types and +their arguments. + Advanced Patterns ================= diff --git a/docs/index.rst b/docs/index.rst index d6a5164..b64bfb2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -133,6 +133,7 @@ Documentation Index :maxdepth: 2 :caption: Advanced: Protocol Reference + protocol/quick_reference protocol/rest_api protocol/mqtt_protocol protocol/device_status diff --git a/docs/protocol/device_features.rst b/docs/protocol/device_features.rst index 6a2721e..842cde4 100644 --- a/docs/protocol/device_features.rst +++ b/docs/protocol/device_features.rst @@ -31,7 +31,7 @@ The DeviceFeature data contains comprehensive device capabilities, configuration * - ``countryCode`` - int - None - - Country/region code where device is certified for operation (1=USA, complies with FCC Part 15 Class B, NSF/ANSI 372) + - Country/region code where device is certified for operation. Device-specific code defined by Navien; earlier documentation referenced code 1, but current USA devices report code 3 - None * - ``modelTypeCode`` - int diff --git a/docs/protocol/quick_reference.rst b/docs/protocol/quick_reference.rst new file mode 100644 index 0000000..f83385e --- /dev/null +++ b/docs/protocol/quick_reference.rst @@ -0,0 +1,161 @@ +Protocol Quick Reference +======================== + +This document serves as a "cheat sheet" for developers working with the Navien +device protocol. It documents the non-standard boolean logic, key enumerations, +and common command codes used throughout the system. + +Boolean Values +-------------- + +The device uses non-standard boolean encoding in many status fields: + +.. list-table:: + :header-rows: 1 + :widths: 10 20 70 + + * - Value + - Meaning + - Notes + * - **1** + - OFF / False + - Standard: False value. Used for power, TOU status, and most feature flags. + * - **2** + - ON / True + - Standard: True value. + +**Why 1 & 2?** +This likely stems from legacy firmware design where: + +* 0 = reserved/error/null +* 1 = off/false/disabled +* 2 = on/true/enabled + +**Example: Device Power State** + +.. code-block:: json + + { + "power": 2 // Device is ON + } + +When parsed via ``DeviceStatus``, this becomes ``status.power == True``. + +Key Enum Values +--------------- + +CurrentOperationMode +^^^^^^^^^^^^^^^^^^^^ + +Used in real-time status to show what the device is currently doing. + +.. list-table:: + :header-rows: 1 + :widths: 10 20 70 + + * - Value + - Mode + - Description + * - **0** + - Standby + - Device is idle (not heating). Visible as "Idle". + * - **32** + - Heat Pump + - Compressor is active. Visible as "Heating (HP)". + * - **64** + - Energy Saver + - Hybrid efficiency mode active. Visible as "Heating (Eff)". + * - **96** + - High Demand + - Hybrid boost mode active. Visible as "Heating (Boost)". + +.. note:: + These are actual status values, not sequential. Gaps are reserved or correspond + to error states. + +DhwOperationSetting +^^^^^^^^^^^^^^^^^^^ + +User-selected heating mode preference. + +.. list-table:: + :header-rows: 1 + :widths: 10 20 70 + + * - Value + - Mode + - Description + * - **1** + - Heat Pump Only + - High efficiency, slow recovery. + * - **2** + - Electric Only + - Low efficiency, fast recovery. + * - **3** + - Energy Saver + - **Default.** Balanced hybrid mode. + * - **4** + - High Demand + - Hybrid boost for faster recovery. + * - **5** + - Vacation + - Heating suspended to save energy. + * - **6** + - Power Off + - Device is logically powered off. + +MQTT Topics +----------- + +Control Topic +^^^^^^^^^^^^^ + +``cmd/RTU50E-H/{deviceId}/ctrl`` + +Sends JSON commands to the device. + +Status Topic +^^^^^^^^^^^^ + +``cmd/RTU50E-H/{deviceId}/st`` + +Receives JSON status updates from the device. + +Message Format +-------------- + +All MQTT payloads are JSON-formatted strings: + +.. code-block:: json + + { + "header": { + "msg_id": "1", + "cloud_msg_type": "0x1" + }, + "body": { + // Message-specific fields + } + } + +Common Command Codes +-------------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 30 50 + + * - Code + - Command + - Body Fields + * - **0x11** + - Set DHW Temperature + - ``dhwSetTempH``, ``dhwSetTempL`` + * - **0x21** + - Set Operation Mode + - ``dhwOperationSetting`` + * - **0x31** + - Set Power + - ``power`` + +See :doc:`mqtt_protocol` for full command details. diff --git a/examples/README.md b/examples/README.md index 2963399..bce19e1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,158 +1,96 @@ -# Examples +# Examples Guide -This directory contains example scripts demonstrating how to use the nwp500-python library. +This directory contains categorized examples to help you get started with `nwp500-python`. -## Prerequisites +## Setup -Install the library in development mode: - -```bash -cd .. -pip install -e . -``` - -Or install the required dependencies: - -```bash -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. - -### Setting Credentials - -Set your credentials as environment variables: +Before running any example, ensure you have set your credentials: ```bash export NAVIEN_EMAIL='your_email@example.com' export NAVIEN_PASSWORD='your_password' ``` -## Example Files - -### Authentication Example - -`authenticate.py` - Demonstrates basic authentication with the Navien Smart Control API. - -**Usage:** -```bash -python authenticate.py -``` - -**What It Does:** -1. Authenticates with the Navien Smart Control API (automatically) -2. Displays user information (name, status, type) -3. Shows token information (access token, refresh token, expiration) -4. Demonstrates how to use tokens in API requests -5. Shows AWS credentials if available for IoT/MQTT connections - -### API Client Examples +If using the `nwp500` library from the source code (this repository), most examples are configured to find the `src` package automatically. -- `auth_constructor_example.py` - Shows the simplified authentication pattern -- `improved_auth_pattern.py` - Demonstrates the clean pattern for API and MQTT usage -- `test_api_client.py` - Comprehensive API client testing +## Directory Structure -### MQTT Examples +* `beginner/`: Essential scripts for basic operations. Start here. +* `intermediate/`: Common use-cases like real-time monitoring and event handling. +* `advanced/`: Specialized features like schedules, energy analytics, and deep diagnostics. +* `testing/`: Scripts for testing connections and API behavior. -- `combined_callbacks.py` - Device status and feature monitoring -- `device_status_callback.py` - Real-time device status updates -- `device_feature_callback.py` - Device feature monitoring -- `mqtt_client_example.py` - Basic MQTT client usage -- `test_mqtt_connection.py` - MQTT connection testing -- `test_mqtt_messaging.py` - MQTT message handling +## Beginner Examples -### Device Control Examples +Run these first to understand basic concepts. -- `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 +### 01 - Authentication +`beginner/01_authentication.py` -### Time of Use (TOU) Examples +Learn how to authenticate with Navien cloud and inspect tokens. -- `tou_schedule_example.py` - Manually configure TOU pricing schedule -- `tou_openei_example.py` - Retrieve TOU schedule from OpenEI API and configure device +**Requirements:** NAVIEN_EMAIL, NAVIEN_PASSWORD env vars +**Time:** 5 minutes +**Next:** `02_list_devices.py` -**TOU OpenEI Example Usage:** +### 02 - List Devices +`beginner/02_list_devices.py` -This example fetches real utility rate data from the OpenEI API and configures it on your device: +Connect to the API and list your registered devices with their basic info. -```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 +**Requirements:** Authenticated account +**Time:** 3 minutes +**Next:** `03_get_status.py` -python tou_openei_example.py -``` +### 03 - Get Status +`beginner/03_get_status.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 +Retrieve the real-time status (temperatures, flow rates) of a device. -**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 +**Next:** `04_set_temperature.py` -### Scheduling Examples +### 04 - Set Temperature +`beginner/04_set_temperature.py` -- `reservation_schedule_example.py` - Configure heating reservations/schedules +Simple control example: Setting the DHW target temperature. -### Energy Monitoring Examples +## Intermediate Examples -- `energy_usage_example.py` - Monitor real-time energy consumption +Explore more complex interactions. -### Common Pattern +* **`mqtt_realtime_monitoring.py`**: Subscribe to MQTT topics for real-time updates. +* **`event_driven_control.py`**: React to events (like water usage) to trigger actions. +* **`error_handling.py`**: Robust error handling patterns for production code. +* **`periodic_requests.py`**: How to poll for data without overwhelming the API. +* **`set_mode.py`**: Change device operation modes. +* **`vacation_mode.py`**: Enable/Disable vacation mode programmatically. +* **`command_queue.py`**: Using the command queue for reliable control. +* **`improved_auth.py`**: Advanced authentication patterns. -All examples follow this pattern: +## Advanced Examples -```python -import asyncio -import os -from nwp500 import NavienAuthClient, NavienAPIClient +Deep dive into specific features. -async def main(): - email = os.getenv("NAVIEN_EMAIL") - password = os.getenv("NAVIEN_PASSWORD") - - # Authentication happens automatically - async with NavienAuthClient(email, password) as auth_client: - # Use the authenticated client - api_client = NavienAPIClient(auth_client=auth_client) - devices = await api_client.list_devices() - print(f"Found {len(devices)} device(s)") - -asyncio.run(main()) -``` - -## Expected Output - -When running any example with valid credentials, you should see output similar to: - -``` -[SUCCESS] Authenticated as: John Doe -📧 Email: your_email@example.com -🔑 Token expires at: 2024-01-15 14:30:00 -``` +* **`device_capabilities.py`**: Inspect detailed device capabilities and flags. +* **`mqtt_diagnostics.py`**: Low-level MQTT diagnostic tools. +* **`auto_recovery.py`**: Implementing auto-reconnection and state recovery. +* **`energy_analytics.py`**: Analyze energy usage reports. +* **`tou_schedule.py`**: Configure Time-of-Use schedules. +* **`tou_openei.py`**: Integrate with OpenEI for utility rates. +* **`reservation_schedule.py`**: Manage heating reservation schedules. +* **`power_control.py`**: Turn device on/off. +* **`recirculation_control.py`**: Manage recirculation pump settings. +* **`demand_response.py`**: Handling utility demand response signals. +* **`token_restoration.py`**: Recovering sessions from saved tokens. -## Troubleshooting +## Testing -**Error: name 'auth_response' is not defined** -- This means an example file hasn't been updated. Use `auth_client.current_user` and `auth_client.current_tokens` instead. +Utilities for verifying your environment and library function. -**Error: NavienAuthClient() missing 2 required positional arguments** -- Credentials are now required. Pass email and password to the constructor. +* **`test_api_client.py`**: Verify API connectivity and response parsing. +* **`test_mqtt_connection.py`**: Verify MQTT broker connectivity. +* **`test_mqtt_messaging.py`**: Test messaging reliability. +* **`periodic_device_info.py`**: Debug tool for periodic polling. -**Authentication fails** -- Verify your NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables are set correctly -- Check that your credentials are valid -- Ensure internet connectivity +--- +**Note:** Some examples might require specific device models to function fully (e.g., recirculation control). \ No newline at end of file diff --git a/examples/air_filter_reset_example.py b/examples/advanced/air_filter_reset.py similarity index 100% rename from examples/air_filter_reset_example.py rename to examples/advanced/air_filter_reset.py diff --git a/examples/anti_legionella_example.py b/examples/advanced/anti_legionella.py similarity index 100% rename from examples/anti_legionella_example.py rename to examples/advanced/anti_legionella.py diff --git a/examples/auto_recovery_example.py b/examples/advanced/auto_recovery.py similarity index 99% rename from examples/auto_recovery_example.py rename to examples/advanced/auto_recovery.py index 5cb2c43..c2f1e39 100644 --- a/examples/auto_recovery_example.py +++ b/examples/advanced/auto_recovery.py @@ -17,10 +17,11 @@ import os import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient -from nwp500.mqtt_client import MqttConnectionConfig +from nwp500.mqtt import MqttConnectionConfig # Configure logging logging.basicConfig( diff --git a/examples/combined_callbacks.py b/examples/advanced/combined_callbacks.py similarity index 97% rename from examples/combined_callbacks.py rename to examples/advanced/combined_callbacks.py index 16762a8..259731b 100644 --- a/examples/combined_callbacks.py +++ b/examples/advanced/combined_callbacks.py @@ -26,13 +26,14 @@ # If running from examples directory, add parent to path if __name__ == "__main__": - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500.api_client import NavienAPIClient from nwp500.auth import NavienAuthClient from nwp500.enums import OnOffFlag from nwp500.models import DeviceFeature, DeviceStatus -from nwp500.mqtt_client import NavienMqttClient +from nwp500.mqtt import NavienMqttClient async def main(): diff --git a/examples/demand_response_example.py b/examples/advanced/demand_response.py similarity index 100% rename from examples/demand_response_example.py rename to examples/advanced/demand_response.py diff --git a/examples/device_feature_callback.py b/examples/advanced/device_capabilities.py similarity index 98% rename from examples/device_feature_callback.py rename to examples/advanced/device_capabilities.py index 0ec3ab4..e7c88e8 100644 --- a/examples/device_feature_callback.py +++ b/examples/advanced/device_capabilities.py @@ -28,16 +28,17 @@ # If running from examples directory, add parent to path if __name__ == "__main__": - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500.api_client import NavienAPIClient from nwp500.auth import NavienAuthClient from nwp500.exceptions import AuthenticationError from nwp500.models import DeviceFeature -from nwp500.mqtt_client import NavienMqttClient +from nwp500.mqtt import NavienMqttClient try: - from examples.mask import mask_mac # type: ignore + from mask import mask_mac # type: ignore except Exception: def mask_mac(mac): # pragma: no cover - fallback diff --git a/examples/device_status_callback_debug.py b/examples/advanced/device_status_debug.py similarity index 96% rename from examples/device_status_callback_debug.py rename to examples/advanced/device_status_debug.py index 0ab72b0..6ff6012 100644 --- a/examples/device_status_callback_debug.py +++ b/examples/advanced/device_status_debug.py @@ -20,16 +20,17 @@ # If running from examples directory, add parent to path if __name__ == "__main__": - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500.api_client import NavienAPIClient from nwp500.auth import NavienAuthClient from nwp500.exceptions import AuthenticationError from nwp500.models import DeviceStatus -from nwp500.mqtt_client import NavienMqttClient +from nwp500.mqtt import NavienMqttClient try: - from examples.mask import mask_mac # type: ignore + from mask import mask_mac # type: ignore except Exception: def mask_mac(mac): # pragma: no cover - fallback @@ -77,7 +78,7 @@ async def main(): device_type = device.device_info.device_type try: - from examples.mask import mask_any # type: ignore + from mask import mask_any # type: ignore except Exception: def mask_any(_): diff --git a/examples/energy_usage_example.py b/examples/advanced/energy_analytics.py similarity index 100% rename from examples/energy_usage_example.py rename to examples/advanced/energy_analytics.py diff --git a/examples/error_code_demo.py b/examples/advanced/error_code_demo.py similarity index 100% rename from examples/error_code_demo.py rename to examples/advanced/error_code_demo.py diff --git a/examples/mqtt_diagnostics_example.py b/examples/advanced/mqtt_diagnostics.py similarity index 99% rename from examples/mqtt_diagnostics_example.py rename to examples/advanced/mqtt_diagnostics.py index 83106b1..81da334 100755 --- a/examples/mqtt_diagnostics_example.py +++ b/examples/advanced/mqtt_diagnostics.py @@ -27,7 +27,7 @@ from pathlib import Path from nwp500 import NavienAuthClient, NavienMqttClient -from nwp500.mqtt_diagnostics import MqttDiagnosticsCollector +from nwp500.mqtt import MqttDiagnosticsCollector from nwp500.mqtt_utils import MqttConnectionConfig # Configure logging to show detailed MQTT information diff --git a/examples/power_control_example.py b/examples/advanced/power_control.py similarity index 100% rename from examples/power_control_example.py rename to examples/advanced/power_control.py diff --git a/examples/recirculation_control_example.py b/examples/advanced/recirculation_control.py similarity index 100% rename from examples/recirculation_control_example.py rename to examples/advanced/recirculation_control.py diff --git a/examples/reconnection_demo.py b/examples/advanced/reconnection_demo.py similarity index 97% rename from examples/reconnection_demo.py rename to examples/advanced/reconnection_demo.py index d8e94f9..7eecdfe 100644 --- a/examples/reconnection_demo.py +++ b/examples/advanced/reconnection_demo.py @@ -17,10 +17,11 @@ import os import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient -from nwp500.mqtt_client import MqttConnectionConfig +from nwp500.mqtt import MqttConnectionConfig async def main(): diff --git a/examples/reservation_schedule_example.py b/examples/advanced/reservation_schedule.py similarity index 100% rename from examples/reservation_schedule_example.py rename to examples/advanced/reservation_schedule.py diff --git a/examples/simple_auto_recovery.py b/examples/advanced/simple_auto_recovery.py similarity index 98% rename from examples/simple_auto_recovery.py rename to examples/advanced/simple_auto_recovery.py index 698cf1e..86ad0cc 100644 --- a/examples/simple_auto_recovery.py +++ b/examples/advanced/simple_auto_recovery.py @@ -20,10 +20,11 @@ import os import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient -from nwp500.mqtt_client import MqttConnectionConfig +from nwp500.mqtt import MqttConnectionConfig # Configure logging logging.basicConfig( diff --git a/examples/token_restoration_example.py b/examples/advanced/token_restoration.py similarity index 100% rename from examples/token_restoration_example.py rename to examples/advanced/token_restoration.py diff --git a/examples/tou_openei_example.py b/examples/advanced/tou_openei.py similarity index 100% rename from examples/tou_openei_example.py rename to examples/advanced/tou_openei.py diff --git a/examples/tou_schedule_example.py b/examples/advanced/tou_schedule.py similarity index 100% rename from examples/tou_schedule_example.py rename to examples/advanced/tou_schedule.py diff --git a/examples/water_program_reservation_example.py b/examples/advanced/water_reservation.py similarity index 100% rename from examples/water_program_reservation_example.py rename to examples/advanced/water_reservation.py diff --git a/examples/authenticate.py b/examples/beginner/01_authentication.py similarity index 97% rename from examples/authenticate.py rename to examples/beginner/01_authentication.py index d2e9bcf..ee2762d 100755 --- a/examples/authenticate.py +++ b/examples/beginner/01_authentication.py @@ -19,7 +19,8 @@ # If running from examples directory, add parent to path if __name__ == "__main__": - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500.auth import NavienAuthClient from nwp500.exceptions import ( diff --git a/examples/api_client_example.py b/examples/beginner/02_list_devices.py similarity index 97% rename from examples/api_client_example.py rename to examples/beginner/02_list_devices.py index 4b51945..e99db24 100644 --- a/examples/api_client_example.py +++ b/examples/beginner/02_list_devices.py @@ -19,7 +19,8 @@ # If running from examples directory, add parent to path if __name__ == "__main__": - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import NavienAPIClient from nwp500.auth import NavienAuthClient @@ -70,7 +71,7 @@ async def example_basic_usage(): # Display device information try: - from examples.mask import mask_mac # type: ignore + from mask import mask_mac # type: ignore except Exception: # fallback helper if import fails when running examples directly @@ -79,7 +80,7 @@ def mask_mac(mac: str) -> str: # pragma: no cover - small fallback return "[REDACTED_MAC]" try: - from examples.mask import mask_any, mask_location # type: ignore + from mask import mask_any, mask_location # type: ignore except Exception: def mask_any(_): @@ -197,7 +198,7 @@ async def example_convenience_function(): print(f"[SUCCESS] Found {len(devices)} device(s):\n") try: - from examples.mask import mask_any, mask_location # type: ignore + from mask import mask_any, mask_location # type: ignore except Exception: def mask_any(_): diff --git a/examples/simple_periodic_status.py b/examples/beginner/03_get_status.py similarity index 95% rename from examples/simple_periodic_status.py rename to examples/beginner/03_get_status.py index 3e0a7cf..2b7cb16 100755 --- a/examples/simple_periodic_status.py +++ b/examples/beginner/03_get_status.py @@ -9,7 +9,8 @@ import os import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import ( DeviceStatus, diff --git a/examples/set_dhw_temperature_example.py b/examples/beginner/04_set_temperature.py similarity index 100% rename from examples/set_dhw_temperature_example.py rename to examples/beginner/04_set_temperature.py diff --git a/examples/intermediate/advanced_auth_patterns.py b/examples/intermediate/advanced_auth_patterns.py new file mode 100644 index 0000000..f042b51 --- /dev/null +++ b/examples/intermediate/advanced_auth_patterns.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Example: Complete Authentication and Client Setup Pattern + +This example demonstrates the recommended pattern for: +1. Creating an authenticated auth client +2. Sharing the session with API and MQTT clients +3. Properly managing the session lifecycle +""" + +import asyncio +import os + +from nwp500 import ( + NavienAPIClient, + NavienAuthClient, + NavienMqttClient, +) + + +async def example_basic_pattern(): + """Demonstrate the basic authentication pattern.""" + print("=" * 60) + print("Basic Authentication Pattern") + print("=" * 60) + + 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 + + # Step 1: Create and enter the auth context + # Authentication happens automatically here + async with NavienAuthClient(email, password) as auth_client: + print(f"✓ Authenticated as: {auth_client.user_email}") + print("✓ Session active (will close after this block)") + + # Step 2: Create API client sharing the same session + api_client = NavienAPIClient(auth_client=auth_client) + print("✓ API client created (using shared session)") + + # Step 3: Use the API client + devices = await api_client.list_devices() + print(f"✓ Found {len(devices)} device(s)") + + if devices: + device = devices[0] + print(f" Device: {device.device_info.device_name}") + print(f" Temperature: {device.status.dhw_temperature}°F") + + print("✓ Context exited, session closed") + + +async def example_with_mqtt(): + """Demonstrate sharing session between API and MQTT clients.""" + print("\n" + "=" * 60) + print("Multi-Client Pattern (API + MQTT)") + print("=" * 60) + + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + + if not email or not password: + print("Skipping - credentials not set") + return + + async with NavienAuthClient(email, password) as auth_client: + print(f"✓ Authenticated: {auth_client.user_email}") + + # Both clients share the same session + api_client = NavienAPIClient(auth_client=auth_client) + mqtt_client = NavienMqttClient(auth_client=auth_client) + print("✓ Created API and MQTT clients (shared session)") + + # Get device + devices = await api_client.list_devices() + if not devices: + print("No devices found") + return + + device = devices[0] + print(f"✓ Device: {device.device_info.device_name}") + + # Connect MQTT for real-time updates + try: + await mqtt_client.connect() + print("✓ MQTT Connected") + + # Subscribe to status updates + def on_status(status): + print( + f" 📊 Status: Temp={status.dhw_temperature}°F, " + f"Mode={status.operation_mode}, " + f"Power={status.current_inst_power}W" + ) + + await mqtt_client.subscribe_device_status(device, on_status) + + # Request initial status + await mqtt_client.control.request_device_status(device) + + # Wait for a moment to receive updates + await asyncio.sleep(3) + + await mqtt_client.disconnect() + print("✓ MQTT Disconnected") + + except Exception as e: + print(f"✗ MQTT error: {e}") + + print("✓ Context exited, session closed") + + +async def example_explicit_initialization(): + """ + Demonstrate explicit initialization steps. + + This shows exactly what happens at each step for clarity. + """ + print("\n" + "=" * 60) + print("Explicit Initialization Steps") + print("=" * 60) + + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + + if not email or not password: + print("Skipping - credentials not set") + return + + # Step 1: Create auth client (doesn't authenticate yet) + print("Step 1: Create auth client (no session yet)") + auth_client = NavienAuthClient(email, password) + print(" ✓ Auth client created") + print(f" - Email: {auth_client._user_email or 'not set'}") + print(f" - Session exists: {auth_client._session is not None}") + + # Step 2: Enter context manager (creates session and authenticates) + print("\nStep 2: Enter context manager (creates session, authenticates)") + await auth_client.__aenter__() + print(" ✓ Session created") + print(f" - Email: {auth_client.user_email}") + print(f" - Session exists: {auth_client._session is not None}") + print(f" - Tokens available: {auth_client.current_tokens is not None}") + + # Step 3: Create other clients + print("\nStep 3: Create API and MQTT clients (share session)") + api_client = NavienAPIClient(auth_client=auth_client) + _mqtt_client = NavienMqttClient(auth_client=auth_client) + print(" ✓ Clients created") + + # Step 4: Use clients + print("\nStep 4: Use clients (session is active)") + devices = await api_client.list_devices() + print(f" ✓ API call succeeded: {len(devices)} device(s) found") + + # Step 5: Exit context manager (closes session) + print("\nStep 5: Exit context manager (closes session)") + await auth_client.__aexit__(None, None, None) + print(" ✓ Session closed") + print(f" - Session exists: {auth_client._session is not None}") + + print("\nNote: Clients can no longer be used after context exits") + + +async def main(): + """Run all examples.""" + try: + await example_basic_pattern() + await example_with_mqtt() + await example_explicit_initialization() + except KeyboardInterrupt: + print("\n⚠ Interrupted by user") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/command_queue_demo.py b/examples/intermediate/command_queue.py similarity index 99% rename from examples/command_queue_demo.py rename to examples/intermediate/command_queue.py index 65bb1d6..0f7fb3c 100644 --- a/examples/command_queue_demo.py +++ b/examples/intermediate/command_queue.py @@ -26,7 +26,7 @@ ) from nwp500.auth import NavienAuthClient -from nwp500.mqtt_client import MqttConnectionConfig, NavienMqttClient +from nwp500.mqtt import MqttConnectionConfig, NavienMqttClient async def command_queue_demo(): diff --git a/examples/device_status_callback.py b/examples/intermediate/device_status_callback.py similarity index 98% rename from examples/device_status_callback.py rename to examples/intermediate/device_status_callback.py index 1c1ace9..289baf0 100755 --- a/examples/device_status_callback.py +++ b/examples/intermediate/device_status_callback.py @@ -31,16 +31,17 @@ # If running from examples directory, add parent to path if __name__ == "__main__": - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500.api_client import NavienAPIClient from nwp500.auth import NavienAuthClient from nwp500.exceptions import AuthenticationError from nwp500.models import DeviceStatus -from nwp500.mqtt_client import NavienMqttClient +from nwp500.mqtt import NavienMqttClient try: - from examples.mask import mask_mac, mask_mac_in_topic # type: ignore + from mask import mask_mac, mask_mac_in_topic # type: ignore except Exception: def mask_mac(mac): # pragma: no cover - fallback diff --git a/examples/exception_handling_example.py b/examples/intermediate/error_handling.py similarity index 99% rename from examples/exception_handling_example.py rename to examples/intermediate/error_handling.py index 499b935..4dfcd73 100755 --- a/examples/exception_handling_example.py +++ b/examples/intermediate/error_handling.py @@ -28,7 +28,8 @@ # If running from examples directory, add parent to path if __name__ == "__main__": - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import NavienAPIClient, NavienMqttClient from nwp500.auth import NavienAuthClient diff --git a/examples/event_emitter_demo.py b/examples/intermediate/event_driven_control.py similarity index 78% rename from examples/event_emitter_demo.py rename to examples/intermediate/event_driven_control.py index 4d3a29a..2adb879 100644 --- a/examples/event_emitter_demo.py +++ b/examples/intermediate/event_driven_control.py @@ -32,6 +32,7 @@ NavienAPIClient, NavienAuthClient, NavienMqttClient, + MqttClientEvents, CurrentOperationMode, ) from nwp500.models import DeviceStatus @@ -156,36 +157,39 @@ async def main(): # Step 3: Register event listeners BEFORE connecting print("3. Registering event listeners...") + print(" (Using MqttClientEvents for type-safe event constants)") # Temperature change - multiple handlers - mqtt_client.on("temperature_changed", log_temperature) - mqtt_client.on("temperature_changed", alert_on_high_temp) - mqtt_client.on("temperature_changed", save_temperature_to_db) + mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, log_temperature) + mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, alert_on_high_temp) + mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, save_temperature_to_db) print(" [SUCCESS] Registered 3 temperature change handlers") # Mode change - multiple handlers - mqtt_client.on("mode_changed", log_mode_change) - mqtt_client.on("mode_changed", optimize_on_mode_change) + mqtt_client.on(MqttClientEvents.MODE_CHANGED, log_mode_change) + mqtt_client.on(MqttClientEvents.MODE_CHANGED, optimize_on_mode_change) print(" [SUCCESS] Registered 2 mode change handlers") # Power state changes - mqtt_client.on("heating_started", on_heating_started) - mqtt_client.on("heating_stopped", on_heating_stopped) + mqtt_client.on(MqttClientEvents.HEATING_STARTED, on_heating_started) + mqtt_client.on(MqttClientEvents.HEATING_STOPPED, on_heating_stopped) print(" [SUCCESS] Registered heating start/stop handlers") # Error handling - mqtt_client.on("error_detected", on_error_detected) - mqtt_client.on("error_cleared", on_error_cleared) + mqtt_client.on(MqttClientEvents.ERROR_DETECTED, on_error_detected) + mqtt_client.on(MqttClientEvents.ERROR_CLEARED, on_error_cleared) print(" [SUCCESS] Registered error handlers") # Connection state - mqtt_client.on("connection_interrupted", on_connection_interrupted) - mqtt_client.on("connection_resumed", on_connection_resumed) + mqtt_client.on( + MqttClientEvents.CONNECTION_INTERRUPTED, on_connection_interrupted + ) + mqtt_client.on(MqttClientEvents.CONNECTION_RESUMED, on_connection_resumed) print(" [SUCCESS] Registered connection handlers") # One-time listener example mqtt_client.once( - "status_received", + MqttClientEvents.STATUS_RECEIVED, lambda s: print(f" 🎉 First status received: {s.dhw_temperature}°F"), ) print(" [SUCCESS] Registered one-time status handler") @@ -194,16 +198,20 @@ async def main(): # Show listener counts print("4. Listener statistics:") print( - f" temperature_changed: {mqtt_client.listener_count('temperature_changed')} listeners" + f" {MqttClientEvents.TEMPERATURE_CHANGED}: {mqtt_client.listener_count(MqttClientEvents.TEMPERATURE_CHANGED)} listeners" ) print( - f" mode_changed: {mqtt_client.listener_count('mode_changed')} listeners" + f" {MqttClientEvents.MODE_CHANGED}: {mqtt_client.listener_count(MqttClientEvents.MODE_CHANGED)} listeners" ) print( - f" heating_started: {mqtt_client.listener_count('heating_started')} listeners" + f" {MqttClientEvents.HEATING_STARTED}: {mqtt_client.listener_count(MqttClientEvents.HEATING_STARTED)} listeners" ) print(f" Total events registered: {len(mqtt_client.event_names())}") print() + print( + f" Available events: {', '.join(MqttClientEvents.get_all_events())}" + ) + print() # Step 4: Connect and subscribe print("5. Connecting to MQTT...") @@ -238,32 +246,32 @@ async def main(): # Step 7: Show event statistics print("9. Event statistics:") print( - f" temperature_changed: emitted {mqtt_client.event_count('temperature_changed')} times" + f" {MqttClientEvents.TEMPERATURE_CHANGED}: emitted {mqtt_client.event_count(MqttClientEvents.TEMPERATURE_CHANGED)} times" ) print( - f" mode_changed: emitted {mqtt_client.event_count('mode_changed')} times" + f" {MqttClientEvents.MODE_CHANGED}: emitted {mqtt_client.event_count(MqttClientEvents.MODE_CHANGED)} times" ) print( - f" status_received: emitted {mqtt_client.event_count('status_received')} times" + f" {MqttClientEvents.STATUS_RECEIVED}: emitted {mqtt_client.event_count(MqttClientEvents.STATUS_RECEIVED)} times" ) print() # Step 8: Dynamic listener management print("10. Demonstrating dynamic listener removal...") print( - f" Before: {mqtt_client.listener_count('temperature_changed')} listeners" + f" Before: {mqtt_client.listener_count(MqttClientEvents.TEMPERATURE_CHANGED)} listeners" ) # Remove one listener - mqtt_client.off("temperature_changed", alert_on_high_temp) + mqtt_client.off(MqttClientEvents.TEMPERATURE_CHANGED, alert_on_high_temp) print( - f" After removing alert: {mqtt_client.listener_count('temperature_changed')} listeners" + f" After removing alert: {mqtt_client.listener_count(MqttClientEvents.TEMPERATURE_CHANGED)} listeners" ) # Add it back - mqtt_client.on("temperature_changed", alert_on_high_temp) + mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, alert_on_high_temp) print( - f" After adding back: {mqtt_client.listener_count('temperature_changed')} listeners" + f" After adding back: {mqtt_client.listener_count(MqttClientEvents.TEMPERATURE_CHANGED)} listeners" ) print() diff --git a/examples/improved_auth_pattern.py b/examples/intermediate/improved_auth.py similarity index 100% rename from examples/improved_auth_pattern.py rename to examples/intermediate/improved_auth.py diff --git a/examples/auth_constructor_example.py b/examples/intermediate/legacy_auth_constructor.py similarity index 100% rename from examples/auth_constructor_example.py rename to examples/intermediate/legacy_auth_constructor.py diff --git a/examples/mqtt_client_example.py b/examples/intermediate/mqtt_realtime_monitoring.py similarity index 97% rename from examples/mqtt_client_example.py rename to examples/intermediate/mqtt_realtime_monitoring.py index d84ad23..fdd53c2 100755 --- a/examples/mqtt_client_example.py +++ b/examples/intermediate/mqtt_realtime_monitoring.py @@ -27,7 +27,8 @@ # If running from examples directory, add parent to path if __name__ == "__main__": - sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500.api_client import NavienAPIClient from nwp500.auth import NavienAuthClient @@ -35,10 +36,10 @@ AuthenticationError, ) from nwp500.models import DeviceFeature, DeviceStatus -from nwp500.mqtt_client import NavienMqttClient +from nwp500.mqtt import NavienMqttClient try: - from examples.mask import mask_mac # type: ignore + from mask import mask_mac # type: ignore except Exception: def mask_mac(mac): # pragma: no cover - fallback for examples @@ -115,7 +116,7 @@ async def main(): device_type = device.device_info.device_type try: - from examples.mask import mask_any # type: ignore + from mask import mask_any # type: ignore except Exception: def mask_any(_): # pragma: no cover - fallback diff --git a/examples/periodic_requests.py b/examples/intermediate/periodic_requests.py similarity index 98% rename from examples/periodic_requests.py rename to examples/intermediate/periodic_requests.py index f37ec9f..79b6708 100755 --- a/examples/periodic_requests.py +++ b/examples/intermediate/periodic_requests.py @@ -10,7 +10,8 @@ import os import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import ( DeviceFeature, @@ -22,7 +23,7 @@ ) try: - from examples.mask import mask_mac # type: ignore + from mask import mask_mac # type: ignore except Exception: def mask_mac(mac): # pragma: no cover - fallback for examples diff --git a/examples/set_mode_example.py b/examples/intermediate/set_mode.py similarity index 100% rename from examples/set_mode_example.py rename to examples/intermediate/set_mode.py diff --git a/examples/vacation_mode_example.py b/examples/intermediate/vacation_mode.py similarity index 100% rename from examples/vacation_mode_example.py rename to examples/intermediate/vacation_mode.py diff --git a/examples/periodic_device_info.py b/examples/testing/periodic_device_info.py similarity index 97% rename from examples/periodic_device_info.py rename to examples/testing/periodic_device_info.py index b6a6d2d..ee4b6df 100755 --- a/examples/periodic_device_info.py +++ b/examples/testing/periodic_device_info.py @@ -17,7 +17,8 @@ import sys # Add src directory to path for development -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import ( DeviceFeature, @@ -27,7 +28,7 @@ ) try: - from examples.mask import mask_mac # type: ignore + from mask import mask_mac # type: ignore except Exception: def mask_mac(mac): # pragma: no cover - fallback for examples diff --git a/examples/simple_periodic_info.py b/examples/testing/simple_periodic_info.py similarity index 95% rename from examples/simple_periodic_info.py rename to examples/testing/simple_periodic_info.py index e0ee405..95d4190 100644 --- a/examples/simple_periodic_info.py +++ b/examples/testing/simple_periodic_info.py @@ -9,7 +9,8 @@ import os import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import ( DeviceFeature, diff --git a/examples/test_api_client.py b/examples/testing/test_api_client.py similarity index 99% rename from examples/test_api_client.py rename to examples/testing/test_api_client.py index 82f17bd..fa7c29c 100755 --- a/examples/test_api_client.py +++ b/examples/testing/test_api_client.py @@ -71,7 +71,7 @@ def _mask_mac(mac: str) -> str: return re.sub(mac_regex, "[REDACTED_MAC]", mac) try: - from examples.mask import mask_any, mask_location # type: ignore + from mask import mask_any, mask_location # type: ignore except Exception: def mask_any(_): diff --git a/examples/test_mqtt_connection.py b/examples/testing/test_mqtt_connection.py similarity index 98% rename from examples/test_mqtt_connection.py rename to examples/testing/test_mqtt_connection.py index 72dfe6a..7c6d74b 100755 --- a/examples/test_mqtt_connection.py +++ b/examples/testing/test_mqtt_connection.py @@ -23,7 +23,7 @@ ) from nwp500.auth import NavienAuthClient -from nwp500.mqtt_client import NavienMqttClient +from nwp500.mqtt import NavienMqttClient async def test_mqtt_connection(): diff --git a/examples/test_mqtt_messaging.py b/examples/testing/test_mqtt_messaging.py similarity index 98% rename from examples/test_mqtt_messaging.py rename to examples/testing/test_mqtt_messaging.py index 8f56d11..7bfa4d3 100644 --- a/examples/test_mqtt_messaging.py +++ b/examples/testing/test_mqtt_messaging.py @@ -25,7 +25,7 @@ from nwp500.api_client import NavienAPIClient from nwp500.auth import NavienAuthClient -from nwp500.mqtt_client import NavienMqttClient +from nwp500.mqtt import NavienMqttClient async def test_mqtt_messaging(): @@ -93,7 +93,7 @@ def message_handler(topic: str, message: dict): additional_value = device.device_info.additional_value try: - from examples.mask import mask_any, mask_location # type: ignore + from mask import mask_any, mask_location # type: ignore except Exception: def mask_any(_): @@ -156,7 +156,7 @@ def mask_mac_in_topic(topic: str, mac_addr: str) -> str: # Avoid printing exception contents which may contain sensitive identifiers try: # mask_any should be available from earlier import - from examples.mask import mask_any # type: ignore + from mask import mask_any # type: ignore except Exception: def mask_any(_): diff --git a/examples/test_periodic_minimal.py b/examples/testing/test_periodic_minimal.py similarity index 97% rename from examples/test_periodic_minimal.py rename to examples/testing/test_periodic_minimal.py index bdb67c3..6b6ce88 100755 --- a/examples/test_periodic_minimal.py +++ b/examples/testing/test_periodic_minimal.py @@ -8,7 +8,8 @@ import sys from datetime import datetime -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from nwp500 import ( DeviceStatus, diff --git a/pyproject.toml b/pyproject.toml index dc5ec7c..0c888ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,3 +146,42 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = "pydantic.*" ignore_missing_imports = true + +[tool.pyright] +# Pyright configuration for strict type checking +pythonVersion = "3.13" +typeCheckingMode = "strict" +include = ["src/nwp500", "tests"] +exclude = [".venv", "build", "dist", ".tox"] + +# Strict error reporting +reportGeneralTypeIssues = "error" +reportUnboundVariable = "error" +reportUnusedImport = "error" +reportUnsupportedDunderAll = "error" +reportPrivateUsage = "error" +reportConstantRedefinition = "error" +reportOptionalCall = "error" +reportUnnecessaryComparison = "error" +reportPossiblyUnboundVariable = "error" +reportUnnecessaryIsInstance = "error" +reportUnusedVariable = "error" +reportIncompatibleMethodOverride = "error" +reportIncompatibleVariableOverride = "error" + +# Type stub warnings +reportMissingTypeStubs = "warning" +reportUnknownParameterType = "warning" +reportUnknownMemberType = "warning" +reportUnknownVariableType = "warning" +reportUnknownArgumentType = "warning" +reportMissingParameterType = "warning" +reportPrivateImportUsage = "warning" + +# Untyped decorators and base classes from third-party libraries (Click, Pydantic) +# These libraries don't have complete type stubs, so we report these as warnings +reportUntypedFunctionDecorator = "warning" +reportUntypedBaseClass = "warning" + +# Ignore missing imports for external libraries without stubs +reportMissingImports = false diff --git a/scripts/lint.py b/scripts/lint.py index 3ff7801..d3e1716 100644 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -28,9 +28,13 @@ def run_command(cmd, description): if e.stderr: print(f"STDERR:\n{e.stderr}") return False - except FileNotFoundError: - print(f"[ERROR] {description} - FAILED (ruff not found)") - print("Install ruff with: python3 -m pip install ruff>=0.1.0") + except FileNotFoundError as err: + tool = str(err).split("'")[1] if "'" in str(err) else "tool" + print(f"[ERROR] {description} - FAILED ({tool} not found)") + if tool == "ruff": + print("Install ruff with: python3 -m pip install ruff>=0.1.0") + elif tool == "pyright": + print("Install pyright with: python3 -m pip install pyright>=1.1.0") return False @@ -71,6 +75,15 @@ def main(): ], "Ruff format check", ), + ( + [ + sys.executable, + "-m", + "pyright", + "src/nwp500", + ], + "Pyright type checking", + ), ] all_passed = True @@ -90,6 +103,7 @@ def main(): print("Run the following commands to fix issues:") print(" python3 -m ruff check --fix src/ tests/ examples/") print(" python3 -m ruff format src/ tests/ examples/") + print(" python3 -m pyright src/nwp500 tests") return 1 diff --git a/setup.cfg b/setup.cfg index 602876f..32515a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,6 +54,7 @@ install_requires = aiohttp>=3.8.0 awsiotsdk>=1.27.0 pydantic>=2.0.0 + click>=8.0.0 [options.packages.find] @@ -66,6 +67,10 @@ exclude = # `pip install nwp500-python[PDF]` like: # PDF = ReportLab; RXP +# CLI enhancements with rich library +cli = + rich>=13.0.0 + # Add here test requirements (semicolon/line-separated) testing = setuptools @@ -76,6 +81,7 @@ testing = # Development tools dev = ruff>=0.1.0 + pyright>=1.1.0 %(testing)s [options.entry_points] diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 13ed9bc..0397781 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -34,10 +34,10 @@ requires_capability, ) from nwp500.device_capabilities import ( - DeviceCapabilityChecker, + MqttDeviceCapabilityChecker, ) from nwp500.device_info_cache import ( - DeviceInfoCache, + MqttDeviceInfoCache, ) from nwp500.encoding import ( build_reservation_entry, @@ -65,6 +65,7 @@ TouRateType, TouWeekType, UnitType, + VolumeCode, ) from nwp500.events import ( EventEmitter, @@ -92,6 +93,9 @@ TokenRefreshError, ValidationError, ) +from nwp500.factory import ( + create_navien_clients, +) from nwp500.models import ( Device, DeviceFeature, @@ -109,14 +113,18 @@ TOUSchedule, fahrenheit_to_half_celsius, ) -from nwp500.mqtt_client import NavienMqttClient -from nwp500.mqtt_diagnostics import ( +from nwp500.mqtt import ( ConnectionDropEvent, ConnectionEvent, + MqttConnectionConfig, MqttDiagnosticsCollector, MqttMetrics, + NavienMqttClient, + PeriodicRequestType, +) +from nwp500.mqtt_events import ( + MqttClientEvents, ) -from nwp500.mqtt_utils import MqttConnectionConfig, PeriodicRequestType from nwp500.utils import ( log_performance, ) @@ -124,10 +132,12 @@ __all__ = [ "__version__", # Device Capabilities & Caching - "DeviceCapabilityChecker", + "MqttDeviceCapabilityChecker", "DeviceCapabilityError", - "DeviceInfoCache", + "MqttDeviceInfoCache", "requires_capability", + # Factory functions + "create_navien_clients", # Models "DeviceStatus", "DeviceFeature", @@ -159,6 +169,7 @@ "TouRateType", "TouWeekType", "UnitType", + "VolumeCode", # Conversion utilities "fahrenheit_to_half_celsius", # Authentication @@ -188,8 +199,6 @@ "DeviceNotFoundError", "DeviceOfflineError", "DeviceOperationError", - # Constants - "constants", # API Client "NavienAPIClient", # MQTT Client @@ -203,6 +212,7 @@ # Event Emitter "EventEmitter", "EventListener", + "MqttClientEvents", # Encoding utilities "encode_week_bitfield", "decode_week_bitfield", diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index be70247..42584f7 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -14,7 +14,7 @@ import json import logging from datetime import datetime, timedelta -from typing import Any, Self +from typing import Any, Self, cast import aiohttp from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator @@ -235,24 +235,36 @@ class NavienAuthClient: This client handles: - User authentication with email/password - Token management and automatic refresh - - Session management + - Session management via aiohttp ClientSession - AWS credentials (if provided by API) + Session and Context Manager + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + The auth client manages an aiohttp session that is shared with other + clients (API, MQTT). The session is created when entering the context + manager and closed when exiting. + Authentication is performed automatically when entering the async context manager, unless valid stored tokens are provided. + **Important:** All API and MQTT clients must be created and used within + the context manager. Once the context manager exits, the session is closed + and clients can no longer be used. + Example: >>> async with NavienAuthClient(user_id="user@example.com", password="password") as client: ... print(f"Welcome {client.current_user.full_name}") ... # Token is securely stored and not printed in production ... - ... # Use the token in API requests - ... headers = client.get_auth_headers() + ... # Create other clients within the context + ... api_client = NavienAPIClient(auth_client=client) + ... mqtt_client = NavienMqttClient(auth_client=client) ... - ... # Refresh when needed - ... if client.current_tokens.is_expired: - ... await client.refresh_token() + ... # Use the clients + ... devices = await api_client.list_devices() + ... await mqtt_client.connect() Restore session from stored tokens: >>> stored_tokens = AuthTokens.from_dict(saved_data) @@ -516,7 +528,9 @@ async def refresh_token( old_tokens.authorization_expires_in ) # Also preserve the AWS expiration timestamp - new_tokens._aws_expires_at = old_tokens._aws_expires_at + cast(Any, new_tokens)._aws_expires_at = cast( + Any, old_tokens + )._aws_expires_at # Update stored auth response if we have one if self._auth_response: @@ -702,11 +716,12 @@ async def authenticate(user_id: str, password: str) -> AuthenticationResponse: >>> # Do not print tokens in production code """ async with NavienAuthClient(user_id, password) as client: - if client._auth_response is None: + auth_response = cast(Any, client)._auth_response + if auth_response is None: raise AuthenticationError( "Authentication failed: no response received" ) - return client._auth_response + return auth_response async def refresh_access_token(refresh_token: str) -> AuthTokens: diff --git a/src/nwp500/cli/__init__.py b/src/nwp500/cli/__init__.py index 59f23fa..0e8659e 100644 --- a/src/nwp500/cli/__init__.py +++ b/src/nwp500/cli/__init__.py @@ -1,7 +1,7 @@ """CLI package for nwp500-python.""" from .__main__ import run -from .commands import ( +from .handlers import ( handle_device_info_request, handle_get_controller_serial_request, handle_get_energy_request, diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 6db5be5..998755d 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -1,10 +1,12 @@ -"""Navien Water Heater Control Script - Main Entry Point.""" +"""Navien Water Heater Control CLI - Main Entry Point.""" -import argparse import asyncio +import functools import logging -import os import sys +from typing import Any + +import click from nwp500 import ( NavienAPIClient, @@ -23,190 +25,181 @@ ValidationError, ) -from . import commands as cmds -from .commands import ( - handle_configure_reservation_water_program_request as handle_water_prog, -) -from .commands import ( - handle_trigger_recirculation_hot_button_request as handle_hot_btn, -) -from .monitoring import handle_monitoring +from . import handlers +from .rich_output import get_formatter from .token_storage import load_tokens, save_tokens _logger = logging.getLogger(__name__) +_formatter = get_formatter() -async def async_main(args: argparse.Namespace) -> int: - """Asynchronous main function.""" - email = args.email or os.getenv("NAVIEN_EMAIL") - password = args.password or os.getenv("NAVIEN_PASSWORD") - tokens, cached_email = load_tokens() - email = cached_email or email - - if not email or not password: - _logger.error( - "Credentials missing. Use --email/--password or env vars." - ) - return 1 - - try: - async with NavienAuthClient( - email, password, stored_tokens=tokens - ) as auth: - if auth.current_tokens and auth.user_email: - save_tokens(auth.current_tokens, auth.user_email) - - api = NavienAPIClient(auth_client=auth) - device = await api.get_first_device() - if not device: - _logger.error("No devices found.") - return 1 +def async_command(f: Any) -> Any: + """Decorator to run click commands asynchronously with device connection.""" + + @click.pass_context + @functools.wraps(f) + def wrapper(ctx: click.Context, *args: Any, **kwargs: Any) -> Any: + async def runner() -> int: + email = ctx.obj.get("email") + password = ctx.obj.get("password") - _logger.info(f"Using device: {device.device_info.device_name}") + # Load cached tokens if available + tokens, cached_email = load_tokens() + # If email not provided in args, try cached email + email = email or cached_email + + if not email or not password: + _logger.error( + "Credentials missing. Use --email/--password or env vars." + ) + return 1 - mqtt = NavienMqttClient(auth) - await mqtt.connect() try: - # Command Dispatching - cmd = args.command - if cmd == "info": - await cmds.handle_device_info_request( - mqtt, device, args.raw - ) - elif cmd == "status": - await cmds.handle_status_request(mqtt, device, args.raw) - elif cmd == "serial": - await cmds.handle_get_controller_serial_request( - mqtt, device - ) - elif cmd == "power": - await cmds.handle_power_request( - mqtt, device, args.state == "on" - ) - elif cmd == "mode": - await cmds.handle_set_mode_request(mqtt, device, args.name) - elif cmd == "temp": - await cmds.handle_set_dhw_temp_request( - mqtt, device, args.value - ) - elif cmd == "vacation": - await cmds.handle_set_vacation_days_request( - mqtt, device, args.days - ) - elif cmd == "recirc": - await cmds.handle_set_recirculation_mode_request( - mqtt, device, args.mode - ) - elif cmd == "reservations": - if args.action == "get": - await cmds.handle_get_reservations_request(mqtt, device) - else: - await cmds.handle_update_reservations_request( - mqtt, device, args.json, not args.disabled - ) - elif cmd == "tou": - if args.action == "get": - await cmds.handle_get_tou_request(mqtt, device, api) - else: - await cmds.handle_set_tou_enabled_request( - mqtt, device, args.state == "on" - ) - elif cmd == "energy": - months = [int(m.strip()) for m in args.months.split(",")] - await cmds.handle_get_energy_request( - mqtt, device, args.year, months + async with NavienAuthClient( + email, password, stored_tokens=tokens + ) as auth: + if auth.current_tokens and auth.user_email: + save_tokens(auth.current_tokens, auth.user_email) + + api = NavienAPIClient(auth_client=auth) + device = await api.get_first_device() + if not device: + _logger.error("No devices found.") + return 1 + + _logger.info( + f"Using device: {device.device_info.device_name}" ) - elif cmd == "dr": - if args.action == "enable": - await cmds.handle_enable_demand_response_request( - mqtt, device - ) - else: - await cmds.handle_disable_demand_response_request( - mqtt, device - ) - elif cmd == "hot-button": - await handle_hot_btn(mqtt, device) - elif cmd == "reset-filter": - await cmds.handle_reset_air_filter_request(mqtt, device) - elif cmd == "water-program": - await handle_water_prog(mqtt, device) - elif cmd == "monitor": - await handle_monitoring(mqtt, device, args.output) - - finally: - await mqtt.disconnect() - return 0 - - except ( - InvalidCredentialsError, - AuthenticationError, - TokenRefreshError, - ) as e: - _logger.error(f"Auth failed: {e}") - except (MqttNotConnectedError, MqttConnectionError, MqttError) as e: - _logger.error(f"MQTT error: {e}") - except ValidationError as e: - _logger.error(f"Validation error: {e}") - except Nwp500Error as e: - _logger.error(f"Library error: {e}") - except Exception as e: - _logger.error(f"Unexpected error: {e}", exc_info=True) - return 1 - - -def parse_args(args: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Navien NWP500 CLI") - parser.add_argument( - "--version", action="version", version=f"nwp500-python {__version__}" - ) - parser.add_argument("--email", help="Navien email") - parser.add_argument("--password", help="Navien password") - parser.add_argument( - "-v", - "--verbose", - dest="loglevel", - action="store_const", - const=logging.INFO, - ) - parser.add_argument( - "-vv", - "--very-verbose", - dest="loglevel", - action="store_const", - const=logging.DEBUG, - ) - subparsers = parser.add_subparsers(dest="command", required=True) - - # Simple commands - subparsers.add_parser( - "info", - help="Show device information (firmware, capabilities, serial number)", - ).add_argument("--raw", action="store_true") - subparsers.add_parser( - "status", - help="Show current device status (temperature, mode, power usage)", - ).add_argument("--raw", action="store_true") - subparsers.add_parser("serial", help="Get controller serial number") - subparsers.add_parser( - "hot-button", help="Trigger hot button (instant hot water)" - ) - subparsers.add_parser( - "reset-filter", help="Reset air filter maintenance timer" - ) - subparsers.add_parser( - "water-program", help="Enable water program reservation scheduling mode" + mqtt = NavienMqttClient(auth) + await mqtt.connect() + try: + # Attach api to context for commands that need it + ctx.obj["api"] = api + + await f(mqtt, device, *args, **kwargs) + finally: + await mqtt.disconnect() + return 0 + + except ( + InvalidCredentialsError, + AuthenticationError, + TokenRefreshError, + ) as e: + _logger.error(f"Auth failed: {e}") + _formatter.print_error(str(e), title="Authentication Failed") + except (MqttNotConnectedError, MqttConnectionError, MqttError) as e: + _logger.error(f"MQTT error: {e}") + _formatter.print_error(str(e), title="MQTT Connection Error") + except ValidationError as e: + _logger.error(f"Validation error: {e}") + _formatter.print_error(str(e), title="Validation Error") + except Nwp500Error as e: + _logger.error(f"Library error: {e}") + _formatter.print_error(str(e), title="Library Error") + except Exception as e: + _logger.error(f"Unexpected error: {e}", exc_info=True) + _formatter.print_error(str(e), title="Unexpected Error") + return 1 + + return asyncio.run(runner()) + + return wrapper + + +@click.group() +@click.option("--email", envvar="NAVIEN_EMAIL", help="Navien account email") +@click.option( + "--password", envvar="NAVIEN_PASSWORD", help="Navien account password" +) +@click.option("-v", "--verbose", count=True, help="Increase verbosity") +@click.version_option(version=__version__) +@click.pass_context +def cli( + ctx: click.Context, email: str | None, password: str | None, verbose: int +) -> None: + """Navien NWP500 Control CLI.""" + ctx.ensure_object(dict) + ctx.obj["email"] = email + ctx.obj["password"] = password + + log_level = logging.WARNING + if verbose == 1: + log_level = logging.INFO + elif verbose >= 2: + log_level = logging.DEBUG + + logging.basicConfig( + level=logging.WARNING, # Default for other libraries + stream=sys.stdout, + format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s", ) + logging.getLogger("nwp500").setLevel(log_level) + # Ensure this module's logger respects the level + _logger.setLevel(log_level) + logging.getLogger("aiohttp").setLevel(logging.WARNING) + + +@cli.command() # type: ignore[attr-defined] +@click.option("--raw", is_flag=True, help="Output raw JSON response") +@async_command +async def info(mqtt: NavienMqttClient, device: Any, raw: bool) -> None: + """Show device information (firmware, capabilities).""" + await handlers.handle_device_info_request(mqtt, device, raw) + + +@cli.command() # type: ignore[attr-defined] +@click.option("--raw", is_flag=True, help="Output raw JSON response") +@async_command +async def status(mqtt: NavienMqttClient, device: Any, raw: bool) -> None: + """Show current device status (temps, mode, etc).""" + await handlers.handle_status_request(mqtt, device, raw) + + +@cli.command() # type: ignore[attr-defined] +@async_command +async def serial(mqtt: NavienMqttClient, device: Any) -> None: + """Get controller serial number.""" + await handlers.handle_get_controller_serial_request(mqtt, device) + - # Command with args - subparsers.add_parser("power", help="Turn device on or off").add_argument( - "state", choices=["on", "off"] +@cli.command() # type: ignore[attr-defined] +@async_command +async def hot_button(mqtt: NavienMqttClient, device: Any) -> None: + """Trigger hot button (instant hot water).""" + await handlers.handle_trigger_recirculation_hot_button_request(mqtt, device) + + +@cli.command() # type: ignore[attr-defined] +@async_command +async def reset_filter(mqtt: NavienMqttClient, device: Any) -> None: + """Reset air filter maintenance timer.""" + await handlers.handle_reset_air_filter_request(mqtt, device) + + +@cli.command() # type: ignore[attr-defined] +@async_command +async def water_program(mqtt: NavienMqttClient, device: Any) -> None: + """Enable water program reservation scheduling mode.""" + await handlers.handle_configure_reservation_water_program_request( + mqtt, device ) - subparsers.add_parser("mode", help="Set operation mode").add_argument( - "name", - help="Mode name", - choices=[ + + +@cli.command() # type: ignore[attr-defined] +@click.argument("state", type=click.Choice(["on", "off"], case_sensitive=False)) +@async_command +async def power(mqtt: NavienMqttClient, device: Any, state: str) -> None: + """Turn device on or off.""" + await handlers.handle_power_request(mqtt, device, state.lower() == "on") + + +@cli.command() # type: ignore[attr-defined] +@click.argument( + "mode_name", + type=click.Choice( + [ "standby", "heat-pump", "electric", @@ -214,77 +207,142 @@ def parse_args(args: list[str]) -> argparse.Namespace: "high-demand", "vacation", ], - ) - subparsers.add_parser( - "temp", help="Set target hot water temperature" - ).add_argument("value", type=float, help="Temp °F") - subparsers.add_parser( - "vacation", help="Enable vacation mode for N days" - ).add_argument("days", type=int) - subparsers.add_parser( - "recirc", help="Set recirculation pump mode (1-4)" - ).add_argument("mode", type=int, choices=[1, 2, 3, 4]) - - # Sub-sub commands - res = subparsers.add_parser( - "reservations", - help="Schedule mode and temperature changes at specific times", - ) - res_sub = res.add_subparsers(dest="action", required=True) - res_sub.add_parser("get", help="Get current reservation schedule") - res_set = res_sub.add_parser( - "set", help="Set reservation schedule from JSON" - ) - res_set.add_argument("json", help="Reservation JSON") - res_set.add_argument("--disabled", action="store_true") + case_sensitive=False, + ), +) +@async_command +async def mode(mqtt: NavienMqttClient, device: Any, mode_name: str) -> None: + """Set operation mode.""" + await handlers.handle_set_mode_request(mqtt, device, mode_name) - tou = subparsers.add_parser( - "tou", help="Configure time-of-use pricing schedule" - ) - tou_sub = tou.add_subparsers(dest="action", required=True) - tou_sub.add_parser("get", help="Get current TOU schedule") - tou_set = tou_sub.add_parser("set", help="Enable or disable TOU pricing") - tou_set.add_argument("state", choices=["on", "off"]) - energy = subparsers.add_parser( - "energy", help="Query historical energy usage by month" - ) - energy.add_argument("--year", type=int, required=True) - energy.add_argument( - "--months", required=True, help="Comma-separated months" - ) +@cli.command() # type: ignore[attr-defined] +@click.argument("value", type=float) +@async_command +async def temp(mqtt: NavienMqttClient, device: Any, value: float) -> None: + """Set target hot water temperature (deg F).""" + await handlers.handle_set_dhw_temp_request(mqtt, device, value) - dr = subparsers.add_parser( - "dr", help="Enable or disable utility demand response" + +@cli.command() # type: ignore[attr-defined] +@click.argument("days", type=int) +@async_command +async def vacation(mqtt: NavienMqttClient, device: Any, days: int) -> None: + """Enable vacation mode for N days.""" + await handlers.handle_set_vacation_days_request(mqtt, device, days) + + +@cli.command() # type: ignore[attr-defined] +@click.argument( + "mode_val", type=click.Choice(["1", "2", "3", "4"]), metavar="MODE" +) +@async_command +async def recirc(mqtt: NavienMqttClient, device: Any, mode_val: str) -> None: + """Set recirculation pump mode (1-4).""" + await handlers.handle_set_recirculation_mode_request( + mqtt, device, int(mode_val) ) - dr.add_argument("action", choices=["enable", "disable"]) - monitor = subparsers.add_parser( - "monitor", help="Monitor device status in real-time (logs to CSV)" + +@cli.group() # type: ignore[attr-defined] +def reservations() -> None: + """Manage reservations.""" + pass + + +@reservations.command("get") # type: ignore[attr-defined] +@async_command +async def reservations_get(mqtt: NavienMqttClient, device: Any) -> None: + """Get current reservation schedule.""" + await handlers.handle_get_reservations_request(mqtt, device) + + +@reservations.command("set") # type: ignore[attr-defined] +@click.argument("json_str", metavar="JSON") +@click.option("--disabled", is_flag=True, help="Disable reservations") +@async_command +async def reservations_set( + mqtt: NavienMqttClient, device: Any, json_str: str, disabled: bool +) -> None: + """Set reservation schedule from JSON.""" + await handlers.handle_update_reservations_request( + mqtt, device, json_str, not disabled ) - monitor.add_argument("-o", "--output", default="nwp500_status.csv") - return parser.parse_args(args) +@cli.group() # type: ignore[attr-defined] +def tou() -> None: + """Manage Time-of-Use settings.""" + pass -def main(args_list: list[str]) -> None: - args = parse_args(args_list) - logging.basicConfig( - level=logging.WARNING, - stream=sys.stdout, - format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s", + +@tou.command("get") # type: ignore[attr-defined] +@click.pass_context # We need context to access api +@async_command +async def tou_get( + mqtt: NavienMqttClient, device: Any, ctx: click.Context | None = None +) -> None: + """Get current TOU schedule.""" + ctx = click.get_current_context() + api = None + if ctx and hasattr(ctx, "obj") and ctx.obj is not None: + api = ctx.obj.get("api") + if api: + await handlers.handle_get_tou_request(mqtt, device, api) + else: + _logger.error("API client not available") + + +@tou.command("set") # type: ignore[attr-defined] +@click.argument("state", type=click.Choice(["on", "off"], case_sensitive=False)) +@async_command +async def tou_set(mqtt: NavienMqttClient, device: Any, state: str) -> None: + """Enable or disable TOU pricing.""" + await handlers.handle_set_tou_enabled_request( + mqtt, device, state.lower() == "on" ) - _logger.setLevel(args.loglevel or logging.INFO) - logging.getLogger("aiohttp").setLevel(logging.WARNING) - try: - sys.exit(asyncio.run(async_main(args))) - except KeyboardInterrupt: - _logger.info("Interrupted.") -def run() -> None: - main(sys.argv[1:]) +@cli.command() # type: ignore[attr-defined] +@click.option("--year", type=int, required=True) +@click.option( + "--months", required=True, help="Comma-separated months (e.g. 1,2,3)" +) +@async_command +async def energy( + mqtt: NavienMqttClient, device: Any, year: int, months: str +) -> None: + """Query historical energy usage.""" + month_list = [int(m.strip()) for m in months.split(",")] + await handlers.handle_get_energy_request(mqtt, device, year, month_list) + + +@cli.command() # type: ignore[attr-defined] +@click.argument( + "action", type=click.Choice(["enable", "disable"], case_sensitive=False) +) +@async_command +async def dr(mqtt: NavienMqttClient, device: Any, action: str) -> None: + """Enable or disable Demand Response.""" + if action.lower() == "enable": + await handlers.handle_enable_demand_response_request(mqtt, device) + else: + await handlers.handle_disable_demand_response_request(mqtt, device) + + +@cli.command() # type: ignore[attr-defined] +@click.option( + "-o", "--output", default="nwp500_status.csv", help="Output CSV file" +) +@async_command +async def monitor(mqtt: NavienMqttClient, device: Any, output: str) -> None: + """Monitor device status in real-time.""" + from .monitoring import handle_monitoring + + await handle_monitoring(mqtt, device, output) if __name__ == "__main__": - run() + cli() # type: ignore[call-arg] + +run = cli diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py index 8f05577..5e3f5b8 100644 --- a/src/nwp500/cli/commands.py +++ b/src/nwp500/cli/commands.py @@ -1,502 +1,89 @@ -"""Command handlers for CLI operations.""" - -import asyncio -import json -import logging -from collections.abc import Callable, Coroutine -from typing import Any, TypeVar, cast - -from nwp500 import ( - Device, - DeviceFeature, - DeviceStatus, - EnergyUsageResponse, - NavienMqttClient, -) -from nwp500.exceptions import ( - DeviceError, - MqttError, - Nwp500Error, - RangeValidationError, - ValidationError, -) -from nwp500.mqtt_utils import redact_serial -from nwp500.topic_builder import MqttTopicBuilder - -from .output_formatters import ( - print_device_info, - print_device_status, - print_energy_usage, - print_json, -) - -_logger = logging.getLogger(__name__) - -T = TypeVar("T") - - -async def _wait_for_response( - subscribe_func: Callable[ - [Device, Callable[[Any], None]], Coroutine[Any, Any, Any] - ], - device: Device, - action_func: Callable[[], Coroutine[Any, Any, Any]], - timeout: float = 10.0, - action_name: str = "operation", -) -> Any: - """Generic helper to wait for a specific MQTT response.""" - future = asyncio.get_running_loop().create_future() - - def callback(res: Any) -> None: - if not future.done(): - future.set_result(res) - - await subscribe_func(device, callback) - _logger.info(f"Requesting {action_name}...") - await action_func() - - try: - return await asyncio.wait_for(future, timeout=timeout) - except TimeoutError: - _logger.error(f"Timed out waiting for {action_name} response.") - raise - - -async def _handle_command_with_status_feedback( - mqtt: NavienMqttClient, - device: Device, - action_func: Callable[[], Coroutine[Any, Any, Any]], - action_name: str, - success_msg: str, - print_status: bool = False, -) -> DeviceStatus | None: - """Helper for commands that wait for a DeviceStatus response.""" - try: - status: Any = await _wait_for_response( - mqtt.subscribe_device_status, - device, - action_func, - action_name=action_name, - ) - if print_status: - print_json(status.model_dump()) - _logger.info(success_msg) - print(success_msg) - return cast(DeviceStatus, status) - except (ValidationError, RangeValidationError) as e: - _logger.error(f"Invalid parameters: {e}") - except (MqttError, DeviceError, Nwp500Error) as e: - _logger.error(f"Error {action_name}: {e}") - except Exception as e: - _logger.error(f"Unexpected error {action_name}: {e}") - return None - - -async def get_controller_serial_number( - mqtt: NavienMqttClient, device: Device, timeout: float = 10.0 -) -> str | None: - """Retrieve controller serial number from device.""" - try: - feature: Any = await _wait_for_response( - mqtt.subscribe_device_feature, - device, - lambda: mqtt.control.request_device_info(device), - timeout=timeout, - action_name="controller serial", - ) - serial = cast(DeviceFeature, feature).controller_serial_number - _logger.info( - f"Controller serial number retrieved: {redact_serial(serial)}" - ) - return serial - except Exception: - return None - - -async def handle_get_controller_serial_request( - mqtt: NavienMqttClient, device: Device -) -> None: - """Request and display just the controller serial number.""" - serial = await get_controller_serial_number(mqtt, device) - if serial: - print(serial) - else: - _logger.error("Failed to retrieve controller serial number.") - - -async def _handle_info_request( - mqtt: NavienMqttClient, - device: Device, - subscribe_method: Callable[ - [Device, Callable[[Any], None]], Coroutine[Any, Any, Any] - ], - request_method: Callable[[Device], Coroutine[Any, Any, Any]], - data_key: str, - action_name: str, - raw: bool = False, - formatter: Callable[[Any], None] | None = None, -) -> None: - """Generic helper for requesting and displaying device information.""" - try: - if not raw: - res = await _wait_for_response( - subscribe_method, - device, - lambda: request_method(device), - action_name=action_name, - ) - if formatter: - formatter(res) - else: - print_json(res.model_dump()) - else: - future = asyncio.get_running_loop().create_future() - - def raw_cb(topic: str, message: dict[str, Any]) -> None: - if not future.done(): - res = message.get("response", {}).get( - data_key - ) or message.get(data_key) - if res: - print_json(res) - future.set_result(None) - - await mqtt.subscribe_device(device, raw_cb) - await request_method(device) - await asyncio.wait_for(future, timeout=10) - except Exception as e: - _logger.error(f"Failed to get {action_name}: {e}") - - -async def handle_status_request( - mqtt: NavienMqttClient, device: Device, raw: bool = False -) -> None: - """Request device status and print it.""" - await _handle_info_request( - mqtt, - device, - mqtt.subscribe_device_status, - mqtt.control.request_device_status, - "status", - "device status", - raw, - formatter=print_device_status if not raw else None, - ) - - -async def handle_device_info_request( - mqtt: NavienMqttClient, device: Device, raw: bool = False -) -> None: - """Request comprehensive device information.""" - await _handle_info_request( - mqtt, - device, - mqtt.subscribe_device_feature, - mqtt.control.request_device_info, - "feature", - "device information", - raw, - formatter=print_device_info if not raw else None, - ) - - -async def handle_set_mode_request( - mqtt: NavienMqttClient, device: Device, mode_name: str -) -> None: - """Set device operation mode.""" - mode_mapping = { - "standby": 0, - "heat-pump": 1, - "electric": 2, - "energy-saver": 3, - "high-demand": 4, - "vacation": 5, - } - mode_id = mode_mapping.get(mode_name.lower()) - if mode_id is None: - _logger.error( - f"Invalid mode '{mode_name}'. Valid: {list(mode_mapping.keys())}" - ) - return - - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.set_dhw_mode(device, mode_id), - "setting mode", - f"Mode changed to {mode_name}", - ) - - -async def handle_set_dhw_temp_request( - mqtt: NavienMqttClient, device: Device, temperature: float -) -> None: - """Set DHW target temperature.""" - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.set_dhw_temperature(device, temperature), - "setting temperature", - f"Temperature set to {temperature}°F", - ) - - -async def handle_power_request( - mqtt: NavienMqttClient, device: Device, power_on: bool -) -> None: - """Set device power state.""" - state = "on" if power_on else "off" - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.set_power(device, power_on), - f"turning {state}", - f"Device turned {state}", - ) - - -async def handle_get_reservations_request( - mqtt: NavienMqttClient, device: Device -) -> None: - """Request current reservation schedule.""" - future = asyncio.get_running_loop().create_future() - - def raw_callback(topic: str, message: dict[str, Any]) -> None: - if not future.done() and "response" in message: - from nwp500.encoding import ( - decode_reservation_hex, - decode_week_bitfield, - ) - - response = message.get("response", {}) - reservation_hex = response.get("reservation", "") - reservations = ( - decode_reservation_hex(reservation_hex) - if isinstance(reservation_hex, str) - else [] - ) - - output = { - "reservationUse": response.get("reservationUse", 0), - "reservationEnabled": response.get("reservationUse") == 1, - "reservations": [ - { - "number": i + 1, - "enabled": e.get("enable") == 1, - "days": decode_week_bitfield(e.get("week", 0)), - "time": f"{e.get('hour', 0):02d}:{e.get('min', 0):02d}", - "mode": e.get("mode"), - "temperatureF": e.get("param", 0) + 20, - "raw": e, - } - for i, e in enumerate(reservations) - ], - } - print_json(output) - future.set_result(None) - - device_type = str(device.device_info.device_type) - response_pattern = MqttTopicBuilder.command_topic( - device_type, mac_address="+", suffix="#" - ) - await mqtt.subscribe(response_pattern, raw_callback) - await mqtt.control.request_reservations(device) - try: - await asyncio.wait_for(future, timeout=10) - except TimeoutError: - _logger.error("Timed out waiting for reservations.") - - -async def handle_update_reservations_request( - mqtt: NavienMqttClient, - device: Device, - reservations_json: str, - enabled: bool, -) -> None: - """Update reservation schedule.""" - try: - reservations = json.loads(reservations_json) - if not isinstance(reservations, list): - raise ValueError("Must be a JSON array") - except (json.JSONDecodeError, ValueError) as e: - _logger.error(f"Invalid reservations JSON: {e}") - return - - future = asyncio.get_running_loop().create_future() - - def raw_callback(topic: str, message: dict[str, Any]) -> None: - if not future.done() and "response" in message: - print_json(message) - future.set_result(None) - - device_type = device.device_info.device_type - response_topic = f"cmd/{device_type}/+/+/{mqtt.client_id}/res/rsv/rd" - await mqtt.subscribe(response_topic, raw_callback) - await mqtt.control.update_reservations( - device, reservations, enabled=enabled - ) - try: - await asyncio.wait_for(future, timeout=10) - except TimeoutError: - _logger.error("Timed out updating reservations.") - - -async def handle_get_tou_request( - mqtt: NavienMqttClient, device: Device, api_client: Any -) -> None: - """Request Time-of-Use settings from REST API.""" - try: - serial = await get_controller_serial_number(mqtt, device) - if not serial: - _logger.error("Failed to get controller serial.") - return - - tou_info = await api_client.get_tou_info( - mac_address=device.device_info.mac_address, - additional_value=device.device_info.additional_value, - controller_id=serial, - user_type="O", - ) - print_json( - { - "name": tou_info.name, - "utility": tou_info.utility, - "zipCode": tou_info.zip_code, - "schedule": [ - {"season": s.season, "intervals": s.intervals} - for s in tou_info.schedule - ], - } - ) - except Exception as e: - _logger.error(f"Error fetching TOU: {e}") - - -async def handle_set_tou_enabled_request( - mqtt: NavienMqttClient, device: Device, enabled: bool -) -> None: - """Enable or disable Time-of-Use.""" - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.set_tou_enabled(device, enabled), - f"{'enabling' if enabled else 'disabling'} TOU", - f"TOU {'enabled' if enabled else 'disabled'}", - ) - - -async def handle_get_energy_request( - mqtt: NavienMqttClient, device: Device, year: int, months: list[int] -) -> None: - """Request energy usage data.""" - try: - res: Any = await _wait_for_response( - mqtt.subscribe_energy_usage, - device, - lambda: mqtt.control.request_energy_usage(device, year, months), - action_name="energy usage", - timeout=15, - ) - print_energy_usage(cast(EnergyUsageResponse, res)) - except Exception as e: - _logger.error(f"Error getting energy data: {e}") - - -async def handle_reset_air_filter_request( - mqtt: NavienMqttClient, device: Device -) -> None: - """Reset air filter timer.""" - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.reset_air_filter(device), - "resetting air filter", - "Air filter timer reset", - ) - - -async def handle_set_vacation_days_request( - mqtt: NavienMqttClient, device: Device, days: int -) -> None: - """Set vacation mode duration.""" - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.set_vacation_days(device, days), - "setting vacation days", - f"Vacation days set to {days}", - ) - - -async def handle_set_recirculation_mode_request( - mqtt: NavienMqttClient, device: Device, mode: int -) -> None: - """Set recirculation pump mode.""" - mode_map = {1: "ALWAYS", 2: "BUTTON", 3: "SCHEDULE", 4: "TEMPERATURE"} - mode_name = mode_map.get(mode, str(mode)) - status = await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.set_recirculation_mode(device, mode), - "setting recirculation mode", - f"Recirculation mode set to {mode_name}", - ) - - if status and status.recirc_operation_mode.value != mode: - _logger.warning( - f"Device reported mode {status.recirc_operation_mode.name} " - f"instead of expected {mode_name}. External factor or " - "device state may have prevented the change." - ) - - -async def handle_trigger_recirculation_hot_button_request( - mqtt: NavienMqttClient, device: Device -) -> None: - """Trigger hot button.""" - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.trigger_recirculation_hot_button(device), - "triggering hot button", - "Hot button triggered", - ) - - -async def handle_enable_demand_response_request( - mqtt: NavienMqttClient, device: Device -) -> None: - """Enable demand response.""" - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.enable_demand_response(device), - "enabling DR", - "Demand response enabled", - ) - - -async def handle_disable_demand_response_request( - mqtt: NavienMqttClient, device: Device -) -> None: - """Disable demand response.""" - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.disable_demand_response(device), - "disabling DR", - "Demand response disabled", - ) - - -async def handle_configure_reservation_water_program_request( - mqtt: NavienMqttClient, device: Device -) -> None: - """Configure water program.""" - await _handle_command_with_status_feedback( - mqtt, - device, - lambda: mqtt.control.configure_reservation_water_program(device), - "configuring water program", - "Water program configured", - ) +"""Command registry for NWP500 CLI.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from . import handlers + + +@dataclass +class CliCommand: + """Definition of a CLI command.""" + + name: str + help: str + callback: Callable[..., Any] + args: list[str] # Required arguments + options: list[str] # Optional arguments + examples: list[str] # Usage examples + + +CLI_COMMANDS = [ + CliCommand( + name="status", + help="Show current device status", + callback=handlers.handle_status_request, + args=[], + options=["--format {text,json,csv}"], + examples=["nwp-cli status"], + ), + CliCommand( + name="info", + help="Show device information", + callback=handlers.handle_device_info_request, + args=[], + options=["--raw"], + examples=["nwp-cli info"], + ), + CliCommand( + name="mode", + help="Set operation mode", + callback=handlers.handle_set_mode_request, + args=["MODE"], + options=[], + examples=["nwp-cli mode heat-pump"], + ), + CliCommand( + name="power", + help="Turn device on or off", + callback=handlers.handle_power_request, + args=["STATE"], + options=[], + examples=["nwp-cli power on"], + ), + CliCommand( + name="temp", + help="Set target hot water temperature", + callback=handlers.handle_set_dhw_temp_request, + args=["VALUE"], + options=[], + examples=["nwp-cli temp 120"], + ), + CliCommand( + name="vacation", + help="Enable vacation mode for N days", + callback=handlers.handle_set_vacation_days_request, + args=["DAYS"], + options=[], + examples=["nwp-cli vacation 7"], + ), + CliCommand( + name="recirc", + help="Set recirculation pump mode", + callback=handlers.handle_set_recirculation_mode_request, + args=["MODE"], + options=[], + examples=["nwp-cli recirc 2"], + ), +] + + +def get_command(name: str) -> CliCommand | None: + """Lookup command by name.""" + return next((c for c in CLI_COMMANDS if c.name == name), None) + + +def list_commands() -> list[CliCommand]: + """Get all available commands.""" + return CLI_COMMANDS diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py new file mode 100644 index 0000000..c37d61a --- /dev/null +++ b/src/nwp500/cli/handlers.py @@ -0,0 +1,508 @@ +"""Command handlers for CLI operations.""" + +import asyncio +import json +import logging +from collections.abc import Callable, Coroutine +from typing import Any, TypeVar, cast + +from nwp500 import ( + Device, + DeviceFeature, + DeviceStatus, + EnergyUsageResponse, + NavienMqttClient, +) +from nwp500.exceptions import ( + DeviceError, + MqttError, + Nwp500Error, + RangeValidationError, + ValidationError, +) +from nwp500.mqtt.utils import redact_serial + +from .output_formatters import ( + print_device_info, + print_device_status, + print_energy_usage, + print_json, +) +from .rich_output import get_formatter + +_logger = logging.getLogger(__name__) +_formatter = get_formatter() + +T = TypeVar("T") + + +async def _wait_for_response( + subscribe_func: Callable[ + [Device, Callable[[Any], None]], Coroutine[Any, Any, Any] + ], + device: Device, + action_func: Callable[[], Coroutine[Any, Any, Any]], + timeout: float = 10.0, + action_name: str = "operation", +) -> Any: + """Generic helper to wait for a specific MQTT response.""" + future = asyncio.get_running_loop().create_future() + + def callback(res: Any) -> None: + if not future.done(): + future.set_result(res) + + await subscribe_func(device, callback) + _logger.info(f"Requesting {action_name}...") + await action_func() + + try: + return await asyncio.wait_for(future, timeout=timeout) + except TimeoutError: + _logger.error(f"Timed out waiting for {action_name} response.") + raise + + +async def _handle_command_with_status_feedback( + mqtt: NavienMqttClient, + device: Device, + action_func: Callable[[], Coroutine[Any, Any, Any]], + action_name: str, + success_msg: str, + print_status: bool = False, +) -> DeviceStatus | None: + """Helper for commands that wait for a DeviceStatus response.""" + try: + status: Any = await _wait_for_response( + mqtt.subscribe_device_status, + device, + action_func, + action_name=action_name, + ) + if print_status: + print_json(status.model_dump()) + _logger.info(success_msg) + _formatter.print_success(success_msg) + return cast(DeviceStatus, status) + except (ValidationError, RangeValidationError) as e: + _logger.error(f"Invalid parameters: {e}") + _formatter.print_error(str(e), title="Invalid Parameters") + except (MqttError, DeviceError, Nwp500Error) as e: + _logger.error(f"Error {action_name}: {e}") + _formatter.print_error( + str(e), title=f"Error During {action_name.title()}" + ) + except Exception as e: + _logger.error(f"Unexpected error {action_name}: {e}") + _formatter.print_error(str(e), title="Unexpected Error") + return None + + +async def get_controller_serial_number( + mqtt: NavienMqttClient, device: Device, timeout: float = 10.0 +) -> str | None: + """Retrieve controller serial number from device.""" + try: + feature: Any = await _wait_for_response( + mqtt.subscribe_device_feature, + device, + lambda: mqtt.control.request_device_info(device), + timeout=timeout, + action_name="controller serial", + ) + serial = cast(DeviceFeature, feature).controller_serial_number + _logger.info( + f"Controller serial number retrieved: {redact_serial(serial)}" + ) + return serial + except Exception: + return None + + +async def handle_get_controller_serial_request( + mqtt: NavienMqttClient, device: Device +) -> None: + """Request and display just the controller serial number.""" + serial = await get_controller_serial_number(mqtt, device) + if serial: + print(serial) + else: + _logger.error("Failed to retrieve controller serial number.") + + +async def _handle_info_request( + mqtt: NavienMqttClient, + device: Device, + subscribe_method: Callable[ + [Device, Callable[[Any], None]], Coroutine[Any, Any, Any] + ], + request_method: Callable[[Device], Coroutine[Any, Any, Any]], + data_key: str, + action_name: str, + raw: bool = False, + formatter: Callable[[Any], None] | None = None, +) -> None: + """Generic helper for requesting and displaying device information.""" + try: + if not raw: + res = await _wait_for_response( + subscribe_method, + device, + lambda: request_method(device), + action_name=action_name, + ) + if formatter: + formatter(res) + else: + print_json(res.model_dump()) + else: + future = asyncio.get_running_loop().create_future() + + def raw_cb(topic: str, message: dict[str, Any]) -> None: + if not future.done(): + res = message.get("response", {}).get( + data_key + ) or message.get(data_key) + if res: + print_json(res) + future.set_result(None) + + await mqtt.subscribe_device(device, raw_cb) + await request_method(device) + await asyncio.wait_for(future, timeout=10) + except Exception as e: + _logger.error(f"Failed to get {action_name}: {e}") + + +async def handle_status_request( + mqtt: NavienMqttClient, device: Device, raw: bool = False +) -> None: + """Request device status and print it.""" + await _handle_info_request( + mqtt, + device, + mqtt.subscribe_device_status, + mqtt.control.request_device_status, + "status", + "device status", + raw, + formatter=print_device_status if not raw else None, + ) + + +async def handle_device_info_request( + mqtt: NavienMqttClient, device: Device, raw: bool = False +) -> None: + """Request comprehensive device information.""" + await _handle_info_request( + mqtt, + device, + mqtt.subscribe_device_feature, + mqtt.control.request_device_info, + "feature", + "device information", + raw, + formatter=print_device_info if not raw else None, + ) + + +async def handle_set_mode_request( + mqtt: NavienMqttClient, device: Device, mode_name: str +) -> None: + """Set device operation mode.""" + mode_mapping = { + "standby": 0, + "heat-pump": 1, + "electric": 2, + "energy-saver": 3, + "high-demand": 4, + "vacation": 5, + } + mode_id = mode_mapping.get(mode_name.lower()) + if mode_id is None: + _logger.error( + f"Invalid mode '{mode_name}'. Valid: {list(mode_mapping.keys())}" + ) + return + + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.set_dhw_mode(device, mode_id), + "setting mode", + f"Mode changed to {mode_name}", + ) + + +async def handle_set_dhw_temp_request( + mqtt: NavienMqttClient, device: Device, temperature: float +) -> None: + """Set DHW target temperature.""" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.set_dhw_temperature(device, temperature), + "setting temperature", + f"Temperature set to {temperature}°F", + ) + + +async def handle_power_request( + mqtt: NavienMqttClient, device: Device, power_on: bool +) -> None: + """Set device power state.""" + state = "on" if power_on else "off" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.set_power(device, power_on), + f"turning {state}", + f"Device turned {state}", + ) + + +async def handle_get_reservations_request( + mqtt: NavienMqttClient, device: Device +) -> None: + """Request current reservation schedule.""" + future = asyncio.get_running_loop().create_future() + + def raw_callback(topic: str, message: dict[str, Any]) -> None: + if not future.done() and "response" in message: + from nwp500.encoding import ( + decode_reservation_hex, + decode_week_bitfield, + ) + + response = message.get("response", {}) + reservation_hex = response.get("reservation", "") + reservations = ( + decode_reservation_hex(reservation_hex) + if isinstance(reservation_hex, str) + else [] + ) + + output = { + "reservationUse": response.get("reservationUse", 0), + "reservationEnabled": response.get("reservationUse") == 1, + "reservations": [ + { + "number": i + 1, + "enabled": e.get("enable") == 1, + "days": decode_week_bitfield(e.get("week", 0)), + "time": f"{e.get('hour', 0):02d}:{e.get('min', 0):02d}", + "mode": e.get("mode"), + "temperatureF": e.get("param", 0) + 20, + "raw": e, + } + for i, e in enumerate(reservations) + ], + } + print_json(output) + future.set_result(None) + + device_type = str(device.device_info.device_type) + # Subscribe to all command responses from this device type + # Topic pattern: cmd/{device_type}/+/# matches all responses + response_pattern = f"cmd/{device_type}/+/#" + await mqtt.subscribe(response_pattern, raw_callback) + await mqtt.control.request_reservations(device) + try: + await asyncio.wait_for(future, timeout=10) + except TimeoutError: + _logger.error("Timed out waiting for reservations.") + + +async def handle_update_reservations_request( + mqtt: NavienMqttClient, + device: Device, + reservations_json: str, + enabled: bool, +) -> None: + """Update reservation schedule.""" + try: + reservations = json.loads(reservations_json) + if not isinstance(reservations, list): + raise ValueError("Must be a JSON array") + except (json.JSONDecodeError, ValueError) as e: + _logger.error(f"Invalid reservations JSON: {e}") + return + + future = asyncio.get_running_loop().create_future() + + def raw_callback(topic: str, message: dict[str, Any]) -> None: + if not future.done() and "response" in message: + print_json(message) + future.set_result(None) + + device_type = device.device_info.device_type + response_topic = f"cmd/{device_type}/+/+/{mqtt.client_id}/res/rsv/rd" + await mqtt.subscribe(response_topic, raw_callback) + await mqtt.control.update_reservations( + device, reservations, enabled=enabled + ) + try: + await asyncio.wait_for(future, timeout=10) + except TimeoutError: + _logger.error("Timed out updating reservations.") + + +async def handle_get_tou_request( + mqtt: NavienMqttClient, device: Device, api_client: Any +) -> None: + """Request Time-of-Use settings from REST API.""" + try: + serial = await get_controller_serial_number(mqtt, device) + if not serial: + _logger.error("Failed to get controller serial.") + return + + tou_info = await api_client.get_tou_info( + mac_address=device.device_info.mac_address, + additional_value=device.device_info.additional_value, + controller_id=serial, + user_type="O", + ) + print_json( + { + "name": tou_info.name, + "utility": tou_info.utility, + "zipCode": tou_info.zip_code, + "schedule": [ + {"season": s.season, "intervals": s.intervals} + for s in tou_info.schedule + ], + } + ) + except Exception as e: + _logger.error(f"Error fetching TOU: {e}") + + +async def handle_set_tou_enabled_request( + mqtt: NavienMqttClient, device: Device, enabled: bool +) -> None: + """Enable or disable Time-of-Use.""" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.set_tou_enabled(device, enabled), + f"{'enabling' if enabled else 'disabling'} TOU", + f"TOU {'enabled' if enabled else 'disabled'}", + ) + + +async def handle_get_energy_request( + mqtt: NavienMqttClient, device: Device, year: int, months: list[int] +) -> None: + """Request energy usage data.""" + try: + res: Any = await _wait_for_response( + mqtt.subscribe_energy_usage, + device, + lambda: mqtt.control.request_energy_usage(device, year, months), + action_name="energy usage", + timeout=15, + ) + print_energy_usage(cast(EnergyUsageResponse, res)) + except Exception as e: + _logger.error(f"Error getting energy data: {e}") + + +async def handle_reset_air_filter_request( + mqtt: NavienMqttClient, device: Device +) -> None: + """Reset air filter timer.""" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.reset_air_filter(device), + "resetting air filter", + "Air filter timer reset", + ) + + +async def handle_set_vacation_days_request( + mqtt: NavienMqttClient, device: Device, days: int +) -> None: + """Set vacation mode duration.""" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.set_vacation_days(device, days), + "setting vacation days", + f"Vacation days set to {days}", + ) + + +async def handle_set_recirculation_mode_request( + mqtt: NavienMqttClient, device: Device, mode: int +) -> None: + """Set recirculation pump mode.""" + mode_map = {1: "ALWAYS", 2: "BUTTON", 3: "SCHEDULE", 4: "TEMPERATURE"} + mode_name = mode_map.get(mode, str(mode)) + status = await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.set_recirculation_mode(device, mode), + "setting recirculation mode", + f"Recirculation mode set to {mode_name}", + ) + + if status and status.recirc_operation_mode.value != mode: + _logger.warning( + f"Device reported mode {status.recirc_operation_mode.name} " + f"instead of expected {mode_name}. External factor or " + "device state may have prevented the change." + ) + + +async def handle_trigger_recirculation_hot_button_request( + mqtt: NavienMqttClient, device: Device +) -> None: + """Trigger hot button.""" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.trigger_recirculation_hot_button(device), + "triggering hot button", + "Hot button triggered", + ) + + +async def handle_enable_demand_response_request( + mqtt: NavienMqttClient, device: Device +) -> None: + """Enable demand response.""" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.enable_demand_response(device), + "enabling DR", + "Demand response enabled", + ) + + +async def handle_disable_demand_response_request( + mqtt: NavienMqttClient, device: Device +) -> None: + """Disable demand response.""" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.disable_demand_response(device), + "disabling DR", + "Demand response disabled", + ) + + +async def handle_configure_reservation_water_program_request( + mqtt: NavienMqttClient, device: Device +) -> None: + """Configure water program.""" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.configure_reservation_water_program(device), + "configuring water program", + "Water program configured", + ) diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index 61ebb75..a802bd1 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -11,6 +11,8 @@ from nwp500 import DeviceStatus +from .rich_output import get_formatter + _logger = logging.getLogger(__name__) @@ -21,6 +23,58 @@ def _format_number(value: Any) -> str: return str(value) +def _get_unit_suffix(field_name: str, model_class: Any = DeviceStatus) -> str: + """Extract unit suffix from model field metadata. + + Args: + field_name: Name of the field to get unit for + model_class: The Pydantic model class (default: DeviceStatus) + + Returns: + Unit string (e.g., "°F", "GPM", "Wh") or empty string if not found + """ + if not hasattr(model_class, "model_fields"): + return "" + + model_fields = model_class.model_fields + if field_name not in model_fields: + return "" + + field_info = model_fields[field_name] + if not hasattr(field_info, "json_schema_extra"): + return "" + + extra = field_info.json_schema_extra + if isinstance(extra, dict) and "unit_of_measurement" in extra: + unit = extra["unit_of_measurement"] + return f" {unit}" if unit else "" + + return "" + + +def _add_numeric_item( + items: list[tuple[str, str, str]], + device_status: Any, + field_name: str, + category: str, + label: str, +) -> None: + """Add a numeric field with unit to items list, extracting unit from model. + + Args: + items: List to append to + device_status: DeviceStatus object + field_name: Name of the field to display + category: Category section in the output + label: Display label for the field + """ + if hasattr(device_status, field_name): + value = getattr(device_status, field_name) + unit = _get_unit_suffix(field_name) + formatted = f"{_format_number(value)}{unit}" + items.append((category, label, formatted)) + + def _json_default_serializer(obj: Any) -> Any: """Serialize objects not serializable by default json code. @@ -121,11 +175,48 @@ def print_energy_usage(energy_response: Any) -> None: """ Print energy usage data in human-readable tabular format. + Uses Rich formatting when available, falls back to plain text otherwise. + Args: energy_response: EnergyUsageResponse object """ + # First, print the plain text summary (always works) print(format_energy_usage(energy_response)) + # Also prepare and print rich table if available + months_data = [] + + if energy_response.usage: + for month_data in energy_response.usage: + month_name_str = ( + f"{month_name[month_data.month]} {month_data.year}" + if 1 <= month_data.month <= 12 + else f"Month {month_data.month} {month_data.year}" + ) + total_wh = sum( + d.heat_pump_usage + d.heat_element_usage + for d in month_data.data + ) + hp_wh = sum(d.heat_pump_usage for d in month_data.data) + he_wh = sum(d.heat_element_usage for d in month_data.data) + hp_pct = (hp_wh / total_wh * 100) if total_wh > 0 else 0 + he_pct = (he_wh / total_wh * 100) if total_wh > 0 else 0 + + months_data.append( + { + "month_str": month_name_str, + "total_kwh": total_wh / 1000, + "hp_kwh": hp_wh / 1000, + "hp_pct": hp_pct, + "he_kwh": he_wh / 1000, + "he_pct": he_pct, + } + ) + + # Print rich energy table if available + formatter = get_formatter() + formatter.print_energy_table(months_data) + def write_status_to_csv(file_path: str, status: DeviceStatus) -> None: """ @@ -178,19 +269,25 @@ def format_json_output(data: Any, indent: int = 2) -> str: def print_json(data: Any, indent: int = 2) -> None: """ - Print data as formatted JSON. + Print data as formatted JSON with optional syntax highlighting. + + Uses Rich highlighting when available, falls back to plain JSON otherwise. Args: data: Data to print indent: Number of spaces for indentation (default: 2) """ - print(format_json_output(data, indent)) + json_str = format_json_output(data, indent) + formatter = get_formatter() + formatter.print_json_highlighted(json.loads(json_str)) def print_device_status(device_status: Any) -> None: """ Print device status with aligned columns and dynamic width calculation. + Units are automatically extracted from the DeviceStatus model metadata. + Args: device_status: DeviceStatus object """ @@ -215,192 +312,170 @@ def print_device_status(device_status: Any) -> None: all_items.append( ("OPERATION STATUS", "State", device_status.current_statenum) ) - if hasattr(device_status, "current_inst_power"): - all_items.append( - ( - "OPERATION STATUS", - "Current Power", - f"{_format_number(device_status.current_inst_power)}W", - ) - ) + _add_numeric_item( + all_items, + device_status, + "current_inst_power", + "OPERATION STATUS", + "Current Power", + ) # Water Temperatures - if hasattr(device_status, "dhw_temperature"): - all_items.append( - ( - "WATER TEMPERATURES", - "DHW Current", - f"{_format_number(device_status.dhw_temperature)}°F", - ) - ) - if hasattr(device_status, "dhw_target_temperature_setting"): - all_items.append( - ( - "WATER TEMPERATURES", - "DHW Target", - f"{_format_number(device_status.dhw_target_temperature_setting)}°F", - ) - ) - if hasattr(device_status, "tank_upper_temperature"): - all_items.append( - ( - "WATER TEMPERATURES", - "Tank Upper", - f"{_format_number(device_status.tank_upper_temperature)}°F", - ) - ) - if hasattr(device_status, "tank_lower_temperature"): - all_items.append( - ( - "WATER TEMPERATURES", - "Tank Lower", - f"{_format_number(device_status.tank_lower_temperature)}°F", - ) - ) - if hasattr(device_status, "current_inlet_temperature"): - all_items.append( - ( - "WATER TEMPERATURES", - "Inlet Temp", - f"{_format_number(device_status.current_inlet_temperature)}°F", - ) - ) - if hasattr(device_status, "current_dhw_flow_rate"): - all_items.append( - ( - "WATER TEMPERATURES", - "DHW Flow Rate", - _format_number(device_status.current_dhw_flow_rate), - ) - ) + _add_numeric_item( + all_items, + device_status, + "dhw_temperature", + "WATER TEMPERATURES", + "DHW Current", + ) + _add_numeric_item( + all_items, + device_status, + "dhw_target_temperature_setting", + "WATER TEMPERATURES", + "DHW Target", + ) + _add_numeric_item( + all_items, + device_status, + "tank_upper_temperature", + "WATER TEMPERATURES", + "Tank Upper", + ) + _add_numeric_item( + all_items, + device_status, + "tank_lower_temperature", + "WATER TEMPERATURES", + "Tank Lower", + ) + _add_numeric_item( + all_items, + device_status, + "current_inlet_temperature", + "WATER TEMPERATURES", + "Inlet Temp", + ) + _add_numeric_item( + all_items, + device_status, + "current_dhw_flow_rate", + "WATER TEMPERATURES", + "DHW Flow Rate", + ) # Ambient Temperatures - if hasattr(device_status, "outside_temperature"): - all_items.append( - ( - "AMBIENT TEMPERATURES", - "Outside", - f"{_format_number(device_status.outside_temperature)}°F", - ) - ) - if hasattr(device_status, "ambient_temperature"): - all_items.append( - ( - "AMBIENT TEMPERATURES", - "Ambient", - f"{_format_number(device_status.ambient_temperature)}°F", - ) - ) + _add_numeric_item( + all_items, + device_status, + "outside_temperature", + "AMBIENT TEMPERATURES", + "Outside", + ) + _add_numeric_item( + all_items, + device_status, + "ambient_temperature", + "AMBIENT TEMPERATURES", + "Ambient", + ) # System Temperatures - if hasattr(device_status, "discharge_temperature"): - all_items.append( - ( - "SYSTEM TEMPERATURES", - "Discharge", - f"{_format_number(device_status.discharge_temperature)}°F", - ) - ) - if hasattr(device_status, "suction_temperature"): - all_items.append( - ( - "SYSTEM TEMPERATURES", - "Suction", - f"{_format_number(device_status.suction_temperature)}°F", - ) - ) - if hasattr(device_status, "evaporator_temperature"): - all_items.append( - ( - "SYSTEM TEMPERATURES", - "Evaporator", - f"{_format_number(device_status.evaporator_temperature)}°F", - ) - ) - if hasattr(device_status, "target_super_heat"): - all_items.append( - ( - "SYSTEM TEMPERATURES", - "Target SuperHeat", - _format_number(device_status.target_super_heat), - ) - ) - if hasattr(device_status, "current_super_heat"): - all_items.append( - ( - "SYSTEM TEMPERATURES", - "Current SuperHeat", - _format_number(device_status.current_super_heat), - ) - ) + _add_numeric_item( + all_items, + device_status, + "discharge_temperature", + "SYSTEM TEMPERATURES", + "Discharge", + ) + _add_numeric_item( + all_items, + device_status, + "suction_temperature", + "SYSTEM TEMPERATURES", + "Suction", + ) + _add_numeric_item( + all_items, + device_status, + "evaporator_temperature", + "SYSTEM TEMPERATURES", + "Evaporator", + ) + _add_numeric_item( + all_items, + device_status, + "target_super_heat", + "SYSTEM TEMPERATURES", + "Target SuperHeat", + ) + _add_numeric_item( + all_items, + device_status, + "current_super_heat", + "SYSTEM TEMPERATURES", + "Current SuperHeat", + ) # Heat Pump Settings - if hasattr(device_status, "hp_upper_on_temp_setting"): - all_items.append( - ( - "HEAT PUMP SETTINGS", - "Upper On", - f"{_format_number(device_status.hp_upper_on_temp_setting)}°F", - ) - ) - if hasattr(device_status, "hp_upper_off_temp_setting"): - all_items.append( - ( - "HEAT PUMP SETTINGS", - "Upper Off", - f"{_format_number(device_status.hp_upper_off_temp_setting)}°F", - ) - ) - if hasattr(device_status, "hp_lower_on_temp_setting"): - all_items.append( - ( - "HEAT PUMP SETTINGS", - "Lower On", - f"{_format_number(device_status.hp_lower_on_temp_setting)}°F", - ) - ) - if hasattr(device_status, "hp_lower_off_temp_setting"): - all_items.append( - ( - "HEAT PUMP SETTINGS", - "Lower Off", - f"{_format_number(device_status.hp_lower_off_temp_setting)}°F", - ) - ) + _add_numeric_item( + all_items, + device_status, + "hp_upper_on_temp_setting", + "HEAT PUMP SETTINGS", + "Upper On", + ) + _add_numeric_item( + all_items, + device_status, + "hp_upper_off_temp_setting", + "HEAT PUMP SETTINGS", + "Upper Off", + ) + _add_numeric_item( + all_items, + device_status, + "hp_lower_on_temp_setting", + "HEAT PUMP SETTINGS", + "Lower On", + ) + _add_numeric_item( + all_items, + device_status, + "hp_lower_off_temp_setting", + "HEAT PUMP SETTINGS", + "Lower Off", + ) # Heat Element Settings - if hasattr(device_status, "he_upper_on_temp_setting"): - all_items.append( - ( - "HEAT ELEMENT SETTINGS", - "Upper On", - f"{_format_number(device_status.he_upper_on_temp_setting)}°F", - ) - ) - if hasattr(device_status, "he_upper_off_temp_setting"): - all_items.append( - ( - "HEAT ELEMENT SETTINGS", - "Upper Off", - f"{_format_number(device_status.he_upper_off_temp_setting)}°F", - ) - ) - if hasattr(device_status, "he_lower_on_temp_setting"): - all_items.append( - ( - "HEAT ELEMENT SETTINGS", - "Lower On", - f"{_format_number(device_status.he_lower_on_temp_setting)}°F", - ) - ) - if hasattr(device_status, "he_lower_off_temp_setting"): - all_items.append( - ( - "HEAT ELEMENT SETTINGS", - "Lower Off", - f"{_format_number(device_status.he_lower_off_temp_setting)}°F", - ) - ) + _add_numeric_item( + all_items, + device_status, + "he_upper_on_temp_setting", + "HEAT ELEMENT SETTINGS", + "Upper On", + ) + _add_numeric_item( + all_items, + device_status, + "he_upper_off_temp_setting", + "HEAT ELEMENT SETTINGS", + "Upper Off", + ) + _add_numeric_item( + all_items, + device_status, + "he_lower_on_temp_setting", + "HEAT ELEMENT SETTINGS", + "Lower On", + ) + _add_numeric_item( + all_items, + device_status, + "he_lower_off_temp_setting", + "HEAT ELEMENT SETTINGS", + "Lower Off", + ) # Power & Energy if hasattr(device_status, "wh_total_power_consumption"): @@ -427,66 +502,64 @@ def print_device_status(device_status: Any) -> None: f"{_format_number(device_status.wh_electric_heater_power)}Wh", ) ) - if hasattr(device_status, "total_energy_capacity"): - all_items.append( - ( - "POWER & ENERGY", - "Total Capacity", - _format_number(device_status.total_energy_capacity), - ) - ) - if hasattr(device_status, "available_energy_capacity"): - all_items.append( - ( - "POWER & ENERGY", - "Available Capacity", - _format_number(device_status.available_energy_capacity), - ) - ) + _add_numeric_item( + all_items, + device_status, + "total_energy_capacity", + "POWER & ENERGY", + "Total Capacity", + ) + _add_numeric_item( + all_items, + device_status, + "available_energy_capacity", + "POWER & ENERGY", + "Available Capacity", + ) # Fan Control - if hasattr(device_status, "target_fan_rpm"): - target_rpm = _format_number(device_status.target_fan_rpm) - all_items.append(("FAN CONTROL", "Target RPM", target_rpm)) - if hasattr(device_status, "current_fan_rpm"): - current_rpm = _format_number(device_status.current_fan_rpm) - all_items.append(("FAN CONTROL", "Current RPM", current_rpm)) + _add_numeric_item( + all_items, device_status, "target_fan_rpm", "FAN CONTROL", "Target RPM" + ) + _add_numeric_item( + all_items, + device_status, + "current_fan_rpm", + "FAN CONTROL", + "Current RPM", + ) if hasattr(device_status, "fan_pwm"): pwm_pct = f"{_format_number(device_status.fan_pwm)}%" all_items.append(("FAN CONTROL", "PWM", pwm_pct)) - if hasattr(device_status, "cumulated_op_time_eva_fan"): - eva_fan_time = _format_number(device_status.cumulated_op_time_eva_fan) - all_items.append( - ( - "FAN CONTROL", - "Eva Fan Time", - eva_fan_time, - ) - ) + _add_numeric_item( + all_items, + device_status, + "cumulated_op_time_eva_fan", + "FAN CONTROL", + "Eva Fan Time", + ) # Compressor & Valve if hasattr(device_status, "mixing_rate"): - mixing = _format_number(device_status.mixing_rate) + mixing = f"{_format_number(device_status.mixing_rate)}%" all_items.append(("COMPRESSOR & VALVE", "Mixing Rate", mixing)) if hasattr(device_status, "eev_step"): - eev = _format_number(device_status.eev_step) + eev = f"{_format_number(device_status.eev_step)} steps" all_items.append(("COMPRESSOR & VALVE", "EEV Step", eev)) - if hasattr(device_status, "target_super_heat"): - all_items.append( - ( - "COMPRESSOR & VALVE", - "Target SuperHeat", - _format_number(device_status.target_super_heat), - ) - ) - if hasattr(device_status, "current_super_heat"): - all_items.append( - ( - "COMPRESSOR & VALVE", - "Current SuperHeat", - _format_number(device_status.current_super_heat), - ) - ) + _add_numeric_item( + all_items, + device_status, + "target_super_heat", + "COMPRESSOR & VALVE", + "Target SuperHeat", + ) + _add_numeric_item( + all_items, + device_status, + "current_super_heat", + "COMPRESSOR & VALVE", + "Current SuperHeat", + ) # Recirculation if hasattr(device_status, "recirc_operation_mode"): @@ -505,22 +578,20 @@ def print_device_status(device_status: Any) -> None: device_status.recirc_pump_operation_status, ) ) - if hasattr(device_status, "recirc_temperature"): - all_items.append( - ( - "RECIRCULATION", - "Temperature", - f"{_format_number(device_status.recirc_temperature)}°F", - ) - ) - if hasattr(device_status, "recirc_faucet_temperature"): - all_items.append( - ( - "RECIRCULATION", - "Faucet Temp", - f"{_format_number(device_status.recirc_faucet_temperature)}°F", - ) - ) + _add_numeric_item( + all_items, + device_status, + "recirc_temperature", + "RECIRCULATION", + "Temperature", + ) + _add_numeric_item( + all_items, + device_status, + "recirc_faucet_temperature", + "RECIRCULATION", + "Faucet Temp", + ) # Status & Alerts if hasattr(device_status, "error_code"): @@ -549,41 +620,41 @@ def print_device_status(device_status: Any) -> None: ) # Vacation Mode - if hasattr(device_status, "vacation_day_setting"): - all_items.append( - ("VACATION MODE", "Days Set", device_status.vacation_day_setting) - ) - if hasattr(device_status, "vacation_day_elapsed"): - all_items.append( - ( - "VACATION MODE", - "Days Elapsed", - device_status.vacation_day_elapsed, - ) - ) + _add_numeric_item( + all_items, + device_status, + "vacation_day_setting", + "VACATION MODE", + "Days Set", + ) + _add_numeric_item( + all_items, + device_status, + "vacation_day_elapsed", + "VACATION MODE", + "Days Elapsed", + ) # Air Filter - if hasattr(device_status, "air_filter_alarm_period"): - all_items.append( - ( - "AIR FILTER", - "Alarm Period", - f"{device_status.air_filter_alarm_period}h", - ) - ) - if hasattr(device_status, "air_filter_alarm_elapsed"): - all_items.append( - ( - "AIR FILTER", - "Alarm Elapsed", - f"{device_status.air_filter_alarm_elapsed}h", - ) - ) + _add_numeric_item( + all_items, + device_status, + "air_filter_alarm_period", + "AIR FILTER", + "Alarm Period", + ) + _add_numeric_item( + all_items, + device_status, + "air_filter_alarm_elapsed", + "AIR FILTER", + "Alarm Elapsed", + ) # WiFi & Network - if hasattr(device_status, "wifi_rssi"): - rssi_dbm = f"{_format_number(device_status.wifi_rssi)} dBm" - all_items.append(("WiFi & NETWORK", "RSSI", rssi_dbm)) + _add_numeric_item( + all_items, device_status, "wifi_rssi", "WiFi & NETWORK", "RSSI" + ) # Demand Response & TOU if hasattr(device_status, "dr_event_status"): @@ -594,14 +665,13 @@ def print_device_status(device_status: Any) -> None: device_status.dr_event_status, ) ) - if hasattr(device_status, "dr_override_status"): - all_items.append( - ( - "DEMAND RESPONSE & TOU", - "DR Override Status", - device_status.dr_override_status, - ) - ) + _add_numeric_item( + all_items, + device_status, + "dr_override_status", + "DEMAND RESPONSE & TOU", + "DR Override Status", + ) if hasattr(device_status, "tou_status"): all_items.append( ("DEMAND RESPONSE & TOU", "TOU Status", device_status.tou_status) @@ -616,14 +686,13 @@ def print_device_status(device_status: Any) -> None: ) # Anti-Legionella - if hasattr(device_status, "anti_legionella_period"): - all_items.append( - ( - "ANTI-LEGIONELLA", - "Period", - f"{device_status.anti_legionella_period}h", - ) - ) + _add_numeric_item( + all_items, + device_status, + "anti_legionella_period", + "ANTI-LEGIONELLA", + "Period", + ) if hasattr(device_status, "anti_legionella_operation_busy"): all_items.append( ( @@ -638,25 +707,11 @@ def print_device_status(device_status: Any) -> None: max_value_len = max( (len(str(value)) for _, _, value in all_items), default=20 ) - line_width = max_label_len + max_value_len + 4 # +4 for padding + _line_width = max_label_len + max_value_len + 4 # +4 for padding - # Print header - print("=" * line_width) - print("DEVICE STATUS") - print("=" * line_width) - - # Print items grouped by category - current_category = None - for category, label, value in all_items: - if category != current_category: - if current_category is not None: - print() - print(category) - print("-" * line_width) - current_category = category - print(f" {label:<{max_label_len}} {value}") - - print("=" * line_width) + # Use rich formatter for output + formatter = get_formatter() + formatter.print_status_table(all_items) def print_device_info(device_feature: Any) -> None: @@ -843,22 +898,8 @@ def print_device_info(device_feature: Any) -> None: max_value_len = max( (len(str(value)) for _, _, value in all_items), default=20 ) - line_width = max_label_len + max_value_len + 4 # +4 for padding - - # Print header - print("=" * line_width) - print("DEVICE INFORMATION") - print("=" * line_width) - - # Print items grouped by category - current_category = None - for category, label, value in all_items: - if category != current_category: - if current_category is not None: - print() - print(category) - print("-" * line_width) - current_category = category - print(f" {label:<{max_label_len}} {value}") - - print("=" * line_width) + _line_width = max_label_len + max_value_len + 4 # +4 for padding + + # Use rich formatter for output + formatter = get_formatter() + formatter.print_status_table(all_items) diff --git a/src/nwp500/cli/rich_output.py b/src/nwp500/cli/rich_output.py new file mode 100644 index 0000000..eae02ac --- /dev/null +++ b/src/nwp500/cli/rich_output.py @@ -0,0 +1,522 @@ +"""Rich-enhanced output formatting with graceful fallback.""" + +import json +import logging +import os +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from rich.console import Console + from rich.markdown import Markdown + from rich.panel import Panel + from rich.syntax import Syntax + from rich.table import Table + from rich.text import Text + from rich.tree import Tree + +_rich_available = False + +try: + from rich.console import Console + from rich.markdown import Markdown + from rich.panel import Panel + from rich.syntax import Syntax + from rich.table import Table + from rich.text import Text + from rich.tree import Tree + + _rich_available = True +except ImportError: + Console = None # type: ignore[assignment,misc] + Markdown = None # type: ignore[assignment,misc] + Panel = None # type: ignore[assignment,misc] + Syntax = None # type: ignore[assignment,misc] + Table = None # type: ignore[assignment,misc] + Text = None # type: ignore[assignment,misc] + Tree = None # type: ignore[assignment,misc] + +_logger = logging.getLogger(__name__) + + +def _should_use_rich() -> bool: + """Check if Rich should be used. + + Returns: + True if Rich is available and enabled, False otherwise. + """ + if not _rich_available: + return False + # Allow explicit override via environment variable + return os.getenv("NWP500_NO_RICH", "0") != "1" + + +class OutputFormatter: + """Unified output formatter with Rich enhancement support. + + Automatically detects Rich availability and routes output to the + appropriate formatter. Falls back to plain text when Rich is + unavailable or explicitly disabled. + """ + + def __init__(self) -> None: + """Initialize the formatter.""" + self.use_rich = _should_use_rich() + if self.use_rich: + assert Console is not None + self.console: Any = Console() + else: + self.console: Any = None + + def print_status_table(self, items: list[tuple[str, str, str]]) -> None: + """Print status items as a formatted table. + + Args: + items: List of (category, label, value) tuples + """ + if not self.use_rich: + self._print_status_plain(items) + else: + self._print_status_rich(items) + + def print_energy_table(self, months: list[dict[str, Any]]) -> None: + """Print energy usage data as a formatted table. + + Args: + months: List of monthly energy data dictionaries + """ + if not self.use_rich: + self._print_energy_plain(months) + else: + self._print_energy_rich(months) + + def print_error( + self, + message: str, + title: str = "Error", + details: list[str] | None = None, + ) -> None: + """Print an error message. + + Args: + message: Main error message + title: Panel title + details: Optional list of detail lines + """ + if not self.use_rich: + self._print_error_plain(message, title, details) + else: + self._print_error_rich(message, title, details) + + def print_success(self, message: str) -> None: + """Print a success message. + + Args: + message: Success message to display + """ + if not self.use_rich: + print(f"✓ {message}") + else: + self._print_success_rich(message) + + def print_info(self, message: str) -> None: + """Print an info message. + + Args: + message: Info message to display + """ + if not self.use_rich: + print(f"ℹ {message}") + else: + self._print_info_rich(message) + + def print_device_list(self, devices: list[dict[str, Any]]) -> None: + """Print list of devices with status indicators. + + Args: + devices: List of device dictionaries with status info + """ + if not self.use_rich: + self._print_device_list_plain(devices) + else: + self._print_device_list_rich(devices) + + # Plain text implementations (fallback) + + def _print_status_plain(self, items: list[tuple[str, str, str]]) -> None: + """Plain text status output (fallback).""" + # Calculate widths + max_label = max((len(label) for _, label, _ in items), default=20) + max_value = max((len(str(value)) for _, _, value in items), default=20) + width = max_label + max_value + 4 + + # Print header + print("=" * width) + print("DEVICE STATUS") + print("=" * width) + + # Print items grouped by category + if items: + current_category: str | None = None + for category, label, value in items: + if category != current_category: + if current_category is not None: + print() + print(category) + print("-" * width) + current_category = category + print(f" {label:<{max_label}} {value}") + + print("=" * width) + + def _print_energy_plain(self, months: list[dict[str, Any]]) -> None: + """Plain text energy output (fallback).""" + # This is a simplified version - the actual rendering comes from + # output_formatters.format_energy_usage() + print("ENERGY USAGE REPORT") + print("=" * 90) + for month in months: + print(f"{month}") + + def _print_device_list_plain(self, devices: list[dict[str, Any]]) -> None: + """Plain text device list output (fallback).""" + if not devices: + print("No devices found") + return + + print("DEVICES") + print("-" * 80) + for device in devices: + name = device.get("name", "Unknown") + status = device.get("status", "Unknown") + temp = device.get("temperature", "N/A") + print(f" {name:<20} {status:<15} {temp}") + print("-" * 80) + + def _print_error_plain( + self, + message: str, + title: str, + details: list[str] | None = None, + ) -> None: + """Plain text error output (fallback).""" + print(f"{title}: {message}") + if details: + for detail in details: + print(f" • {detail}") + + def _print_success_rich(self, message: str) -> None: + """Rich-enhanced success output.""" + assert self.console is not None + assert _rich_available + panel = cast(Any, Panel)( + f"[green]✓ {message}[/green]", + border_style="green", + padding=(0, 2), + ) + self.console.print(panel) + + def _print_info_rich(self, message: str) -> None: + """Rich-enhanced info output.""" + assert self.console is not None + assert _rich_available + panel = cast(Any, Panel)( + f"[blue]ℹ {message}[/blue]", + border_style="blue", + padding=(0, 2), + ) + self.console.print(panel) + + def _print_device_list_rich(self, devices: list[dict[str, Any]]) -> None: + """Rich-enhanced device list output.""" + assert self.console is not None + assert _rich_available + + if not devices: + panel = cast(Any, Panel)("No devices found", border_style="yellow") + self.console.print(panel) + return + + table = cast(Any, Table)(title="🏘️ Devices", show_header=True) + table.add_column("Device Name", style="cyan", width=20) + table.add_column("Status", width=15) + table.add_column("Temperature", style="magenta", width=15) + table.add_column("Power", width=12) + table.add_column("Updated", style="dim", width=12) + + for device in devices: + name = device.get("name", "Unknown") + status = device.get("status", "unknown").lower() + temp = device.get("temperature", "N/A") + power = device.get("power", "N/A") + updated = device.get("updated", "Never") + + # Status indicator + if status == "online": + status_indicator = "🟢 Online" + elif status == "idle": + status_indicator = "🟡 Idle" + elif status == "offline": + status_indicator = "🔴 Offline" + else: + status_indicator = f"⚪ {status}" + + table.add_row( + name, status_indicator, str(temp), str(power), updated + ) + + self.console.print(table) + + # Rich implementations + + def _print_status_rich(self, items: list[tuple[str, str, str]]) -> None: + """Rich-enhanced status output.""" + assert self.console is not None + assert _rich_available + + table = cast(Any, Table)(title="DEVICE STATUS", show_header=False) + + if not items: + # If no items, just print the header using plain text + # to match expected output + self._print_status_plain(items) + return + + current_category: str | None = None + for category, label, value in items: + if category != current_category: + # Add category row + if current_category is not None: + table.add_row() + table.add_row( + cast(Any, Text)(category, style="bold cyan"), + ) + current_category = category + + # Add data row with styling + table.add_row( + cast(Any, Text)(f" {label}", style="magenta"), + cast(Any, Text)(str(value), style="green"), + ) + + self.console.print(table) + + def _print_energy_rich(self, months: list[dict[str, Any]]) -> None: + """Rich-enhanced energy output.""" + assert self.console is not None + assert _rich_available + + table = cast(Any, Table)(title="ENERGY USAGE REPORT", show_header=True) + table.add_column("Month", style="cyan", width=15) + table.add_column( + "Total kWh", style="magenta", justify="right", width=12 + ) + table.add_column("HP Usage", width=18) + table.add_column("HE Usage", width=18) + + for month in months: + month_str = month.get("month_str", "N/A") + total_kwh = month.get("total_kwh", 0) + hp_kwh = month.get("hp_kwh", 0) + he_kwh = month.get("he_kwh", 0) + hp_pct = month.get("hp_pct", 0) + he_pct = month.get("he_pct", 0) + + # Create progress bar representations + hp_bar = self._create_progress_bar(hp_pct, 10) + he_bar = self._create_progress_bar(he_pct, 10) + + # Color code based on efficiency + hp_color = ( + "green" + if hp_pct >= 70 + else ("yellow" if hp_pct >= 50 else "red") + ) + he_color = ( + "red" + if he_pct >= 50 + else ("yellow" if he_pct >= 30 else "green") + ) + + hp_text = ( + f"{hp_kwh:.1f} kWh " + f"[{hp_color}]{hp_pct:.0f}%[/{hp_color}]\n{hp_bar}" + ) + he_text = ( + f"{he_kwh:.1f} kWh " + f"[{he_color}]{he_pct:.0f}%[/{he_color}]\n{he_bar}" + ) + + table.add_row(month_str, f"{total_kwh:.1f}", hp_text, he_text) + + self.console.print(table) + + def _create_progress_bar(self, percentage: float, width: int = 10) -> str: + """Create a simple progress bar string. + + Args: + percentage: Percentage value (0-100) + width: Width of the bar in characters + + Returns: + Progress bar string + """ + filled = int((percentage / 100) * width) + bar = "█" * filled + "░" * (width - filled) + return f"[{bar}]" + + def _print_error_rich( + self, + message: str, + title: str, + details: list[str] | None = None, + ) -> None: + """Rich-enhanced error output.""" + assert self.console is not None + assert _rich_available + + content = f"❌ {title}\n\n{message}" + if details: + content += "\n\nDetails:" + for detail in details: + content += f"\n • {detail}" + + panel = cast(Any, Panel)( + content, + border_style="red", + padding=(1, 2), + ) + self.console.print(panel) + + # Phase 3: Advanced Features + + def print_json_highlighted(self, data: Any) -> None: + """Print JSON with syntax highlighting. + + Args: + data: Data to print as JSON + """ + if not self.use_rich: + print(json.dumps(data, indent=2, default=str)) + else: + self._print_json_highlighted_rich(data) + + def print_device_tree( + self, device_name: str, device_info: dict[str, Any] + ) -> None: + """Print device information as a tree structure. + + Args: + device_name: Name of the device + device_info: Dictionary of device information + """ + if not self.use_rich: + self._print_device_tree_plain(device_name, device_info) + else: + self._print_device_tree_rich(device_name, device_info) + + def print_markdown_report(self, markdown_content: str) -> None: + """Print markdown-formatted content. + + Args: + markdown_content: Markdown formatted string + """ + if not self.use_rich: + print(markdown_content) + else: + self._print_markdown_rich(markdown_content) + + # Plain text implementations (Phase 3 fallback) + + def _print_json_highlighted_plain(self, data: Any) -> None: + """Plain text JSON output (fallback).""" + print(json.dumps(data, indent=2, default=str)) + + def _print_device_tree_plain( + self, device_name: str, device_info: dict[str, Any] + ) -> None: + """Plain text tree output (fallback).""" + print(f"Device: {device_name}") + for key, value in device_info.items(): + print(f" {key}: {value}") + + # Rich implementations (Phase 3) + + def _print_json_highlighted_rich(self, data: Any) -> None: + """Rich-enhanced JSON output with syntax highlighting.""" + assert self.console is not None + assert _rich_available + + json_str = json.dumps(data, indent=2, default=str) + syntax = cast(Any, Syntax)( + json_str, "json", theme="monokai", line_numbers=False + ) + self.console.print(syntax) + + def _print_device_tree_rich( + self, device_name: str, device_info: dict[str, Any] + ) -> None: + """Rich-enhanced tree output for device information.""" + assert self.console is not None + assert _rich_available + + tree = cast(Any, Tree)(f"📱 {device_name}", guide_style="bold cyan") + + # Organize info into categories + categories = { + "🆔 Identity": [ + "serial_number", + "model_type", + "country_code", + "volume_code", + ], + "🔧 Firmware": [ + "controller_version", + "panel_version", + "wifi_version", + "recirc_version", + ], + "⚙️ Configuration": [ + "temperature_unit", + "dhw_temp_range", + "freeze_protection_range", + ], + "✨ Features": [ + "power_control", + "heat_pump_mode", + "recirculation", + "energy_usage", + ], + } + + for category, keys in categories.items(): + category_node = tree.add(category) + for key in keys: + if key in device_info: + value = device_info[key] + category_node.add(f"{key}: [green]{value}[/green]") + + self.console.print(tree) + + def _print_markdown_rich(self, content: str) -> None: + """Rich-enhanced markdown rendering.""" + assert self.console is not None + assert _rich_available + + markdown = cast(Any, Markdown)(content) + self.console.print(markdown) + + +# Global formatter instance +_formatter: OutputFormatter | None = None + + +def get_formatter() -> OutputFormatter: + """Get the global formatter instance. + + Returns: + OutputFormatter instance with Rich support if available. + """ + global _formatter + if _formatter is None: + _formatter = OutputFormatter() + return _formatter diff --git a/src/nwp500/command_decorators.py b/src/nwp500/command_decorators.py index f92fcdc..4395212 100644 --- a/src/nwp500/command_decorators.py +++ b/src/nwp500/command_decorators.py @@ -10,7 +10,7 @@ from collections.abc import Callable from typing import Any, TypeVar -from .device_capabilities import DeviceCapabilityChecker +from .device_capabilities import MqttDeviceCapabilityChecker from .exceptions import DeviceCapabilityError __author__ = "Emmanuel Levijarvi" @@ -94,13 +94,13 @@ async def async_wrapper( # Validate capability if feature is defined in DeviceFeature if hasattr(cached_features, feature): - supported = DeviceCapabilityChecker.supports( + supported = MqttDeviceCapabilityChecker.supports( feature, cached_features ) _logger.debug( f"Cap '{feature}': {'OK' if supported else 'FAIL'}" ) - DeviceCapabilityChecker.assert_supported( + MqttDeviceCapabilityChecker.assert_supported( feature, cached_features ) else: diff --git a/src/nwp500/constants.py b/src/nwp500/constants.py index 411867a..474d9df 100644 --- a/src/nwp500/constants.py +++ b/src/nwp500/constants.py @@ -6,7 +6,7 @@ # Note for maintainers: # Command codes and expected payload fields are defined in -# `docs/MQTT_MESSAGES.rst` under the "Control Messages" section and +# `docs/protocol/mqtt_protocol.rst` under the "Control Messages" section and # the subsections for Power Control, DHW Mode, Anti-Legionella, # Reservation Management and TOU Settings. When updating constants or # payload builders, verify against that document to avoid protocol diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py new file mode 100644 index 0000000..2957381 --- /dev/null +++ b/src/nwp500/converters.py @@ -0,0 +1,154 @@ +"""Protocol-specific converters for Navien device communication. + +This module handles conversion of device-specific data formats to Python types. +The Navien device uses non-standard representations for boolean and numeric +values. + +See docs/protocol/quick_reference.rst for comprehensive protocol details. +""" + +from collections.abc import Callable +from typing import Any + +__all__ = [ + "device_bool_to_python", + "device_bool_from_python", + "tou_status_to_python", + "tou_override_to_python", + "div_10", +] + + +def device_bool_to_python(value: Any) -> bool: + """Convert device boolean representation to Python bool. + + Device protocol uses: 1 = OFF/False, 2 = ON/True + + This design (using 1 and 2 instead of 0 and 1) is likely due to: + - 0 being reserved for null/uninitialized state + - 1 representing "off" in legacy firmware + - 2 representing "on" state + + Args: + value: Device value (typically 1 or 2). + + Returns: + Python boolean (1→False, 2→True). + + Example: + >>> device_bool_to_python(2) + True + >>> device_bool_to_python(1) + False + """ + return bool(value == 2) + + +def device_bool_from_python(value: bool) -> int: + """Convert Python bool to device boolean representation. + + Args: + value: Python boolean. + + Returns: + Device value (True→2, False→1). + + Example: + >>> device_bool_from_python(True) + 2 + >>> device_bool_from_python(False) + 1 + """ + return 2 if value else 1 + + +def tou_status_to_python(value: Any) -> bool: + """Convert Time of Use status to Python bool. + + Device representation: 0 = Off/False, 1 = On/True + + Args: + value: Device TOU status value. + + Returns: + Python boolean. + + Example: + >>> tou_status_to_python(1) + True + >>> tou_status_to_python(0) + False + """ + return bool(value == 1) + + +def tou_override_to_python(value: Any) -> bool: + """Convert TOU override status to Python bool. + + Device representation: 1 = Override Active, 2 = Override Inactive + + Args: + value: Device TOU override status value. + + Returns: + Python boolean. + + Example: + >>> tou_override_to_python(1) + True + >>> tou_override_to_python(2) + False + """ + return bool(value == 1) + + +def div_10(value: Any) -> float: + """Divide numeric value by 10.0. + + Used for fields that need 0.1 precision conversion. + + Args: + value: Numeric value to divide. + + Returns: + Value divided by 10.0. + + Example: + >>> div_10(150) + 15.0 + >>> div_10(25.5) + 2.55 + """ + if isinstance(value, (int, float)): + return float(value) / 10.0 + return float(value) + + +def enum_validator(enum_class: type[Any]) -> Callable[[Any], Any]: + """Create a validator for converting int/value to Enum. + + Args: + enum_class: The Enum class to validate against. + + Returns: + A validator function compatible with Pydantic BeforeValidator. + + Example: + >>> from enum import Enum + >>> class Color(Enum): + ... RED = 1 + ... BLUE = 2 + >>> validator = enum_validator(Color) + >>> validator(1) + + """ + + def validate(value: Any) -> Any: + """Validate and convert value to enum.""" + if isinstance(value, enum_class): + return value + if isinstance(value, int): + return enum_class(value) + return enum_class(int(value)) + + return validate diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py index 456afd0..06f48c8 100644 --- a/src/nwp500/device_capabilities.py +++ b/src/nwp500/device_capabilities.py @@ -21,8 +21,8 @@ CapabilityCheckFn = Callable[["DeviceFeature"], bool] -class DeviceCapabilityChecker: - """Generalized device capability checker using a capability map. +class MqttDeviceCapabilityChecker: + """Generalized MQTT device capability checker using a capability map. This class uses a mapping of controllable feature names to their check functions, allowing capabilities to be validated in a centralized, diff --git a/src/nwp500/device_info_cache.py b/src/nwp500/device_info_cache.py index 16f5177..5228e5b 100644 --- a/src/nwp500/device_info_cache.py +++ b/src/nwp500/device_info_cache.py @@ -9,8 +9,6 @@ from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, TypedDict -from .mqtt_utils import redact_mac - if TYPE_CHECKING: from .models import DeviceFeature @@ -36,7 +34,7 @@ class CacheInfoResult(TypedDict): devices: list[CachedDeviceInfo] -class DeviceInfoCache: +class MqttDeviceInfoCache: """Manages caching of device information with periodic updates. This cache stores device features (capabilities, firmware info, etc.) @@ -103,6 +101,8 @@ async def invalidate(self, device_mac: str) -> None: async with self._lock: if device_mac in self._cache: del self._cache[device_mac] + from .mqtt.utils import redact_mac + redacted = redact_mac(device_mac) _logger.debug(f"Invalidated cache for {redacted}") diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 57078d0..6da33a2 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -72,7 +72,8 @@ def encode_week_bitfield(days: Iterable[str | int]) -> int: value=value, ) bitfield |= WEEKDAY_NAME_TO_BIT[key] - elif isinstance(value, int): + else: + # At this point, value must be int (from type hint str | int) if 0 <= value <= 6: bitfield |= 1 << value elif 1 <= value <= 7: @@ -86,8 +87,6 @@ def encode_week_bitfield(days: Iterable[str | int]) -> int: min_value=0, max_value=7, ) - else: - raise TypeError("Weekday values must be strings or integers") return bitfield diff --git a/src/nwp500/enums.py b/src/nwp500/enums.py index a5c788a..cfc6ab0 100644 --- a/src/nwp500/enums.py +++ b/src/nwp500/enums.py @@ -3,6 +3,8 @@ This module contains enumerations for the Navien device protocol. These enums define valid values for device control commands, status fields, and capabilities. + +See docs/protocol/quick_reference.rst for comprehensive protocol details. """ from enum import IntEnum @@ -214,6 +216,18 @@ class HeatControl(IntEnum): # ============================================================================ +class VolumeCode(IntEnum): + """Tank volume capacity codes for NWP500 heat pump water heater models. + + Represents the nominal tank capacity in gallons for NWP500 series devices. + These correspond to the different model variants available. + """ + + VOLUME_50 = 1 # NWP500-50: 50-gallon (189.2 liters) tank capacity + VOLUME_65 = 2 # NWP500-65: 65-gallon (246.0 liters) tank capacity + VOLUME_80 = 3 # NWP500-80: 80-gallon (302.8 liters) tank capacity + + class UnitType(IntEnum): """Navien device/unit model types.""" @@ -261,7 +275,7 @@ class CommandCode(IntEnum): - Control commands (33554xxx): Change device settings All commands and their expected payloads are documented in - docs/MQTT_MESSAGES.rst under the "Control Messages" section. + docs/protocol/mqtt_protocol.rst under the "Control Messages" section. """ # Query Commands (Information Retrieval) @@ -403,6 +417,12 @@ class FirmwareType(IntEnum): DHWControlTypeFlag.ENABLE_3_STAGE: "3 Stage", } +VOLUME_CODE_TEXT = { + VolumeCode.VOLUME_50: "50 gallons", + VolumeCode.VOLUME_65: "65 gallons", + VolumeCode.VOLUME_80: "80 gallons", +} + # ============================================================================ # Error Code Enumerations diff --git a/src/nwp500/events.py b/src/nwp500/events.py index 0add1e2..437ea5a 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -398,11 +398,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, _ = await asyncio.wait_for(future, timeout=timeout) else: - args_tuple, kwargs_dict = await future + args_tuple, _ = await future # Return just args for simplicity (most common case) return args_tuple diff --git a/src/nwp500/factory.py b/src/nwp500/factory.py new file mode 100644 index 0000000..b543000 --- /dev/null +++ b/src/nwp500/factory.py @@ -0,0 +1,82 @@ +"""Factory functions for convenient client creation and initialization. + +This module provides helper functions to simplify the process of creating +and authenticating all Navien clients with a single function call. + +Use factory functions when you want: +- Simplified initialization of all clients at once +- Automatic error handling during authentication +- Clear initialization order and dependencies +- Convenience over fine-grained control + +Example: + >>> auth, api, mqtt = await create_navien_clients( + ... email="user@example.com", + ... password="password" + ... ) + >>> async with auth: + ... devices = await api.list_devices() +""" + +from .api_client import NavienAPIClient +from .auth import NavienAuthClient +from .mqtt import NavienMqttClient + +__all__ = ["create_navien_clients"] + + +async def create_navien_clients( + email: str, + password: str, +) -> tuple[NavienAuthClient, NavienAPIClient, NavienMqttClient]: + """Create and authenticate all Navien clients with one call. + + This factory function handles the complete initialization sequence: + 1. Creates an auth client with the provided credentials + 2. Authenticates with the Navien API (via context manager) + 3. Creates API and MQTT clients using the authenticated session + 4. Returns all clients ready to use + + Args: + email: Navien account email address + password: Navien account password + + Returns: + Tuple of (auth_client, api_client, mqtt_client) ready to use + + Raises: + AuthenticationError: If authentication fails + InvalidCredentialsError: If email/password are incorrect + + Example: + >>> auth, api, mqtt = await create_navien_clients( + ... email="user@example.com", + ... password="password" + ... ) + >>> async with auth: + ... # All clients are ready to use + ... devices = await api.list_devices() + ... await mqtt.connect() + ... # Use clients ... + + Note: + You must still use the auth client as a context manager to ensure + the session is properly cleaned up: + + >>> auth, api, mqtt = await create_navien_clients(email, password) + >>> async with auth: + ... # Use api and mqtt clients here + ... ... + >>> # Session is automatically closed when exiting the context + """ + # Create auth client (doesn't authenticate yet) + auth_client = NavienAuthClient(email, password) + + # Authenticate and enter context manager + await auth_client.__aenter__() + + # Create API and MQTT clients that share the session + api_client = NavienAPIClient(auth_client=auth_client) + mqtt_client = NavienMqttClient(auth_client=auth_client) + + return auth_client, api_client, mqtt_client diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 6d31f19..d3c01e8 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -12,6 +12,13 @@ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field from pydantic.alias_generators import to_camel +from .converters import ( + device_bool_to_python, + div_10, + enum_validator, + tou_override_to_python, + tou_status_to_python, +) from .enums import ( CurrentOperationMode, DeviceType, @@ -24,11 +31,17 @@ TemperatureType, TempFormulaType, UnitType, + VolumeCode, ) from .field_factory import ( signal_strength_field, temperature_field, ) +from .temperature import ( + HalfCelsius, + deci_celsius_to_fahrenheit, + half_celsius_to_fahrenheit, +) _logger = logging.getLogger(__name__) @@ -37,55 +50,33 @@ # Conversion Helpers & Validators # ============================================================================ - -def _device_bool_validator(v: Any) -> bool: - """Convert device boolean flag (2=True, 1=False).""" - return bool(v == 2) - - -def _div_10_validator(v: Any) -> float: - """Divide by 10.""" - return float(v) / 10.0 if isinstance(v, (int, float)) else float(v) - - -def _half_celsius_to_fahrenheit(v: Any) -> float: - """Convert half-degrees Celsius to Fahrenheit.""" - if isinstance(v, (int, float)): - return (float(v) / 2.0 * 9 / 5) + 32 - return float(v) +# Reusable Annotated types for conversions +DeviceBool = Annotated[bool, BeforeValidator(device_bool_to_python)] +CapabilityFlag = Annotated[bool, BeforeValidator(device_bool_to_python)] +Div10 = Annotated[float, BeforeValidator(div_10)] +HalfCelsiusToF = Annotated[float, BeforeValidator(half_celsius_to_fahrenheit)] +DeciCelsiusToF = Annotated[float, BeforeValidator(deci_celsius_to_fahrenheit)] +TouStatus = Annotated[bool, BeforeValidator(tou_status_to_python)] +TouOverride = Annotated[bool, BeforeValidator(tou_override_to_python)] +VolumeCodeField = Annotated[ + VolumeCode, BeforeValidator(enum_validator(VolumeCode)) +] def fahrenheit_to_half_celsius(fahrenheit: float) -> int: - """Convert Fahrenheit to half-degrees Celsius (for device commands).""" - celsius = (fahrenheit - 32) * 5 / 9 - return round(celsius * 2) - - -def _deci_celsius_to_fahrenheit(v: Any) -> float: - """Convert decicelsius (tenths of Celsius) to Fahrenheit.""" - if isinstance(v, (int, float)): - return (float(v) / 10.0 * 9 / 5) + 32 - return float(v) + """Convert Fahrenheit to half-degrees Celsius (for device commands). + Args: + fahrenheit: Temperature in Fahrenheit. -def _tou_status_validator(v: Any) -> bool: - """Convert TOU status (0=False, 1=True).""" - return bool(v == 1) + Returns: + Raw device value in half-Celsius format. - -def _tou_override_validator(v: Any) -> bool: - """Convert TOU override status (1=True, 2=False).""" - return bool(v == 1) - - -# Reusable Annotated types for conversions -DeviceBool = Annotated[bool, BeforeValidator(_device_bool_validator)] -CapabilityFlag = Annotated[bool, BeforeValidator(_device_bool_validator)] -Div10 = Annotated[float, BeforeValidator(_div_10_validator)] -HalfCelsiusToF = Annotated[float, BeforeValidator(_half_celsius_to_fahrenheit)] -DeciCelsiusToF = Annotated[float, BeforeValidator(_deci_celsius_to_fahrenheit)] -TouStatus = Annotated[bool, BeforeValidator(_tou_status_validator)] -TouOverride = Annotated[bool, BeforeValidator(_tou_override_validator)] + Example: + >>> fahrenheit_to_half_celsius(140.0) + 120 + """ + return int(HalfCelsius.from_fahrenheit(fahrenheit).raw_value) class NavienBaseModel(BaseModel): @@ -753,21 +744,33 @@ class DeviceFeature(NavienBaseModel): country_code: int = Field( description=( - "Country/region code where device is certified for operation " - "(1=USA, complies with FCC Part 15 Class B)" + "Country/region code where device is certified for operation. " + "Device-specific code defined by Navien. " + "Example: USA devices report code 3. Earlier project " + "documentation incorrectly listed code 1 for USA; field " + "observations of production devices confirm that code 3 is " + "the correct value." ) ) model_type_code: UnitType | int = Field( - description="Model type identifier: NWP500 series model variant" + description=( + "Model type identifier: Maps to UnitType enum " + "(e.g., NPF=513 for heat pump water heater). " + "Identifies the device family and available capabilities" + ) ) control_type_code: int = Field( description=( - "Control system type: " - "Advanced digital control with LCD display and WiFi" + "Control system type identifier: Specifies the version of the " + "digital control system (LCD display, WiFi, firmware variant). " + "Device-specific numeric code" ) ) - volume_code: int = Field( - description="Tank nominal capacity: 50, 65, or 80 gallons", + volume_code: VolumeCodeField = Field( + description=( + "Tank nominal capacity: 50 gallons (code 1), 65 gallons (code 2), " + "or 80 gallons (code 3)" + ), json_schema_extra={"unit_of_measurement": "gal"}, ) controller_sw_version: int = Field( @@ -814,8 +817,9 @@ class DeviceFeature(NavienBaseModel): ) recirc_model_type_code: int = Field( description=( - "Recirculation module model identifier - " - "specifies installed recirculation system variant" + "Recirculation module model identifier: Specifies the type and " + "capabilities of the installed recirculation system. " + "Device-specific numeric code (0 if recirculation not installed)" ) ) controller_serial_number: str = Field( diff --git a/src/nwp500/mqtt/__init__.py b/src/nwp500/mqtt/__init__.py new file mode 100644 index 0000000..2a0d205 --- /dev/null +++ b/src/nwp500/mqtt/__init__.py @@ -0,0 +1,31 @@ +"""MQTT package for Navien device communication. + +This package provides MQTT client functionality for real-time communication +with Navien devices using AWS IoT Core. + +Main exports: +- NavienMqttClient: Main MQTT client class +- MqttConnectionConfig: Configuration for MQTT connections +- PeriodicRequestType: Enum for periodic request types +- MqttDiagnosticsCollector: Metrics and diagnostics collector +- MqttMetrics, ConnectionDropEvent, ConnectionEvent: Diagnostic types +""" + +from .client import NavienMqttClient +from .diagnostics import ( + ConnectionDropEvent, + ConnectionEvent, + MqttDiagnosticsCollector, + MqttMetrics, +) +from .utils import MqttConnectionConfig, PeriodicRequestType + +__all__ = [ + "NavienMqttClient", + "MqttConnectionConfig", + "PeriodicRequestType", + "MqttDiagnosticsCollector", + "MqttMetrics", + "ConnectionDropEvent", + "ConnectionEvent", +] diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt/client.py similarity index 94% rename from src/nwp500/mqtt_client.py rename to src/nwp500/mqtt/client.py index 19ed187..f24fd53 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt/client.py @@ -21,9 +21,9 @@ from awscrt import mqtt from awscrt.exceptions import AwsCrtError -from .auth import NavienAuthClient -from .events import EventEmitter -from .exceptions import ( +from ..auth import NavienAuthClient +from ..events import EventEmitter +from ..exceptions import ( AuthenticationError, MqttConnectionError, MqttCredentialsError, @@ -33,20 +33,20 @@ ) if TYPE_CHECKING: - from .models import ( + from ..models import ( Device, DeviceFeature, DeviceStatus, EnergyUsageResponse, ) -from .mqtt_command_queue import MqttCommandQueue -from .mqtt_connection import MqttConnection -from .mqtt_device_control import MqttDeviceController -from .mqtt_diagnostics import MqttDiagnosticsCollector -from .mqtt_periodic import MqttPeriodicRequestManager -from .mqtt_reconnection import MqttReconnectionHandler -from .mqtt_subscriptions import MqttSubscriptionManager -from .mqtt_utils import ( +from .command_queue import MqttCommandQueue +from .connection import MqttConnection +from .control import MqttDeviceController +from .diagnostics import MqttDiagnosticsCollector +from .periodic import MqttPeriodicRequestManager +from .reconnection import MqttReconnectionHandler +from .subscriptions import MqttSubscriptionManager +from .utils import ( MqttConnectionConfig, PeriodicRequestType, ) @@ -96,33 +96,39 @@ class NavienMqttClient(EventEmitter): Example (Event Emitter):: + >>> from nwp500.mqtt_events import MqttClientEvents >>> mqtt_client = NavienMqttClient(auth_client) ... - ... # Register multiple listeners - ... mqtt_client.on('temperature_changed', log_temperature) - ... mqtt_client.on('temperature_changed', update_ui) - ... mqtt_client.on('mode_changed', handle_mode_change) + ... # Type-safe event listeners with IDE autocomplete + ... mqtt_client.on( + ... MqttClientEvents.TEMPERATURE_CHANGED, log_temperature + ... ) + ... mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, update_ui) + ... mqtt_client.on( + ... MqttClientEvents.MODE_CHANGED, handle_mode_change + ... ) ... ... # One-time listener - ... mqtt_client.once('device_ready', initialize) + ... mqtt_client.once(MqttClientEvents.STATUS_RECEIVED, initialize) ... ... await mqtt_client.connect() Events Emitted: - - status_received: Raw status update (DeviceStatus) - - feature_received: Device feature/info (DeviceFeature) - - 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) - - connection_interrupted: Connection lost (error) - - connection_resumed: Connection restored (return_code, - session_present) - - reconnection_failed: Reconnection permanently failed after max - attempts (attempt_count) + See :class:`nwp500.mqtt_events.MqttClientEvents` for a complete, + type-safe registry of all events with full documentation. + + Key events include: + - status_received: Raw status update + - feature_received: Device feature/capability information + - temperature_changed: DHW temperature changed + - mode_changed: Operation mode changed + - power_changed: Power consumption changed + - heating_started: Device started heating + - heating_stopped: Device stopped heating + - error_detected: Device error occurred + - error_cleared: Device error resolved + - connection_interrupted: Connection lost + - connection_resumed: Connection restored """ def __init__( @@ -245,9 +251,8 @@ def _on_connection_interrupted_internal( # Record diagnostic event active_subs = 0 if self._subscription_manager: - # Access protected subscriber count for diagnostics - # pylint: disable=protected-access - active_subs = len(self._subscription_manager._subscriptions) + # Access subscription count for diagnostics + active_subs = len(self._subscription_manager.subscriptions) # Record drop asynchronously self._schedule_coroutine( @@ -533,10 +538,12 @@ async def connect(self) -> bool: self._reconnection_handler.enable() # Initialize shared device info cache and client_id - from .device_info_cache import DeviceInfoCache + from ..device_info_cache import MqttDeviceInfoCache client_id = self.config.client_id or "" - device_info_cache = DeviceInfoCache(update_interval_minutes=30) + device_info_cache = MqttDeviceInfoCache( + update_interval_minutes=30 + ) # Initialize subscription manager with cache self._subscription_manager = MqttSubscriptionManager( @@ -560,7 +567,7 @@ async def connect(self) -> bool: async def ensure_callback(device: Device) -> bool: return await self.ensure_device_info_cached(device) - self._device_controller._ensure_device_info_callback = ( + self._device_controller.set_ensure_device_info_callback( ensure_callback ) # Note: These will be implemented later when we @@ -903,11 +910,11 @@ async def ensure_device_info_cached( if not self._connected or not self._device_controller: raise MqttNotConnectedError("Not connected to MQTT broker") - from .mqtt_utils import redact_mac + from .utils import redact_mac mac = device.device_info.mac_address redacted_mac = redact_mac(mac) - cached = await self._device_controller._device_info_cache.get(mac) + cached = await self._device_controller.device_info_cache.get(mac) if cached is not None: return True @@ -929,7 +936,7 @@ def on_feature(feature: DeviceFeature) -> None: _logger.info(f"Waiting for device feature (timeout={timeout}s)") feature = await asyncio.wait_for(future, timeout=timeout) # Cache the feature immediately - await self._device_controller._device_info_cache.set(mac, feature) + await self._device_controller.device_info_cache.set(mac, feature) return True except TimeoutError: _logger.error( diff --git a/src/nwp500/mqtt_command_queue.py b/src/nwp500/mqtt/command_queue.py similarity index 98% rename from src/nwp500/mqtt_command_queue.py rename to src/nwp500/mqtt/command_queue.py index f15da7c..aa933ab 100644 --- a/src/nwp500/mqtt_command_queue.py +++ b/src/nwp500/mqtt/command_queue.py @@ -15,10 +15,10 @@ from awscrt import mqtt -from .mqtt_utils import QueuedCommand, redact_topic +from .utils import QueuedCommand, redact_topic if TYPE_CHECKING: - from .mqtt_utils import MqttConnectionConfig + from .utils import MqttConnectionConfig __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" diff --git a/src/nwp500/mqtt_connection.py b/src/nwp500/mqtt/connection.py similarity index 93% rename from src/nwp500/mqtt_connection.py rename to src/nwp500/mqtt/connection.py index cee5c80..fa41143 100644 --- a/src/nwp500/mqtt_connection.py +++ b/src/nwp500/mqtt/connection.py @@ -16,15 +16,14 @@ from awscrt.exceptions import AwsCrtError from awsiot import mqtt_connection_builder -from .exceptions import ( - MqttConnectionError, +from ..exceptions import ( MqttCredentialsError, MqttNotConnectedError, ) if TYPE_CHECKING: - from .auth import NavienAuthClient - from .mqtt_utils import MqttConnectionConfig + from ..auth import NavienAuthClient + from .utils import MqttConnectionConfig __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" @@ -49,7 +48,7 @@ def __init__( config: "MqttConnectionConfig", auth_client: "NavienAuthClient", on_connection_interrupted: ( - Callable[[mqtt.Connection, Exception], None] | None + Callable[[mqtt.Connection, AwsCrtError], None] | None ) = None, on_connection_resumed: Callable[[Any, Any | None], None] | None = None, ): @@ -143,23 +142,22 @@ async def connect(self) -> bool: # Convert concurrent.futures.Future to asyncio.Future and await # Use shield to prevent cancellation from propagating to # underlying future - if self._connection is not None: - connect_future = self._connection.connect() - try: - connect_result = await asyncio.shield( - asyncio.wrap_future(connect_future) - ) - except asyncio.CancelledError: - # Shield was cancelled - the underlying connect will - # complete independently, preventing InvalidStateError - # in AWS CRT callbacks - _logger.debug( - "Connect operation was cancelled but will complete " - "in background" - ) - raise - else: - raise MqttConnectionError("Connection not initialized") + if not self._connection: + raise RuntimeError("Connection not initialized") + connect_future = self._connection.connect() + try: + connect_result = await asyncio.shield( + asyncio.wrap_future(connect_future) + ) + except asyncio.CancelledError: + # Shield was cancelled - the underlying connect will + # complete independently, preventing InvalidStateError + # in AWS CRT callbacks + _logger.debug( + "Connect operation was cancelled but will complete " + "in background" + ) + raise self._connected = True _logger.info( diff --git a/src/nwp500/mqtt_device_control.py b/src/nwp500/mqtt/control.py similarity index 93% rename from src/nwp500/mqtt_device_control.py rename to src/nwp500/mqtt/control.py index 37badab..43effd2 100644 --- a/src/nwp500/mqtt_device_control.py +++ b/src/nwp500/mqtt/control.py @@ -19,21 +19,20 @@ import logging from collections.abc import Awaitable, Callable, Sequence -from datetime import datetime +from datetime import UTC, datetime from typing import Any -from nwp500.topic_builder import MqttTopicBuilder - -from .command_decorators import requires_capability -from .device_capabilities import DeviceCapabilityChecker -from .device_info_cache import DeviceInfoCache -from .enums import CommandCode, DhwOperationSetting -from .exceptions import ( +from ..command_decorators import requires_capability +from ..device_capabilities import MqttDeviceCapabilityChecker +from ..device_info_cache import MqttDeviceInfoCache +from ..enums import CommandCode, DhwOperationSetting +from ..exceptions import ( DeviceCapabilityError, ParameterValidationError, RangeValidationError, ) -from .models import Device, DeviceFeature, fahrenheit_to_half_celsius +from ..models import Device, DeviceFeature, fahrenheit_to_half_celsius +from ..topic_builder import MqttTopicBuilder __author__ = "Emmanuel Levijarvi" @@ -47,7 +46,7 @@ class MqttDeviceController: Handles all device control operations including status requests, mode changes, temperature control, scheduling, and energy queries. - This controller integrates with DeviceCapabilityChecker to validate + This controller integrates with MqttDeviceCapabilityChecker to validate device capabilities before executing commands. Use check_support() or assert_support() methods to verify feature availability based on device capabilities before attempting to execute commands: @@ -63,7 +62,7 @@ def __init__( client_id: str, session_id: str, publish_func: Callable[..., Awaitable[int]], - device_info_cache: DeviceInfoCache | None = None, + device_info_cache: MqttDeviceInfoCache | None = None, ) -> None: """ Initialize device controller. @@ -78,7 +77,7 @@ def __init__( self._client_id = client_id self._session_id = session_id self._publish: Callable[..., Awaitable[int]] = publish_func - self._device_info_cache = device_info_cache or DeviceInfoCache( + self._device_info_cache = device_info_cache or MqttDeviceInfoCache( update_interval_minutes=30 ) # Callback for auto-requesting device info when needed @@ -86,6 +85,17 @@ def __init__( Callable[[Device], Awaitable[bool]] | None ) = None + def set_ensure_device_info_callback( + self, callback: Callable[[Device], Awaitable[bool]] | None + ) -> None: + """Set the callback for ensuring device info is cached.""" + self._ensure_device_info_callback = callback + + @property + def device_info_cache(self) -> "MqttDeviceInfoCache": + """Get the device info cache.""" + return self._device_info_cache + async def _ensure_device_info_cached( self, device: Device, timeout: float = 5.0 ) -> None: @@ -156,7 +166,7 @@ def check_support( Raises: ValueError: If feature is not recognized """ - return DeviceCapabilityChecker.supports(feature, device_features) + return MqttDeviceCapabilityChecker.supports(feature, device_features) def assert_support( self, feature: str, device_features: DeviceFeature @@ -171,7 +181,7 @@ def assert_support( DeviceCapabilityError: If feature is not supported ValueError: If feature is not recognized """ - DeviceCapabilityChecker.assert_supported(feature, device_features) + MqttDeviceCapabilityChecker.assert_supported(feature, device_features) def _build_command( self, @@ -414,7 +424,7 @@ async def update_reservations( Returns: Publish packet ID """ - # See docs/MQTT_MESSAGES.rst "Reservation Management" for the + # See docs/protocol/mqtt_protocol.rst "Reservation Management" for the # command code (16777226) and the reservation object fields # (enable, week, hour, min, mode, param). reservation_use = 1 if enabled else 2 @@ -470,7 +480,7 @@ async def configure_tou_schedule( Raises: ValueError: If controller_serial_number is empty or periods is empty """ - # See docs/MQTT_MESSAGES.rst "TOU (Time of Use) Settings" for + # See docs/protocol/mqtt_protocol.rst "TOU (Time of Use) Settings" for # the command code (33554439) and TOU period fields # (season, week, startHour, startMinute, endHour, endMinute, # priceMin, priceMax, decimalPoint). @@ -594,7 +604,7 @@ async def signal_app_connection(self, device: Device) -> int: ) message = { "clientID": self._client_id, - "timestamp": datetime.utcnow().isoformat() + "Z", + "timestamp": (datetime.now(UTC).isoformat().replace("+00:00", "Z")), } return await self._publish(topic, message) diff --git a/src/nwp500/mqtt_diagnostics.py b/src/nwp500/mqtt/diagnostics.py similarity index 100% rename from src/nwp500/mqtt_diagnostics.py rename to src/nwp500/mqtt/diagnostics.py diff --git a/src/nwp500/mqtt_periodic.py b/src/nwp500/mqtt/periodic.py similarity index 99% rename from src/nwp500/mqtt_periodic.py rename to src/nwp500/mqtt/periodic.py index c43f8b9..24b6f7d 100644 --- a/src/nwp500/mqtt_periodic.py +++ b/src/nwp500/mqtt/periodic.py @@ -19,8 +19,8 @@ from awscrt.exceptions import AwsCrtError -from .models import Device -from .mqtt_utils import PeriodicRequestType +from ..models import Device +from .utils import PeriodicRequestType __author__ = "Emmanuel Levijarvi" diff --git a/src/nwp500/mqtt_reconnection.py b/src/nwp500/mqtt/reconnection.py similarity index 99% rename from src/nwp500/mqtt_reconnection.py rename to src/nwp500/mqtt/reconnection.py index bd6e316..ca26797 100644 --- a/src/nwp500/mqtt_reconnection.py +++ b/src/nwp500/mqtt/reconnection.py @@ -16,7 +16,7 @@ from awscrt.exceptions import AwsCrtError if TYPE_CHECKING: - from .mqtt_utils import MqttConnectionConfig + from .utils import MqttConnectionConfig __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" diff --git a/src/nwp500/mqtt_subscriptions.py b/src/nwp500/mqtt/subscriptions.py similarity index 97% rename from src/nwp500/mqtt_subscriptions.py rename to src/nwp500/mqtt/subscriptions.py index d9df732..e9443cc 100644 --- a/src/nwp500/mqtt_subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -21,14 +21,14 @@ from awscrt.exceptions import AwsCrtError from pydantic import ValidationError -from .events import EventEmitter -from .exceptions import MqttNotConnectedError -from .models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse -from .mqtt_utils import redact_topic, topic_matches_pattern -from .topic_builder import MqttTopicBuilder +from ..events import EventEmitter +from ..exceptions import MqttNotConnectedError +from ..models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse +from ..topic_builder import MqttTopicBuilder +from .utils import redact_topic, topic_matches_pattern if TYPE_CHECKING: - from .device_info_cache import DeviceInfoCache + from ..device_info_cache import MqttDeviceInfoCache __author__ = "Emmanuel Levijarvi" @@ -53,7 +53,7 @@ def __init__( client_id: str, event_emitter: EventEmitter, schedule_coroutine: Callable[[Any], None], - device_info_cache: DeviceInfoCache | None = None, + device_info_cache: MqttDeviceInfoCache | None = None, ): """ Initialize subscription manager. @@ -63,7 +63,7 @@ def __init__( client_id: Client ID for response topics event_emitter: Event emitter for state changes schedule_coroutine: Function to schedule async tasks - device_info_cache: Optional DeviceInfoCache for caching device + device_info_cache: Optional MqttDeviceInfoCache for caching device features """ self._connection = connection diff --git a/src/nwp500/mqtt_utils.py b/src/nwp500/mqtt/utils.py similarity index 99% rename from src/nwp500/mqtt_utils.py rename to src/nwp500/mqtt/utils.py index ae205b6..26b4e99 100644 --- a/src/nwp500/mqtt_utils.py +++ b/src/nwp500/mqtt/utils.py @@ -16,7 +16,7 @@ from awscrt import mqtt -from .config import AWS_IOT_ENDPOINT, AWS_REGION +from ..config import AWS_IOT_ENDPOINT, AWS_REGION __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" diff --git a/src/nwp500/mqtt_events.py b/src/nwp500/mqtt_events.py new file mode 100644 index 0000000..087196e --- /dev/null +++ b/src/nwp500/mqtt_events.py @@ -0,0 +1,344 @@ +"""Typed event definitions for NavienMqttClient. + +This module provides a centralized registry of all events emitted by the +NavienMqttClient, with full type information and documentation. This enables: + +- IDE autocomplete for event names +- Type-safe event handlers +- Clear contracts for event data +- Programmatic event discovery + +Example:: + + from nwp500.mqtt_events import MqttClientEvents + + # Type-safe event listening with autocomplete + mqtt_client.on( + MqttClientEvents.TEMPERATURE_CHANGED, + lambda old_temp, new_temp: print(f"Temp: {old_temp}°F → {new_temp}°F") + ) + + # List all available events + for event_name in MqttClientEvents.get_all_events(): + print(event_name) +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from .enums import CurrentOperationMode, ErrorCode + from .models import DeviceFeature, DeviceStatus + + +@dataclass(frozen=True) +class ConnectionInterruptedEvent: + """Emitted when MQTT connection is interrupted. + + Attributes: + error: The error that caused the interruption + """ + + error: Exception + + +@dataclass(frozen=True) +class ConnectionResumedEvent: + """Emitted when MQTT connection is resumed after interruption. + + Attributes: + return_code: MQTT return code (0 = success) + session_present: Whether session state was preserved + """ + + return_code: int + session_present: bool + + +@dataclass(frozen=True) +class StatusReceivedEvent: + """Emitted when a device status message is received. + + Attributes: + status: The current device status snapshot + """ + + status: "DeviceStatus" + + +@dataclass(frozen=True) +class TemperatureChangedEvent: + """Emitted when the DHW temperature changes. + + Attributes: + old_temperature: Previous DHW temperature in °F + new_temperature: New DHW temperature in °F + """ + + old_temperature: float + new_temperature: float + + +@dataclass(frozen=True) +class ModeChangedEvent: + """Emitted when the device operation mode changes. + + Attributes: + old_mode: Previous operation mode + new_mode: New operation mode + """ + + old_mode: "CurrentOperationMode" + new_mode: "CurrentOperationMode" + + +@dataclass(frozen=True) +class PowerChangedEvent: + """Emitted when instantaneous power consumption changes. + + Attributes: + old_power: Previous power consumption in watts + new_power: New power consumption in watts + """ + + old_power: float + new_power: float + + +@dataclass(frozen=True) +class HeatingStartedEvent: + """Emitted when device transitions from idle to heating. + + Attributes: + status: Device status when heating started + """ + + status: "DeviceStatus" + + +@dataclass(frozen=True) +class HeatingStoppedEvent: + """Emitted when device transitions from heating to idle. + + Attributes: + status: Device status when heating stopped + """ + + status: "DeviceStatus" + + +@dataclass(frozen=True) +class ErrorDetectedEvent: + """Emitted when a device error is first detected. + + Attributes: + error_code: The error code that occurred + status: Device status when error was detected + """ + + error_code: "ErrorCode" + status: "DeviceStatus" + + +@dataclass(frozen=True) +class ErrorClearedEvent: + """Emitted when a device error is resolved. + + Attributes: + error_code: The error code that was cleared + """ + + error_code: "ErrorCode" + + +@dataclass(frozen=True) +class FeatureReceivedEvent: + """Emitted when device feature information is received. + + Attributes: + feature: The device feature information + """ + + feature: "DeviceFeature" + + +class MqttClientEvents: + """Registry of all NavienMqttClient events. + + This class provides string constants for all events emitted by + NavienMqttClient, with associated event data types documented in + their dataclass definitions. + + Usage:: + + mqtt_client.on( + MqttClientEvents.TEMPERATURE_CHANGED, + lambda old_temp, new_temp: update_display(new_temp) + ) + + # Wait for a specific event + await mqtt_client.wait_for(MqttClientEvents.CONNECTION_RESUMED) + + # List all available events + events = ', '.join(MqttClientEvents.get_all_events()) + print(f"Available events: {events}") + + See Also: + :doc:`../guides/event_system` - Comprehensive event handling guide + """ + + # Connection lifecycle events + CONNECTION_INTERRUPTED = "connection_interrupted" + """Emitted: MQTT connection interrupted with error. + + Args: + error (Exception): The error that caused the interruption + + See: :class:`ConnectionInterruptedEvent` + """ + + CONNECTION_RESUMED = "connection_resumed" + """Emitted: MQTT connection resumed after interruption. + + Args: + return_code (int): MQTT return code (0 = success) + session_present (bool): Whether session state was preserved + + See: :class:`ConnectionResumedEvent` + """ + + # Device status events + STATUS_RECEIVED = "status_received" + """Emitted: Device status message received. + + Args: + status (DeviceStatus): Current device status snapshot + + See: :class:`StatusReceivedEvent` + """ + + TEMPERATURE_CHANGED = "temperature_changed" + """Emitted: DHW temperature changed. + + Args: + old_temperature (float): Previous DHW temperature (°F) + new_temperature (float): New DHW temperature (°F) + + See: :class:`TemperatureChangedEvent` + """ + + MODE_CHANGED = "mode_changed" + """Emitted: Device operation mode changed. + + Args: + old_mode (CurrentOperationMode): Previous mode + new_mode (CurrentOperationMode): New mode + + See: :class:`ModeChangedEvent` + """ + + POWER_CHANGED = "power_changed" + """Emitted: Instantaneous power consumption changed. + + Args: + old_power (float): Previous power consumption (W) + new_power (float): New power consumption (W) + + See: :class:`PowerChangedEvent` + """ + + # Heating events + HEATING_STARTED = "heating_started" + """Emitted: Device started heating. + + Args: + status (DeviceStatus): Device status when heating started + + See: :class:`HeatingStartedEvent` + """ + + HEATING_STOPPED = "heating_stopped" + """Emitted: Device stopped heating. + + Args: + status (DeviceStatus): Device status when heating stopped + + See: :class:`HeatingStoppedEvent` + """ + + # Error events + ERROR_DETECTED = "error_detected" + """Emitted: Device error detected. + + Args: + error_code (ErrorCode): The error code + status (DeviceStatus): Status when error was detected + + See: :class:`ErrorDetectedEvent` + """ + + ERROR_CLEARED = "error_cleared" + """Emitted: Device error cleared. + + Args: + error_code (ErrorCode): The error code that was cleared + + See: :class:`ErrorClearedEvent` + """ + + # Feature events + FEATURE_RECEIVED = "feature_received" + """Emitted: Device feature information received. + + Args: + feature (DeviceFeature): Device feature information + + See: :class:`FeatureReceivedEvent` + """ + + @classmethod + def get_all_events(cls) -> list[str]: + """Get list of all available event names. + + Returns: + List of event constant names (not including metadata strings) + + Example:: + + for event_name in MqttClientEvents.get_all_events(): + print(f"- {event_name}") + + # Output: + # - CONNECTION_INTERRUPTED + # - CONNECTION_RESUMED + # - STATUS_RECEIVED + # - TEMPERATURE_CHANGED + # - ... + """ + return [ + attr + for attr in dir(cls) + if not attr.startswith("_") + and attr.isupper() + and isinstance(getattr(cls, attr), str) + ] + + @classmethod + def get_event_value(cls, event_name: str) -> str: + """Get the string value of an event constant. + + Args: + event_name: Event constant name (e.g., "TEMPERATURE_CHANGED") + + Returns: + Event string value (e.g., "temperature_changed") + + Raises: + AttributeError: If event_name does not exist + + Example:: + + value = MqttClientEvents.get_event_value("TEMPERATURE_CHANGED") + print(value) # Output: "temperature_changed" + """ + return cast(str, getattr(cls, event_name)) diff --git a/src/nwp500/temperature.py b/src/nwp500/temperature.py new file mode 100644 index 0000000..abeed81 --- /dev/null +++ b/src/nwp500/temperature.py @@ -0,0 +1,187 @@ +"""Temperature conversion utilities for different device representations. + +The Navien NWP500 uses different temperature precision formats: +- HalfCelsius: 0.5°C precision (value / 2.0) +- DeciCelsius: 0.1°C precision (value / 10.0) + +All values are converted to Fahrenheit for API responses and user interaction. +""" + +from abc import ABC, abstractmethod +from typing import Any + + +class Temperature(ABC): + """Base class for temperature conversions with device protocol support.""" + + def __init__(self, raw_value: int | float): + """Initialize with raw device value. + + Args: + raw_value: The raw value from the device in its native format. + """ + self.raw_value = float(raw_value) + + @abstractmethod + def to_celsius(self) -> float: + """Convert to Celsius. + + Returns: + Temperature in Celsius. + """ + + @abstractmethod + def to_fahrenheit(self) -> float: + """Convert to Fahrenheit. + + Returns: + Temperature in Fahrenheit. + """ + + @classmethod + def from_fahrenheit(cls, fahrenheit: float) -> "Temperature": + """Create instance from Fahrenheit value (for commands). + + Args: + fahrenheit: Temperature in Fahrenheit. + + Returns: + Instance with raw value set for device command. + """ + raise NotImplementedError( + f"{cls.__name__} does not support creation from Fahrenheit" + ) + + +class HalfCelsius(Temperature): + """Temperature in half-degree Celsius (0.5°C precision). + + Used for DHW (domestic hot water) temperatures in device status. + Formula: raw_value / 2.0 converts to Celsius. + + Example: + >>> temp = HalfCelsius(120) # Raw device value 120 + >>> temp.to_celsius() + 60.0 + >>> temp.to_fahrenheit() + 140.0 + """ + + def to_celsius(self) -> float: + """Convert to Celsius. + + Returns: + Temperature in Celsius. + """ + return self.raw_value / 2.0 + + def to_fahrenheit(self) -> float: + """Convert to Fahrenheit. + + Returns: + Temperature in Fahrenheit. + """ + celsius = self.to_celsius() + return celsius * 9 / 5 + 32 + + @classmethod + def from_fahrenheit(cls, fahrenheit: float) -> "HalfCelsius": + """Create HalfCelsius from Fahrenheit (for device commands). + + Args: + fahrenheit: Temperature in Fahrenheit. + + Returns: + HalfCelsius instance with raw value for device. + + Example: + >>> temp = HalfCelsius.from_fahrenheit(140.0) + >>> temp.raw_value + 120 + """ + celsius = (fahrenheit - 32) * 5 / 9 + raw_value = round(celsius * 2) + return cls(raw_value) + + +class DeciCelsius(Temperature): + """Temperature in decicelsius (0.1°C precision). + + Used for high-precision temperature measurements. + Formula: raw_value / 10.0 converts to Celsius. + + Example: + >>> temp = DeciCelsius(600) # Raw device value 600 + >>> temp.to_celsius() + 60.0 + >>> temp.to_fahrenheit() + 140.0 + """ + + def to_celsius(self) -> float: + """Convert to Celsius. + + Returns: + Temperature in Celsius. + """ + return self.raw_value / 10.0 + + def to_fahrenheit(self) -> float: + """Convert to Fahrenheit. + + Returns: + Temperature in Fahrenheit. + """ + celsius = self.to_celsius() + return celsius * 9 / 5 + 32 + + @classmethod + def from_fahrenheit(cls, fahrenheit: float) -> "DeciCelsius": + """Create DeciCelsius from Fahrenheit (for device commands). + + Args: + fahrenheit: Temperature in Fahrenheit. + + Returns: + DeciCelsius instance with raw value for device. + + Example: + >>> temp = DeciCelsius.from_fahrenheit(140.0) + >>> temp.raw_value + 600 + """ + celsius = (fahrenheit - 32) * 5 / 9 + raw_value = round(celsius * 10) + return cls(raw_value) + + +def half_celsius_to_fahrenheit(value: Any) -> float: + """Convert half-degrees Celsius to Fahrenheit. + + Validator function for Pydantic fields using HalfCelsius format. + + Args: + value: Raw device value in half-Celsius format. + + Returns: + Temperature in Fahrenheit. + """ + if isinstance(value, (int, float)): + return HalfCelsius(value).to_fahrenheit() + return float(value) + + +def deci_celsius_to_fahrenheit(value: Any) -> float: + """Convert decicelsius to Fahrenheit. + + Validator function for Pydantic fields using DeciCelsius format. + + Args: + value: Raw device value in decicelsius format. + + Returns: + Temperature in Fahrenheit. + """ + if isinstance(value, (int, float)): + return DeciCelsius(value).to_fahrenheit() + return float(value) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 6ad9160..be72255 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -4,7 +4,7 @@ import pytest -from nwp500.cli.commands import ( +from nwp500.cli.handlers import ( get_controller_serial_number, handle_set_dhw_temp_request, handle_set_mode_request, diff --git a/tests/test_command_decorators.py b/tests/test_command_decorators.py index 24df735..dd4baf3 100644 --- a/tests/test_command_decorators.py +++ b/tests/test_command_decorators.py @@ -6,14 +6,14 @@ import pytest from nwp500.command_decorators import requires_capability -from nwp500.device_info_cache import DeviceInfoCache +from nwp500.device_info_cache import MqttDeviceInfoCache from nwp500.exceptions import DeviceCapabilityError class BaseMockController: """Base class for mock controllers to avoid duplication.""" - def __init__(self, cache: DeviceInfoCache) -> None: + def __init__(self, cache: MqttDeviceInfoCache) -> None: self._device_info_cache = cache async def _get_device_features(self, device: Any) -> Any: @@ -38,7 +38,7 @@ class TestRequiresCapabilityDecorator: @pytest.mark.asyncio async def test_decorator_allows_supported_capability(self) -> None: """Test decorator allows execution when capability is supported.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" @@ -64,7 +64,7 @@ async def set_power(self, device: Mock, power_on: bool) -> None: @pytest.mark.asyncio async def test_decorator_blocks_unsupported_capability(self) -> None: """Test decorator blocks execution when capability is not supported.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" @@ -91,7 +91,7 @@ async def set_power(self, device: Mock, power_on: bool) -> None: @pytest.mark.asyncio async def test_decorator_auto_requests_device_info(self) -> None: """Test decorator auto-requests device info when not cached.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" @@ -123,7 +123,7 @@ async def _auto_request_device_info(self, device: Mock) -> None: @pytest.mark.asyncio async def test_decorator_fails_when_info_not_available(self) -> None: """Test decorator fails when device info cannot be obtained.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" @@ -146,7 +146,7 @@ async def _auto_request_device_info(self, device: Mock) -> None: @pytest.mark.asyncio async def test_decorator_with_multiple_arguments(self) -> None: """Test decorator works with multiple function arguments.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" @@ -177,7 +177,7 @@ async def command( @pytest.mark.asyncio async def test_decorator_preserves_function_name(self) -> None: """Test decorator preserves function name.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" @@ -200,7 +200,7 @@ async def my_special_command(self, device: Mock) -> None: @pytest.mark.asyncio async def test_decorator_with_different_capabilities(self) -> None: """Test decorator works with different capability requirements.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" @@ -239,7 +239,7 @@ async def set_dhw(self, device: Mock) -> None: @pytest.mark.asyncio async def test_decorator_with_sync_function_logs_warning(self) -> None: """Test decorator with sync function raises TypeError.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() class MockController(BaseMockController): @@ -262,7 +262,7 @@ def set_power_sync(self, device: Mock, power_on: bool) -> None: @pytest.mark.asyncio async def test_decorator_handles_auto_request_exception(self) -> None: """Test decorator handles exception during auto-request.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" @@ -286,7 +286,7 @@ async def _auto_request_device_info(self, device: Mock) -> None: @pytest.mark.asyncio async def test_decorator_returns_function_result(self) -> None: """Test decorator properly returns function result.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" @@ -310,7 +310,7 @@ async def get_status(self, device: Mock) -> str: @pytest.mark.asyncio async def test_decorator_with_exception_in_decorated_function(self) -> None: """Test decorator propagates exceptions from decorated function.""" - cache = DeviceInfoCache() + cache = MqttDeviceInfoCache() mock_device = Mock() mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" diff --git a/tests/test_command_queue.py b/tests/test_command_queue.py index 67c44fe..7bcd9f4 100644 --- a/tests/test_command_queue.py +++ b/tests/test_command_queue.py @@ -5,8 +5,8 @@ from awscrt import mqtt -from nwp500.mqtt_client import MqttConnectionConfig -from nwp500.mqtt_utils import QueuedCommand +from nwp500.mqtt import MqttConnectionConfig +from nwp500.mqtt.utils import QueuedCommand def test_queued_command_dataclass(): diff --git a/tests/test_device_capabilities.py b/tests/test_device_capabilities.py index 83da247..3cf8893 100644 --- a/tests/test_device_capabilities.py +++ b/tests/test_device_capabilities.py @@ -4,45 +4,51 @@ import pytest -from nwp500.device_capabilities import DeviceCapabilityChecker +from nwp500.device_capabilities import MqttDeviceCapabilityChecker from nwp500.enums import DHWControlTypeFlag from nwp500.exceptions import DeviceCapabilityError -class TestDeviceCapabilityChecker: - """Tests for DeviceCapabilityChecker.""" +class TestMqttDeviceCapabilityChecker: + """Tests for MqttDeviceCapabilityChecker.""" def test_supports_true_feature(self) -> None: """Test supports with feature that returns True.""" mock_feature = Mock() mock_feature.power_use = True - assert DeviceCapabilityChecker.supports("power_use", mock_feature) + assert MqttDeviceCapabilityChecker.supports("power_use", mock_feature) def test_supports_false_feature(self) -> None: """Test supports with feature that returns False.""" mock_feature = Mock() mock_feature.power_use = False - assert not DeviceCapabilityChecker.supports("power_use", mock_feature) + assert not MqttDeviceCapabilityChecker.supports( + "power_use", mock_feature + ) def test_supports_unknown_feature_raises_value_error(self) -> None: """Test that unknown feature raises ValueError.""" mock_feature = Mock() with pytest.raises(ValueError, match="Unknown controllable feature"): - DeviceCapabilityChecker.supports("unknown_feature", mock_feature) + MqttDeviceCapabilityChecker.supports( + "unknown_feature", mock_feature + ) def test_assert_supported_success(self) -> None: """Test assert_supported with supported feature.""" mock_feature = Mock() mock_feature.power_use = True # Should not raise - DeviceCapabilityChecker.assert_supported("power_use", mock_feature) + MqttDeviceCapabilityChecker.assert_supported("power_use", mock_feature) def test_assert_supported_failure(self) -> None: """Test assert_supported with unsupported feature.""" mock_feature = Mock() mock_feature.power_use = False with pytest.raises(DeviceCapabilityError): - DeviceCapabilityChecker.assert_supported("power_use", mock_feature) + MqttDeviceCapabilityChecker.assert_supported( + "power_use", mock_feature + ) def test_dhw_temperature_control_enabled(self) -> None: """Test DHW temperature control detection when enabled.""" @@ -50,7 +56,7 @@ def test_dhw_temperature_control_enabled(self) -> None: mock_feature.dhw_temperature_setting_use = ( DHWControlTypeFlag.ENABLE_1_DEGREE ) - assert DeviceCapabilityChecker.supports( + assert MqttDeviceCapabilityChecker.supports( "dhw_temperature_setting_use", mock_feature ) @@ -58,7 +64,7 @@ def test_dhw_temperature_control_disabled(self) -> None: """Test DHW temperature control detection when disabled.""" mock_feature = Mock() mock_feature.dhw_temperature_setting_use = DHWControlTypeFlag.DISABLE - assert not DeviceCapabilityChecker.supports( + assert not MqttDeviceCapabilityChecker.supports( "dhw_temperature_setting_use", mock_feature ) @@ -66,7 +72,7 @@ def test_dhw_temperature_control_unknown(self) -> None: """Test DHW temperature control detection when UNKNOWN.""" mock_feature = Mock() mock_feature.dhw_temperature_setting_use = DHWControlTypeFlag.UNKNOWN - assert not DeviceCapabilityChecker.supports( + assert not MqttDeviceCapabilityChecker.supports( "dhw_temperature_setting_use", mock_feature ) @@ -84,7 +90,9 @@ def test_get_available_controls(self) -> None: mock_feature.recirc_reservation_use = False mock_feature.anti_legionella_setting_use = True - controls = DeviceCapabilityChecker.get_available_controls(mock_feature) + controls = MqttDeviceCapabilityChecker.get_available_controls( + mock_feature + ) assert controls["power_use"] is True assert controls["dhw_use"] is False @@ -101,32 +109,32 @@ def test_register_capability(self) -> None: mock_feature = Mock() custom_check = lambda f: True # noqa: E731 - DeviceCapabilityChecker.register_capability( + MqttDeviceCapabilityChecker.register_capability( "custom_feature", custom_check ) try: - assert DeviceCapabilityChecker.supports( + assert MqttDeviceCapabilityChecker.supports( "custom_feature", mock_feature ) finally: # Clean up - del DeviceCapabilityChecker._CAPABILITY_MAP["custom_feature"] + del MqttDeviceCapabilityChecker._CAPABILITY_MAP["custom_feature"] def test_register_capability_override(self) -> None: """Test overriding an existing capability.""" - original = DeviceCapabilityChecker._CAPABILITY_MAP["power_use"] + original = MqttDeviceCapabilityChecker._CAPABILITY_MAP["power_use"] mock_feature = Mock() try: # Override to always return False - DeviceCapabilityChecker.register_capability( + MqttDeviceCapabilityChecker.register_capability( "power_use", lambda f: False ) mock_feature.power_use = True - assert not DeviceCapabilityChecker.supports( + assert not MqttDeviceCapabilityChecker.supports( "power_use", mock_feature ) finally: # Restore original - DeviceCapabilityChecker._CAPABILITY_MAP["power_use"] = original + MqttDeviceCapabilityChecker._CAPABILITY_MAP["power_use"] = original diff --git a/tests/test_device_info_cache.py b/tests/test_device_info_cache.py index d298ba6..5c87e61 100644 --- a/tests/test_device_info_cache.py +++ b/tests/test_device_info_cache.py @@ -5,7 +5,7 @@ import pytest -from nwp500.device_info_cache import DeviceInfoCache +from nwp500.device_info_cache import MqttDeviceInfoCache @pytest.fixture @@ -15,23 +15,23 @@ def device_feature() -> dict: @pytest.fixture -def cache_with_updates() -> DeviceInfoCache: +def cache_with_updates() -> MqttDeviceInfoCache: """Create a cache with 30-minute update interval.""" - return DeviceInfoCache(update_interval_minutes=30) + return MqttDeviceInfoCache(update_interval_minutes=30) @pytest.fixture -def cache_no_updates() -> DeviceInfoCache: +def cache_no_updates() -> MqttDeviceInfoCache: """Create a cache with auto-updates disabled.""" - return DeviceInfoCache(update_interval_minutes=0) + return MqttDeviceInfoCache(update_interval_minutes=0) -class TestDeviceInfoCache: - """Tests for DeviceInfoCache.""" +class TestMqttDeviceInfoCache: + """Tests for MqttDeviceInfoCache.""" @pytest.mark.asyncio async def test_cache_get_returns_none_when_empty( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test that get returns None for uncached device.""" result = await cache_with_updates.get("AA:BB:CC:DD:EE:FF") @@ -39,7 +39,7 @@ async def test_cache_get_returns_none_when_empty( @pytest.mark.asyncio async def test_cache_set_and_get( - self, cache_with_updates: DeviceInfoCache, device_feature: dict + self, cache_with_updates: MqttDeviceInfoCache, device_feature: dict ) -> None: """Test basic set and get operations.""" mac = "AA:BB:CC:DD:EE:FF" @@ -49,7 +49,7 @@ async def test_cache_set_and_get( @pytest.mark.asyncio async def test_cache_set_overwrites_previous( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test that set overwrites previous cache entry.""" mac = "AA:BB:CC:DD:EE:FF" @@ -66,7 +66,7 @@ async def test_cache_set_overwrites_previous( @pytest.mark.asyncio async def test_cache_multiple_devices( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test caching multiple devices.""" mac1 = "AA:BB:CC:DD:EE:FF" @@ -87,7 +87,7 @@ async def test_cache_multiple_devices( @pytest.mark.asyncio async def test_cache_expiration(self) -> None: """Test that cache entries expire.""" - cache_exp = DeviceInfoCache(update_interval_minutes=1) + cache_exp = MqttDeviceInfoCache(update_interval_minutes=1) mac = "AA:BB:CC:DD:EE:FF" feature = {"data": "test"} old_time = datetime.now(UTC) - timedelta(minutes=2) @@ -99,7 +99,7 @@ async def test_cache_expiration(self) -> None: @pytest.mark.asyncio async def test_is_expired_with_zero_interval( - self, cache_no_updates: DeviceInfoCache + self, cache_no_updates: MqttDeviceInfoCache ) -> None: """Test is_expired returns False when interval is 0 (no updates).""" old_time = datetime.now(UTC) - timedelta(hours=1) @@ -107,7 +107,7 @@ async def test_is_expired_with_zero_interval( @pytest.mark.asyncio async def test_is_expired_with_fresh_entry( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test is_expired returns False for fresh entries.""" recent_time = datetime.now(UTC) - timedelta(minutes=5) @@ -115,7 +115,7 @@ async def test_is_expired_with_fresh_entry( @pytest.mark.asyncio async def test_is_expired_with_old_entry( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test is_expired returns True for old entries.""" old_time = datetime.now(UTC) - timedelta(minutes=60) @@ -123,7 +123,7 @@ async def test_is_expired_with_old_entry( @pytest.mark.asyncio async def test_cache_invalidate( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test cache invalidation.""" mac = "AA:BB:CC:DD:EE:FF" @@ -136,7 +136,7 @@ async def test_cache_invalidate( @pytest.mark.asyncio async def test_cache_invalidate_nonexistent( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test invalidating nonexistent entry doesn't raise.""" # Should not raise @@ -144,7 +144,7 @@ async def test_cache_invalidate_nonexistent( @pytest.mark.asyncio async def test_cache_clear( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test clearing entire cache.""" mac1 = "AA:BB:CC:DD:EE:FF" @@ -164,7 +164,7 @@ async def test_cache_clear( @pytest.mark.asyncio async def test_get_all_cached( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test get_all_cached returns all cached devices.""" mac1 = "AA:BB:CC:DD:EE:FF" @@ -185,7 +185,7 @@ async def test_get_all_cached( @pytest.mark.asyncio async def test_get_all_cached_excludes_expired(self) -> None: """Test get_all_cached excludes expired entries.""" - cache = DeviceInfoCache(update_interval_minutes=1) + cache = MqttDeviceInfoCache(update_interval_minutes=1) mac1 = "AA:BB:CC:DD:EE:FF" mac2 = "11:22:33:44:55:66" feature = {"data": "test"} @@ -204,7 +204,7 @@ async def test_get_all_cached_excludes_expired(self) -> None: @pytest.mark.asyncio async def test_get_cache_info( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test get_cache_info returns correct information.""" mac = "AA:BB:CC:DD:EE:FF" @@ -223,7 +223,7 @@ async def test_get_cache_info( @pytest.mark.asyncio async def test_get_cache_info_with_no_updates( - self, cache_no_updates: DeviceInfoCache + self, cache_no_updates: MqttDeviceInfoCache ) -> None: """Test get_cache_info with auto-updates disabled.""" mac = "AA:BB:CC:DD:EE:FF" @@ -238,7 +238,7 @@ async def test_get_cache_info_with_no_updates( @pytest.mark.asyncio async def test_cache_thread_safety( - self, cache_with_updates: DeviceInfoCache + self, cache_with_updates: MqttDeviceInfoCache ) -> None: """Test concurrent cache operations.""" macs = [f"AA:BB:CC:DD:EE:{i:02X}" for i in range(10)] @@ -260,9 +260,9 @@ async def test_cache_thread_safety( @pytest.mark.asyncio async def test_initialization_with_different_intervals(self) -> None: """Test cache initialization with different intervals.""" - cache_60 = DeviceInfoCache(update_interval_minutes=60) - cache_5 = DeviceInfoCache(update_interval_minutes=5) - cache_0 = DeviceInfoCache(update_interval_minutes=0) + cache_60 = MqttDeviceInfoCache(update_interval_minutes=60) + cache_5 = MqttDeviceInfoCache(update_interval_minutes=5) + cache_0 = MqttDeviceInfoCache(update_interval_minutes=0) assert cache_60.update_interval == timedelta(minutes=60) assert cache_5.update_interval == timedelta(minutes=5) diff --git a/tests/test_model_converters.py b/tests/test_model_converters.py new file mode 100644 index 0000000..050d1c5 --- /dev/null +++ b/tests/test_model_converters.py @@ -0,0 +1,365 @@ +"""Tests for device data converter validators. + +Tests cover: +- device_bool_to_python (device 1=False, 2=True) +- tou_status_to_python (TOU status encoding) +- tou_override_to_python (TOU override status encoding) +- div_10 (divide by 10 converter) +- enum_validator (enum validation and conversion) +""" + +import pytest + +from nwp500.converters import ( + device_bool_to_python, + div_10, + enum_validator, + tou_override_to_python, + tou_status_to_python, +) +from nwp500.enums import DhwOperationSetting, OnOffFlag + + +class TestDeviceBoolConverter: + """Test device_bool_to_python converter. + + Device encoding: 1 = OFF (False), 2 = ON (True) + This is the standard boolean encoding for Navien devices. + NOTE: Uses comparison `value == 2`, so string "2" does NOT equal int 2. + """ + + def test_off_value(self): + """Device value 1 converts to False.""" + assert device_bool_to_python(1) is False + + def test_on_value(self): + """Device value 2 converts to True.""" + assert device_bool_to_python(2) is True + + def test_string_off(self): + """String '1' is not equal to int 2, so returns False.""" + assert device_bool_to_python("1") is False + + def test_string_on(self): + """String '2' is not equal to int 2, so returns False.""" + # String "2" != int 2 in Python, so result is False + assert device_bool_to_python("2") is False + + def test_int_off(self): + """Integer 1 converts to False.""" + result = device_bool_to_python(1) + assert result is False + + def test_int_on(self): + """Integer 2 converts to True.""" + result = device_bool_to_python(2) + assert result is True + + def test_float_off(self): + """Float 1.0 is not equal to 2.""" + assert device_bool_to_python(1.0) is False + + def test_float_on(self): + """Float 2.0 equals int 2 in Python.""" + assert device_bool_to_python(2.0) is True + + def test_invalid_value_zero(self): + """Invalid value 0 returns False (0 != 2).""" + result = device_bool_to_python(0) + assert result is False + + def test_invalid_value_three(self): + """Invalid value 3 returns False (3 != 2).""" + result = device_bool_to_python(3) + assert result is False + + def test_invalid_value_negative(self): + """Invalid value -1 returns False (-1 != 2).""" + result = device_bool_to_python(-1) + assert result is False + + @pytest.mark.parametrize("on_value", [2, 2.0]) + def test_on_value_variations(self, on_value): + """Test various representations that equal 2.""" + # Numeric 2 and float 2.0 equal int 2 + assert device_bool_to_python(on_value) is True + + @pytest.mark.parametrize("off_value", [1, "1", 1.0, 0, 3, -1, "2"]) + def test_off_value_variations(self, off_value): + """Test various representations of False value.""" + assert device_bool_to_python(off_value) is False + + +class TestTouStatusConverter: + """Test tou_status_to_python converter. + + TOU (Time of Use) status encoding converts device state to boolean. + Device: 1 = Enabled (True), anything else = Disabled (False) + NOTE: String values are NOT converted to int before comparison. + """ + + def test_tou_disabled(self): + """TOU disabled state: 0 = False.""" + result = tou_status_to_python(0) + assert isinstance(result, bool) + assert result is False + + def test_tou_enabled(self): + """TOU enabled state: 1 = True.""" + result = tou_status_to_python(1) + assert isinstance(result, bool) + assert result is True + + def test_string_disabled(self): + """String '0' = TOU disabled.""" + assert tou_status_to_python("0") is False + + def test_string_enabled(self): + """String '1' is not equal to int 1, so returns False.""" + # tou_status_to_python uses: bool(value == 1) + # String "1" != int 1, so result is False + assert tou_status_to_python("1") is False + + def test_invalid_value(self): + """Value other than 1 is treated as False.""" + assert tou_status_to_python(2) is False + assert tou_status_to_python(3) is False + assert tou_status_to_python(-1) is False + + @pytest.mark.parametrize("enabled_value", [1, 1.0]) + def test_enabled_variations(self, enabled_value): + """Test numeric variations of enabled (value == 1).""" + # Only numeric 1 and float 1.0 equal int 1 + assert tou_status_to_python(enabled_value) is True + + @pytest.mark.parametrize("disabled_value", [0, "0", 0.0, 2, 3, -1, "1"]) + def test_disabled_variations(self, disabled_value): + """Test various representations of disabled (value != 1).""" + assert tou_status_to_python(disabled_value) is False + + +class TestTouOverrideConverter: + """Test tou_override_to_python converter. + + TOU override status encoding converts override state to boolean. + Device: 1 = Override Active (True), anything else = False + NOTE: String values are NOT converted to int before comparison. + """ + + def test_override_active(self): + """Override active state: 1 = True.""" + result = tou_override_to_python(1) + assert isinstance(result, bool) + assert result is True + + def test_override_inactive(self): + """Override inactive state: 2 = False.""" + result = tou_override_to_python(2) + assert isinstance(result, bool) + assert result is False + + def test_string_active(self): + """String '1' is not equal to int 1, so returns False.""" + # tou_override_to_python uses: bool(value == 1) + # String "1" != int 1, so result is False + assert tou_override_to_python("1") is False + + def test_string_inactive(self): + """String '2' is not equal to int 1, so returns False.""" + result = tou_override_to_python("2") + assert isinstance(result, bool) + assert result is False + + def test_zero_value(self): + """Value 0 is not equal to 1.""" + result = tou_override_to_python(0) + assert isinstance(result, bool) + assert result is False + + def test_other_value(self): + """Other non-1 values are False.""" + result = tou_override_to_python(99) + assert isinstance(result, bool) + assert result is False + + @pytest.mark.parametrize("active_value", [1, 1.0]) + def test_active_variations(self, active_value): + """Test numeric variations of active (value == 1).""" + # Only numeric 1 and float 1.0 equal int 1 + assert tou_override_to_python(active_value) is True + + @pytest.mark.parametrize("inactive_value", [2, "2", 2.0, 0, 3, -1, "1"]) + def test_inactive_variations(self, inactive_value): + """Test various representations of inactive (value != 1).""" + assert tou_override_to_python(inactive_value) is False + + +class TestDiv10Converter: + """Test div_10 converter (divide by 10). + + Used for fields that need precision of 0.1 units. + Only divides numeric types (int, float), returns float(value) for others. + """ + + def test_zero(self): + """0 / 10 = 0.0.""" + assert div_10(0) == 0.0 + + def test_positive_value(self): + """100 / 10 = 10.0.""" + assert div_10(100) == 10.0 + + def test_negative_value(self): + """-50 / 10 = -5.0.""" + assert div_10(-50) == -5.0 + + def test_single_digit(self): + """5 / 10 = 0.5.""" + assert div_10(5) == 0.5 + + def test_float_input(self): + """50.5 / 10 = 5.05.""" + assert div_10(50.5) == pytest.approx(5.05) + + def test_string_numeric(self): + """String '100' is converted to float without division.""" + # div_10 converts non-numeric to float but doesn't divide + result = div_10("100") + assert result == pytest.approx(100.0) + + def test_large_value(self): + """1000 / 10 = 100.0.""" + assert div_10(1000) == 100.0 + + def test_very_small_value(self): + """0.1 / 10 = 0.01.""" + assert div_10(0.1) == pytest.approx(0.01) + + def test_negative_small_value(self): + """-0.5 / 10 = -0.05.""" + assert div_10(-0.5) == pytest.approx(-0.05) + + @pytest.mark.parametrize( + "input_value,expected", + [ + (0, 0.0), + (10, 1.0), + (50, 5.0), + (100, 10.0), + (1000, 100.0), + (-100, -10.0), + (1.5, 0.15), + (99.9, 9.99), + ], + ) + def test_known_values(self, input_value, expected): + """Test known div_10 conversions for numeric types.""" + result = div_10(input_value) + assert result == pytest.approx(expected, abs=0.001) + + +class TestEnumValidator: + """Test enum_validator factory function. + + Creates validators that convert integer device values to enum values. + """ + + def test_validator_creation(self): + """Enum validator can be created.""" + validator = enum_validator(OnOffFlag) + assert callable(validator) + + def test_onoff_flag_off(self): + """OnOffFlag: 1 = OFF.""" + validator = enum_validator(OnOffFlag) + result = validator(OnOffFlag.OFF) + assert result == OnOffFlag.OFF + + def test_onoff_flag_on(self): + """OnOffFlag: 2 = ON.""" + validator = enum_validator(OnOffFlag) + result = validator(OnOffFlag.ON) + assert result == OnOffFlag.ON + + def test_onoff_flag_by_value_off(self): + """Convert enum value 1 to OFF.""" + validator = enum_validator(OnOffFlag) + result = validator(1) + assert result == OnOffFlag.OFF + + def test_onoff_flag_by_value_on(self): + """Convert enum value 2 to ON.""" + validator = enum_validator(OnOffFlag) + result = validator(2) + assert result == OnOffFlag.ON + + def test_dhw_operation_setting(self): + """Test DhwOperationSetting enum validator.""" + validator = enum_validator(DhwOperationSetting) + result = validator(DhwOperationSetting.HEAT_PUMP) + assert result == DhwOperationSetting.HEAT_PUMP + + def test_dhw_all_values(self): + """Test all DhwOperationSetting values.""" + validator = enum_validator(DhwOperationSetting) + + hp = DhwOperationSetting.HEAT_PUMP + assert validator(hp) == hp + elec = DhwOperationSetting.ELECTRIC + assert validator(elec) == elec + es = DhwOperationSetting.ENERGY_SAVER + assert validator(es) == es + hd = DhwOperationSetting.HIGH_DEMAND + assert validator(hd) == hd + vac = DhwOperationSetting.VACATION + assert validator(vac) == vac + + def test_invalid_enum_value(self): + """Invalid enum value should raise.""" + validator = enum_validator(OnOffFlag) + with pytest.raises((ValueError, KeyError)): + validator(99) + + def test_enum_pass_through(self): + """Passing enum object returns same enum.""" + validator = enum_validator(OnOffFlag) + enum_obj = OnOffFlag.ON + result = validator(enum_obj) + assert result is enum_obj + + @pytest.mark.parametrize( + "input_val,expected_enum", + [ + (1, OnOffFlag.OFF), + (2, OnOffFlag.ON), + (OnOffFlag.OFF, OnOffFlag.OFF), + (OnOffFlag.ON, OnOffFlag.ON), + ], + ) + def test_onoff_conversions(self, input_val, expected_enum): + """Test various input formats for OnOffFlag.""" + validator = enum_validator(OnOffFlag) + result = validator(input_val) + assert result == expected_enum + + def test_multiple_validators_independent(self): + """Multiple validators are independent.""" + onoff_validator = enum_validator(OnOffFlag) + dhw_validator = enum_validator(DhwOperationSetting) + + assert onoff_validator(1) == OnOffFlag.OFF + assert dhw_validator(1) == DhwOperationSetting.HEAT_PUMP + + def test_validator_consistency(self): + """Validator gives consistent results.""" + validator = enum_validator(OnOffFlag) + result1 = validator(2) + result2 = validator(2) + assert result1 == result2 + assert result1 is result2 + + def test_string_integer_conversion(self): + """String integers are converted to enum.""" + validator = enum_validator(OnOffFlag) + result = validator("1") + assert result == OnOffFlag.OFF diff --git a/tests/test_mqtt_client_init.py b/tests/test_mqtt_client_init.py index 82fb307..e17d5c2 100644 --- a/tests/test_mqtt_client_init.py +++ b/tests/test_mqtt_client_init.py @@ -11,7 +11,7 @@ UserInfo, ) from nwp500.exceptions import MqttCredentialsError -from nwp500.mqtt_client import NavienMqttClient +from nwp500.mqtt import NavienMqttClient @pytest.fixture diff --git a/tests/test_temperature_converters.py b/tests/test_temperature_converters.py new file mode 100644 index 0000000..405f98a --- /dev/null +++ b/tests/test_temperature_converters.py @@ -0,0 +1,360 @@ +"""Comprehensive tests for temperature conversion utilities. + +Tests cover: +- HalfCelsius conversion from device values to Fahrenheit +- DeciCelsius conversion from device values to Fahrenheit +- Reverse conversions (Fahrenheit to device values) +- Edge cases and boundary conditions +- Known temperature reference points +""" + +import pytest + +from nwp500.temperature import DeciCelsius, HalfCelsius + + +class TestHalfCelsius: + """Test HalfCelsius temperature conversion (0.5°C precision). + + HalfCelsius format: raw_value / 2.0 = Celsius + Example: raw_value=120 → 60°C → 140°F + """ + + def test_zero_celsius(self): + """0°C = 32°F.""" + temp = HalfCelsius(0) + assert temp.to_fahrenheit() == 32.0 + + def test_freezing_point(self): + """0°C = 32°F (freezing point of water).""" + temp = HalfCelsius(0) + assert temp.to_celsius() == 0.0 + assert temp.to_fahrenheit() == 32.0 + + def test_boiling_point(self): + """100°C = 212°F (boiling point of water).""" + temp = HalfCelsius(200) # 200 half-degrees = 100°C + assert temp.to_celsius() == 100.0 + assert temp.to_fahrenheit() == pytest.approx(212.0) + + def test_body_temperature(self): + """37°C ≈ 98.6°F (normal body temperature).""" + temp = HalfCelsius(74) # 74 half-degrees = 37°C + assert temp.to_celsius() == pytest.approx(37.0) + assert temp.to_fahrenheit() == pytest.approx(98.6) + + def test_room_temperature(self): + """20°C = 68°F (typical room temperature).""" + temp = HalfCelsius(40) # 40 half-degrees = 20°C + assert temp.to_celsius() == 20.0 + assert temp.to_fahrenheit() == pytest.approx(68.0) + + def test_typical_dhw_temperature(self): + """60°C = 140°F (typical DHW temperature).""" + temp = HalfCelsius(120) # 120 half-degrees = 60°C + assert temp.to_celsius() == 60.0 + assert temp.to_fahrenheit() == pytest.approx(140.0) + + def test_high_dhw_temperature(self): + """80°C = 176°F (high DHW temperature).""" + temp = HalfCelsius(160) # 160 half-degrees = 80°C + assert temp.to_celsius() == 80.0 + assert temp.to_fahrenheit() == pytest.approx(176.0) + + def test_negative_temperature(self): + """-10°C = 14°F (freezing outdoor temp).""" + temp = HalfCelsius(-20) # -20 half-degrees = -10°C + assert temp.to_celsius() == -10.0 + assert temp.to_fahrenheit() == pytest.approx(14.0) + + @pytest.mark.parametrize( + "raw_value,expected_celsius,expected_fahrenheit", + [ + (0, 0.0, 32.0), + (10, 5.0, 41.0), + (20, 10.0, 50.0), + (40, 20.0, 68.0), + (74, 37.0, 98.6), + (100, 50.0, 122.0), + (120, 60.0, 140.0), + (140, 70.0, 158.0), + (160, 80.0, 176.0), + (200, 100.0, 212.0), + (-20, -10.0, 14.0), + (-40, -20.0, -4.0), + ], + ) + def test_known_conversions( + self, raw_value, expected_celsius, expected_fahrenheit + ): + """Test known temperature conversion points.""" + temp = HalfCelsius(raw_value) + assert temp.to_celsius() == pytest.approx(expected_celsius, abs=0.01) + assert temp.to_fahrenheit() == pytest.approx( + expected_fahrenheit, abs=0.1 + ) + + def test_from_fahrenheit_zero(self): + """32°F = 0°C = 0 in HalfCelsius.""" + temp = HalfCelsius.from_fahrenheit(32.0) + assert temp.raw_value == 0 + assert temp.to_celsius() == pytest.approx(0.0) + + def test_from_fahrenheit_room_temp(self): + """68°F ≈ 20°C ≈ 40 in HalfCelsius.""" + temp = HalfCelsius.from_fahrenheit(68.0) + assert temp.raw_value == pytest.approx(40, abs=1) + assert temp.to_fahrenheit() == pytest.approx(68.0, abs=0.1) + + def test_from_fahrenheit_typical_dhw(self): + """140°F ≈ 60°C ≈ 120 in HalfCelsius.""" + temp = HalfCelsius.from_fahrenheit(140.0) + assert temp.raw_value == pytest.approx(120, abs=1) + assert temp.to_fahrenheit() == pytest.approx(140.0, abs=0.1) + + @pytest.mark.parametrize( + "fahrenheit,expected_raw", + [ + (32.0, 0), + (50.0, 20), + (68.0, 40), + (86.0, 60), + (104.0, 80), + (122.0, 100), + (140.0, 120), + (158.0, 140), + (176.0, 160), + (212.0, 200), + (14.0, -20), + (-4.0, -40), + ], + ) + def test_from_fahrenheit_known_points(self, fahrenheit, expected_raw): + """Test reverse conversion from Fahrenheit to raw value.""" + temp = HalfCelsius.from_fahrenheit(fahrenheit) + # Allow some rounding tolerance + assert temp.raw_value == pytest.approx(expected_raw, abs=1) + + def test_roundtrip_conversion(self): + """Test roundtrip: raw → Celsius → Fahrenheit → raw.""" + original_raw = 120 + temp = HalfCelsius(original_raw) + fahrenheit = temp.to_fahrenheit() + + temp2 = HalfCelsius.from_fahrenheit(fahrenheit) + assert temp2.raw_value == pytest.approx(original_raw, abs=1) + + def test_float_raw_value(self): + """Test handling of float raw values.""" + temp = HalfCelsius(120.5) + assert temp.raw_value == 120.5 + assert temp.to_celsius() == pytest.approx(60.25) + assert temp.to_fahrenheit() == pytest.approx(140.45) + + def test_very_large_value(self): + """Test handling of very large temperature values.""" + temp = HalfCelsius(10000) # 5000°C + assert temp.to_celsius() == 5000.0 + assert temp.to_fahrenheit() == pytest.approx(9032.0) + + def test_very_small_value(self): + """Test handling of very small temperature values.""" + temp = HalfCelsius(-10000) # -5000°C + assert temp.to_celsius() == -5000.0 + assert temp.to_fahrenheit() == pytest.approx(-8968.0) + + +class TestDeciCelsius: + """Test DeciCelsius temperature conversion (0.1°C precision). + + DeciCelsius format: raw_value / 10.0 = Celsius + Example: raw_value=600 → 60°C → 140°F + """ + + def test_zero_celsius(self): + """0°C = 32°F.""" + temp = DeciCelsius(0) + assert temp.to_fahrenheit() == 32.0 + + def test_freezing_point(self): + """0°C = 32°F (freezing point).""" + temp = DeciCelsius(0) + assert temp.to_celsius() == 0.0 + assert temp.to_fahrenheit() == 32.0 + + def test_boiling_point(self): + """100°C = 212°F (boiling point).""" + temp = DeciCelsius(1000) # 1000 deci-degrees = 100°C + assert temp.to_celsius() == 100.0 + assert temp.to_fahrenheit() == pytest.approx(212.0) + + def test_body_temperature(self): + """37°C ≈ 98.6°F (normal body temperature).""" + temp = DeciCelsius(370) # 370 deci-degrees = 37°C + assert temp.to_celsius() == pytest.approx(37.0) + assert temp.to_fahrenheit() == pytest.approx(98.6) + + def test_room_temperature(self): + """20°C = 68°F (typical room temperature).""" + temp = DeciCelsius(200) # 200 deci-degrees = 20°C + assert temp.to_celsius() == 20.0 + assert temp.to_fahrenheit() == pytest.approx(68.0) + + def test_typical_dhw_temperature(self): + """60°C = 140°F (typical DHW temperature).""" + temp = DeciCelsius(600) # 600 deci-degrees = 60°C + assert temp.to_celsius() == 60.0 + assert temp.to_fahrenheit() == pytest.approx(140.0) + + def test_high_precision_value(self): + """60.5°C = 140.9°F (tests 0.1°C precision).""" + temp = DeciCelsius(605) # 605 deci-degrees = 60.5°C + assert temp.to_celsius() == 60.5 + assert temp.to_fahrenheit() == pytest.approx(140.9) + + def test_negative_temperature(self): + """-10°C = 14°F (freezing outdoor temp).""" + temp = DeciCelsius(-100) # -100 deci-degrees = -10°C + assert temp.to_celsius() == -10.0 + assert temp.to_fahrenheit() == pytest.approx(14.0) + + @pytest.mark.parametrize( + "raw_value,expected_celsius,expected_fahrenheit", + [ + (0, 0.0, 32.0), + (50, 5.0, 41.0), + (100, 10.0, 50.0), + (200, 20.0, 68.0), + (370, 37.0, 98.6), + (500, 50.0, 122.0), + (600, 60.0, 140.0), + (700, 70.0, 158.0), + (800, 80.0, 176.0), + (1000, 100.0, 212.0), + (-100, -10.0, 14.0), + (-200, -20.0, -4.0), + ], + ) + def test_known_conversions( + self, raw_value, expected_celsius, expected_fahrenheit + ): + """Test known temperature conversion points.""" + temp = DeciCelsius(raw_value) + assert temp.to_celsius() == pytest.approx(expected_celsius, abs=0.01) + assert temp.to_fahrenheit() == pytest.approx( + expected_fahrenheit, abs=0.1 + ) + + def test_from_fahrenheit_zero(self): + """32°F = 0°C = 0 in DeciCelsius.""" + temp = DeciCelsius.from_fahrenheit(32.0) + assert temp.raw_value == 0 + assert temp.to_celsius() == pytest.approx(0.0) + + def test_from_fahrenheit_room_temp(self): + """68°F ≈ 20°C ≈ 200 in DeciCelsius.""" + temp = DeciCelsius.from_fahrenheit(68.0) + assert temp.raw_value == pytest.approx(200, abs=1) + assert temp.to_fahrenheit() == pytest.approx(68.0, abs=0.1) + + def test_from_fahrenheit_typical_dhw(self): + """140°F ≈ 60°C ≈ 600 in DeciCelsius.""" + temp = DeciCelsius.from_fahrenheit(140.0) + assert temp.raw_value == pytest.approx(600, abs=1) + assert temp.to_fahrenheit() == pytest.approx(140.0, abs=0.1) + + @pytest.mark.parametrize( + "fahrenheit,expected_raw", + [ + (32.0, 0), + (50.0, 100), + (68.0, 200), + (86.0, 300), + (104.0, 400), + (122.0, 500), + (140.0, 600), + (158.0, 700), + (176.0, 800), + (212.0, 1000), + (14.0, -100), + (-4.0, -200), + ], + ) + def test_from_fahrenheit_known_points(self, fahrenheit, expected_raw): + """Test reverse conversion from Fahrenheit to raw value.""" + temp = DeciCelsius.from_fahrenheit(fahrenheit) + assert temp.raw_value == pytest.approx(expected_raw, abs=1) + + def test_roundtrip_conversion(self): + """Test roundtrip: raw → Celsius → Fahrenheit → raw.""" + original_raw = 600 + temp = DeciCelsius(original_raw) + fahrenheit = temp.to_fahrenheit() + + temp2 = DeciCelsius.from_fahrenheit(fahrenheit) + assert temp2.raw_value == pytest.approx(original_raw, abs=1) + + def test_float_raw_value(self): + """Test handling of float raw values.""" + temp = DeciCelsius(600.5) + assert temp.raw_value == 600.5 + assert temp.to_celsius() == pytest.approx(60.05) + assert temp.to_fahrenheit() == pytest.approx(140.09) + + def test_very_large_value(self): + """Test handling of very large temperature values.""" + temp = DeciCelsius(100000) # 10000°C + assert temp.to_celsius() == 10000.0 + assert temp.to_fahrenheit() == pytest.approx(18032.0) + + def test_very_small_value(self): + """Test handling of very small temperature values.""" + temp = DeciCelsius(-100000) # -10000°C + assert temp.to_celsius() == -10000.0 + assert temp.to_fahrenheit() == pytest.approx(-17968.0) + + +class TestTemperatureComparison: + """Compare HalfCelsius and DeciCelsius for same temperature.""" + + def test_same_temperature_different_precision(self): + """HalfCelsius(120) and DeciCelsius(600) represent same 60°C.""" + half_temp = HalfCelsius(120) + deci_temp = DeciCelsius(600) + + assert half_temp.to_celsius() == pytest.approx(deci_temp.to_celsius()) + assert half_temp.to_fahrenheit() == pytest.approx( + deci_temp.to_fahrenheit() + ) + + @pytest.mark.parametrize( + "half_raw,deci_raw,celsius", + [ + # For equivalence: half_raw/2 = deci_raw/10 + # So: deci_raw = half_raw * 5 + (0, 0, 0.0), + (10, 50, 5.0), + (20, 100, 10.0), + (40, 200, 20.0), + (100, 500, 50.0), + (120, 600, 60.0), + (200, 1000, 100.0), + ], + ) + def test_equivalent_temperatures(self, half_raw, deci_raw, celsius): + """Test that half and deci celsius represent same actual temp.""" + # HalfCelsius: raw/2 = celsius + # DeciCelsius: raw/10 = celsius + # For same temp: half_raw/2 = deci_raw/10 + # Therefore: deci_raw = half_raw * 5 + half = HalfCelsius(half_raw) + deci = DeciCelsius(deci_raw) + + # HalfCelsius: raw/2 = celsius + half_celsius = half_raw / 2.0 + # DeciCelsius: raw/10 = celsius + deci_celsius = deci_raw / 10.0 + + assert half_celsius == pytest.approx(celsius) + assert deci_celsius == pytest.approx(celsius) + assert half.to_fahrenheit() == pytest.approx(deci.to_fahrenheit()) diff --git a/tox.ini b/tox.ini index 4b9b244..7f00677 100644 --- a/tox.ini +++ b/tox.ini @@ -8,8 +8,8 @@ envlist = default,lint isolated_build = True -[testenv] -description = Invoke pytest to run automated tests +[testenv:default] +description = Invoke pytest to run automated tests, then pyright for type checking setenv = TOXINIDIR = {toxinidir} TERM = xterm-256color @@ -18,20 +18,26 @@ passenv = SETUPTOOLS_* extras = testing +deps = + pyright>=1.1.0 commands = pytest {posargs} + pyright src/nwp500 {posargs} [testenv:lint] -description = Perform static analysis and style checks with ruff +description = Perform static analysis and style checks with ruff and pyright skip_install = True -deps = ruff>=0.1.0 +deps = + ruff>=0.1.0 + pyright>=1.1.0 passenv = HOME SETUPTOOLS_* commands = ruff check src/ tests/ examples/ {posargs} ruff format --check src/ tests/ examples/ {posargs} + pyright src/nwp500 {posargs} [testenv:format]