diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f8d45fb..eea8b80 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -53,6 +53,12 @@ Always run these checks before finalizing changes to ensure your code will pass This prevents "passes locally but fails in CI" issues. +**IMPORTANT - Error Fixing Policy**: +- **Fix ALL linting and type errors**, even if they're in files you didn't modify or weren't introduced by your changes +- Pre-existing errors must be fixed as part of the task +- It's acceptable to fix unrelated errors in the codebase while completing a task +- Do not leave type errors or linting issues unfixed + **Important**: When updating CHANGELOG.rst or any file with dates, always use `date +"%Y-%m-%d"` to get the correct current date. Never hardcode or guess dates. ### Before Completing a Task - REQUIRED VALIDATION @@ -65,6 +71,8 @@ This prevents "passes locally but fails in CI" issues. **Do not mark a task as complete or create a PR without running all three checks.** +**CRITICAL - Fix ALL Errors**: Fix all linting and type errors reported by these tools, regardless of whether they exist in files you modified or were introduced by your changes. Pre-existing errors must be fixed as part of completing any task. This ensures a clean, passing test suite. + These checks prevent "works locally but fails in CI" issues and catch integration problems early. Report the results of these checks in your final summary, including: diff --git a/.gitignore b/.gitignore index 5bf0e4f..3e9ff94 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.orig *.log *.pot +*.csv __pycache__/* .cache/* .*.swp @@ -38,6 +39,10 @@ junit*.xml coverage.xml .pytest_cache/ +# Diagnostics output +diagnostics_output/ +examples/mqtt_diagnostics_output/ + # Build and docs folder/files build/* dist/* diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ccf270d..d29d7eb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,11 +2,12 @@ Changelog ========= -Version 7.1.0 (2025-12-19) +Version 7.1.0 (2025-12-22) ========================== Added ----- + - **Device Capability System**: New device capability detection and validation framework: - ``DeviceCapabilityChecker``: Validates device feature support based on device models - ``DeviceInfoCache``: Efficient caching of device information with configurable update intervals @@ -20,19 +21,40 @@ Added - ``configure_reservation_water_program()``: Water program reservation management - ``set_recirculation_mode()`` / ``configure_recirculation_schedule()`` / ``trigger_recirculation_hot_button()``: Recirculation pump control and scheduling -- **CLI Enhancements**: Extended command-line interface with new subcommands and diagnostics -- **New Examples**: Example scripts for demand response, air filter reset, vacation mode, recirculation control, and water program reservations -- **Documentation**: Enhanced device control documentation with capability matrix +- **CLI Documentation Updates**: Comprehensive documentation updates for subcommand-based CLI + - Complete rewrite of ``docs/python_api/cli.rst`` with full command reference + - Updated README.rst with new subcommand syntax and examples + - Added 8+ practical usage examples (cron jobs, automation, monitoring) + - Added troubleshooting guide and best practices section + +- **Model Field Factory Pattern**: New field factory to reduce boilerplate in model definitions + - Automatic field conversion and validation + - Cleaner model architecture Changed ------- + +- **CLI Output**: Numeric values in status output now rounded to one decimal place for better readability - ``MqttDeviceController`` now integrates device capability checking with auto-caching of device info - Exception type hints improved with proper None handling in optional parameters -- CLI diagnostics output now available in structured format +- **MQTT Control Refactoring**: Centralized device control via ``.control`` namespace + - Standardized periodic request patterns + - Public API method ``ensure_device_info_cached()`` for better cache management +- **Logging Security**: Enhanced sensitive data redaction + - MAC addresses consistently redacted across all logging output + - Token logging removed from docstrings and examples + - Intermediate variables used for redacted data Fixed ----- + - Type annotation consistency: Optional parameters now properly annotated as ``type | None`` instead of ``type`` +- **Type System Fixes**: Resolved multiple type annotation issues for CI compatibility +- **Mixing Valve Field**: Corrected alias field name and removed unused TOU status validator +- **Vacation Days Validation**: Enforced maximum value validation for vacation mode days +- **CI Linting**: Fixed line length violations and import sorting issues +- **Security Scanning**: Resolved intermediate variable issues in redacted MAC address handling +- **Parser Regressions**: Fixed data parsing issues introduced in MQTT refactoring Version 7.0.1 (2025-12-18) ========================== diff --git a/README.rst b/README.rst index e78744a..63fa7a1 100644 --- a/README.rst +++ b/README.rst @@ -65,7 +65,7 @@ Basic Usage Command Line Interface ====================== -The library includes a command line interface for quick monitoring and device information retrieval: +The library includes a command line interface for monitoring and controlling your Navien water heater: .. code-block:: bash @@ -73,58 +73,89 @@ The library includes a command line interface for quick monitoring and device in export NAVIEN_EMAIL="your_email@example.com" export NAVIEN_PASSWORD="your_password" - # Get current device status (one-time) - python -m nwp500.cli --status - - # Get device information - python -m nwp500.cli --device-info - - # Get device feature/capability information - python -m nwp500.cli --device-feature - - # Turn device on - python -m nwp500.cli --power-on - - # Turn device off - python -m nwp500.cli --power-off - - # Turn device on and see updated status - python -m nwp500.cli --power-on --status - - # Set operation mode and see response - python -m nwp500.cli --set-mode energy-saver - - # Set DHW target temperature and see response - python -m nwp500.cli --set-dhw-temp 140 - - # Set temperature and then get updated status - python -m nwp500.cli --set-dhw-temp 140 --status - - # Set mode and then get updated status - python -m nwp500.cli --set-mode energy-saver --status - - # Just get current status (one-time) - python -m nwp500.cli --status - - # Monitor continuously (default - writes to CSV) - python -m nwp500.cli --monitor - - # Monitor with custom output file - python -m nwp500.cli --monitor --output my_data.csv - -**Available CLI Options:** - -* ``--status``: Print current device status as JSON. Can be combined with control commands to see updated status. -* ``--device-info``: Print comprehensive device information (firmware, model, capabilities) as JSON and exit -* ``--device-feature``: Print device capabilities and feature settings as JSON and exit -* ``--power-on``: Turn the device on and display response -* ``--power-off``: Turn the device off and display response -* ``--set-mode MODE``: Set operation mode and display response. Valid modes: heat-pump, energy-saver, high-demand, electric, vacation, standby -* ``--set-dhw-temp TEMP``: Set DHW (Domestic Hot Water) target temperature in Fahrenheit (115-150°F) and display response -* ``--monitor``: Continuously monitor status every 30 seconds and log to CSV (default) -* ``-o, --output``: Specify CSV output filename for monitoring mode -* ``--email``: Override email (alternative to environment variable) -* ``--password``: Override password (alternative to environment variable) + # Get current device status + python3 -m nwp500.cli status + + # Get device information and firmware + python3 -m nwp500.cli info + + # Get controller serial number + python3 -m nwp500.cli serial + + # Turn device on/off + python3 -m nwp500.cli power on + python3 -m nwp500.cli power off + + # Set operation mode + python3 -m nwp500.cli mode heat-pump + python3 -m nwp500.cli mode energy-saver + python3 -m nwp500.cli mode high-demand + python3 -m nwp500.cli mode electric + python3 -m nwp500.cli mode vacation + python3 -m nwp500.cli mode standby + + # Set target temperature + python3 -m nwp500.cli temp 140 + + # Set vacation days + python3 -m nwp500.cli vacation 7 + + # Trigger instant hot water + python3 -m nwp500.cli hot-button + + # Set recirculation pump mode (1-4) + python3 -m nwp500.cli recirc 2 + + # Reset air filter timer + python3 -m nwp500.cli reset-filter + + # Enable water program mode + python3 -m nwp500.cli water-program + + # View and update schedules + python3 -m nwp500.cli reservations get + python3 -m nwp500.cli reservations set '[{"hour": 6, "min": 0, ...}]' + + # Time-of-use settings + python3 -m nwp500.cli tou get + python3 -m nwp500.cli tou set on + + # Energy usage data + python3 -m nwp500.cli energy --year 2024 --months 10,11,12 + + # Demand response + python3 -m nwp500.cli dr enable + python3 -m nwp500.cli dr disable + + # Real-time monitoring (logs to CSV) + python3 -m nwp500.cli monitor + python3 -m nwp500.cli monitor -o my_data.csv + +**Global Options:** + +* ``--email EMAIL``: Navien account email (or use ``NAVIEN_EMAIL`` env var) +* ``--password PASSWORD``: Navien account password (or use ``NAVIEN_PASSWORD`` env var) +* ``-v, --verbose``: Enable debug logging +* ``--version``: Show version and exit + +**Available Commands:** + +* ``status``: Show current device status (temperature, mode, power) +* ``info``: Show device information (firmware, capabilities) +* ``serial``: Get controller serial number +* ``power on|off``: Turn device on or off +* ``mode MODE``: Set operation mode (heat-pump, electric, energy-saver, high-demand, vacation, standby) +* ``temp TEMPERATURE``: Set target water temperature in °F +* ``vacation DAYS``: Enable vacation mode for N days +* ``recirc MODE``: Set recirculation pump (1=always, 2=button, 3=schedule, 4=temperature) +* ``hot-button``: Trigger instant hot water +* ``reset-filter``: Reset air filter maintenance timer +* ``water-program``: Enable water program reservation mode +* ``reservations get|set``: View or update schedule +* ``tou get|set STATE``: View or configure time-of-use settings +* ``energy``: Query historical energy usage (requires ``--year`` and ``--months``) +* ``dr enable|disable``: Enable or disable demand response +* ``monitor``: Monitor device status in real-time (logs to CSV with ``-o`` option) Device Status Fields ==================== diff --git a/docs/MQTT_DIAGNOSTICS.rst b/docs/MQTT_DIAGNOSTICS.rst index 69376bb..250ca92 100644 --- a/docs/MQTT_DIAGNOSTICS.rst +++ b/docs/MQTT_DIAGNOSTICS.rst @@ -715,7 +715,7 @@ Device Control Integration diagnostics.record_publish(queued=not mqtt_client.is_connected) # Set temperature - await mqtt_client.set_dhw_temperature(device, 140.0) + await mqtt_client.control.set_dhw_temperature(device, 140.0) if not mqtt_client.is_connected: _logger.info( diff --git a/docs/guides/advanced_features_explained.rst b/docs/guides/advanced_features_explained.rst index 9820280..ec17be2 100644 --- a/docs/guides/advanced_features_explained.rst +++ b/docs/guides/advanced_features_explained.rst @@ -109,7 +109,7 @@ The ``outsideTemperature`` field is transmitted in the device status update. Pyt .. code-block:: python # From device status updates - status = await mqtt_client.get_status() + status = await mqtt_client.control.request_device_status() # Access ambient temperature data outdoor_temp = status.outside_temperature # Raw integer value @@ -389,7 +389,7 @@ Monitoring Stratification from Python async def monitor_stratification(mqtt_client: NavienMQTTClient, device_id: str): """Monitor tank stratification quality""" - status = await mqtt_client.get_status(device_id) + status = await mqtt_client.control.request_device_status(device_id) upper_temp = status.tank_upper_temperature # float in °F lower_temp = status.tank_lower_temperature # float in °F diff --git a/docs/guides/auto_recovery.rst b/docs/guides/auto_recovery.rst index 026fba7..e0949f0 100644 --- a/docs/guides/auto_recovery.rst +++ b/docs/guides/auto_recovery.rst @@ -89,7 +89,7 @@ Create a new MQTT client instance when reconnection fails. # Restore subscriptions await mqtt_client.subscribe_device_status(device, on_status) - await mqtt_client.start_periodic_device_status_requests(device) + await mqtt_client.start_periodic_requests(device) return mqtt_client @@ -125,7 +125,7 @@ Refresh authentication tokens before retrying (handles token expiry). # Restore subscriptions await mqtt_client.subscribe_device_status(device, on_status) - await mqtt_client.start_periodic_device_status_requests(device) + await mqtt_client.start_periodic_requests(device) **Pros:** Handles token expiry, more robust @@ -183,7 +183,7 @@ Use exponential backoff between recovery attempts with token refresh. await self.mqtt_client.subscribe_device_status( self.device, self.callbacks['status'] ) - await self.mqtt_client.start_periodic_device_status_requests( + await self.mqtt_client.start_periodic_requests( self.device ) diff --git a/docs/guides/command_queue.rst b/docs/guides/command_queue.rst index 21e2389..f243925 100644 --- a/docs/guides/command_queue.rst +++ b/docs/guides/command_queue.rst @@ -165,7 +165,7 @@ Basic Usage (Default Configuration) # Command queue is enabled by default # Commands sent during disconnection are automatically queued - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) # If disconnected, command is queued and sent on reconnection # No user action needed @@ -222,7 +222,7 @@ Handle Queue Full Condition # Queue has max size of 100 by default # Oldest commands automatically dropped when full for i in range(150): - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) # First 100 queued, remaining 50 replace oldest print(f"Queued: {mqtt_client.queued_commands_count}") # Will be 100 @@ -258,8 +258,8 @@ Reliable Device Control .. code-block:: python # Even during network issues, commands are preserved - await mqtt_client.set_dhw_temperature(device, 140.0) - await mqtt_client.set_dhw_mode(device, 2) # Energy Saver mode + await mqtt_client.control.set_dhw_temperature(device, 140.0) + await mqtt_client.control.set_dhw_mode(device, 2) # Energy Saver mode # Commands queued if disconnected, sent when reconnected @@ -269,7 +269,7 @@ Monitoring with Interruptions .. code-block:: python # Periodic status requests continue even with network issues - await mqtt_client.start_periodic_device_status_requests(device, 60) + await mqtt_client.start_periodic_requests(device, 60) # Requests queued during disconnection, sent on reconnection @@ -280,8 +280,8 @@ Batch Operations # Send multiple commands without worrying about connection state for device in devices: - await mqtt_client.request_device_status(device) - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_status(device) + await mqtt_client.control.request_device_info(device) # All commands reach their destination eventually diff --git a/docs/guides/energy_monitoring.rst b/docs/guides/energy_monitoring.rst index d3d879a..b7ffaac 100644 --- a/docs/guides/energy_monitoring.rst +++ b/docs/guides/energy_monitoring.rst @@ -101,10 +101,10 @@ Request detailed daily energy usage data for specific months: await mqtt_client.subscribe_energy_usage(device, on_energy_usage) # Request energy usage for September 2025 - await mqtt_client.request_energy_usage(device, year=2025, months=[9]) + await mqtt_client.control.request_energy_usage(device, year=2025, months=[9]) # Request multiple months - await mqtt_client.request_energy_usage(device, year=2025, months=[7, 8, 9]) + await mqtt_client.control.request_energy_usage(device, year=2025, months=[7, 8, 9]) **Key Methods:** @@ -243,7 +243,7 @@ Complete Energy Monitoring Example ) # Request initial status - await mqtt_client.request_device_status( + await mqtt_client.control.request_device_status( device.device_info.mac_address, device.device_info.device_type, device.device_info.additional_value diff --git a/docs/guides/event_system.rst b/docs/guides/event_system.rst index 9cce974..a523a16 100644 --- a/docs/guides/event_system.rst +++ b/docs/guides/event_system.rst @@ -59,7 +59,7 @@ Simple Event Handler # Subscribe to status updates await mqtt.subscribe_device_status(device, on_status_update) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # Monitor for 5 minutes await asyncio.sleep(300) diff --git a/docs/guides/reservations.rst b/docs/guides/reservations.rst index 1f18aab..404b93d 100644 --- a/docs/guides/reservations.rst +++ b/docs/guides/reservations.rst @@ -57,7 +57,7 @@ Here's a simple example that sets up a weekday morning reservation: # Send to device mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.update_reservations( + await mqtt.control.update_reservations( device, [weekday_morning], enabled=True @@ -303,7 +303,7 @@ Send a new reservation schedule to the device: # Send to device mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.update_reservations( + await mqtt.control.update_reservations( device, reservations, enabled=True # Enable reservation system @@ -367,7 +367,7 @@ Request the current reservation schedule from the device: await mqtt.subscribe(response_topic, on_reservation_response) # Request current schedule - await mqtt.request_reservations(device) + await mqtt.control.request_reservations(device) # Wait for response await asyncio.sleep(5) @@ -389,7 +389,7 @@ To disable the reservation system while keeping entries stored: await mqtt.connect() # Keep existing entries but disable execution - await mqtt.update_reservations( + await mqtt.control.update_reservations( device, [], # Empty list keeps existing entries enabled=False # Disable reservation system @@ -413,7 +413,7 @@ To completely clear the reservation schedule: await mqtt.connect() # Send empty list with disabled flag - await mqtt.update_reservations( + await mqtt.control.update_reservations( device, [], enabled=False @@ -680,7 +680,7 @@ Full working example with error handling and response monitoring: # Send new schedule print("\nUpdating reservation schedule...") - await mqtt.update_reservations( + await mqtt.control.update_reservations( device, reservations, enabled=True @@ -689,7 +689,7 @@ Full working example with error handling and response monitoring: # Request confirmation print("\nRequesting current schedule...") - await mqtt.request_reservations(device) + await mqtt.control.request_reservations(device) # Wait for response try: diff --git a/docs/guides/time_of_use.rst b/docs/guides/time_of_use.rst index 66730aa..5341d11 100644 --- a/docs/guides/time_of_use.rst +++ b/docs/guides/time_of_use.rst @@ -444,7 +444,7 @@ Configure two rate periods - off-peak and peak pricing: feature_future.set_result(feature) await mqtt_client.subscribe_device_feature(device, capture_feature) - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_info(device) feature = await asyncio.wait_for(feature_future, timeout=15) controller_serial = feature.controllerSerialNumber @@ -475,7 +475,7 @@ Configure two rate periods - off-peak and peak pricing: ) # Configure TOU schedule - await mqtt_client.configure_tou_schedule( + await mqtt_client.control.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=[off_peak, peak], @@ -556,7 +556,7 @@ Configure different rates for summer and winter: ) # Configure all periods - await mqtt_client.configure_tou_schedule( + await mqtt_client.control.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=[summer_off_peak, summer_peak, winter_off_peak, winter_peak], @@ -617,7 +617,7 @@ Query the device for its current TOU configuration: await mqtt_client.subscribe(response_topic, on_tou_response) # Request current settings - await mqtt_client.request_tou_settings(device, controller_serial) + await mqtt_client.control.request_tou_settings(device, controller_serial) # Wait for response await asyncio.sleep(5) @@ -641,7 +641,7 @@ Enable or disable TOU operation: await mqtt_client.connect() # Enable or disable TOU - await mqtt_client.set_tou_enabled(device, enabled=enable) + await mqtt_client.control.set_tou_enabled(device, enabled=enable) print(f"TOU {'enabled' if enable else 'disabled'}") await mqtt_client.disconnect() @@ -782,7 +782,7 @@ data from the OpenEI API and configuring it on your device: # ... obtain controller_serial ... # Configure the schedule - await mqtt_client.configure_tou_schedule( + await mqtt_client.control.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=tou_periods, diff --git a/docs/index.rst b/docs/index.rst index 236ca4c..d6a5164 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -71,11 +71,11 @@ Basic Example print(f"Power: {status.current_inst_power}W") await mqtt.subscribe_device_status(device, on_status) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # Control device - await mqtt.set_power(device, power_on=True) - await mqtt.set_dhw_temperature(device, 120.0) + await mqtt.control.set_power(device, power_on=True) + await mqtt.control.set_dhw_temperature(device, 120.0) await asyncio.sleep(30) await mqtt.disconnect() @@ -100,6 +100,7 @@ Documentation Index python_api/auth_client python_api/api_client python_api/mqtt_client + python_api/device_control python_api/models enumerations python_api/constants diff --git a/docs/protocol/data_conversions.rst b/docs/protocol/data_conversions.rst index 93115cd..cd73564 100644 --- a/docs/protocol/data_conversions.rst +++ b/docs/protocol/data_conversions.rst @@ -593,7 +593,7 @@ These fields reflect device settings (as opposed to real-time measurements): * - ``tempFormulaType`` - None (direct value) - Enum - - **Temperature conversion formula type**. Advanced: used for non-standard sensor calibrations. + - **Temperature conversion formula type**. See Temperature Formula Types section below for details on display calculation. * - ``errorBuzzerUse`` - device_bool - Boolean @@ -631,6 +631,22 @@ Understanding these conversions helps with: 4. **Maintenance Scheduling**: Track ``airFilterAlarmElapsed`` and ``cumulatedOpTimeEvaFan`` for preventative maintenance 5. **User Experience**: Use ``dhwChargePer`` to show users remaining hot water in tank; correlate with ``currentInstPower`` to show recovery ETA + +Temperature Formula Types +------------------------- + +The ``temp_formula_type`` field indicates which temperature conversion formula the device uses. The library automatically applies the correct formula. + +**Type 0: ASYMMETRIC** + +- If the raw encoded temperature value satisfies ``raw_value % 10 == 9`` (i.e., the remainder of ``raw_value`` divided by 10 is 9, indicating a half-degree step): ``floor(fahrenheit)`` +- Otherwise: ``ceil(fahrenheit)`` + +**Type 1: STANDARD** (most devices) +- Standard rounding: ``round(fahrenheit)`` + +Both formulas convert from half-degrees Celsius to Fahrenheit based on the raw encoded temperature value. This ensures temperature display matches the device's built-in LCD. + See Also -------- diff --git a/docs/protocol/device_features.rst b/docs/protocol/device_features.rst index 9d7abe5..6a2721e 100644 --- a/docs/protocol/device_features.rst +++ b/docs/protocol/device_features.rst @@ -365,7 +365,7 @@ Usage Example print(f"Available: {', '.join(features)}") await mqtt_client.subscribe_device_feature(device, analyze_features) - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_info(device) # Wait for response await asyncio.sleep(5) diff --git a/docs/protocol/firmware_tracking.rst b/docs/protocol/firmware_tracking.rst index dfb7c86..0f6b2bc 100644 --- a/docs/protocol/firmware_tracking.rst +++ b/docs/protocol/firmware_tracking.rst @@ -89,17 +89,17 @@ You can get your firmware versions by running: print(f"Panel SW: {feature.panelSwVersion}") print(f"WiFi SW: {feature.wifiSwVersion}") - await mqtt.request_device_info(feature_callback) + await mqtt.control.request_device_info(feature_callback) await asyncio.sleep(2) await mqtt.disconnect() asyncio.run(get_firmware()) -Or using the CLI (if implemented): +Or using the CLI: .. code-block:: bash - nwp-cli --device-info + python3 -m nwp500.cli info Please report issues at: https://github.com/eman/nwp500-python/issues diff --git a/docs/protocol/mqtt_protocol.rst b/docs/protocol/mqtt_protocol.rst index 356dfd0..8cc8ba8 100644 --- a/docs/protocol/mqtt_protocol.rst +++ b/docs/protocol/mqtt_protocol.rst @@ -119,17 +119,17 @@ Status and Info Requests * - Command - Code - Description - * - Device Status Request - - 16777221 - - Request current device status * - Device Info Request - - 16777222 + - 16777217 - Request device features/capabilities + * - Device Status Request + - 16777219 + - Request current device status * - Reservation Read - 16777222 - Read reservation schedule * - Energy Usage Query - - 33554435 + - 16777225 - Query energy usage data Control Commands @@ -733,7 +733,7 @@ Energy Usage Query .. code-block:: json { - "command": 33554435, + "command": 16777225, "mode": "energy-usage-daily-query", "param": [], "paramStr": "", @@ -755,7 +755,7 @@ Status Response "requestTopic": "...", "responseTopic": "...", "response": { - "command": 16777221, + "command": 16777219, "deviceType": 52, "macAddress": "...", "status": { @@ -887,7 +887,7 @@ Example: Request Status "responseTopic": "cmd/52/my-client-id/res/status/rd", "protocolVersion": 2, "request": { - "command": 16777221, + "command": 16777219, "deviceType": 52, "macAddress": "04786332fca0", "additionalValue": "...", @@ -917,7 +917,7 @@ this protocol. mqtt = NavienMqttClient(auth) await mqtt.connect() await mqtt.subscribe_device_status(device, callback) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) Related Documentation ===================== diff --git a/docs/python_api/cli.rst b/docs/python_api/cli.rst index 658c014..0c7d54f 100644 --- a/docs/python_api/cli.rst +++ b/docs/python_api/cli.rst @@ -3,30 +3,28 @@ Command Line Interface ====================== The ``nwp500`` CLI provides a command-line interface for monitoring and -controlling Navien water heaters without writing Python code. +controlling Navien NWP500 water heaters without writing Python code. .. code-block:: bash # Python module - python3 -m nwp500.cli [options] + python3 -m nwp500.cli [global-options] [command-options] # Or if installed - navien-cli [options] + nwp-cli [global-options] [command-options] Overview ======== The CLI supports: -* **Real-time monitoring** - Continuous device status updates -* **Device status** - One-time status queries -* **Power control** - Turn device on/off -* **Mode control** - Change operation mode (Heat Pump, Electric, etc.) -* **Temperature control** - Set target temperature -* **Energy queries** - Get historical energy usage -* **Reservations** - View and update schedule -* **Time-of-Use** - Configure TOU settings -* **Device information** - Firmware, features, capabilities +* **Real-time monitoring** - Continuous device status updates (logs to CSV) +* **Device control** - Power, mode, temperature, vacation mode +* **Device information** - Status, firmware, features, serial number +* **Instant hot water** - Trigger hot button for immediate hot water +* **Energy management** - Historical usage data, demand response, TOU settings +* **Scheduling** - Reservations and time-of-use configuration +* **Maintenance** - Air filter reset, recirculation control, water program mode Authentication ============== @@ -41,7 +39,7 @@ Environment Variables (Recommended) export NAVIEN_EMAIL="your@email.com" export NAVIEN_PASSWORD="your_password" - python3 -m nwp500.cli --status + python3 -m nwp500.cli status Command Line Arguments ---------------------- @@ -51,7 +49,7 @@ Command Line Arguments python3 -m nwp500.cli \ --email "your@email.com" \ --password "your_password" \ - --status + status Token Caching ------------- @@ -76,389 +74,471 @@ Global Options .. option:: -v, --verbose - Enable debug logging output. + Enable verbose logging output (log level: INFO). + +.. option:: -vv, --very-verbose + + Enable very verbose logging output (log level: DEBUG). Commands ======== -Monitoring Commands -------------------- +Status & Information Commands +----------------------------- -monitor (default) -^^^^^^^^^^^^^^^^^ +status +^^^^^^ -Real-time continuous monitoring of device status. +Get current device status (one-time query). .. code-block:: bash - # Monitor with JSON output (default) - python3 -m nwp500.cli + python3 -m nwp500.cli status - # Monitor with formatted text output - python3 -m nwp500.cli --output text +**Output:** Device status including water temperature, target temperature, mode, +power consumption, tank charge percentage, and component states. - # Monitor with compact output - python3 -m nwp500.cli --output compact +**Example:** -**Options:** +.. code-block:: json -.. option:: --output FORMAT + { + "dhwTemperature": 138.5, + "dhwTargetTemp": 140, + "dhwChargePer": 85, + "currentInstPower": 1250, + "operationMode": "energy-saver", + "compressorStatus": 1, + "heatPumpStatus": 1, + "upperHeaterStatus": 0, + "lowerHeaterStatus": 0 + } - Output format: ``json``, ``text``, or ``compact`` (default: ``json``) +info +^^^^ -**Example Output (text format):** +Show comprehensive device information (firmware, model, capabilities, serial). -.. code-block:: text +.. code-block:: bash - [12:34:56] Navien Water Heater Status - ═══════════════════════════════════════ - Temperature: 138.0°F (Target: 140.0°F) - Power: 1250W - Mode: ENERGY_SAVER - State: HEAT_PUMP - Energy: 85.5% - - Components: - ENABLED: Heat Pump Running - DISABLED: Upper Heater - DISABLED: Lower Heater - - [12:35:01] Temperature changed: 139.0°F - ---status -^^^^^^^^ + python3 -m nwp500.cli info -Get current device status (one-time query). +**Output:** Device name, MAC address, firmware versions, features supported, +temperature ranges, and capabilities. + +serial +^^^^^^ + +Get controller serial number (useful for troubleshooting and TOU configuration). .. code-block:: bash - python3 -m nwp500.cli --status + python3 -m nwp500.cli serial -**Output:** Complete device status with temperatures, power, mode, and -component states. +**Output:** Controller serial number (plain text). ---status-raw -^^^^^^^^^^^^ +**Example:** + +.. code-block:: text + + NV123ABC456789 -Get raw device status without conversions. +Power Control Commands +---------------------- + +power +^^^^^ + +Turn device on or off. .. code-block:: bash - python3 -m nwp500.cli --status-raw + # Turn on + python3 -m nwp500.cli power on -**Output:** Raw JSON status data as received from device (no temperature -conversions or formatting). + # Turn off + python3 -m nwp500.cli power off -Device Information Commands ---------------------------- +**Syntax:** + +.. code-block:: bash + + python3 -m nwp500.cli power + +**Output:** Confirmation message and updated device status. + +Temperature & Mode Commands +---------------------------- ---device-info -^^^^^^^^^^^^^ +mode +^^^^ -Get comprehensive device information. +Set operation mode. .. code-block:: bash - python3 -m nwp500.cli --device-info + # Heat Pump Only (most efficient) + python3 -m nwp500.cli mode heat-pump + + # Electric Only (fastest recovery) + python3 -m nwp500.cli mode electric + + # Energy Saver (recommended, balanced) + python3 -m nwp500.cli mode energy-saver + + # High Demand (maximum capacity) + python3 -m nwp500.cli mode high-demand -**Output:** Device name, MAC address, connection status, firmware versions, -and location. + # Vacation Mode + python3 -m nwp500.cli mode vacation ---device-feature -^^^^^^^^^^^^^^^^ + # Standby + python3 -m nwp500.cli mode standby -Get device features and capabilities. +**Syntax:** .. code-block:: bash - python3 -m nwp500.cli --device-feature + python3 -m nwp500.cli mode -**Output:** Supported features, temperature limits, firmware versions, serial -number. +**Available Modes:** -**Example Output:** +* ``standby`` - Device off but ready +* ``heat-pump`` - Heat pump only (0) +* ``electric`` - Electric heating only (2) +* ``energy-saver`` - Hybrid/balanced mode (3) **recommended** +* ``high-demand`` - Maximum heating capacity (4) +* ``vacation`` - Extended vacancy mode (5) -.. code-block:: text +**Output:** Confirmation message and updated device status. - Device Features: - Serial Number: ABC123456789 - Controller FW: 184614912 - WiFi FW: 34013184 - - Temperature Range: 100°F - 150°F - - Supported Features: - ENABLED: Energy Monitoring - ENABLED: Anti-Legionella - ENABLED: Reservations - ENABLED: Heat Pump Mode - ENABLED: Electric Mode - ENABLED: Energy Saver Mode - ENABLED: High Demand Mode +temp +^^^^ ---get-controller-serial -^^^^^^^^^^^^^^^^^^^^^^^ +Set target DHW (Domestic Hot Water) temperature. -Get controller serial number (required for TOU commands). +.. code-block:: bash + + # Set to 140°F + python3 -m nwp500.cli temp 140 + + # Set to 130°F + python3 -m nwp500.cli temp 130 + +**Syntax:** .. code-block:: bash - python3 -m nwp500.cli --get-controller-serial + python3 -m nwp500.cli temp -**Output:** Controller serial number. +**Notes:** -Control Commands ----------------- +* Temperature specified in Fahrenheit (typically 115-150°F) +* Check device capabilities with ``info`` command for valid range +* CLI automatically converts to device message format ---power-on -^^^^^^^^^^ +**Output:** Confirmation message and updated device status. -Turn device on. +Vacation & Maintenance Commands +-------------------------------- + +vacation +^^^^^^^^ + +Enable vacation mode for N days (reduces water heating to minimize energy use). .. code-block:: bash - python3 -m nwp500.cli --power-on + # Set vacation for 7 days + python3 -m nwp500.cli vacation 7 - # Get status after power on - python3 -m nwp500.cli --power-on --status + # Set vacation for 30 days + python3 -m nwp500.cli vacation 30 ---power-off -^^^^^^^^^^^ +**Syntax:** -Turn device off. +.. code-block:: bash + + python3 -m nwp500.cli vacation + +**Output:** Confirmation message and updated device status. + +hot-button +^^^^^^^^^^ + +Trigger hot button for instant hot water (recirculation pump). .. code-block:: bash - python3 -m nwp500.cli --power-off + python3 -m nwp500.cli hot-button - # Get status after power off - python3 -m nwp500.cli --power-off --status +**Output:** Confirmation message. ---set-mode MODE -^^^^^^^^^^^^^^^ +recirc +^^^^^^ -Change operation mode. +Set recirculation pump mode. .. code-block:: bash - # Heat Pump Only (most efficient) - python3 -m nwp500.cli --set-mode heat-pump + # Always on + python3 -m nwp500.cli recirc 1 - # Electric Only (fastest recovery) - python3 -m nwp500.cli --set-mode electric + # Button triggered + python3 -m nwp500.cli recirc 2 - # Energy Saver (recommended, balanced) - python3 -m nwp500.cli --set-mode energy-saver + # Scheduled + python3 -m nwp500.cli recirc 3 - # High Demand (maximum capacity) - python3 -m nwp500.cli --set-mode high-demand + # Temperature triggered + python3 -m nwp500.cli recirc 4 - # Vacation mode for 7 days - python3 -m nwp500.cli --set-mode vacation --vacation-days 7 +**Syntax:** + +.. code-block:: bash - # Get status after mode change - python3 -m nwp500.cli --set-mode energy-saver --status + python3 -m nwp500.cli recirc **Available Modes:** -* ``heat-pump`` - Heat pump only (1) -* ``electric`` - Electric only (2) -* ``energy-saver`` - Energy Saver/Hybrid (3) **recommended** -* ``high-demand`` - High Demand (4) -* ``vacation`` - Vacation mode (5) - requires ``--vacation-days`` +* ``1`` - ALWAYS (always running) +* ``2`` - BUTTON (manual trigger only) +* ``3`` - SCHEDULE (based on schedule) +* ``4`` - TEMPERATURE (based on temperature) -**Options:** +**Output:** Confirmation message and updated device status. -.. option:: --vacation-days DAYS +reset-filter +^^^^^^^^^^^^ - Number of vacation days (required when ``--set-mode vacation``). +Reset air filter maintenance timer. ---set-dhw-temp TEMPERATURE -^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. code-block:: bash -Set target DHW temperature. + python3 -m nwp500.cli reset-filter -.. code-block:: bash +**Output:** Confirmation message. - # Set to 140°F - python3 -m nwp500.cli --set-dhw-temp 140 +water-program +^^^^^^^^^^^^^^ + +Enable water program reservation scheduling mode. + +.. code-block:: bash - # Set to 130°F and get status - python3 -m nwp500.cli --set-dhw-temp 130 --status + python3 -m nwp500.cli water-program -.. important:: - Temperature is specified as **display value** (what you see on the device). - The CLI automatically converts to message value (display - 20°F). +**Output:** Confirmation message. -Energy Commands ---------------- +Scheduling Commands +------------------- ---get-energy +reservations ^^^^^^^^^^^^ -Query historical energy usage data. +View and update reservation schedule. .. code-block:: bash - # Get current month - python3 -m nwp500.cli --get-energy \ - --energy-year 2024 \ - --energy-months "10" + # Get current reservations + python3 -m nwp500.cli reservations get - # Get multiple months - python3 -m nwp500.cli --get-energy \ - --energy-year 2024 \ - --energy-months "8,9,10" + # Set reservations from JSON + python3 -m nwp500.cli reservations set '[{"hour": 6, "min": 0, ...}]' - # Get full year - python3 -m nwp500.cli --get-energy \ - --energy-year 2024 \ - --energy-months "1,2,3,4,5,6,7,8,9,10,11,12" +**Syntax:** -**Options:** +.. code-block:: bash -.. option:: --energy-year YEAR + python3 -m nwp500.cli reservations get + python3 -m nwp500.cli reservations set [--disabled] - Year to query (e.g., 2024). +**Options:** + +.. option:: --disabled -.. option:: --energy-months MONTHS + Create reservation in disabled state. - Comma-separated list of months (1-12). +**Output (get):** Current reservation schedule configuration. **Example Output:** -.. code-block:: text +.. code-block:: json - Energy Usage Report - ═══════════════════ - - Total Usage: 1,234,567 Wh (1,234.6 kWh) - Heat Pump: 75.5% (932,098 Wh, 245 hours) - Electric: 24.5% (302,469 Wh, 67 hours) - - Daily Breakdown - October 2024: - Day 1: 42,345 Wh (HP: 32,100 Wh, HE: 10,245 Wh) - Day 2: 38,921 Wh (HP: 30,450 Wh, HE: 8,471 Wh) - Day 3: 45,678 Wh (HP: 35,200 Wh, HE: 10,478 Wh) - ... + { + "reservationUse": 1, + "reservationEnabled": true, + "reservations": [ + { + "number": 1, + "enabled": true, + "days": [1, 1, 1, 1, 1, 0, 0], + "time": "06:00", + "mode": 3, + "temperatureF": 140 + } + ] + } -Reservation Commands --------------------- +Energy & Utility Commands +-------------------------- ---get-reservations -^^^^^^^^^^^^^^^^^^ +energy +^^^^^^ -Get current reservation schedule. +Query historical energy usage data by month. .. code-block:: bash - python3 -m nwp500.cli --get-reservations + # Get October 2024 + python3 -m nwp500.cli energy --year 2024 --months 10 -**Output:** Current reservation schedule configuration. + # Get multiple months + python3 -m nwp500.cli energy --year 2024 --months 8,9,10 ---set-reservations FILE -^^^^^^^^^^^^^^^^^^^^^^^ + # Get full year + python3 -m nwp500.cli energy --year 2024 --months 1,2,3,4,5,6,7,8,9,10,11,12 -Update reservation schedule from JSON file. +**Syntax:** .. code-block:: bash - python3 -m nwp500.cli --set-reservations schedule.json \ - --reservations-enabled + python3 -m nwp500.cli energy --year --months **Options:** -.. option:: --reservations-enabled +.. option:: --year YEAR - Enable reservation schedule (use ``--reservations-disabled`` to disable). + Year to query (e.g., 2024). **Required.** -.. option:: --reservations-disabled +.. option:: --months MONTHS - Disable reservation schedule. + Comma-separated list of months (1-12). **Required.** -**JSON Format:** +**Output:** Energy usage breakdown by heat pump vs. electric heating. + +**Example Output:** .. code-block:: json - [ - { - "startHour": 6, - "startMinute": 0, - "endHour": 22, - "endMinute": 0, - "weekDays": [1, 1, 1, 1, 1, 0, 0], - "temperature": 120 - }, - { - "startHour": 8, - "startMinute": 0, - "endHour": 20, - "endMinute": 0, - "weekDays": [0, 0, 0, 0, 0, 1, 1], - "temperature": 130 - } - ] + { + "total_wh": 1234567, + "heat_pump_wh": 932098, + "heat_pump_hours": 245, + "electric_wh": 302469, + "electric_hours": 67, + "by_day": [...] + } + +tou +^^^ + +Configure time-of-use (TOU) pricing schedule. + +.. code-block:: bash -Time-of-Use Commands --------------------- + # Get current TOU configuration + python3 -m nwp500.cli tou get ---get-tou -^^^^^^^^^ + # Enable TOU optimization + python3 -m nwp500.cli tou set on -Get Time-of-Use configuration (requires controller serial). + # Disable TOU optimization + python3 -m nwp500.cli tou set off + +**Syntax:** .. code-block:: bash - # First get controller serial - python3 -m nwp500.cli --get-controller-serial - # Output: ABC123456789 + python3 -m nwp500.cli tou get + python3 -m nwp500.cli tou set - # Then query TOU (done automatically by CLI) - python3 -m nwp500.cli --get-tou +**Output (get):** Utility name, schedule name, ZIP code, and pricing intervals. -**Output:** TOU utility, schedule name, ZIP code, and pricing intervals. +**Output (set):** Confirmation message and updated device status. ---set-tou-enabled STATE -^^^^^^^^^^^^^^^^^^^^^^^ +dr +^^ -Enable or disable TOU optimization. +Enable or disable utility demand response. .. code-block:: bash - # Enable TOU - python3 -m nwp500.cli --set-tou-enabled on + # Enable demand response + python3 -m nwp500.cli dr enable + + # Disable demand response + python3 -m nwp500.cli dr disable - # Disable TOU - python3 -m nwp500.cli --set-tou-enabled off +**Syntax:** + +.. code-block:: bash - # Get status after change - python3 -m nwp500.cli --set-tou-enabled on --status + python3 -m nwp500.cli dr + +**Output:** Confirmation message and updated device status. + +Monitoring Commands +------------------- + +monitor +^^^^^^^ + +Monitor device status in real-time and log to CSV file. + +.. code-block:: bash + + # Monitor with default output file (nwp500_status.csv) + python3 -m nwp500.cli monitor + + # Monitor with custom output file + python3 -m nwp500.cli monitor -o my_data.csv + + # Monitor with verbose logging + python3 -m nwp500.cli -v monitor + +**Syntax:** + +.. code-block:: bash + + python3 -m nwp500.cli monitor [-o OUTPUT_FILE] + +**Options:** + +.. option:: -o OUTPUT_FILE, --output OUTPUT_FILE + + Output CSV filename (default: ``nwp500_status.csv``). + +**Output:** CSV file with timestamp, temperature, mode, power, and other metrics. + +**Example CSV:** + +.. code-block:: text + + timestamp,water_temp,target_temp,mode,power_w,tank_charge_pct + 2024-12-23 12:34:56,138.5,140,energy-saver,1250,85 + 2024-12-23 12:35:26,138.7,140,energy-saver,1240,85 + 2024-12-23 12:35:56,138.9,140,energy-saver,1230,86 Complete Examples ================= -Example 1: Quick Status Check ------------------------------- +Example 1: Check Status +----------------------- .. code-block:: bash - #!/bin/bash export NAVIEN_EMAIL="your@email.com" export NAVIEN_PASSWORD="your_password" - python3 -m nwp500.cli --status + python3 -m nwp500.cli status Example 2: Change Mode and Verify ---------------------------------- .. code-block:: bash - #!/bin/bash - - # Set to Energy Saver and check status - python3 -m nwp500.cli \ - --set-mode energy-saver \ - --status + python3 -m nwp500.cli mode energy-saver Example 3: Morning Boost Script -------------------------------- @@ -467,48 +547,32 @@ Example 3: Morning Boost Script #!/bin/bash # Boost temperature in the morning - - python3 -m nwp500.cli \ - --set-mode high-demand \ - --set-dhw-temp 150 \ - --status - - echo "Morning boost activated!" -Example 4: Energy Report -------------------------- + python3 -m nwp500.cli mode high-demand + python3 -m nwp500.cli temp 150 + +Example 4: Get Last 3 Months Energy +------------------------------------ .. code-block:: bash #!/bin/bash - # Get last 3 months energy usage - YEAR=$(date +%Y) - M1=$(date +%-m) - M2=$((M1 - 1)) - M3=$((M1 - 2)) - - python3 -m nwp500.cli --get-energy \ - --energy-year $YEAR \ - --energy-months "$M3,$M2,$M1" \ - > energy_report.txt - - echo "Energy report saved to energy_report.txt" + MONTH=$(date +%-m) + PREV1=$((MONTH - 1)) + PREV2=$((MONTH - 2)) + + python3 -m nwp500.cli energy --year $YEAR --months "$PREV2,$PREV1,$MONTH" -Example 5: Vacation Mode Setup -------------------------------- +Example 5: Vacation Setup +--------------------------- .. code-block:: bash #!/bin/bash # Set vacation mode for 14 days - - python3 -m nwp500.cli \ - --set-mode vacation \ - --vacation-days 14 \ - --status - - echo "Vacation mode set for 14 days" + + python3 -m nwp500.cli vacation 14 Example 6: Continuous Monitoring --------------------------------- @@ -516,9 +580,9 @@ Example 6: Continuous Monitoring .. code-block:: bash #!/bin/bash - # Monitor device with formatted output - - python3 -m nwp500.cli --output text + # Monitor with custom output file + + python3 -m nwp500.cli monitor -o ~/navien_logs/daily_$(date +%Y%m%d).csv Example 7: Cron Job for Daily Status ------------------------------------- @@ -527,23 +591,18 @@ Example 7: Cron Job for Daily Status # Add to crontab: crontab -e # Run daily at 6 AM - 0 6 * * * /usr/bin/python3 -m nwp500.cli --status >> /var/log/navien_daily.log 2>&1 + 0 6 * * * /usr/bin/python3 -m nwp500.cli status >> /var/log/navien_daily.log 2>&1 -Example 8: Temperature Alert Script ------------------------------------- +Example 8: Smart Scheduling with Reservations +----------------------------------------------- .. code-block:: bash #!/bin/bash - # Check temperature and alert if too low - - STATUS=$(python3 -m nwp500.cli --status 2>&1) - TEMP=$(echo "$STATUS" | grep -oP 'dhwTemperature.*?\K\d+') - - if [ "$TEMP" -lt 120 ]; then - echo "WARNING: Water temperature is $TEMP°F (below 120°F)" - # Send notification, email, etc. - fi + # Set reservation schedule: 6 AM - 10 PM at 140°F on weekdays + + python3 -m nwp500.cli reservations set \ + '[{"hour": 6, "min": 0, "mode": 3, "temp": 140, "days": [1,1,1,1,1,0,0]}]' Troubleshooting =============== @@ -561,7 +620,7 @@ Authentication Errors python3 -m nwp500.cli \ --email "your@email.com" \ --password "your_password" \ - --status + status # Clear cached tokens rm ~/.navien_tokens.json @@ -571,8 +630,11 @@ Connection Issues .. code-block:: bash - # Enable debug logging - python3 -m nwp500.cli --verbose --status + # Enable verbose debug logging + python3 -m nwp500.cli -vv status + + # Check network connectivity + ping api.navienlink.com No Devices Found ---------------- @@ -580,7 +642,9 @@ No Devices Found .. code-block:: bash # Verify account has devices registered - python3 -m nwp500.cli --device-info + python3 -m nwp500.cli info + + # If no output, check Navienlink app for registered devices Command Not Found ----------------- @@ -590,7 +654,7 @@ Command Not Found # Use full Python module path python3 -m nwp500.cli --help - # Or install package + # Or install package in development mode pip install -e . Best Practices @@ -610,8 +674,8 @@ Best Practices # In ~/.bashrc or ~/.zshrc alias navien='python3 -m nwp500.cli' - alias navien-status='navien --status' - alias navien-monitor='navien --output text' + alias navien-status='navien status' + alias navien-monitor='navien monitor' 3. **Use scripts for common operations:** @@ -619,31 +683,33 @@ Best Practices # morning_boost.sh #!/bin/bash - python3 -m nwp500.cli --set-mode high-demand --set-dhw-temp 150 + python3 -m nwp500.cli mode high-demand + python3 -m nwp500.cli temp 150 - # vacation.sh + # evening_saver.sh #!/bin/bash - python3 -m nwp500.cli --set-mode vacation --vacation-days ${1:-7} + python3 -m nwp500.cli mode heat-pump + python3 -m nwp500.cli temp 120 -4. **Combine commands efficiently:** +4. **Log output for analysis:** .. code-block:: bash - # Make change and verify in one command - python3 -m nwp500.cli --set-mode energy-saver --status + # Append to log with timestamp + python3 -m nwp500.cli status >> ~/navien_$(date +%Y%m%d).log 5. **Use cron for automation:** .. code-block:: bash # Morning boost: 6 AM - 0 6 * * * python3 -m nwp500.cli --set-mode high-demand - + 0 6 * * * python3 -m nwp500.cli mode high-demand + # Night economy: 10 PM - 0 22 * * * python3 -m nwp500.cli --set-mode heat-pump - - # Daily status report: 6 PM - 0 18 * * * python3 -m nwp500.cli --status >> ~/navien_log.txt + 0 22 * * * python3 -m nwp500.cli mode heat-pump + + # Daily status: 6 PM + 0 18 * * * python3 -m nwp500.cli status >> ~/navien_log.txt Related Documentation ===================== @@ -651,4 +717,4 @@ Related Documentation * :doc:`auth_client` - Python authentication API * :doc:`api_client` - Python REST API * :doc:`mqtt_client` - Python MQTT API -* :doc:`models` - Data models +* :doc:`../guides/mqtt_basics` - MQTT protocol guide diff --git a/docs/python_api/constants.rst b/docs/python_api/constants.rst index 1126b80..f46df56 100644 --- a/docs/python_api/constants.rst +++ b/docs/python_api/constants.rst @@ -33,7 +33,7 @@ Query Commands .. code-block:: python - await mqtt.request_device_info(device) + await mqtt.control.request_device_info(device) .. py:attribute:: STATUS_REQUEST = 16777219 @@ -46,7 +46,7 @@ Query Commands .. code-block:: python - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) .. py:attribute:: RESERVATION_READ = 16777222 @@ -58,7 +58,7 @@ Query Commands .. code-block:: python - await mqtt.request_reservations(device) + await mqtt.control.request_reservations(device) .. py:attribute:: ENERGY_USAGE_QUERY = 16777225 @@ -75,7 +75,7 @@ Query Commands .. code-block:: python - await mqtt.request_energy_usage(device, 2024, [10, 11]) + await mqtt.control.request_energy_usage(device, 2024, [10, 11]) .. py:attribute:: RESERVATION_MANAGEMENT = 16777226 @@ -89,7 +89,7 @@ Query Commands .. code-block:: python - await mqtt.update_reservations(device, True, reservations) + await mqtt.control.update_reservations(device, True, reservations) Power Control Commands ^^^^^^^^^^^^^^^^^^^^^^ @@ -102,7 +102,7 @@ Power Control Commands .. code-block:: python - await mqtt.set_power(device, power_on=False) + await mqtt.control.set_power(device, power_on=False) .. py:attribute:: POWER_ON = 33554434 @@ -112,7 +112,7 @@ Power Control Commands .. code-block:: python - await mqtt.set_power(device, power_on=True) + await mqtt.control.set_power(device, power_on=True) DHW Control Commands ^^^^^^^^^^^^^^^^^^^^ @@ -133,10 +133,10 @@ DHW Control Commands from nwp500 import DhwOperationSetting # Energy Saver mode - await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) # Vacation mode for 7 days - await mqtt.set_dhw_mode( + await mqtt.control.set_dhw_mode( device, DhwOperationSetting.VACATION.value, vacation_days=7 @@ -158,7 +158,7 @@ DHW Control Commands .. code-block:: python # Set temperature to 140°F - await mqtt.set_dhw_temperature(device, 140.0) + await mqtt.control.set_dhw_temperature(device, 140.0) Anti-Legionella Commands ^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -171,7 +171,7 @@ Anti-Legionella Commands .. code-block:: python - await mqtt.disable_anti_legionella(device) + await mqtt.control.disable_anti_legionella(device) .. py:attribute:: ANTI_LEGIONELLA_ENABLE = 33554472 @@ -185,7 +185,7 @@ Anti-Legionella Commands .. code-block:: python # Enable weekly cycle - await mqtt.enable_anti_legionella(device, period_days=7) + await mqtt.control.enable_anti_legionella(device, period_days=7) Time-of-Use Commands ^^^^^^^^^^^^^^^^^^^^ @@ -198,7 +198,7 @@ Time-of-Use Commands .. code-block:: python - await mqtt.configure_tou_schedule(device, schedule_data) + await mqtt.control.configure_tou_schedule(device, schedule_data) .. py:attribute:: TOU_DISABLE = 33554475 @@ -208,7 +208,7 @@ Time-of-Use Commands .. code-block:: python - await mqtt.set_tou_enabled(device, False) + await mqtt.control.set_tou_enabled(device, False) .. py:attribute:: TOU_ENABLE = 33554476 @@ -218,7 +218,7 @@ Time-of-Use Commands .. code-block:: python - await mqtt.set_tou_enabled(device, True) + await mqtt.control.set_tou_enabled(device, True) Usage Examples ============== @@ -331,7 +331,7 @@ Best Practices .. code-block:: python # [OK] Preferred - client handles command codes - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # ✗ Manual - only for advanced use cases await mqtt.publish(topic, build_command(CommandCode.STATUS_REQUEST)) diff --git a/docs/python_api/device_control.rst b/docs/python_api/device_control.rst new file mode 100644 index 0000000..07c5b2c --- /dev/null +++ b/docs/python_api/device_control.rst @@ -0,0 +1,1188 @@ +=========================== +Device Control and Commands +=========================== + +The ``MqttDeviceController`` manages all device control operations including status requests, +mode changes, temperature control, scheduling, and energy queries. + +Overview +======== + +The device controller provides: + +* **Status & Info Requests** - Request device status and feature information +* **Power Control** - Turn device on/off +* **Mode Management** - Change DHW operation modes +* **Temperature Control** - Set target water temperature +* **Anti-Legionella** - Enable/disable disinfection cycles +* **Scheduling** - Configure reservations and time-of-use pricing +* **Energy Monitoring** - Query historical energy usage +* **Recirculation** - Control hot water recirculation pump +* **Demand Response** - Participate in utility demand response +* **Capability Checking** - Validate device features before commanding +* **Automatic Capability Checking** - Decorator-based validation with automatic device info requests + +All control methods are fully asynchronous and require device capability information +to be cached before execution. + +Quick Start +=========== + +Basic Control +------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + import asyncio + + async def control_device(): + async with NavienAuthClient("email@example.com", "password") as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Request device info to populate capability cache + await mqtt.subscribe_device_feature(device, lambda f: None) + await mqtt.control.request_device_info(device) + + # Now control operations work with automatic capability checking + await mqtt.control.set_power(device, power_on=True) + await mqtt.control.set_dhw_mode(device, mode_id=3) # Energy Saver + await mqtt.control.set_dhw_temperature(device, 140.0) + + await mqtt.disconnect() + + asyncio.run(control_device()) + +Capability Checking +------------------- + +Before executing control commands, check device capabilities: + +.. code-block:: python + + from nwp500 import NavienMqttClient, DeviceCapabilityError + + async def safe_control(): + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Request device info first + await mqtt.subscribe_device_feature(device, on_feature) + await mqtt.control.request_device_info(device) + + # Wait for device info to be cached, then control + try: + # Control commands automatically check capabilities via decorator + msg_id = await mqtt.control.set_recirculation_mode(device, 1) + print(f"Command sent with ID {msg_id}") + except DeviceCapabilityError as e: + print(f"Device doesn't support: {e}") + +API Reference +============= + +MqttDeviceController +-------------------- + +The ``NavienMqttClient`` includes a built-in device controller for all operations. + +Status and Info Methods +----------------------- + +request_device_status() +^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_device_status(device) + + Request current device status. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.control.request_device_status(device) + +request_device_info() +^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_device_info(device) + + Request device features and capabilities. + + This populates the device info cache used for capability checking in control commands. + Always call this before using control commands. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + await mqtt.subscribe_device_feature(device, on_feature) + await mqtt.control.request_device_info(device) + +Power Control +-------------- + +set_power() +^^^^^^^^^^^ + +.. py:method:: set_power(device, power_on) + + Turn device on or off. + + **Capability Required:** ``power_use`` - Must be present in device features + + :param device: Device object + :type device: Device + :param power_on: True to turn on, False to turn off + :type power_on: bool + :return: Publish packet ID + :rtype: int + :raises DeviceCapabilityError: If device doesn't support power control + + **Example:** + + .. code-block:: python + + # Turn on + await mqtt.control.set_power(device, power_on=True) + + # Turn off + await mqtt.control.set_power(device, power_on=False) + +DHW Mode Control +----------------- + +set_dhw_mode() +^^^^^^^^^^^^^^ + +.. py:method:: set_dhw_mode(device, mode_id, vacation_days=None) + + Set DHW (Domestic Hot Water) operation mode. + + **Capability Required:** ``dhw_use`` - Must be present in device features + + :param device: Device object + :type device: Device + :param mode_id: Mode ID (1-5) + :type mode_id: int + :param vacation_days: Number of days for vacation mode (required if mode_id=5, 1-30) + :type vacation_days: int or None + :return: Publish packet ID + :rtype: int + :raises ParameterValidationError: If vacation_days invalid for non-vacation modes + :raises RangeValidationError: If vacation_days not in 1-30 range + :raises DeviceCapabilityError: If device doesn't support DHW mode control + + **Operation Modes:** + + * 1 = Heat Pump Only - Most efficient, uses only heat pump + * 2 = Electric Only - Fast recovery, uses only electric heaters + * 3 = Energy Saver - Balanced, recommended for most users + * 4 = High Demand - Maximum heating capacity + * 5 = Vacation - Low power mode for extended absence + + **Example:** + + .. code-block:: python + + from nwp500 import DhwOperationSetting + + # Set to Energy Saver (balanced, recommended) + await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + # or just: + await mqtt.control.set_dhw_mode(device, 3) + + # Set vacation mode for 7 days + await mqtt.control.set_dhw_mode( + device, + DhwOperationSetting.VACATION.value, + vacation_days=7 + ) + +Temperature Control +-------------------- + +set_dhw_temperature() +^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: set_dhw_temperature(device, temperature_f) + + Set DHW target temperature. + + **Capability Required:** ``dhw_temperature_setting_use`` - DHW temperature control enabled + + :param device: Device object + :type device: Device + :param temperature_f: Target temperature in Fahrenheit (95-150°F) + :type temperature_f: float + :return: Publish packet ID + :rtype: int + :raises RangeValidationError: If temperature is outside 95-150°F range + :raises DeviceCapabilityError: If device doesn't support temperature control + + The temperature is automatically converted to the device's internal format + (half-degrees Celsius). + + **Example:** + + .. code-block:: python + + # Set temperature to 140°F + await mqtt.control.set_dhw_temperature(device, 140.0) + + # Common temperatures + await mqtt.control.set_dhw_temperature(device, 120.0) # Standard + await mqtt.control.set_dhw_temperature(device, 130.0) # Medium + await mqtt.control.set_dhw_temperature(device, 140.0) # Hot + await mqtt.control.set_dhw_temperature(device, 150.0) # Maximum + +Anti-Legionella Control +------------------------ + +enable_anti_legionella() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: enable_anti_legionella(device, period_days) + + Enable anti-Legionella disinfection cycle. + + :param device: Device object + :type device: Device + :param period_days: Cycle period in days (1-30) + :type period_days: int + :return: Publish packet ID + :rtype: int + :raises RangeValidationError: If period_days not in 1-30 range + + **Example:** + + .. code-block:: python + + # Enable weekly anti-Legionella cycle + await mqtt.control.enable_anti_legionella(device, period_days=7) + + # Enable bi-weekly cycle + await mqtt.control.enable_anti_legionella(device, period_days=14) + +disable_anti_legionella() +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: disable_anti_legionella(device) + + Disable anti-Legionella disinfection cycle. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + await mqtt.control.disable_anti_legionella(device) + +Vacation Mode +-------------- + +set_vacation_days() +^^^^^^^^^^^^^^^^^^^ + +.. py:method:: set_vacation_days(device, days) + + Set vacation/away mode duration in days. + + **Capability Required:** ``holiday_use`` - Must be present in device features + + Configures the device to operate in energy-saving mode for the specified number + of days during absence. + + :param device: Device object + :type device: Device + :param days: Number of vacation days (1-365 recommended, positive values) + :type days: int + :return: Publish packet ID + :rtype: int + :raises RangeValidationError: If days is not positive + :raises DeviceCapabilityError: If device doesn't support vacation mode + + **Example:** + + .. code-block:: python + + # Set vacation for 14 days + await mqtt.control.set_vacation_days(device, 14) + + # Set for full month + await mqtt.control.set_vacation_days(device, 30) + +Recirculation Control +--------------------- + +set_recirculation_mode() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: set_recirculation_mode(device, mode) + + Set recirculation pump operation mode. + + **Capability Required:** ``recirculation_use`` - Must be present in device features + + Configures how the recirculation pump operates: + + * 1 = Always On - Pump runs continuously + * 2 = Button Only - Pump activates only via button press + * 3 = Schedule - Pump follows configured schedule + * 4 = Temperature - Pump maintains water temperature + + :param device: Device object + :type device: Device + :param mode: Recirculation mode (1-4) + :type mode: int + :return: Publish packet ID + :rtype: int + :raises RangeValidationError: If mode not in 1-4 range + :raises DeviceCapabilityError: If device doesn't support recirculation + + **Example:** + + .. code-block:: python + + # Enable always-on recirculation + await mqtt.control.set_recirculation_mode(device, 1) + + # Set to temperature-based control + await mqtt.control.set_recirculation_mode(device, 4) + +trigger_recirculation_hot_button() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: trigger_recirculation_hot_button(device) + + Manually trigger the recirculation pump hot button. + + **Capability Required:** ``recirculation_use`` - Must be present in device features + + Activates the recirculation pump for immediate hot water delivery. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + :raises DeviceCapabilityError: If device doesn't support recirculation + + **Example:** + + .. code-block:: python + + # Manually activate recirculation for immediate hot water + await mqtt.control.trigger_recirculation_hot_button(device) + +configure_recirculation_schedule() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: configure_recirculation_schedule(device, schedule) + + Configure recirculation pump schedule. + + **Capability Required:** ``recirc_reservation_use`` - Recirculation scheduling enabled + + Sets up the recirculation pump operating schedule with specified periods and settings. + + :param device: Device object + :type device: Device + :param schedule: Recirculation schedule configuration + :type schedule: dict + :return: Publish packet ID + :rtype: int + :raises DeviceCapabilityError: If device doesn't support recirculation scheduling + + **Example:** + + .. code-block:: python + + schedule = { + "enabled": True, + "periods": [ + { + "startHour": 6, + "startMinute": 0, + "endHour": 22, + "endMinute": 0, + "weekDays": [1, 1, 1, 1, 1, 0, 0] # Mon-Fri + } + ] + } + + await mqtt.control.configure_recirculation_schedule(device, schedule) + +Time-of-Use Control +-------------------- + +set_tou_enabled() +^^^^^^^^^^^^^^^^^ + +.. py:method:: set_tou_enabled(device, enabled) + + Enable or disable Time-of-Use optimization. + + **Capability Required:** ``program_reservation_use`` - Must be present in device features + + :param device: Device object + :type device: Device + :param enabled: True to enable, False to disable + :type enabled: bool + :return: Publish packet ID + :rtype: int + :raises DeviceCapabilityError: If device doesn't support TOU + + **Example:** + + .. code-block:: python + + # Enable TOU + await mqtt.control.set_tou_enabled(device, True) + + # Disable TOU + await mqtt.control.set_tou_enabled(device, False) + +configure_tou_schedule() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: configure_tou_schedule(device, controller_serial_number, periods, enabled=True) + + Configure Time-of-Use pricing schedule via MQTT. + + **Capability Required:** ``program_reservation_use`` - Must be present in device features + + :param device: Device object + :type device: Device + :param controller_serial_number: Controller serial number + :type controller_serial_number: str + :param periods: List of TOU period definitions + :type periods: list[dict] + :param enabled: Whether TOU is enabled (default: True) + :type enabled: bool + :return: Publish packet ID + :rtype: int + :raises ParameterValidationError: If controller_serial_number empty or periods empty + :raises DeviceCapabilityError: If device doesn't support TOU + + **Example:** + + .. code-block:: python + + periods = [ + { + "season": 0, + "week": 0, + "startHour": 9, + "startMinute": 0, + "endHour": 17, + "endMinute": 0, + "priceMin": 0.10, + "priceMax": 0.25, + "decimalPoint": 2 + } + ] + + await mqtt.control.configure_tou_schedule( + device, + controller_serial_number="ABC123", + periods=periods + ) + +request_tou_settings() +^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_tou_settings(device, controller_serial_number) + + Request current Time-of-Use schedule from the device. + + :param device: Device object + :type device: Device + :param controller_serial_number: Controller serial number + :type controller_serial_number: str + :return: Publish packet ID + :rtype: int + :raises ParameterValidationError: If controller_serial_number empty + +Reservation Management +---------------------- + +update_reservations() +^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: update_reservations(device, reservations, enabled=True) + + Update device reservation schedule. + + **Capability Required:** ``program_reservation_use`` - Must be present in device features + + :param device: Device object + :type device: Device + :param reservations: List of reservation objects + :type reservations: list[dict] + :param enabled: Enable/disable reservation schedule (default: True) + :type enabled: bool + :return: Publish packet ID + :rtype: int + :raises DeviceCapabilityError: If device doesn't support reservations + + **Example:** + + .. code-block:: python + + reservations = [ + { + "startHour": 6, + "startMinute": 0, + "endHour": 22, + "endMinute": 0, + "weekDays": [1, 1, 1, 1, 1, 0, 0], # Mon-Fri + "temperature": 120 + }, + { + "startHour": 8, + "startMinute": 0, + "endHour": 20, + "endMinute": 0, + "weekDays": [0, 0, 0, 0, 0, 1, 1], # Sat-Sun + "temperature": 130 + } + ] + + await mqtt.control.update_reservations(device, reservations, enabled=True) + +request_reservations() +^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_reservations(device) + + Request current reservation schedule from the device. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + +configure_reservation_water_program() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: configure_reservation_water_program(device) + + Enable/configure water program reservation mode. + + **Capability Required:** ``program_reservation_use`` - Must be present in device features + + Enables the water program reservation system for scheduling. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + :raises DeviceCapabilityError: If device doesn't support reservation programs + +Energy Monitoring +------------------ + +request_energy_usage() +^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_energy_usage(device, year, months) + + Request daily energy usage data for specified period. + + Retrieves historical energy usage data showing heat pump and electric heating + element consumption broken down by day. + + :param device: Device object + :type device: Device + :param year: Year to query (e.g., 2024) + :type year: int + :param months: List of months to query (1-12) + :type months: list[int] + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Subscribe first + await mqtt.subscribe_energy_usage(device, on_energy) + + # Request current month + from datetime import datetime + now = datetime.now() + await mqtt.control.request_energy_usage(device, now.year, [now.month]) + + # Request multiple months + await mqtt.control.request_energy_usage(device, 2024, [8, 9, 10]) + +Demand Response +---------------- + +enable_demand_response() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: enable_demand_response(device) + + Enable utility demand response participation. + + Allows the device to respond to utility demand response signals to reduce + consumption (shed) or pre-heat (load up) before peak periods. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Enable demand response + await mqtt.control.enable_demand_response(device) + +disable_demand_response() +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: disable_demand_response(device) + + Disable utility demand response participation. + + Prevents the device from responding to utility demand response signals. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Disable demand response + await mqtt.control.disable_demand_response(device) + +Air Filter Maintenance +----------------------- + +reset_air_filter() +^^^^^^^^^^^^^^^^^^ + +.. py:method:: reset_air_filter(device) + + Reset air filter maintenance timer. + + Used for heat pump models to reset the maintenance timer after filter + cleaning or replacement. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + # Reset air filter timer after maintenance + await mqtt.control.reset_air_filter(device) + +Utility Methods +--------------- + +signal_app_connection() +^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: signal_app_connection(device) + + Signal that an application has connected. + + Recommended to call at startup to notify the device of app connection. + + :param device: Device object + :type device: Device + :return: Publish packet ID + :rtype: int + + **Example:** + + .. code-block:: python + + await mqtt.connect() + await mqtt.control.signal_app_connection(device) + +Device Capabilities Module +========================== + +The ``DeviceCapabilityChecker`` provides a mapping-based approach to validate +device capabilities without requiring individual checker functions. + +.. py:class:: DeviceCapabilityChecker + + Generalized device capability checker using a capability map. + + Class Methods + ^^^^^^^^^^^^^ + +supports() +"""""""""" + +.. py:staticmethod:: supports(feature, device_features) + + Check if device supports control of a specific feature. + + :param feature: Name of the controllable feature + :type feature: str + :param device_features: Device feature information + :type device_features: DeviceFeature + :return: True if feature control is supported, False otherwise + :rtype: bool + :raises ValueError: If feature is not recognized + + **Supported Features:** + + * ``power_use`` - Device power on/off control + * ``dhw_use`` - DHW mode changes + * ``dhw_temperature_setting_use`` - DHW temperature control + * ``holiday_use`` - Vacation/away mode + * ``program_reservation_use`` - Reservations and TOU scheduling + * ``recirculation_use`` - Recirculation pump control + * ``recirc_reservation_use`` - Recirculation scheduling + + **Example:** + + .. code-block:: python + + from nwp500.device_capabilities import DeviceCapabilityChecker + + if DeviceCapabilityChecker.supports("recirculation_use", device_features): + print("Device supports recirculation pump control") + else: + print("Device doesn't support recirculation pump") + +assert_supported() +"""""""""""""""""" + +.. py:staticmethod:: assert_supported(feature, device_features) + + Assert that device supports control of a feature. + + :param feature: Name of the controllable feature + :type feature: str + :param device_features: Device feature information + :type device_features: DeviceFeature + :raises DeviceCapabilityError: If feature control is not supported + :raises ValueError: If feature is not recognized + + **Example:** + + .. code-block:: python + + from nwp500.device_capabilities import DeviceCapabilityChecker + from nwp500 import DeviceCapabilityError + + try: + DeviceCapabilityChecker.assert_supported("recirculation_use", features) + await mqtt.control.set_recirculation_mode(device, 1) + except DeviceCapabilityError as e: + print(f"Cannot set recirculation: {e}") + +get_available_controls() +"""""""""""""""""""""""" + +.. py:staticmethod:: get_available_controls(device_features) + + Get all controllable features available on a device. + + :param device_features: Device feature information + :type device_features: DeviceFeature + :return: Dictionary mapping feature names to whether they can be controlled + :rtype: dict[str, bool] + + **Example:** + + .. code-block:: python + + from nwp500.device_capabilities import DeviceCapabilityChecker + + controls = DeviceCapabilityChecker.get_available_controls(device_features) + for feature, supported in controls.items(): + status = "✓" if supported else "✗" + print(f"{status} {feature}") + +register_capability() +""""""""""""""""""""" + +.. py:staticmethod:: register_capability(name, check_fn) + + Register a custom controllable feature check. + + Allows extensions or applications to define custom capability checks without + modifying the core library. + + :param name: Feature name + :type name: str + :param check_fn: Function that takes DeviceFeature and returns bool + :type check_fn: Callable[[DeviceFeature], bool] + + **Example:** + + .. code-block:: python + + from nwp500.device_capabilities import DeviceCapabilityChecker + + def check_custom_feature(features): + return features.some_custom_field is not None + + # Register custom capability + DeviceCapabilityChecker.register_capability("custom_feature", check_custom_feature) + + # Now can use it with control methods + if DeviceCapabilityChecker.supports("custom_feature", device_features): + # Execute custom command + pass + +Controller Capability Methods +------------------------------ + +MqttDeviceController also provides direct capability checking methods: + +check_support() +^^^^^^^^^^^^^^^ + +.. py:method:: check_support(feature, device_features) + + Check if device supports a controllable feature. + + :param feature: Name of the controllable feature + :type feature: str + :param device_features: Device feature information + :type device_features: DeviceFeature + :return: True if feature is supported, False otherwise + :rtype: bool + :raises ValueError: If feature is not recognized + + **Example:** + + .. code-block:: python + + if mqtt.check_support("recirculation_use", device_features): + await mqtt.control.set_recirculation_mode(device, 1) + +assert_support() +^^^^^^^^^^^^^^^^ + +.. py:method:: assert_support(feature, device_features) + + Assert that device supports a controllable feature. + + :param feature: Name of the controllable feature + :type feature: str + :param device_features: Device feature information + :type device_features: DeviceFeature + :raises DeviceCapabilityError: If feature is not supported + :raises ValueError: If feature is not recognized + + **Example:** + + .. code-block:: python + + try: + mqtt.assert_support("recirculation_use", device_features) + await mqtt.control.set_recirculation_mode(device, 1) + except DeviceCapabilityError as e: + print(f"Device doesn't support: {e}") + +Capability Checking Decorator +============================== + +The ``@requires_capability`` decorator automatically validates device capabilities +before command execution. + +.. py:function:: requires_capability(feature) + + Decorator that validates device capability before executing command. + + This decorator automatically checks if a device supports a specific controllable + feature before allowing the command to execute. If the device doesn't support + the feature, a ``DeviceCapabilityError`` is raised. + + **Requirements:** + + The decorated method must: + + 1. Have ``self`` (controller instance with ``_device_info_cache``) + 2. Have ``device`` parameter (Device object with ``mac_address``) + 3. Be async (sync methods log a warning and bypass checking for backward compatibility) + + The device info must be cached (via ``request_device_info``) before calling + the command, otherwise a ``DeviceCapabilityError`` is raised. The decorator + supports automatic device info requests if the controller callback is configured. + + :param feature: Name of the required capability (e.g., "recirculation_use") + :type feature: str + :return: Decorator function + :rtype: Callable + + :raises DeviceCapabilityError: If device doesn't support the feature + :raises ValueError: If feature name is not recognized + + **How It Works:** + + 1. Extracts device MAC address from ``device`` parameter + 2. Checks if device info is already cached + 3. If not cached, automatically attempts to request it (if callback configured) + 4. Validates the capability using ``DeviceCapabilityChecker`` + 5. Executes command only if capability check passes + 6. Logs all operations for debugging + + **Example Usage:** + + .. code-block:: python + + from nwp500.mqtt_device_control import MqttDeviceController + from nwp500.command_decorators import requires_capability + + class MyController(MqttDeviceController): + @requires_capability("recirculation_use") + async def set_recirculation_mode(self, device, mode): + # Capability automatically checked before this executes + return await self._publish(...) + + **Automatic Device Info Requests:** + + When a control method is called and device info isn't cached, the decorator + attempts to automatically request it: + + .. code-block:: python + + # Device info is automatically requested if not cached + await mqtt.control.set_recirculation_mode(device, 1) + + # This triggers: + # 1. Check cache (not found) + # 2. Auto-request device info + # 3. Wait for response + # 4. Validate capability + # 5. Execute command + +Error Handling +-------------- + +**DeviceCapabilityError** is raised when: + +1. Device doesn't support the required feature +2. Device info cannot be obtained (for automatic requests) +3. Feature name is not recognized + +.. code-block:: python + + from nwp500 import DeviceCapabilityError + + try: + await mqtt.control.set_recirculation_mode(device, 1) + except DeviceCapabilityError as e: + print(f"Cannot execute command: {e}") + print(f"Missing capability: {e.feature}") + +Best Practices +============== + +1. **Always request device info first:** + + .. code-block:: python + + # Request device info before control commands + await mqtt.subscribe_device_feature(device, on_feature) + await mqtt.control.request_device_info(device) + + # Now control commands can proceed + await mqtt.control.set_power(device, True) + +2. **Check capabilities manually for custom logic:** + + .. code-block:: python + + from nwp500.device_capabilities import DeviceCapabilityChecker + + controls = DeviceCapabilityChecker.get_available_controls(features) + + if controls.get("recirculation_use"): + await mqtt.control.set_recirculation_mode(device, 1) + else: + print("Recirculation not supported") + +3. **Handle capability errors gracefully:** + + .. code-block:: python + + from nwp500 import DeviceCapabilityError + + try: + await mqtt.control.set_recirculation_mode(device, 1) + except DeviceCapabilityError as e: + logger.warning(f"Feature not supported: {e.feature}") + # Fallback to alternative command + +4. **Use try/except for robust error handling:** + + .. code-block:: python + + from nwp500 import DeviceCapabilityError, RangeValidationError + + try: + await mqtt.control.set_dhw_temperature(device, 140.0) + except DeviceCapabilityError as e: + print(f"Device doesn't support temperature control: {e}") + except RangeValidationError as e: + print(f"Invalid temperature {e.value}°F: {e.message}") + +5. **Implement device capability discovery:** + + .. code-block:: python + + from nwp500.device_capabilities import DeviceCapabilityChecker + + def print_device_capabilities(device_features): + """Print all supported controls.""" + controls = DeviceCapabilityChecker.get_available_controls(device_features) + + print("Available Controls:") + for feature in sorted(controls.keys()): + supported = controls[feature] + status = "✓" if supported else "✗" + print(f" {status} {feature}") + +Examples +======== + +Example 1: Safe Device Control with Capability Checking +-------------------------------------------------------- + +.. code-block:: python + + from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + from nwp500.device_capabilities import DeviceCapabilityChecker + from nwp500 import DeviceCapabilityError + import asyncio + + async def safe_device_control(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Store features from device info + features = None + + def on_feature(f): + nonlocal features + features = f + + # Request device info + await mqtt.subscribe_device_feature(device, on_feature) + await mqtt.control.request_device_info(device) + + # Wait a bit for response + await asyncio.sleep(2) + + if features: + # Check what's supported + controls = DeviceCapabilityChecker.get_available_controls(features) + + # Power control + if controls.get("power_use"): + try: + await mqtt.control.set_power(device, True) + print("✓ Device powered ON") + except DeviceCapabilityError as e: + print(f"✗ Power control failed: {e}") + + # Recirculation control + if controls.get("recirculation_use"): + try: + await mqtt.control.set_recirculation_mode(device, 1) + print("✓ Recirculation enabled") + except DeviceCapabilityError as e: + print(f"✗ Recirculation failed: {e}") + + # Temperature control + if controls.get("dhw_temperature_setting_use"): + try: + await mqtt.control.set_dhw_temperature(device, 140.0) + print("✓ Temperature set to 140°F") + except DeviceCapabilityError as e: + print(f"✗ Temperature control failed: {e}") + + await mqtt.disconnect() + + asyncio.run(safe_device_control()) + +Example 2: Automatic Capability Checking with Decorator +-------------------------------------------------------- + +.. code-block:: python + + # Control methods are automatically decorated with @requires_capability + # No additional code needed - just call them! + + from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + from nwp500 import DeviceCapabilityError + import asyncio + + async def simple_control(): + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + device = await api.get_first_device() + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Request device info once + await mqtt.subscribe_device_feature(device, lambda f: None) + await mqtt.control.request_device_info(device) + + # All control methods now have automatic capability checking + try: + await mqtt.control.set_power(device, True) + await mqtt.control.set_dhw_mode(device, 3) + await mqtt.control.set_recirculation_mode(device, 1) + except DeviceCapabilityError as e: + print(f"Device doesn't support: {e}") + + await mqtt.disconnect() + + asyncio.run(simple_control()) + +Related Documentation +===================== + +* :doc:`mqtt_client` - MQTT client overview +* :doc:`models` - Data models (DeviceStatus, DeviceFeature, etc.) +* :doc:`exceptions` - Exception handling (DeviceCapabilityError, etc.) +* :doc:`../protocol/device_features` - Device features reference +* :doc:`../guides/scheduling_features` - Scheduling guide +* :doc:`../guides/energy_monitoring` - Energy monitoring guide +* :doc:`../guides/reservations` - Reservations guide +* :doc:`../guides/time_of_use` - Time-of-use guide diff --git a/docs/python_api/events.rst b/docs/python_api/events.rst index c9ae218..31814e3 100644 --- a/docs/python_api/events.rst +++ b/docs/python_api/events.rst @@ -114,7 +114,7 @@ Emitted when MQTT connection is restored. def handle_reconnect(return_code, session_present): print("Connection restored") # Re-request status, resume operations - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) mqtt.on('connection_resumed', handle_reconnect) diff --git a/docs/python_api/exceptions.rst b/docs/python_api/exceptions.rst index 2bdabcc..6086a2e 100644 --- a/docs/python_api/exceptions.rst +++ b/docs/python_api/exceptions.rst @@ -30,7 +30,8 @@ All library exceptions inherit from ``Nwp500Error``:: └── DeviceError ├── DeviceNotFoundError ├── DeviceOfflineError - └── DeviceOperationError + ├── DeviceOperationError + └── DeviceCapabilityError Base Exception ============== @@ -74,7 +75,7 @@ Nwp500Error try: mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) except Nwp500Error as e: # Catches all library exceptions print(f"Library error: {e}") @@ -266,11 +267,11 @@ MqttNotConnectedError mqtt = NavienMqttClient(auth) try: - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) except MqttNotConnectedError: # Not connected - establish connection first await mqtt.connect() - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) MqttPublishError ---------------- @@ -389,7 +390,7 @@ RangeValidationError from nwp500 import NavienMqttClient, RangeValidationError try: - await mqtt.set_dhw_temperature(device, 200.0) + await mqtt.control.set_dhw_temperature(device, 200.0) except RangeValidationError as e: print(f"Invalid {e.field}: {e.value}") print(f"Valid range: {e.min_value} to {e.max_value}") @@ -440,6 +441,80 @@ DeviceOperationError fails. This may occur due to invalid commands, device restrictions, or device-side errors. +DeviceCapabilityError +--------------------- + +.. py:class:: DeviceCapabilityError(feature, message=None, **kwargs) + + Device doesn't support a required controllable feature. + + Raised when attempting to execute a command on a device that doesn't support + the feature. This is raised by control commands decorated with + ``@requires_capability`` when the device doesn't have the necessary capability. + + :param feature: Name of the unsupported feature (e.g., "recirculation_use") + :type feature: str + :param message: Detailed error message (optional) + :type message: str or None + + **Attributes:** + + * ``feature`` (str) - Name of the unsupported feature + * ``message`` (str) - Human-readable error message + + **Example:** + + .. code-block:: python + + from nwp500 import NavienMqttClient, DeviceCapabilityError + + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Request device info first + await mqtt.subscribe_device_feature(device, lambda f: None) + await mqtt.control.request_device_info(device) + + try: + # This raises DeviceCapabilityError if device doesn't support recirculation + await mqtt.control.set_recirculation_mode(device, 1) + except DeviceCapabilityError as e: + print(f"Feature not supported: {e.feature}") + print(f"Error: {e}") + + **Supported Controllable Features:** + + * ``power_use`` - Device power on/off control + * ``dhw_use`` - DHW mode changes + * ``dhw_temperature_setting_use`` - DHW temperature control + * ``holiday_use`` - Vacation/away mode + * ``program_reservation_use`` - Reservations and TOU scheduling + * ``recirculation_use`` - Recirculation pump control + * ``recirc_reservation_use`` - Recirculation scheduling + + **Checking Capabilities Before Control:** + + .. code-block:: python + + from nwp500.device_capabilities import DeviceCapabilityChecker + + # Check if device supports a feature + if DeviceCapabilityChecker.supports("recirculation_use", device_features): + await mqtt.control.set_recirculation_mode(device, 1) + else: + print("Device doesn't support recirculation") + + **Viewing All Available Controls:** + + .. code-block:: python + + from nwp500.device_capabilities import DeviceCapabilityChecker + + controls = DeviceCapabilityChecker.get_available_controls(device_features) + for feature, supported in controls.items(): + status = "✓" if supported else "✗" + print(f"{status} {feature}") + Error Handling Patterns ======================= @@ -464,7 +539,7 @@ Handle specific exception types for granular control: mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.set_dhw_temperature(device, 120.0) + await mqtt.control.set_dhw_temperature(device, 120.0) except InvalidCredentialsError: print("Invalid credentials - check email/password") @@ -531,7 +606,44 @@ Implement intelligent retry strategies: print(f"Operation failed: {e}") raise -Pattern 4: Structured Logging +Pattern 4: Device Capability Checking +-------------------------------------- + +Handle capability errors for device control commands: + +.. code-block:: python + + from nwp500 import NavienMqttClient, DeviceCapabilityError + from nwp500.device_capabilities import DeviceCapabilityChecker + + async def control_with_capability_check(): + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + # Request device info first + await mqtt.subscribe_device_feature(device, lambda f: None) + await mqtt.control.request_device_info(device) + + # Option 1: Try control and catch capability error + try: + await mqtt.control.set_recirculation_mode(device, 1) + except DeviceCapabilityError as e: + print(f"Device doesn't support: {e.feature}") + # Fallback to alternative command + + # Option 2: Check capability before attempting + if DeviceCapabilityChecker.supports("recirculation_use", device_features): + await mqtt.control.set_recirculation_mode(device, 1) + else: + print("Recirculation not supported") + + # Option 3: View all available controls + controls = DeviceCapabilityChecker.get_available_controls(device_features) + for feature, supported in controls.items(): + if supported: + print(f"✓ {feature} supported") + +Pattern 5: Structured Logging ------------------------------ Use ``to_dict()`` for structured error logging: @@ -544,7 +656,7 @@ Use ``to_dict()`` for structured error logging: logger = logging.getLogger(__name__) try: - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) except Nwp500Error as e: # Log structured error data logger.error("Operation failed", extra=e.to_dict()) @@ -562,7 +674,7 @@ Catch all library exceptions with ``Nwp500Error``: try: # Any library operation await mqtt.connect() - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) except Nwp500Error as e: # All nwp500 exceptions inherit from Nwp500Error @@ -632,7 +744,7 @@ Best Practices .. code-block:: python try: - await mqtt.set_dhw_temperature(device, 200.0) + await mqtt.control.set_dhw_temperature(device, 200.0) except RangeValidationError as e: # Show helpful message print(f"Temperature must be between {e.min_value}°F and {e.max_value}°F") @@ -685,7 +797,7 @@ If upgrading from v4.x, update your exception handling: .. code-block:: python try: - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) except RuntimeError as e: if "Not connected" in str(e): await mqtt.connect() @@ -697,10 +809,10 @@ If upgrading from v4.x, update your exception handling: from nwp500 import MqttNotConnectedError try: - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) except MqttNotConnectedError: await mqtt.connect() - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) See the CHANGELOG.rst for complete migration guide with more examples. diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index f719e9a..d942bf5 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -36,7 +36,7 @@ See :doc:`../enumerations` for the complete enumeration reference including: from nwp500 import DhwOperationSetting, CurrentOperationMode, HeatSource, TemperatureType # Set operation mode (user preference) - await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) # Check current heat source if status.current_heat_use == HeatSource.HEATPUMP: @@ -350,7 +350,7 @@ Device capabilities, features, and firmware information. * ``smart_diagnostic_use`` - Smart diagnostics available * ``wifi_rssi_use`` - WiFi signal strength available * ``holiday_use`` - Holiday/vacation mode - * ``mixing_value_use`` - Mixing valve + * ``mixing_valve_use`` - Mixing valve * ``dr_setting_use`` - Demand response * ``dhw_refill_use`` - DHW refill * ``eco_use`` - Eco mode @@ -571,10 +571,10 @@ Best Practices # ✓ Type-safe from nwp500 import DhwOperationSetting - await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) # ✗ Magic numbers - await mqtt.set_dhw_mode(device, 3) + await mqtt.control.set_dhw_mode(device, 3) 2. **Check feature support:** @@ -583,7 +583,7 @@ Best Practices def on_feature(feature): if feature.energy_usage_use: # Device supports energy monitoring - await mqtt.request_energy_usage(device, year, months) + await mqtt.control.request_energy_usage(device, year, months) 3. **Monitor operation state:** diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index ec758a2..4e9474d 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -55,7 +55,7 @@ Basic Monitoring print(f"Mode: {status.dhw_operation_setting.name}") await mqtt.subscribe_device_status(device, on_status) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # Monitor for 60 seconds await asyncio.sleep(60) @@ -66,6 +66,10 @@ Basic Monitoring Device Control -------------- +Control operations require device capability information to be cached. Always request +device info before using control commands. See :doc:`device_control` for complete +control method reference, capability checking, and advanced features. + .. code-block:: python async def control_device(): @@ -76,10 +80,14 @@ Device Control mqtt = NavienMqttClient(auth) await mqtt.connect() - # Control operations - await mqtt.set_power(device, power_on=True) - await mqtt.set_dhw_mode(device, mode_id=3) # Energy Saver - await mqtt.set_dhw_temperature(device, 140.0) + # Request device info first (populates capability cache) + await mqtt.subscribe_device_feature(device, lambda f: None) + await mqtt.control.request_device_info(device) + + # Control operations (with automatic capability checking) + await mqtt.control.set_power(device, power_on=True) + await mqtt.control.set_dhw_mode(device, mode_id=3) # Energy Saver + await mqtt.control.set_dhw_temperature(device, 140.0) await mqtt.disconnect() @@ -230,7 +238,7 @@ subscribe_device_status() print(f"ERROR: {status.error_code}") await mqtt.subscribe_device_status(device, on_status) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) request_device_status() ^^^^^^^^^^^^^^^^^^^^^^^ @@ -252,11 +260,11 @@ request_device_status() await mqtt.subscribe_device_status(device, on_status) # Then request - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # Can request periodically while monitoring: - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) await asyncio.sleep(30) # Every 30 seconds subscribe_device_feature() @@ -296,7 +304,7 @@ subscribe_device_feature() print("Reservations: Supported") await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.request_device_info(device) + await mqtt.control.request_device_info(device) request_device_info() ^^^^^^^^^^^^^^^^^^^^^ @@ -315,7 +323,7 @@ request_device_info() .. code-block:: python await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.request_device_info(device) + await mqtt.control.request_device_info(device) subscribe_device() ^^^^^^^^^^^^^^^^^^ @@ -376,11 +384,11 @@ set_power() .. code-block:: python # Turn on - await mqtt.set_power(device, power_on=True) + await mqtt.control.set_power(device, power_on=True) print("Device powered ON") # Turn off - await mqtt.set_power(device, power_on=False) + await mqtt.control.set_power(device, power_on=False) print("Device powered OFF") set_dhw_mode() @@ -414,18 +422,18 @@ set_dhw_mode() from nwp500 import DhwOperationSetting # Set to Heat Pump Only (most efficient) - await mqtt.set_dhw_mode(device, DhwOperationSetting.HEAT_PUMP.value) + await mqtt.control.set_dhw_mode(device, DhwOperationSetting.HEAT_PUMP.value) # Set to Energy Saver (balanced, recommended) - await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) # or just: - await mqtt.set_dhw_mode(device, 3) + await mqtt.control.set_dhw_mode(device, 3) # Set to High Demand (maximum heating) - await mqtt.set_dhw_mode(device, DhwOperationSetting.HIGH_DEMAND.value) + await mqtt.control.set_dhw_mode(device, DhwOperationSetting.HIGH_DEMAND.value) # Set vacation mode for 7 days - await mqtt.set_dhw_mode( + await mqtt.control.set_dhw_mode( device, DhwOperationSetting.VACATION.value, vacation_days=7 @@ -454,13 +462,13 @@ set_dhw_temperature() .. code-block:: python # Set temperature to 140°F - await mqtt.set_dhw_temperature(device, 140.0) + await mqtt.control.set_dhw_temperature(device, 140.0) # Common temperatures - await mqtt.set_dhw_temperature(device, 120.0) # Standard - await mqtt.set_dhw_temperature(device, 130.0) # Medium - await mqtt.set_dhw_temperature(device, 140.0) # Hot - await mqtt.set_dhw_temperature(device, 150.0) # Maximum + await mqtt.control.set_dhw_temperature(device, 120.0) # Standard + await mqtt.control.set_dhw_temperature(device, 130.0) # Medium + await mqtt.control.set_dhw_temperature(device, 140.0) # Hot + await mqtt.control.set_dhw_temperature(device, 150.0) # Maximum enable_anti_legionella() ^^^^^^^^^^^^^^^^^^^^^^^^ @@ -481,10 +489,10 @@ enable_anti_legionella() .. code-block:: python # Enable weekly anti-Legionella cycle - await mqtt.enable_anti_legionella(device, period_days=7) + await mqtt.control.enable_anti_legionella(device, period_days=7) # Enable bi-weekly cycle - await mqtt.enable_anti_legionella(device, period_days=14) + await mqtt.control.enable_anti_legionella(device, period_days=14) disable_anti_legionella() ^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -502,7 +510,7 @@ disable_anti_legionella() .. code-block:: python - await mqtt.disable_anti_legionella(device) + await mqtt.control.disable_anti_legionella(device) Energy Monitoring Methods -------------------------- @@ -533,13 +541,13 @@ request_energy_usage() # Request current month from datetime import datetime now = datetime.now() - await mqtt.request_energy_usage(device, now.year, [now.month]) + await mqtt.control.request_energy_usage(device, now.year, [now.month]) # Request multiple months - await mqtt.request_energy_usage(device, 2024, [8, 9, 10]) + await mqtt.control.request_energy_usage(device, 2024, [8, 9, 10]) # Request full year - await mqtt.request_energy_usage(device, 2024, list(range(1, 13))) + await mqtt.control.request_energy_usage(device, 2024, list(range(1, 13))) subscribe_energy_usage() ^^^^^^^^^^^^^^^^^^^^^^^^ @@ -577,7 +585,7 @@ subscribe_energy_usage() print(f" HE: {day_data.heat_element_usage} Wh ({day_data.heat_element_time}h)") await mqtt.subscribe_energy_usage(device, on_energy) - await mqtt.request_energy_usage(device, year=2024, months=[10]) + await mqtt.control.request_energy_usage(device, year=2024, months=[10]) Reservation Methods ------------------- @@ -623,7 +631,7 @@ update_reservations() ] # Update schedule - await mqtt.update_reservations(device, True, reservations) + await mqtt.control.update_reservations(device, True, reservations) request_reservations() ^^^^^^^^^^^^^^^^^^^^^^ @@ -659,10 +667,10 @@ set_tou_enabled() .. code-block:: python # Enable TOU - await mqtt.set_tou_enabled(device, True) + await mqtt.control.set_tou_enabled(device, True) # Disable TOU - await mqtt.set_tou_enabled(device, False) + await mqtt.control.set_tou_enabled(device, False) Periodic Request Methods ------------------------ @@ -748,7 +756,7 @@ signal_app_connection() .. code-block:: python await mqtt.connect() - await mqtt.signal_app_connection(device) + await mqtt.control.signal_app_connection(device) subscribe(), unsubscribe(), publish() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -772,7 +780,7 @@ is_connected .. code-block:: python if mqtt.is_connected: - await mqtt.set_power(device, True) + await mqtt.control.set_power(device, True) else: print("Not connected") @@ -880,7 +888,7 @@ Example 1: Complete Monitoring Application print(f"[{now}] Heating: {', '.join(components)}") await mqtt.subscribe_device_status(device, on_status) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # Monitor indefinitely try: @@ -966,7 +974,7 @@ Example 3: Multi-Device Monitoring for device in devices: callback = create_callback(device.device_info.device_name) await mqtt.subscribe_device_status(device, callback) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # Monitor await asyncio.sleep(3600) @@ -983,10 +991,10 @@ Best Practices # CORRECT order await mqtt.subscribe_device_status(device, on_status) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # WRONG - response will be missed - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) await mqtt.subscribe_device_status(device, on_status) 2. **Use context managers:** @@ -1036,7 +1044,7 @@ Best Practices .. code-block:: python if mqtt.is_connected: - await mqtt.set_power(device, True) + await mqtt.control.set_power(device, True) else: print("Not connected - reconnecting...") await mqtt.connect() @@ -1046,6 +1054,7 @@ Related Documentation * :doc:`auth_client` - Authentication client * :doc:`api_client` - REST API client +* :doc:`device_control` - Device control commands and capability checking * :doc:`models` - Data models (DeviceStatus, DeviceFeature, etc.) * :doc:`events` - Event system * :doc:`exceptions` - Exception handling diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 595e90e..2f16b73 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -126,7 +126,7 @@ Connect to MQTT for real-time device monitoring: # Subscribe and request status await mqtt.subscribe_device_status(device, on_status) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # Monitor for 60 seconds print("Monitoring device...") @@ -163,18 +163,18 @@ Send control commands to change device settings: await mqtt.connect() # Turn on the device - await mqtt.set_power(device, power_on=True) + await mqtt.control.set_power(device, power_on=True) print("Device powered on") # Set to Energy Saver mode - await mqtt.set_dhw_mode( + await mqtt.control.set_dhw_mode( device, mode_id=DhwOperationSetting.ENERGY_SAVER.value ) print("Set to Energy Saver mode") # Set temperature to 120°F - await mqtt.set_dhw_temperature(device, 120.0) + await mqtt.control.set_dhw_temperature(device, 120.0) print("Temperature set to 120°F") await asyncio.sleep(2) diff --git a/examples/air_filter_reset_example.py b/examples/air_filter_reset_example.py new file mode 100644 index 0000000..535fef2 --- /dev/null +++ b/examples/air_filter_reset_example.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +Example: Air filter maintenance via MQTT. + +This demonstrates how to reset the air filter maintenance timer +after cleaning or replacing the filter on heat pump models. +""" + +import asyncio +import logging +from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + +# Set up logging to see the air filter control process +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def air_filter_example(): + """Example of resetting the air filter maintenance timer.""" + + # Use environment variables or replace with your credentials + email = "your_email@example.com" + password = "your_password" + + async with NavienAuthClient(email, password) as auth_client: + # Get device information + api_client = NavienAPIClient(auth_client) + devices = await api_client.list_devices() + + if not devices: + logger.error("No devices found") + return + + device = devices[0] + logger.info(f"Found device: {device.device_info.device_name}") + + # Connect MQTT client + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + logger.info("MQTT client connected") + + try: + # Get current device info to see filter status + logger.info("Getting current device feature information...") + device_features = None + + def on_device_info(features): + nonlocal device_features + device_features = features + if hasattr(features, "air_filter_maintenance_required"): + logger.info( + f"Air filter maintenance required: " + f"{features.air_filter_maintenance_required}" + ) + + await mqtt_client.subscribe_device_feature(device, on_device_info) + await mqtt_client.control.request_device_info(device) + await asyncio.sleep(3) # Wait for device info + + # Reset air filter maintenance timer + logger.info("Resetting air filter maintenance timer...") + + filter_reset_complete = False + + def on_filter_reset(status): + nonlocal filter_reset_complete + logger.info("Air filter maintenance timer reset!") + logger.info(f"Operation mode: {status.operation_mode.name}") + filter_reset_complete = True + + await mqtt_client.subscribe_device_status(device, on_filter_reset) + await mqtt_client.control.reset_air_filter(device) + + # Wait for confirmation + for i in range(10): # Wait up to 10 seconds + if filter_reset_complete: + logger.info("Air filter timer reset successfully!") + break + await asyncio.sleep(1) + else: + logger.warning("Timeout waiting for filter reset confirmation") + + # Verify the reset by requesting device info again + logger.info("Verifying filter reset by requesting updated device info...") + await asyncio.sleep(2) + + def on_updated_device_info(features): + if hasattr(features, "air_filter_maintenance_required"): + logger.info( + f"Air filter maintenance now required: " + f"{features.air_filter_maintenance_required}" + ) + logger.info("Filter reset appears to have been successful!") + + await mqtt_client.subscribe_device_feature(device, on_updated_device_info) + await mqtt_client.control.request_device_info(device) + await asyncio.sleep(3) + + finally: + await mqtt_client.disconnect() + logger.info("Disconnected from MQTT") + + +if __name__ == "__main__": + print("=== Air Filter Maintenance Example ===") + print("This example demonstrates:") + print("1. Connecting to device via MQTT") + print("2. Checking current air filter maintenance status") + print("3. Resetting the air filter maintenance timer") + print("4. Verifying the reset was successful") + print() + + # Note: This requires valid credentials + print( + "Note: Update email/password or set NAVIEN_EMAIL/NAVIEN_PASSWORD environment variables" + ) + print() + + # Uncomment to run (requires valid credentials) + # asyncio.run(air_filter_example()) + + print("CLI equivalent commands:") + print(" python -m nwp500.cli reset-filter") + print() + print("Note: This feature is primarily for heat pump models.") diff --git a/examples/anti_legionella_example.py b/examples/anti_legionella_example.py index cd70a9c..b872ed9 100644 --- a/examples/anti_legionella_example.py +++ b/examples/anti_legionella_example.py @@ -116,7 +116,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.STATUS_REQUEST - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) try: await asyncio.wait_for(status_received.wait(), timeout=10) @@ -134,7 +134,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.ANTI_LEGIONELLA_ON - await mqtt_client.enable_anti_legionella(device, period_days=7) + await mqtt_client.control.enable_anti_legionella(device, period_days=7) try: await asyncio.wait_for(status_received.wait(), timeout=10) @@ -152,7 +152,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.ANTI_LEGIONELLA_OFF - await mqtt_client.disable_anti_legionella(device) + await mqtt_client.control.disable_anti_legionella(device) try: await asyncio.wait_for(status_received.wait(), timeout=10) @@ -169,7 +169,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.ANTI_LEGIONELLA_ON - await mqtt_client.enable_anti_legionella(device, period_days=14) + await mqtt_client.control.enable_anti_legionella(device, period_days=14) try: await asyncio.wait_for(status_received.wait(), timeout=10) diff --git a/examples/auto_recovery_example.py b/examples/auto_recovery_example.py index e6e25b0..5cb2c43 100644 --- a/examples/auto_recovery_example.py +++ b/examples/auto_recovery_example.py @@ -87,7 +87,7 @@ async def on_reconnection_failed(attempts): # Subscribe and monitor await mqtt_client.subscribe_device_status(device, lambda s: None) - await mqtt_client.start_periodic_device_status_requests(device) + await mqtt_client.start_periodic_requests(device) # Monitor for 120 seconds for i in range(120): @@ -141,7 +141,7 @@ async def create_and_connect(): # Re-subscribe await mqtt_client.subscribe_device_status(device, lambda s: None) - await mqtt_client.start_periodic_device_status_requests(device) + await mqtt_client.start_periodic_requests(device) return mqtt_client @@ -245,7 +245,7 @@ async def on_reconnection_failed(attempts): # Re-subscribe await mqtt_client.subscribe_device_status(device, lambda s: None) - await mqtt_client.start_periodic_device_status_requests(device) + await mqtt_client.start_periodic_requests(device) recovery_attempt = 0 # Reset on success @@ -261,7 +261,7 @@ async def on_reconnection_failed(attempts): # Subscribe and monitor await mqtt_client.subscribe_device_status(device, lambda s: None) - await mqtt_client.start_periodic_device_status_requests(device) + await mqtt_client.start_periodic_requests(device) # Monitor for 120 seconds for i in range(120): @@ -359,7 +359,7 @@ async def on_reconnection_failed(attempts): # Re-subscribe await mqtt_client.subscribe_device_status(device, lambda s: None) - await mqtt_client.start_periodic_device_status_requests(device) + await mqtt_client.start_periodic_requests(device) recovery_attempt = 0 # Reset on success logger.info("All subscriptions restored") @@ -376,7 +376,7 @@ async def on_reconnection_failed(attempts): # Subscribe and monitor await mqtt_client.subscribe_device_status(device, lambda s: None) - await mqtt_client.start_periodic_device_status_requests(device) + await mqtt_client.start_periodic_requests(device) # Monitor for 120 seconds for i in range(120): diff --git a/examples/combined_callbacks.py b/examples/combined_callbacks.py index 31f5033..16762a8 100644 --- a/examples/combined_callbacks.py +++ b/examples/combined_callbacks.py @@ -130,13 +130,13 @@ def on_feature(feature: DeviceFeature): # Request both types of data print("Requesting device info and status...") - await mqtt_client.signal_app_connection(device) + await mqtt_client.control.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_info(device) await asyncio.sleep(2) - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) print("[SUCCESS] Requests sent") print() diff --git a/examples/command_queue_demo.py b/examples/command_queue_demo.py index 7c99bdc..65bb1d6 100644 --- a/examples/command_queue_demo.py +++ b/examples/command_queue_demo.py @@ -111,7 +111,7 @@ def on_message(topic, message): # Step 5: Test normal operation print("\n5. Testing normal operation (connected)...") print(" Sending status request...") - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) print(" [SUCCESS] Command sent successfully") await asyncio.sleep(2) @@ -131,15 +131,15 @@ def on_message(topic, message): # These will be queued print(" Queuing status request...") - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) print(f" Queue size: {mqtt_client.queued_commands_count}") print(" Queuing device info request...") - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_info(device) print(f" Queue size: {mqtt_client.queued_commands_count}") print(" Queuing temperature change...") - await mqtt_client.set_dhw_temperature_display(device, 130) + await mqtt_client.control.set_dhw_temperature(device, 130) print(f" Queue size: {mqtt_client.queued_commands_count}") print(f" [SUCCESS] Queued {mqtt_client.queued_commands_count} command(s)") @@ -164,7 +164,7 @@ def on_message(topic, message): # Try to exceed queue limit print(f" Sending {config.max_queued_commands + 5} commands...") for _i in range(config.max_queued_commands + 5): - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) print( f" Queue size: {mqtt_client.queued_commands_count} (max: {config.max_queued_commands})" diff --git a/examples/demand_response_example.py b/examples/demand_response_example.py new file mode 100644 index 0000000..ec2264a --- /dev/null +++ b/examples/demand_response_example.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Example: Controlling utility demand response via MQTT. + +This demonstrates how to enable/disable demand response participation +to help utilities manage peak loads. +""" + +import asyncio +import logging +from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + +# Set up logging to see the demand response control process +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def demand_response_example(): + """Example of controlling demand response participation.""" + + # Use environment variables or replace with your credentials + email = "your_email@example.com" + password = "your_password" + + async with NavienAuthClient(email, password) as auth_client: + # Get device information + api_client = NavienAPIClient(auth_client) + devices = await api_client.list_devices() + + if not devices: + logger.error("No devices found") + return + + device = devices[0] + logger.info(f"Found device: {device.device_info.device_name}") + + # Connect MQTT client + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + logger.info("MQTT client connected") + + try: + # Get current status first + logger.info("Getting current device status...") + current_status = None + + def on_current_status(status): + nonlocal current_status + current_status = status + logger.info(f"Current operation mode: {status.operation_mode.name}") + logger.info( + f"Current DHW temperature setting: {status.dhw_target_temperature_setting}°F" + ) + + await mqtt_client.subscribe_device_status(device, on_current_status) + await mqtt_client.control.request_device_status(device) + await asyncio.sleep(3) # Wait for current status + + # Enable demand response + logger.info("Enabling demand response...") + + dr_enabled = False + + def on_dr_enabled(status): + nonlocal dr_enabled + logger.info("Demand response enabled!") + logger.info("Device is now ready to respond to utility signals") + dr_enabled = True + + await mqtt_client.subscribe_device_status(device, on_dr_enabled) + await mqtt_client.control.enable_demand_response(device) + + # Wait for confirmation + for i in range(10): # Wait up to 10 seconds + if dr_enabled: + logger.info("Demand response participation enabled successfully!") + break + await asyncio.sleep(1) + else: + logger.warning( + "Timeout waiting for demand response enable confirmation" + ) + + # Wait a bit before disabling + logger.info("Waiting 5 seconds before disabling demand response...") + await asyncio.sleep(5) + + # Disable demand response + logger.info("Disabling demand response...") + + dr_disabled = False + + def on_dr_disabled(status): + nonlocal dr_disabled + logger.info("Demand response disabled!") + logger.info("Device will no longer respond to utility demand signals") + dr_disabled = True + + await mqtt_client.subscribe_device_status(device, on_dr_disabled) + await mqtt_client.control.disable_demand_response(device) + + # Wait for confirmation + for i in range(10): # Wait up to 10 seconds + if dr_disabled: + logger.info("Demand response participation disabled successfully!") + break + await asyncio.sleep(1) + else: + logger.warning( + "Timeout waiting for demand response disable confirmation" + ) + + finally: + await mqtt_client.disconnect() + logger.info("Disconnected from MQTT") + + +if __name__ == "__main__": + print("=== Demand Response Example ===") + print("This example demonstrates:") + print("1. Connecting to device via MQTT") + print("2. Getting current device status") + print("3. Enabling demand response participation") + print("4. Disabling demand response participation") + print("5. Receiving and displaying the responses") + print() + + # Note: This requires valid credentials + print( + "Note: Update email/password or set NAVIEN_EMAIL/NAVIEN_PASSWORD environment variables" + ) + print() + + # Uncomment to run (requires valid credentials) + # asyncio.run(demand_response_example()) + + print("CLI equivalent commands:") + print(" python -m nwp500.cli dr enable") + print(" python -m nwp500.cli dr disable") diff --git a/examples/device_feature_callback.py b/examples/device_feature_callback.py index 3896e42..0ec3ab4 100644 --- a/examples/device_feature_callback.py +++ b/examples/device_feature_callback.py @@ -32,7 +32,6 @@ from nwp500.api_client import NavienAPIClient from nwp500.auth import NavienAuthClient -from nwp500.enums import OnOffFlag from nwp500.exceptions import AuthenticationError from nwp500.models import DeviceFeature from nwp500.mqtt_client import NavienMqttClient @@ -149,63 +148,59 @@ def on_device_feature(feature: DeviceFeature): print("\nFeature Support:") print( - f" Power Control: {'Yes' if feature.power_use == OnOffFlag.ON else 'No'}" - ) - print( - f" DHW Control: {'Yes' if feature.dhw_use == OnOffFlag.ON else 'No'}" + f" Power Control: {'Yes' if feature.power_use else 'No'}" ) + print(f" DHW Control: {'Yes' if feature.dhw_use else 'No'}") print( f" DHW Temp Setting: Level {feature.dhw_temperature_setting_use}" ) print( - f" Heat Pump Mode: {'Yes' if feature.heatpump_use == OnOffFlag.ON else 'No'}" - ) - print( - f" Electric Mode: {'Yes' if feature.electric_use == OnOffFlag.ON else 'No'}" + f" Heat Pump Mode: {'Yes' if feature.heatpump_use else 'No'}" ) print( - f" Energy Saver: {'Yes' if feature.energy_saver_use == OnOffFlag.ON else 'No'}" + f" Electric Mode: {'Yes' if feature.electric_use else 'No'}" ) print( - f" High Demand: {'Yes' if feature.high_demand_use == OnOffFlag.ON else 'No'}" + f" Energy Saver: {'Yes' if feature.energy_saver_use else 'No'}" ) print( - f" Eco Mode: {'Yes' if feature.eco_use == OnOffFlag.ON else 'No'}" + f" High Demand: {'Yes' if feature.high_demand_use else 'No'}" ) + print(f" Eco Mode: {'Yes' if feature.eco_use else 'No'}") print("\nAdvanced Features:") print( - f" Holiday Mode: {'Yes' if feature.holiday_use == OnOffFlag.ON else 'No'}" + f" Holiday Mode: {'Yes' if feature.holiday_use else 'No'}" ) print( - f" Program Schedule: {'Yes' if feature.program_reservation_use == OnOffFlag.ON else 'No'}" + f" Program Schedule: {'Yes' if feature.program_reservation_use else 'No'}" ) print( - f" Smart Diagnostic: {'Yes' if feature.smart_diagnostic_use == OnOffFlag.ON else 'No'}" + f" Smart Diagnostic: {'Yes' if feature.smart_diagnostic_use else 'No'}" ) print( - f" WiFi RSSI: {'Yes' if feature.wifi_rssi_use == OnOffFlag.ON else 'No'}" + f" WiFi RSSI: {'Yes' if feature.wifi_rssi_use else 'No'}" ) print( - f" Energy Usage: {'Yes' if feature.energy_usage_use == OnOffFlag.ON else 'No'}" + f" Energy Usage: {'Yes' if feature.energy_usage_use else 'No'}" ) print( - f" Freeze Protection: {'Yes' if feature.freeze_protection_use == OnOffFlag.ON else 'No'}" + f" Freeze Protection: {'Yes' if feature.freeze_protection_use else 'No'}" ) print( - f" Mixing Valve: {'Yes' if feature.mixing_value_use == OnOffFlag.ON else 'No'}" + f" Mixing Valve: {'Yes' if feature.mixing_valve_use else 'No'}" ) print( - f" DR Settings: {'Yes' if feature.dr_setting_use == OnOffFlag.ON else 'No'}" + f" DR Settings: {'Yes' if feature.dr_setting_use else 'No'}" ) print( - f" Anti-Legionella: {'Yes' if feature.anti_legionella_setting_use == OnOffFlag.ON else 'No'}" + f" Anti-Legionella: {'Yes' if feature.anti_legionella_setting_use else 'No'}" ) print( - f" HPWH: {'Yes' if feature.hpwh_use == OnOffFlag.ON else 'No'}" + f" HPWH: {'Yes' if feature.hpwh_use else 'No'}" ) print( - f" DHW Refill: {'Yes' if feature.dhw_refill_use == OnOffFlag.ON else 'No'}" + f" DHW Refill: {'Yes' if feature.dhw_refill_use else 'No'}" ) print("=" * 60) @@ -230,10 +225,10 @@ def on_device_feature(feature: DeviceFeature): # Step 5: Request device info to get feature data print("Step 5: Requesting device information...") - await mqtt_client.signal_app_connection(device) + await mqtt_client.control.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_info(device) print("[SUCCESS] Device info request sent") print() diff --git a/examples/device_status_callback.py b/examples/device_status_callback.py index 4afed31..1c1ace9 100755 --- a/examples/device_status_callback.py +++ b/examples/device_status_callback.py @@ -202,10 +202,10 @@ def on_device_status(status: DeviceStatus): # Step 5: Request device status print("Step 5: Requesting device status...") - await mqtt_client.signal_app_connection(device) + await mqtt_client.control.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) print("[SUCCESS] Status request sent") print() diff --git a/examples/device_status_callback_debug.py b/examples/device_status_callback_debug.py index 46855e5..0ab72b0 100644 --- a/examples/device_status_callback_debug.py +++ b/examples/device_status_callback_debug.py @@ -148,10 +148,10 @@ def on_device_status(status: DeviceStatus): # Step 5: Request device status print("Step 5: Requesting device status...") - await mqtt_client.signal_app_connection(device) + await mqtt_client.control.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) print("[SUCCESS] Status request sent") print() diff --git a/examples/energy_usage_example.py b/examples/energy_usage_example.py index d6aaa5e..f580023 100755 --- a/examples/energy_usage_example.py +++ b/examples/energy_usage_example.py @@ -140,7 +140,7 @@ def on_energy_usage(energy: EnergyUsageResponse): current_month = now.month print(f"\nRequesting energy usage for {current_year}-{current_month:02d}...") - await mqtt_client.request_energy_usage( + await mqtt_client.control.request_energy_usage( device, year=current_year, months=[current_month] ) print("[OK] Request sent") diff --git a/examples/event_emitter_demo.py b/examples/event_emitter_demo.py index e936568..4d3a29a 100644 --- a/examples/event_emitter_demo.py +++ b/examples/event_emitter_demo.py @@ -219,7 +219,7 @@ async def main(): # Step 5: Request initial status print("7. Requesting initial status...") - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) print(" [SUCCESS] Request sent") print() diff --git a/examples/exception_handling_example.py b/examples/exception_handling_example.py index 2a0a2ec..499b935 100755 --- a/examples/exception_handling_example.py +++ b/examples/exception_handling_example.py @@ -107,7 +107,7 @@ async def example_mqtt_errors(): await mqtt.disconnect() try: - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) except MqttNotConnectedError as e: print(f"[OK] Caught MqttNotConnectedError: {e}") print(" Can reconnect and retry the operation") @@ -156,7 +156,7 @@ async def example_validation_errors(): # Try to set invalid vacation days try: - await mqtt.set_dhw_mode(device, mode_id=5, vacation_days=50) + await mqtt.control.set_dhw_mode(device, mode_id=5, vacation_days=50) except RangeValidationError as e: print(f"[OK] Caught RangeValidationError: {e}") print(f" Field: {e.field}") diff --git a/examples/improved_auth_pattern.py b/examples/improved_auth_pattern.py index 577a4b8..0bf0aef 100644 --- a/examples/improved_auth_pattern.py +++ b/examples/improved_auth_pattern.py @@ -50,7 +50,7 @@ def on_status(status): print(f" Power: {status.current_inst_power}W") await mqtt.subscribe_device_status(device, on_status) - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) # Keep alive for a few seconds to receive status print("\nMonitoring for 10 seconds...") diff --git a/examples/mqtt_client_example.py b/examples/mqtt_client_example.py index 08a2217..d84ad23 100755 --- a/examples/mqtt_client_example.py +++ b/examples/mqtt_client_example.py @@ -181,17 +181,17 @@ def on_device_feature(feature: DeviceFeature): # Signal app connection print("📤 Signaling app connection...") - await mqtt_client.signal_app_connection(device) + await mqtt_client.control.signal_app_connection(device) await asyncio.sleep(1) # Request device info print("📤 Requesting device information...") - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_info(device) await asyncio.sleep(2) # Request device status print("📤 Requesting device status...") - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) await asyncio.sleep(2) # Wait for messages diff --git a/examples/periodic_device_info.py b/examples/periodic_device_info.py index d48ca00..b6a6d2d 100755 --- a/examples/periodic_device_info.py +++ b/examples/periodic_device_info.py @@ -89,13 +89,13 @@ def on_device_feature(feature: DeviceFeature): # Example 1: Default period (300 seconds = 5 minutes) print("\n3. Starting periodic device info requests (every 5 minutes)...") - await mqtt.start_periodic_device_info_requests( + await mqtt.start_periodic_requests( device=device # period_seconds defaults to 300 ) # Send initial request to get immediate response print(" Sending initial request...") - await mqtt.request_device_info(device) + await mqtt.control.request_device_info(device) # Wait for a few updates with the default period print(" Waiting 15 seconds for response...") @@ -103,14 +103,14 @@ def on_device_feature(feature: DeviceFeature): # Example 2: Custom period (20 seconds for demonstration) print("\n4. Changing to faster period (every 20 seconds)...") - await mqtt.start_periodic_device_info_requests( + await mqtt.start_periodic_requests( device=device, period_seconds=20, # Request every 20 seconds ) # Send initial request for immediate feedback print(" Sending initial request...") - await mqtt.request_device_info(device) + await mqtt.control.request_device_info(device) # Monitor for 2 minutes print("\n Monitoring for 2 minutes...") @@ -122,7 +122,7 @@ def on_device_feature(feature: DeviceFeature): # Example 3: Stop periodic requests print("\n5. Stopping periodic requests...") - await mqtt.stop_periodic_device_info_requests(device) + await mqtt.stop_periodic_requests(device) print("\n Waiting 25 seconds (no new requests should be sent)...") for i in range(5): # 5 x 5 seconds = 25 seconds diff --git a/examples/periodic_requests.py b/examples/periodic_requests.py index 631f4fd..f37ec9f 100755 --- a/examples/periodic_requests.py +++ b/examples/periodic_requests.py @@ -118,7 +118,7 @@ async def monitor_with_dots(seconds: int, interval: int = 5): # Send initial request immediately to get first response print("Sending initial status request...") - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) print("Monitoring for 60 seconds...") print("(First automatic request in ~20 seconds)") @@ -138,7 +138,7 @@ async def monitor_with_dots(seconds: int, interval: int = 5): # Send initial request immediately print("Sending initial device info request...") - await mqtt.request_device_info(device) + await mqtt.control.request_device_info(device) print("Monitoring for 60 seconds...") print("(First automatic request in ~20 seconds)") @@ -167,9 +167,9 @@ async def monitor_with_dots(seconds: int, interval: int = 5): # Send initial requests for both types print("\nSending initial requests for both types...") - await mqtt.request_device_status(device) + await mqtt.control.request_device_status(device) await asyncio.sleep(1) # Small delay between requests - await mqtt.request_device_info(device) + await mqtt.control.request_device_info(device) print("\nMonitoring for 2 minutes...") print("(Status requests: ~20s, ~40s, ~60s, ~80s, ~100s, ~120s)") diff --git a/examples/power_control_example.py b/examples/power_control_example.py index e43cfe6..a425ed3 100644 --- a/examples/power_control_example.py +++ b/examples/power_control_example.py @@ -51,7 +51,7 @@ def on_current_status(status): logger.info(f"Current DHW temperature: {status.dhw_temperature}°F") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Turn device off @@ -67,7 +67,7 @@ def on_power_off_response(status): power_off_complete = True await mqtt_client.subscribe_device_status(device, on_power_off_response) - await mqtt_client.set_power(device, power_on=False) + await mqtt_client.control.set_power(device, power_on=False) # Wait for confirmation for i in range(15): # Wait up to 15 seconds @@ -96,7 +96,7 @@ def on_power_on_response(status): power_on_complete = True await mqtt_client.subscribe_device_status(device, on_power_on_response) - await mqtt_client.set_power(device, power_on=True) + await mqtt_client.control.set_power(device, power_on=True) # Wait for confirmation for i in range(15): # Wait up to 15 seconds @@ -132,6 +132,6 @@ def on_power_on_response(status): # asyncio.run(power_control_example()) print("CLI equivalent commands:") - print(" python -m nwp500.cli --power-off") - print(" python -m nwp500.cli --power-on") - print(" python -m nwp500.cli --power-on --status") + print(" python -m nwp500.cli power off") + print(" python -m nwp500.cli power on") + print(" python -m nwp500.cli power on && python -m nwp500.cli status") diff --git a/examples/recirculation_control_example.py b/examples/recirculation_control_example.py new file mode 100644 index 0000000..f4cef62 --- /dev/null +++ b/examples/recirculation_control_example.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Example: Recirculation pump control via MQTT. + +This demonstrates how to control the recirculation pump operation mode, +trigger the hot button, and configure scheduling. +""" + +import asyncio +import logging +from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + +# Set up logging to see the recirculation control process +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def recirculation_example(): + """Example of controlling the recirculation pump.""" + + # Use environment variables or replace with your credentials + email = "your_email@example.com" + password = "your_password" + + async with NavienAuthClient(email, password) as auth_client: + # Get device information + api_client = NavienAPIClient(auth_client) + devices = await api_client.list_devices() + + if not devices: + logger.error("No devices found") + return + + device = devices[0] + logger.info(f"Found device: {device.device_info.device_name}") + + # Connect MQTT client + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + logger.info("MQTT client connected") + + try: + # Get current status first + logger.info("Getting current device status...") + current_status = None + + def on_current_status(status): + nonlocal current_status + current_status = status + logger.info(f"Current operation mode: {status.operation_mode.name}") + + await mqtt_client.subscribe_device_status(device, on_current_status) + await mqtt_client.control.request_device_status(device) + await asyncio.sleep(3) # Wait for current status + + # Set recirculation mode to "Always On" + logger.info("Setting recirculation pump mode to 'Always On'...") + + mode_set = False + + def on_mode_set(status): + nonlocal mode_set + logger.info("Recirculation pump mode set to 'Always On'!") + logger.info( + "The pump will continuously circulate hot water to fixtures" + ) + mode_set = True + + await mqtt_client.subscribe_device_status(device, on_mode_set) + await mqtt_client.control.set_recirculation_mode(device, 1) # 1 = Always On + + # Wait for confirmation + for i in range(10): # Wait up to 10 seconds + if mode_set: + logger.info("Recirculation mode set successfully!") + break + await asyncio.sleep(1) + else: + logger.warning("Timeout waiting for mode change confirmation") + + logger.info("Waiting 5 seconds before triggering hot button...") + await asyncio.sleep(5) + + # Trigger the recirculation hot button + logger.info("Triggering recirculation pump hot button...") + + hot_button_triggered = False + + def on_hot_button(status): + nonlocal hot_button_triggered + logger.info("Recirculation pump hot button triggered!") + logger.info("Hot water is now being delivered to fixtures") + hot_button_triggered = True + + await mqtt_client.subscribe_device_status(device, on_hot_button) + await mqtt_client.control.trigger_recirculation_hot_button(device) + + # Wait for confirmation + for i in range(10): # Wait up to 10 seconds + if hot_button_triggered: + logger.info("Hot button triggered successfully!") + break + await asyncio.sleep(1) + else: + logger.warning("Timeout waiting for hot button confirmation") + + logger.info("Waiting 5 seconds before changing mode to 'Button Only'...") + await asyncio.sleep(5) + + # Change mode to "Button Only" + logger.info("Changing recirculation pump mode to 'Button Only'...") + + button_only_set = False + + def on_button_only_set(status): + nonlocal button_only_set + logger.info("Recirculation pump mode changed to 'Button Only'!") + logger.info("The pump will only run when the hot button is pressed") + button_only_set = True + + await mqtt_client.subscribe_device_status(device, on_button_only_set) + await mqtt_client.control.set_recirculation_mode( + device, 2 + ) # 2 = Button Only + + # Wait for confirmation + for i in range(10): # Wait up to 10 seconds + if button_only_set: + logger.info("Recirculation mode changed successfully!") + break + await asyncio.sleep(1) + else: + logger.warning("Timeout waiting for mode change confirmation") + + finally: + await mqtt_client.disconnect() + logger.info("Disconnected from MQTT") + + +if __name__ == "__main__": + print("=== Recirculation Pump Control Example ===") + print("This example demonstrates:") + print("1. Connecting to device via MQTT") + print("2. Getting current device status") + print("3. Setting recirculation pump mode to 'Always On'") + print("4. Triggering the recirculation pump hot button") + print("5. Changing recirculation pump mode to 'Button Only'") + print("6. Receiving and displaying responses") + print() + + # Note: This requires valid credentials + print( + "Note: Update email/password or set NAVIEN_EMAIL/NAVIEN_PASSWORD environment variables" + ) + print() + + # Uncomment to run (requires valid credentials) + # asyncio.run(recirculation_example()) + + print("CLI equivalent commands:") + print(" python -m nwp500.cli recirc 1") + print(" python -m nwp500.cli hot-button") + print(" python -m nwp500.cli recirc 2") + print() + print("Valid recirculation modes:") + print(" 1 = Always On (pump continuously circulates hot water)") + print(" 2 = Button Only (pump runs only when hot button is pressed)") + print(" 3 = Schedule (pump operates on a defined schedule)") + print(" 4 = Temperature (pump operates when temperature falls below setpoint)") diff --git a/examples/reconnection_demo.py b/examples/reconnection_demo.py index 090102a..d8e94f9 100644 --- a/examples/reconnection_demo.py +++ b/examples/reconnection_demo.py @@ -95,7 +95,7 @@ def on_status(status): print(f" Reconnecting: attempt {mqtt_client.reconnect_attempts}...") await mqtt_client.subscribe_device_status(device, on_status) - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) # Monitor connection status print("\n" + "=" * 70) @@ -124,7 +124,7 @@ def on_status(status): # Request status update if connected if mqtt_client.is_connected: - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) print("\n" + "=" * 70) print(f"Monitoring complete. Received {status_count} status updates.") diff --git a/examples/reservation_schedule_example.py b/examples/reservation_schedule_example.py index 6385f10..4423d6d 100644 --- a/examples/reservation_schedule_example.py +++ b/examples/reservation_schedule_example.py @@ -68,12 +68,12 @@ def on_reservation_update(topic: str, message: dict[str, Any]) -> None: await mqtt_client.subscribe(response_topic, on_reservation_update) print("Sending reservation program update...") - await mqtt_client.update_reservations( + await mqtt_client.control.update_reservations( device, [weekday_reservation], enabled=True ) print("Requesting current reservation program...") - await mqtt_client.request_reservations(device) + await mqtt_client.control.request_reservations(device) print("Waiting up to 15 seconds for reservation responses...") await asyncio.sleep(15) diff --git a/examples/set_dhw_temperature_example.py b/examples/set_dhw_temperature_example.py index f44ab56..4958fa2 100644 --- a/examples/set_dhw_temperature_example.py +++ b/examples/set_dhw_temperature_example.py @@ -53,7 +53,7 @@ def on_current_status(status): logger.info(f"Current DHW temperature: {status.dhw_temperature}°F") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Set new target temperature to 140°F @@ -77,7 +77,7 @@ def on_temp_change_response(status): await mqtt_client.subscribe_device_status(device, on_temp_change_response) # Send temperature change command using display temperature value - await mqtt_client.set_dhw_temperature_display(device, target_temperature) + await mqtt_client.control.set_dhw_temperature(device, target_temperature) # Wait for confirmation for i in range(15): # Wait up to 15 seconds @@ -112,9 +112,9 @@ def on_temp_change_response(status): # asyncio.run(set_dhw_temperature_example()) print("CLI equivalent commands:") - print(" python -m nwp500.cli --set-dhw-temp 140") - print(" python -m nwp500.cli --set-dhw-temp 130") - print(" python -m nwp500.cli --set-dhw-temp 150") + print(" python -m nwp500.cli temp 140") + print(" python -m nwp500.cli temp 130") + print(" python -m nwp500.cli temp 150") print() print("Valid temperature range: 115-150°F") print("Note: The device may cap temperatures at 150°F maximum") diff --git a/examples/set_mode_example.py b/examples/set_mode_example.py index 393e14f..094eb11 100644 --- a/examples/set_mode_example.py +++ b/examples/set_mode_example.py @@ -50,7 +50,7 @@ def on_current_status(status): logger.info(f"Current mode: {status.operation_mode.name}") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Change to Energy Saver mode @@ -70,7 +70,7 @@ def on_mode_change_response(status): await mqtt_client.subscribe_device_status(device, on_mode_change_response) # Send mode change command (3 = Energy Saver, per MQTT protocol) - await mqtt_client.set_dhw_mode(device, 3) + await mqtt_client.control.set_dhw_mode(device, 3) # Wait for confirmation for i in range(15): # Wait up to 15 seconds @@ -105,9 +105,9 @@ def on_mode_change_response(status): # asyncio.run(set_mode_example()) print("CLI equivalent commands:") - print(" python -m nwp500.cli --set-mode energy-saver") - print(" python -m nwp500.cli --set-mode heat-pump") - print(" python -m nwp500.cli --set-mode electric") - print(" python -m nwp500.cli --set-mode high-demand") - print(" python -m nwp500.cli --set-mode vacation") - print(" python -m nwp500.cli --set-mode standby") + print(" python -m nwp500.cli mode energy-saver") + print(" python -m nwp500.cli mode heat-pump") + print(" python -m nwp500.cli mode electric") + print(" python -m nwp500.cli mode high-demand") + print(" python -m nwp500.cli mode vacation") + print(" python -m nwp500.cli mode standby") diff --git a/examples/simple_auto_recovery.py b/examples/simple_auto_recovery.py index ce4e421..698cf1e 100644 --- a/examples/simple_auto_recovery.py +++ b/examples/simple_auto_recovery.py @@ -92,7 +92,7 @@ async def _create_client(self): await self.mqtt_client.subscribe_device_status( self.device, self.status_callback ) - await self.mqtt_client.start_periodic_device_status_requests(self.device) + await self.mqtt_client.start_periodic_requests(self.device) logger.info("Subscriptions restored") async def _handle_reconnection_failed(self, attempts): diff --git a/examples/simple_periodic_info.py b/examples/simple_periodic_info.py index 0578e70..e0ee405 100644 --- a/examples/simple_periodic_info.py +++ b/examples/simple_periodic_info.py @@ -46,7 +46,7 @@ def on_feature(feature: DeviceFeature): await mqtt.subscribe_device_feature(device, on_feature) # Start periodic requests (every 5 minutes by default) - await mqtt.start_periodic_device_info_requests(device=device) + await mqtt.start_periodic_requests(device=device) print("Periodic device info requests started (every 5 minutes)") print("Press Ctrl+C to stop...") diff --git a/examples/test_mqtt_messaging.py b/examples/test_mqtt_messaging.py index bce82d9..8f56d11 100644 --- a/examples/test_mqtt_messaging.py +++ b/examples/test_mqtt_messaging.py @@ -182,7 +182,7 @@ def mask_any(_): f"📤 [{datetime.now().strftime('%H:%M:%S')}] Signaling app connection..." ) try: - await mqtt_client.signal_app_connection(device) + await mqtt_client.control.signal_app_connection(device) print(" [SUCCESS] Sent") except Exception as e: print(f" [ERROR] Error: {e}") @@ -193,7 +193,7 @@ def mask_any(_): f"📤 [{datetime.now().strftime('%H:%M:%S')}] Requesting device info..." ) try: - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_info(device) print(" [SUCCESS] Sent") except Exception as e: print(f" [ERROR] Error: {e}") @@ -204,7 +204,7 @@ def mask_any(_): f"📤 [{datetime.now().strftime('%H:%M:%S')}] Requesting device status..." ) try: - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) print(" [SUCCESS] Sent") except Exception as e: print(f" [ERROR] Error: {e}") diff --git a/examples/tou_openei_example.py b/examples/tou_openei_example.py index 0bf2fb2..e34f160 100755 --- a/examples/tou_openei_example.py +++ b/examples/tou_openei_example.py @@ -212,7 +212,7 @@ def capture_feature(feature) -> None: feature_future.set_result(feature) await mqtt_client.subscribe_device_feature(device, capture_feature) - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_info(device) feature = await asyncio.wait_for(feature_future, timeout=15) return feature.controller_serial_number @@ -307,7 +307,7 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None: await mqtt_client.subscribe(response_topic, on_tou_response) - await mqtt_client.configure_tou_schedule( + await mqtt_client.control.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=tou_periods, diff --git a/examples/tou_schedule_example.py b/examples/tou_schedule_example.py index 2d3579d..b1249dc 100644 --- a/examples/tou_schedule_example.py +++ b/examples/tou_schedule_example.py @@ -22,7 +22,7 @@ def capture_feature(feature) -> None: await mqtt_client.subscribe_device_feature(device, capture_feature) # Then request device info - await mqtt_client.request_device_info(device) + await mqtt_client.control.request_device_info(device) # Wait for the response feature = await asyncio.wait_for(feature_future, timeout=15) @@ -112,7 +112,7 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None: await mqtt_client.subscribe(response_topic, on_tou_response) print("Uploading TOU schedule (enabling reservation)...") - await mqtt_client.configure_tou_schedule( + await mqtt_client.control.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=[off_peak, peak], @@ -120,17 +120,17 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None: ) print("Requesting current TOU settings for confirmation...") - await mqtt_client.request_tou_settings(device, controller_serial) + await mqtt_client.control.request_tou_settings(device, controller_serial) print("Waiting up to 15 seconds for TOU responses...") await asyncio.sleep(15) print("Toggling TOU off for quick test...") - await mqtt_client.set_tou_enabled(device, enabled=False) + await mqtt_client.control.set_tou_enabled(device, enabled=False) await asyncio.sleep(3) print("Re-enabling TOU...") - await mqtt_client.set_tou_enabled(device, enabled=True) + await mqtt_client.control.set_tou_enabled(device, enabled=True) await asyncio.sleep(3) await mqtt_client.disconnect() diff --git a/examples/vacation_mode_example.py b/examples/vacation_mode_example.py new file mode 100644 index 0000000..1e2487a --- /dev/null +++ b/examples/vacation_mode_example.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Example: Vacation mode configuration via MQTT. + +This demonstrates how to set vacation/away mode duration for energy-saving +during periods of absence. +""" + +import asyncio +import logging +from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + +# Set up logging to see the vacation mode control process +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def vacation_mode_example(): + """Example of configuring vacation mode.""" + + # Use environment variables or replace with your credentials + email = "your_email@example.com" + password = "your_password" + + # Vacation duration in days + vacation_days = 14 + + async with NavienAuthClient(email, password) as auth_client: + # Get device information + api_client = NavienAPIClient(auth_client) + devices = await api_client.list_devices() + + if not devices: + logger.error("No devices found") + return + + device = devices[0] + logger.info(f"Found device: {device.device_info.device_name}") + + # Connect MQTT client + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + logger.info("MQTT client connected") + + try: + # Get current status first + logger.info("Getting current device status...") + current_status = None + + def on_current_status(status): + nonlocal current_status + current_status = status + logger.info(f"Current operation mode: {status.operation_mode.name}") + logger.info( + f"Current DHW temperature setting: {status.dhw_target_temperature_setting}°F" + ) + + await mqtt_client.subscribe_device_status(device, on_current_status) + await mqtt_client.control.request_device_status(device) + await asyncio.sleep(3) # Wait for current status + + # Set vacation mode + logger.info(f"Setting vacation mode for {vacation_days} days...") + + vacation_set = False + + def on_vacation_set(status): + nonlocal vacation_set + logger.info(f"Vacation mode set for {vacation_days} days!") + logger.info("Device is now in energy-saving mode during absence") + logger.info(f"Operation mode: {status.operation_mode.name}") + vacation_set = True + + await mqtt_client.subscribe_device_status(device, on_vacation_set) + await mqtt_client.control.set_vacation_days(device, vacation_days) + + # Wait for confirmation + for i in range(10): # Wait up to 10 seconds + if vacation_set: + logger.info("Vacation mode set successfully!") + break + await asyncio.sleep(1) + else: + logger.warning("Timeout waiting for vacation mode confirmation") + + logger.info( + f"Vacation mode active: Device will operate in energy-saving mode " + f"until {vacation_days} days have elapsed." + ) + logger.info( + "The device will automatically return to normal operation " + "after the vacation period ends." + ) + + finally: + await mqtt_client.disconnect() + logger.info("Disconnected from MQTT") + + +if __name__ == "__main__": + print("=== Vacation Mode Example ===") + print("This example demonstrates:") + print("1. Connecting to device via MQTT") + print("2. Getting current device status") + print("3. Setting vacation mode for a specified number of days") + print("4. Receiving and displaying the response") + print() + + # Note: This requires valid credentials + print( + "Note: Update email/password or set NAVIEN_EMAIL/NAVIEN_PASSWORD environment variables" + ) + print() + + # Uncomment to run (requires valid credentials) + # asyncio.run(vacation_mode_example()) + + print("CLI equivalent commands:") + print(" python -m nwp500.cli vacation 7") + print(" python -m nwp500.cli vacation 14") + print(" python -m nwp500.cli vacation 21") + print() + print("Valid range: 1-365+ days") diff --git a/examples/water_program_reservation_example.py b/examples/water_program_reservation_example.py new file mode 100644 index 0000000..703efd1 --- /dev/null +++ b/examples/water_program_reservation_example.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Example: Water program reservation configuration via MQTT. + +This demonstrates how to enable/configure the water program reservation +system for scheduling water heating. +""" + +import asyncio +import logging +from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient + +# Set up logging to see the water program configuration process +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def water_program_example(): + """Example of configuring water program reservation mode.""" + + # Use environment variables or replace with your credentials + email = "your_email@example.com" + password = "your_password" + + async with NavienAuthClient(email, password) as auth_client: + # Get device information + api_client = NavienAPIClient(auth_client) + devices = await api_client.list_devices() + + if not devices: + logger.error("No devices found") + return + + device = devices[0] + logger.info(f"Found device: {device.device_info.device_name}") + + # Connect MQTT client + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + logger.info("MQTT client connected") + + try: + # Get current status first + logger.info("Getting current device status...") + current_status = None + + def on_current_status(status): + nonlocal current_status + current_status = status + logger.info(f"Current operation mode: {status.operation_mode.name}") + logger.info( + f"Current DHW temperature setting: {status.dhw_target_temperature_setting}°F" + ) + + await mqtt_client.subscribe_device_status(device, on_current_status) + await mqtt_client.control.request_device_status(device) + await asyncio.sleep(3) # Wait for current status + + # Enable water program reservation mode + logger.info("Configuring water program reservation mode...") + + water_program_enabled = False + + def on_water_program_configured(status): + nonlocal water_program_enabled + logger.info("Water program reservation mode enabled!") + logger.info( + "You can now set up water heating schedules for " + "specific times and days" + ) + logger.info(f"Operation mode: {status.operation_mode.name}") + water_program_enabled = True + + await mqtt_client.subscribe_device_status( + device, on_water_program_configured + ) + await mqtt_client.control.configure_reservation_water_program(device) + + # Wait for confirmation + for i in range(10): # Wait up to 10 seconds + if water_program_enabled: + logger.info( + "Water program reservation mode configured successfully!" + ) + break + await asyncio.sleep(1) + else: + logger.warning("Timeout waiting for configuration confirmation") + + logger.info( + "Water program reservation mode is now active. " + "You can use the app or API to set up specific heating schedules." + ) + + finally: + await mqtt_client.disconnect() + logger.info("Disconnected from MQTT") + + +if __name__ == "__main__": + print("=== Water Program Reservation Configuration Example ===") + print("This example demonstrates:") + print("1. Connecting to device via MQTT") + print("2. Getting current device status") + print("3. Enabling water program reservation mode") + print("4. Receiving and displaying the response") + print() + + # Note: This requires valid credentials + print( + "Note: Update email/password or set NAVIEN_EMAIL/NAVIEN_PASSWORD environment variables" + ) + print() + + # Uncomment to run (requires valid credentials) + # asyncio.run(water_program_example()) + + print("CLI equivalent commands:") + print(" python -m nwp500.cli water-program") + print() + print("Once enabled, you can set up specific heating schedules through:") + print("- The official Navien mobile app") + print("- The REST API reservations endpoints") + print("- This library's set_reservations method") diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 0e95b00..13ed9bc 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -30,6 +30,15 @@ authenticate, refresh_access_token, ) +from nwp500.command_decorators import ( + requires_capability, +) +from nwp500.device_capabilities import ( + DeviceCapabilityChecker, +) +from nwp500.device_info_cache import ( + DeviceInfoCache, +) from nwp500.encoding import ( build_reservation_entry, build_tou_period, @@ -64,6 +73,7 @@ from nwp500.exceptions import ( APIError, AuthenticationError, + DeviceCapabilityError, DeviceError, DeviceNotFoundError, DeviceOfflineError, @@ -113,6 +123,11 @@ __all__ = [ "__version__", + # Device Capabilities & Caching + "DeviceCapabilityChecker", + "DeviceCapabilityError", + "DeviceInfoCache", + "requires_capability", # Models "DeviceStatus", "DeviceFeature", diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index 170126f..a059c06 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -5,7 +5,7 @@ """ import logging -from typing import Any, Self +from typing import Any, Self, cast import aiohttp @@ -70,15 +70,14 @@ def __init__( self.base_url = base_url.rstrip("/") self._auth_client = auth_client - self._auth_client = auth_client - _session = session or auth_client._session - if _session is None: - raise ValueError("auth_client must have an active session") - self._session = _session - self._owned_session = ( - False # Never own session when auth_client is provided - ) - self._owned_auth = False # Never own auth_client + self._session = session or getattr(auth_client, "_session", None) + if self._session is None: + raise ValueError( + "auth_client must have an active session or a session " + "must be provided" + ) + self._owned_session = False + self._owned_auth = False async def __aenter__(self) -> Self: """Enter async context manager.""" @@ -92,8 +91,8 @@ async def _make_request( self, method: str, endpoint: str, - json_data: dict[str, Any | None] = None, - params: dict[str, Any | None] = None, + json_data: dict[str, Any | None] | None = None, + params: dict[str, Any | None] | None = None, retry_on_auth_failure: bool = True, ) -> dict[str, Any]: """ @@ -129,10 +128,31 @@ async def _make_request( _logger.debug(f"{method} {url}") + # Filter out None values from params/json_data for aiohttp + # compatibility + clean_params: dict[str, Any] | None = None + clean_json_data: dict[str, Any] | None = None + + if params: + clean_params = {k: v for k, v in params.items() if v is not None} + if json_data: + clean_json_data = { + k: v for k, v in json_data.items() if v is not None + } + try: - async with self._session.request( - method, url, headers=headers, json=json_data, params=params + _logger.debug(f"Starting {method} request to {url}") + session = cast(aiohttp.ClientSession, self._session) + async with session.request( + method, + url, + headers=headers, + json=clean_json_data, + params=clean_params, ) as response: + _logger.debug( + f"Response received from {url}: {response.status}" + ) response_data: dict[str, Any] = await response.json() # Check for API errors @@ -143,7 +163,7 @@ async def _make_request( # If we get a 401 and haven't retried yet, try refreshing # token if code == 401 and retry_on_auth_failure: - _logger.warning( + _logger.info( "Received 401 Unauthorized. " "Attempting to refresh token..." ) diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index 8ce0774..be70247 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -245,15 +245,14 @@ class NavienAuthClient: >>> async with NavienAuthClient(user_id="user@example.com", password="password") as client: ... print(f"Welcome {client.current_user.full_name}") - ... print(f"Access token: {client.current_tokens.access_token}") + ... # Token is securely stored and not printed in production ... ... # Use the token in API requests ... headers = client.get_auth_headers() ... ... # Refresh when needed ... if client.current_tokens.is_expired: - ... new_tokens = await - client.refresh_token(client.current_tokens.refresh_token) + ... await client.refresh_token() Restore session from stored tokens: >>> stored_tokens = AuthTokens.from_dict(saved_data) @@ -263,7 +262,7 @@ class NavienAuthClient: ... stored_tokens=stored_tokens ... ) as client: ... # Authentication skipped if tokens are still valid - ... print(f"Access token: {client.current_tokens.access_token}") + ... print(f"Welcome {client.current_user.full_name}") """ def __init__( @@ -443,19 +442,27 @@ async def sign_in( f"Invalid response format: {str(e)}" ) from e - async def refresh_token(self, refresh_token: str) -> AuthTokens: + async def refresh_token( + self, refresh_token: str | None = None + ) -> AuthTokens: """ Refresh access token using refresh token. Args: - refresh_token: The refresh token obtained from sign-in + refresh_token: The refresh token obtained from sign-in. + If not provided, uses the stored refresh token. Returns: New AuthTokens with refreshed access token Raises: - TokenRefreshError: If token refresh fails + TokenRefreshError: If token refresh fails or no token available """ + if refresh_token is None: + if self._auth_response and self._auth_response.tokens.refresh_token: + refresh_token = self._auth_response.tokens.refresh_token + else: + raise TokenRefreshError("No refresh token available") await self._ensure_session() if self._session is None: @@ -690,7 +697,9 @@ async def authenticate(user_id: str, password: str) -> AuthenticationResponse: Example: >>> response = await authenticate("user@example.com", "password") - >>> print(response.tokens.bearer_token) + >>> print(f"Welcome {response.user.full_name}") + >>> # Use the bearer token for API requests + >>> # Do not print tokens in production code """ async with NavienAuthClient(user_id, password) as client: if client._auth_response is None: diff --git a/src/nwp500/cli/__init__.py b/src/nwp500/cli/__init__.py index 10d4bc8..59f23fa 100644 --- a/src/nwp500/cli/__init__.py +++ b/src/nwp500/cli/__init__.py @@ -2,7 +2,6 @@ from .__main__ import run from .commands import ( - handle_device_feature_request, handle_device_info_request, handle_get_controller_serial_request, handle_get_energy_request, @@ -12,7 +11,6 @@ handle_set_dhw_temp_request, handle_set_mode_request, handle_set_tou_enabled_request, - handle_status_raw_request, handle_status_request, handle_update_reservations_request, ) @@ -28,7 +26,6 @@ # Main entry point "run", # Command handlers - "handle_device_feature_request", "handle_device_info_request", "handle_get_controller_serial_request", "handle_get_energy_request", @@ -39,7 +36,6 @@ "handle_set_dhw_temp_request", "handle_set_mode_request", "handle_set_tou_enabled_request", - "handle_status_raw_request", "handle_status_request", "handle_update_reservations_request", # Output formatters diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 376c47f..6db5be5 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -1,8 +1,4 @@ -"""Navien Water Heater Control Script - Main Entry Point. - -This module provides the command-line interface to monitor and control -Navien water heaters using the nwp500-python library. -""" +"""Navien Water Heater Control Script - Main Entry Point.""" import argparse import asyncio @@ -10,7 +6,12 @@ import os import sys -from nwp500 import NavienAPIClient, NavienAuthClient, __version__ +from nwp500 import ( + NavienAPIClient, + NavienAuthClient, + NavienMqttClient, + __version__, +) from nwp500.exceptions import ( AuthenticationError, InvalidCredentialsError, @@ -18,460 +19,270 @@ MqttError, MqttNotConnectedError, Nwp500Error, - RangeValidationError, TokenRefreshError, ValidationError, ) +from . import commands as cmds +from .commands import ( + handle_configure_reservation_water_program_request as handle_water_prog, +) from .commands import ( - handle_device_feature_request, - handle_device_info_request, - handle_get_controller_serial_request, - handle_get_energy_request, - handle_get_reservations_request, - handle_get_tou_request, - handle_power_request, - handle_set_dhw_temp_request, - handle_set_mode_request, - handle_set_tou_enabled_request, - handle_status_raw_request, - handle_status_request, - handle_update_reservations_request, + handle_trigger_recirculation_hot_button_request as handle_hot_btn, ) from .monitoring import handle_monitoring from .token_storage import load_tokens, save_tokens -__author__ = "Emmanuel Levijarvi" -__copyright__ = "Emmanuel Levijarvi" -__license__ = "MIT" - _logger = logging.getLogger(__name__) async def async_main(args: argparse.Namespace) -> int: - """ - Asynchronous main function. - - Args: - args: Parsed command-line arguments - - Returns: - Exit code (0 for success, 1 for failure) - """ - # Get credentials + """Asynchronous main function.""" email = args.email or os.getenv("NAVIEN_EMAIL") password = args.password or os.getenv("NAVIEN_PASSWORD") - - # Try loading cached tokens tokens, cached_email = load_tokens() - - # Use cached email if available, otherwise fall back to provided email email = cached_email or email if not email or not password: _logger.error( - "Credentials not found. Please provide --email and --password, " - "or set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables." + "Credentials missing. Use --email/--password or env vars." ) return 1 try: - # Use async with to properly manage auth client lifecycle async with NavienAuthClient( email, password, stored_tokens=tokens - ) as auth_client: - # Save refreshed/new tokens after authentication - if auth_client.current_tokens and auth_client.user_email: - save_tokens(auth_client.current_tokens, auth_client.user_email) - - api_client = NavienAPIClient(auth_client=auth_client) - _logger.info("Fetching device information...") - device = await api_client.get_first_device() - - # Save tokens if they were refreshed during API call - if auth_client.current_tokens and auth_client.user_email: - save_tokens(auth_client.current_tokens, auth_client.user_email) + ) 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 for this account.") + _logger.error("No devices found.") return 1 - _logger.info(f"Found device: {device.device_info.device_name}") - - from nwp500 import NavienMqttClient + _logger.info(f"Using device: {device.device_info.device_name}") - mqtt = NavienMqttClient(auth_client) + mqtt = NavienMqttClient(auth) + await mqtt.connect() try: - await mqtt.connect() - _logger.info("MQTT client connected.") - - # Route to appropriate handler based on arguments - if args.device_info: - await handle_device_info_request(mqtt, device) - elif args.device_feature: - await handle_device_feature_request(mqtt, device) - elif args.get_controller_serial: - await handle_get_controller_serial_request(mqtt, device) - elif args.power_on: - await handle_power_request(mqtt, device, power_on=True) - if args.status: - _logger.info("Getting updated status after power on...") - await asyncio.sleep(2) - await handle_status_request(mqtt, device) - elif args.power_off: - await handle_power_request(mqtt, device, power_on=False) - if args.status: - _logger.info( - "Getting updated status after power off..." - ) - await asyncio.sleep(2) - await handle_status_request(mqtt, device) - elif args.set_mode: - await handle_set_mode_request(mqtt, device, args.set_mode) - if args.status: - _logger.info( - "Getting updated status after mode change..." - ) - await asyncio.sleep(2) - await handle_status_request(mqtt, device) - elif args.set_dhw_temp: - await handle_set_dhw_temp_request( - mqtt, device, args.set_dhw_temp + # Command Dispatching + cmd = args.command + if cmd == "info": + await cmds.handle_device_info_request( + mqtt, device, args.raw ) - if args.status: - _logger.info( - "Getting updated status after temperature change..." - ) - await asyncio.sleep(2) - await handle_status_request(mqtt, device) - elif args.get_reservations: - await handle_get_reservations_request(mqtt, device) - elif args.set_reservations: - await handle_update_reservations_request( - mqtt, - device, - args.set_reservations, - args.reservations_enabled, + 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 args.get_tou: - await handle_get_tou_request(mqtt, device, api_client) - elif args.set_tou_enabled: - enabled = args.set_tou_enabled.lower() == "on" - await handle_set_tou_enabled_request(mqtt, device, enabled) - if args.status: - _logger.info( - "Getting updated status after TOU change..." - ) - await asyncio.sleep(2) - await handle_status_request(mqtt, device) - elif args.get_energy: - if not args.energy_year or not args.energy_months: - _logger.error( - "--energy-year and --energy-months are required " - "for --get-energy" + 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 ) - return 1 - try: - months = [ - int(m.strip()) - for m in args.energy_months.split(",") - ] - if not all(1 <= m <= 12 for m in months): - _logger.error("Months must be between 1 and 12") - return 1 - except ValueError: - _logger.error( - "Invalid month format. Use comma-separated " - "numbers (e.g., '9' or '8,9,10')" + 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" ) - return 1 - await handle_get_energy_request( - mqtt, device, args.energy_year, months + elif cmd == "energy": + months = [int(m.strip()) for m in args.months.split(",")] + await cmds.handle_get_energy_request( + mqtt, device, args.year, months ) - elif args.status_raw: - await handle_status_raw_request(mqtt, device) - elif args.status: - await handle_status_request(mqtt, device) - else: # Default to monitor + 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) - except asyncio.CancelledError: - _logger.info("Monitoring stopped by user.") finally: - _logger.info("Disconnecting MQTT client...") await mqtt.disconnect() - - _logger.info("Cleanup complete.") return 0 - except InvalidCredentialsError: - _logger.error("Invalid email or password.") - return 1 - except TokenRefreshError as e: - _logger.error(f"Token refresh failed: {e}") - _logger.info("Try logging in again with fresh credentials.") - return 1 - except AuthenticationError as e: - _logger.error(f"Authentication failed: {e}") - return 1 - except MqttNotConnectedError: - _logger.error("MQTT connection not established.") - _logger.info( - "The device may be offline or network connectivity issues exist." - ) - return 1 - except MqttConnectionError as e: - _logger.error(f"MQTT connection error: {e}") - _logger.info("Check network connectivity and try again.") - return 1 - except MqttError as e: + except ( + InvalidCredentialsError, + AuthenticationError, + TokenRefreshError, + ) as e: + _logger.error(f"Auth failed: {e}") + except (MqttNotConnectedError, MqttConnectionError, MqttError) as e: _logger.error(f"MQTT error: {e}") - return 1 except ValidationError as e: - _logger.error(f"Invalid input: {e}") - # RangeValidationError has min_value/max_value attributes - if isinstance(e, RangeValidationError): - _logger.info( - f"Valid range for {e.field}: {e.min_value} to {e.max_value}" - ) - return 1 - except asyncio.CancelledError: - _logger.info("Operation cancelled by user.") - return 1 + _logger.error(f"Validation error: {e}") except Nwp500Error as e: _logger.error(f"Library error: {e}") - if hasattr(e, "retriable") and e.retriable: - _logger.info("This operation may be retried.") - return 1 except Exception as e: - _logger.error(f"An unexpected error occurred: {e}", exc_info=True) - return 1 + _logger.error(f"Unexpected error: {e}", exc_info=True) + return 1 def parse_args(args: list[str]) -> argparse.Namespace: - """Parse command line parameters.""" - parser = argparse.ArgumentParser( - description="Navien Water Heater Control Script" - ) - parser.add_argument( - "--version", - action="version", - version=f"nwp500-python {__version__}", - ) + parser = argparse.ArgumentParser(description="Navien NWP500 CLI") parser.add_argument( - "--email", - type=str, - help="Navien account email. Overrides NAVIEN_EMAIL env var.", + "--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( - "--password", - type=str, - help="Navien account password. Overrides NAVIEN_PASSWORD env var.", - ) - - # Status check (can be combined with other actions) - parser.add_argument( - "--status", - action="store_true", - help="Fetch and print the current device status. " - "Can be combined with control commands.", + "-v", + "--verbose", + dest="loglevel", + action="store_const", + const=logging.INFO, ) parser.add_argument( - "--status-raw", - action="store_true", - help="Fetch and print the raw device status as received from MQTT " - "(no conversions applied).", + "-vv", + "--very-verbose", + dest="loglevel", + action="store_const", + const=logging.DEBUG, ) - # Primary action modes (mutually exclusive) - group = parser.add_mutually_exclusive_group() - group.add_argument( - "--device-info", - action="store_true", - help="Fetch and print comprehensive device information via MQTT, " - "then exit.", - ) - group.add_argument( - "--device-feature", - action="store_true", - help="Fetch and print device feature and capability information " - "via MQTT, then exit.", + 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)" ) - group.add_argument( - "--get-controller-serial", - action="store_true", - help="Fetch and print controller serial number via MQTT, then exit. " - "This is useful for TOU commands that require the serial number.", + subparsers.add_parser( + "reset-filter", help="Reset air filter maintenance timer" ) - group.add_argument( - "--set-mode", - type=str, - metavar="MODE", - help="Set operation mode and display response. " - "Options: heat-pump, electric, energy-saver, high-demand, " - "vacation, standby", + subparsers.add_parser( + "water-program", help="Enable water program reservation scheduling mode" ) - group.add_argument( - "--set-dhw-temp", - type=float, - metavar="TEMP", - help="Set DHW (Domestic Hot Water) target temperature in Fahrenheit " - "(95-150°F) and display response.", - ) - group.add_argument( - "--power-on", - action="store_true", - help="Turn the device on and display response.", - ) - group.add_argument( - "--power-off", - action="store_true", - help="Turn the device off and display response.", - ) - group.add_argument( - "--get-reservations", - action="store_true", - help="Fetch and print current reservation schedule from device " - "via MQTT, then exit.", - ) - group.add_argument( - "--set-reservations", - type=str, - metavar="JSON", - help="Update reservation schedule with JSON array of reservation " - "objects. Use --reservations-enabled to control if schedule is " - "active.", - ) - group.add_argument( - "--get-tou", - action="store_true", - help="Fetch and print Time-of-Use settings from the REST API, " - "then exit. Controller serial number is automatically retrieved.", + + # Command with args + subparsers.add_parser("power", help="Turn device on or off").add_argument( + "state", choices=["on", "off"] ) - group.add_argument( - "--set-tou-enabled", - type=str, - choices=["on", "off"], - metavar="ON|OFF", - help="Enable or disable Time-of-Use functionality. Options: on, off", + subparsers.add_parser("mode", help="Set operation mode").add_argument( + "name", + help="Mode name", + choices=[ + "standby", + "heat-pump", + "electric", + "energy-saver", + "high-demand", + "vacation", + ], ) - group.add_argument( - "--get-energy", - action="store_true", - help="Request energy usage data for specified year and months " - "via MQTT, then exit. Requires --energy-year and --energy-months " - "options.", + 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", ) - group.add_argument( - "--monitor", - action="store_true", - default=True, # Default action - help="Run indefinitely, polling for status every 30 seconds and " - "logging to a CSV file. (default)", + 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") - # Additional options for new commands - parser.add_argument( - "--reservations-enabled", - action="store_true", - default=True, - help="When used with --set-reservations, enable the reservation " - "schedule. (default: True)", - ) - parser.add_argument( - "--tou-serial", - type=str, - help="(Deprecated) Controller serial number. No longer required; " - "serial number is now retrieved automatically.", + tou = subparsers.add_parser( + "tou", help="Configure time-of-use pricing schedule" ) - parser.add_argument( - "--energy-year", - type=int, - help="Year for energy usage query (e.g., 2025). " - "Required with --get-energy.", - ) - parser.add_argument( - "--energy-months", - type=str, - help="Comma-separated list of months (1-12) for energy usage " - "query (e.g., '9' or '8,9,10'). Required with --get-energy.", + 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" ) - parser.add_argument( - "-o", - "--output", - type=str, - default="nwp500_status.csv", - help="Output CSV file name for monitoring. " - "(default: nwp500_status.csv)", + energy.add_argument("--year", type=int, required=True) + energy.add_argument( + "--months", required=True, help="Comma-separated months" ) - # Logging - parser.add_argument( - "-v", - "--verbose", - dest="loglevel", - help="Set loglevel to INFO", - action="store_const", - const=logging.INFO, + dr = subparsers.add_parser( + "dr", help="Enable or disable utility demand response" ) - parser.add_argument( - "-vv", - "--very-verbose", - dest="loglevel", - help="Set loglevel to DEBUG", - action="store_const", - const=logging.DEBUG, + dr.add_argument("action", choices=["enable", "disable"]) + + monitor = subparsers.add_parser( + "monitor", help="Monitor device status in real-time (logs to CSV)" ) - return parser.parse_args(args) + monitor.add_argument("-o", "--output", default="nwp500_status.csv") + return parser.parse_args(args) -def setup_logging(loglevel: int) -> None: - """Configure basic logging for the application. - Args: - loglevel: Logging level (e.g., logging.DEBUG, logging.INFO) - """ - logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" +def main(args_list: list[str]) -> None: + args = parse_args(args_list) logging.basicConfig( - level=loglevel or logging.WARNING, + level=logging.WARNING, stream=sys.stdout, - format=logformat, - datefmt="%Y-%m-%d %H:%M:%S", + format="[%(asctime)s] %(levelname)s:%(name)s:%(message)s", ) - - -def main(args_list: list[str]) -> None: - """Run the asynchronous main function with argument parsing. - - Args: - args_list: Command-line arguments to parse - """ - args = parse_args(args_list) - - # Validate that --status and --status-raw are not used together - if args.status and args.status_raw: - print( - "Error: --status and --status-raw cannot be used together.", - file=sys.stderr, - ) - return - - # Set default log level for libraries - setup_logging(logging.WARNING) - # Set user-defined log level for this script _logger.setLevel(args.loglevel or logging.INFO) - # aiohttp is very noisy at INFO level logging.getLogger("aiohttp").setLevel(logging.WARNING) - try: - result = asyncio.run(async_main(args)) - sys.exit(result) + sys.exit(asyncio.run(async_main(args))) except KeyboardInterrupt: - _logger.info("Script interrupted by user.") + _logger.info("Interrupted.") def run() -> None: - """Entry point for the CLI application.""" main(sys.argv[1:]) diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py index c82e321..8f05577 100644 --- a/src/nwp500/cli/commands.py +++ b/src/nwp500/cli/commands.py @@ -3,462 +3,308 @@ import asyncio import json import logging -from typing import Any - -from nwp500 import Device, DeviceFeature, DeviceStatus, NavienMqttClient -from nwp500.exceptions import MqttError, Nwp500Error, ValidationError - -from .output_formatters import _json_default_serializer +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 get_controller_serial_number( - mqtt: NavienMqttClient, device: Device, timeout: float = 10.0 -) -> str | None: - """Retrieve controller serial number from device. - - Args: - mqtt: MQTT client instance - device: Device object - timeout: Timeout in seconds - - Returns: - Controller serial number or None if timeout/error - """ - future: asyncio.Future[str] = asyncio.get_running_loop().create_future() - - def on_feature(feature: DeviceFeature) -> None: - if not future.done(): - future.set_result(feature.controller_serial_number) - - await mqtt.subscribe_device_feature(device, on_feature) - _logger.info("Requesting controller serial number...") - await mqtt.request_device_info(device) - - try: - serial_number = await asyncio.wait_for(future, timeout=timeout) - _logger.info(f"Controller serial number retrieved: {serial_number}") - return serial_number - except TimeoutError: - _logger.error("Timed out waiting for controller serial number.") - return None - -async def handle_status_request(mqtt: NavienMqttClient, device: Device) -> None: - """Request device status once and print it.""" +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 on_status(status: DeviceStatus) -> None: + def callback(res: Any) -> None: if not future.done(): - from .output_formatters import format_json_output + future.set_result(res) - print(format_json_output(status.model_dump())) - future.set_result(None) - - await mqtt.subscribe_device_status(device, on_status) - _logger.info("Requesting device status...") - await mqtt.request_device_status(device) + await subscribe_func(device, callback) + _logger.info(f"Requesting {action_name}...") + await action_func() try: - await asyncio.wait_for(future, timeout=10) + return await asyncio.wait_for(future, timeout=timeout) except TimeoutError: - _logger.error("Timed out waiting for device status response.") + _logger.error(f"Timed out waiting for {action_name} response.") + raise -async def handle_status_raw_request( - mqtt: NavienMqttClient, device: Device -) -> None: - """Request device status once and print raw MQTT data (no conversions).""" - future = asyncio.get_running_loop().create_future() +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 - # Subscribe to the raw MQTT topic to capture data before conversion - def raw_callback(topic: str, message: dict[str, Any]) -> None: - if not future.done(): - # Extract and print the raw status portion - if "response" in message and "status" in message["response"]: - print( - json.dumps( - message["response"]["status"], - indent=2, - default=_json_default_serializer, - ) - ) - future.set_result(None) - elif "status" in message: - print( - json.dumps( - message["status"], - indent=2, - default=_json_default_serializer, - ) - ) - future.set_result(None) - - # Subscribe to all device messages - await mqtt.subscribe_device(device, raw_callback) - - _logger.info("Requesting device status (raw)...") - await mqtt.request_device_status(device) +async def get_controller_serial_number( + mqtt: NavienMqttClient, device: Device, timeout: float = 10.0 +) -> str | None: + """Retrieve controller serial number from device.""" try: - await asyncio.wait_for(future, timeout=10) - except TimeoutError: - _logger.error("Timed out waiting for device status response.") + 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_device_info_request( +async def handle_get_controller_serial_request( mqtt: NavienMqttClient, device: Device ) -> None: - """ - Request comprehensive device information via MQTT and print it. - - This fetches detailed device information including firmware versions, - capabilities, temperature ranges, and feature availability - much more - comprehensive than basic API device data. - """ - future = asyncio.get_running_loop().create_future() - - def on_device_info(info: Any) -> None: - if not future.done(): - from .output_formatters import format_json_output - - print(format_json_output(info.model_dump())) - future.set_result(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.") - await mqtt.subscribe_device_feature(device, on_device_info) - _logger.info("Requesting device information...") - await mqtt.request_device_info(device) +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: - await asyncio.wait_for(future, timeout=10) - except TimeoutError: - _logger.error("Timed out waiting for device info response.") + 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_device_feature_request( - mqtt: NavienMqttClient, device: Device +async def handle_status_request( + mqtt: NavienMqttClient, device: Device, raw: bool = False ) -> None: - """Request device feature and capability information via MQTT. - - Alias for handle_device_info_request. Both fetch the same data. - """ - await handle_device_info_request(mqtt, device) + """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_get_controller_serial_request( - mqtt: NavienMqttClient, device: Device +async def handle_device_info_request( + mqtt: NavienMqttClient, device: Device, raw: bool = False ) -> None: - """Request and display just the controller serial number.""" - serial_number = await get_controller_serial_number(mqtt, device) - if serial_number: - print(serial_number) - else: - _logger.error("Failed to retrieve controller serial number.") + """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 and display the response. - - Args: - mqtt: MQTT client instance - device: Device to control - mode_name: Mode name (heat-pump, energy-saver, etc.) - """ - # Map mode names to mode IDs - # Based on MQTT client documentation in set_dhw_mode method: - # - 1: Heat Pump Only (most efficient, slowest recovery) - # - 2: Electric Only (least efficient, fastest recovery) - # - 3: Energy Saver (balanced, good default) - # - 4: High Demand (maximum heating capacity) + """Set device operation mode.""" mode_mapping = { "standby": 0, - "heat-pump": 1, # Heat Pump Only - "electric": 2, # Electric Only - "energy-saver": 3, # Energy Saver - "high-demand": 4, # High Demand + "heat-pump": 1, + "electric": 2, + "energy-saver": 3, + "high-demand": 4, "vacation": 5, } - - mode_name_lower = mode_name.lower() - if mode_name_lower not in mode_mapping: - valid_modes = ", ".join(mode_mapping.keys()) - _logger.error(f"Invalid mode '{mode_name}'. Valid modes: {valid_modes}") - return - - mode_id = mode_mapping[mode_name_lower] - - # Set up callback to capture status response after mode change - future = asyncio.get_running_loop().create_future() - responses = [] - - def on_status_response(status: DeviceStatus) -> None: - if not future.done(): - responses.append(status) - # Complete after receiving response - future.set_result(None) - - # Subscribe to status updates to see the mode change result - await mqtt.subscribe_device_status(device, on_status_response) - - try: - _logger.info( - f"Setting operation mode to '{mode_name}' (mode ID: {mode_id})..." + 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 - # Send the mode change command - await mqtt.set_dhw_mode(device, mode_id) - - # Wait for status response (mode change confirmation) - try: - await asyncio.wait_for(future, timeout=15) - - if responses: - status = responses[0] - from .output_formatters import format_json_output - - print(format_json_output(status.model_dump())) - _logger.info( - f"Mode change successful. New mode: " - f"{status.operation_mode.name}" - ) - else: - _logger.warning( - "Mode command sent but no status response received" - ) - - except TimeoutError: - _logger.error("Timed out waiting for mode change confirmation") - - except ValidationError as e: - _logger.error(f"Invalid mode or parameters: {e}") - if hasattr(e, "field"): - _logger.info(f"Check the value for: {e.field}") - except MqttError as e: - _logger.error(f"MQTT error setting mode: {e}") - except Nwp500Error as e: - _logger.error(f"Error setting mode: {e}") - except Exception as e: - _logger.error(f"Unexpected error setting mode: {e}") + 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 and display the response. - - Args: - mqtt: MQTT client instance - device: Device to control - temperature: Target temperature in Fahrenheit (95-150°F) - """ - # Validate temperature range - if temperature < 95 or temperature > 150: - _logger.error( - f"Temperature {temperature}°F is out of range. " - f"Valid range: 95-150°F" - ) - return - - # Set up callback to capture status response after temperature change - future = asyncio.get_running_loop().create_future() - responses = [] - - def on_status_response(status: DeviceStatus) -> None: - if not future.done(): - responses.append(status) - # Complete after receiving response - future.set_result(None) - - # Subscribe to status updates to see the temperature change result - await mqtt.subscribe_device_status(device, on_status_response) - - try: - _logger.info(f"Setting DHW target temperature to {temperature}°F...") - - # Send the temperature change command - await mqtt.set_dhw_temperature(device, temperature) - - # Wait for status response (temperature change confirmation) - try: - await asyncio.wait_for(future, timeout=15) - - if responses: - status = responses[0] - from .output_formatters import format_json_output - - print(format_json_output(status.model_dump())) - _logger.info( - f"Temperature change successful. New target: " - f"{status.dhw_target_temperature_setting}°F" - ) - else: - _logger.warning( - "Temperature command sent but no status response received" - ) - - except TimeoutError: - _logger.error( - "Timed out waiting for temperature change confirmation" - ) - - except ValidationError as e: - _logger.error(f"Invalid temperature: {e}") - if hasattr(e, "min_value") and hasattr(e, "max_value"): - _logger.info(f"Valid range: {e.min_value}°F to {e.max_value}°F") - except MqttError as e: - _logger.error(f"MQTT error setting temperature: {e}") - except Nwp500Error as e: - _logger.error(f"Error setting temperature: {e}") - except Exception as e: - _logger.error(f"Unexpected error setting temperature: {e}") + """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 and display the response. - - Args: - mqtt: MQTT client instance - device: Device to control - power_on: True to turn on, False to turn off - """ - action = "on" if power_on else "off" - _logger.info(f"Turning device {action}...") - - # Set up callback to capture status response after power change - future = asyncio.get_running_loop().create_future() - - def on_power_change_response(status: DeviceStatus) -> None: - if not future.done(): - future.set_result(status) - - try: - # Subscribe to status updates - await mqtt.subscribe_device_status(device, on_power_change_response) - - # Send power command - await mqtt.set_power(device, power_on) - - # Wait for response with timeout - status = await asyncio.wait_for(future, timeout=10.0) - - _logger.info(f"Device turned {action} successfully!") - - # Display relevant status information - print( - json.dumps( - { - "result": "success", - "action": action, - "status": { - "operationMode": status.operationMode.name, - "dhwOperationSetting": status.dhwOperationSetting.name, - "dhwTemperature": f"{status.dhwTemperature}°F", - "dhwChargePer": f"{status.dhwChargePer}%", - "tankUpperTemperature": ( - f"{status.tankUpperTemperature:.1f}°F" - ), - "tankLowerTemperature": ( - f"{status.tankLowerTemperature:.1f}°F" - ), - }, - }, - indent=2, - ) - ) - - except TimeoutError: - _logger.error(f"Timed out waiting for power {action} confirmation") - - except MqttError as e: - _logger.error(f"MQTT error turning device {action}: {e}") - except Nwp500Error as e: - _logger.error(f"Error turning device {action}: {e}") - except Exception as e: - _logger.error(f"Unexpected error turning device {action}: {e}") + """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 from the device.""" + """Request current reservation schedule.""" future = asyncio.get_running_loop().create_future() def raw_callback(topic: str, message: dict[str, Any]) -> None: - # Device responses have "response" field with actual data if not future.done() and "response" in message: - # Decode and format the reservation data for human readability from nwp500.encoding import ( decode_reservation_hex, decode_week_bitfield, ) response = message.get("response", {}) - reservation_use = response.get("reservationUse", 0) reservation_hex = response.get("reservation", "") + reservations = ( + decode_reservation_hex(reservation_hex) + if isinstance(reservation_hex, str) + else [] + ) - # Decode the hex string into structured entries - if isinstance(reservation_hex, str): - reservations = decode_reservation_hex(reservation_hex) - else: - # Already structured (shouldn't happen but handle it) - reservations = ( - reservation_hex if isinstance(reservation_hex, list) else [] - ) - - # Format for display output = { - "reservationUse": reservation_use, - "reservationEnabled": reservation_use == 1, - "reservations": [], + "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) + ], } - - for idx, entry in enumerate(reservations, start=1): - week_days = decode_week_bitfield(entry.get("week", 0)) - param_value = entry.get("param", 0) - # Temperature is encoded as (display - 20), so display = param + - # 20 - display_temp = param_value + 20 - - formatted_entry = { - "number": idx, - "enabled": entry.get("enable") == 1, - "days": week_days, - "time": ( - f"{entry.get('hour', 0):02d}:{entry.get('min', 0):02d}" - ), - "mode": entry.get("mode"), - "temperatureF": display_temp, - "raw": entry, - } - output["reservations"].append(formatted_entry) - - # Print formatted output - print( - json.dumps(output, indent=2, default=_json_default_serializer) - ) + print_json(output) future.set_result(None) - # Subscribe to all device-type messages to catch the response - # Responses come on various patterns depending on the command - device_type = device.device_info.device_type - response_pattern = f"cmd/{device_type}/#" - + 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) - _logger.info("Requesting current reservation schedule...") - await mqtt.request_reservations(device) - + await mqtt.control.request_reservations(device) try: await asyncio.wait_for(future, timeout=10) except TimeoutError: - _logger.error("Timed out waiting for reservation response.") + _logger.error("Timed out waiting for reservations.") async def handle_update_reservations_request( @@ -467,160 +313,190 @@ async def handle_update_reservations_request( reservations_json: str, enabled: bool, ) -> None: - """Update reservation schedule on the device.""" + """Update reservation schedule.""" try: reservations = json.loads(reservations_json) if not isinstance(reservations, list): - _logger.error("Reservations must be a JSON array.") - return - except json.JSONDecodeError as e: - _logger.error(f"Invalid JSON for reservations: {e}") + 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: - # Only process response messages, not request echoes if not future.done() and "response" in message: - print( - json.dumps(message, indent=2, default=_json_default_serializer) - ) + print_json(message) future.set_result(None) - # Subscribe to client-specific response topic pattern - # Responses come on: cmd/{deviceType}/+/+/{clientId}/res/rsv/rd device_type = device.device_info.device_type - client_id = mqtt.client_id - response_topic = f"cmd/{device_type}/+/+/{client_id}/res/rsv/rd" - + response_topic = f"cmd/{device_type}/+/+/{mqtt.client_id}/res/rsv/rd" await mqtt.subscribe(response_topic, raw_callback) - _logger.info(f"Updating reservation schedule (enabled={enabled})...") - await mqtt.update_reservations(device, reservations, enabled=enabled) - + await mqtt.control.update_reservations( + device, reservations, enabled=enabled + ) try: await asyncio.wait_for(future, timeout=10) except TimeoutError: - _logger.error("Timed out waiting for reservation update response.") + _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 the REST API.""" + """Request Time-of-Use settings from REST API.""" try: - # Get controller serial number via MQTT - controller_id = await get_controller_serial_number(mqtt, device) - if not controller_id: - _logger.error("Failed to retrieve controller serial number.") + serial = await get_controller_serial_number(mqtt, device) + if not serial: + _logger.error("Failed to get controller serial.") return - _logger.info(f"Controller ID: {controller_id}") - _logger.info("Fetching Time-of-Use settings from REST API...") - - # Get TOU info from REST API - mac_address = device.device_info.mac_address - additional_value = device.device_info.additional_value - tou_info = await api_client.get_tou_info( - mac_address=mac_address, - additional_value=additional_value, - controller_id=controller_id, + mac_address=device.device_info.mac_address, + additional_value=device.device_info.additional_value, + controller_id=serial, user_type="O", ) - - # Print the TOU info - print( - json.dumps( - { - "registerPath": tou_info.register_path, - "sourceType": tou_info.source_type, - "controllerId": tou_info.controller_id, - "manufactureId": tou_info.manufacture_id, - "name": tou_info.name, - "utility": tou_info.utility, - "zipCode": tou_info.zip_code, - "schedule": [ - { - "season": schedule.season, - "interval": schedule.intervals, - } - for schedule in tou_info.schedule - ], - }, - indent=2, - ) + 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 MqttError as e: - _logger.error(f"MQTT error fetching TOU settings: {e}") - except Nwp500Error as e: - _logger.error(f"Error fetching TOU settings: {e}") except Exception as e: - _logger.error( - f"Unexpected error fetching TOU settings: {e}", exc_info=True - ) + _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 functionality.""" - action = "enabling" if enabled else "disabling" - _logger.info(f"Time-of-Use {action}...") + """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'}", + ) - future = asyncio.get_running_loop().create_future() - responses = [] - def on_status_response(status: DeviceStatus) -> None: - if not future.done(): - responses.append(status) - future.set_result(None) +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}") - await mqtt.subscribe_device_status(device, on_status_response) - try: - await mqtt.set_tou_enabled(device, enabled) +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." + ) - try: - await asyncio.wait_for(future, timeout=10) - if responses: - status = responses[0] - from .output_formatters import format_json_output - print(format_json_output(status.model_dump())) - _logger.info(f"TOU {action} successful.") - else: - _logger.warning("TOU command sent but no response received") - except TimeoutError: - _logger.error(f"Timed out waiting for TOU {action} confirmation") - - except MqttError as e: - _logger.error(f"MQTT error {action} TOU: {e}") - except Nwp500Error as e: - _logger.error(f"Error {action} TOU: {e}") - except Exception as e: - _logger.error(f"Unexpected error {action} TOU: {e}") +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_get_energy_request( - mqtt: NavienMqttClient, device: Device, year: int, months: list[int] +async def handle_enable_demand_response_request( + mqtt: NavienMqttClient, device: Device ) -> None: - """Request energy usage data for specified months.""" - future = asyncio.get_running_loop().create_future() + """Enable demand response.""" + await _handle_command_with_status_feedback( + mqtt, + device, + lambda: mqtt.control.enable_demand_response(device), + "enabling DR", + "Demand response enabled", + ) - def raw_callback(topic: str, message: dict[str, Any]) -> None: - if not future.done(): - print( - json.dumps(message, indent=2, default=_json_default_serializer) - ) - future.set_result(None) - # Subscribe to energy usage response (uses default device topic) - await mqtt.subscribe_device(device, raw_callback) - _logger.info(f"Requesting energy usage for {year}, months: {months}...") - await mqtt.request_energy_usage(device, year, months) +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", + ) + - try: - await asyncio.wait_for(future, timeout=15) - except TimeoutError: - _logger.error("Timed out waiting for energy usage response.") +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/monitoring.py b/src/nwp500/cli/monitoring.py index 548e169..90cde4d 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -37,8 +37,10 @@ def on_status_update(status: DeviceStatus) -> None: write_status_to_csv(output_file, status) await mqtt.subscribe_device_status(device, on_status_update) - await mqtt.start_periodic_device_status_requests(device, period_seconds=30) - await mqtt.request_device_status(device) # Get an initial status right away + await mqtt.start_periodic_requests(device, period_seconds=30) + await mqtt.control.request_device_status( + device + ) # Get an initial status right away # Keep the script running indefinitely await asyncio.Event().wait() diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index d002ca3..61ebb75 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -3,6 +3,7 @@ import csv import json import logging +from calendar import month_name from datetime import datetime from enum import Enum from pathlib import Path @@ -13,6 +14,13 @@ _logger = logging.getLogger(__name__) +def _format_number(value: Any) -> str: + """Format number to one decimal place if float, otherwise return as-is.""" + if isinstance(value, float): + return f"{value:.1f}" + return str(value) + + def _json_default_serializer(obj: Any) -> Any: """Serialize objects not serializable by default json code. @@ -33,9 +41,92 @@ def _json_default_serializer(obj: Any) -> Any: return obj.isoformat() if isinstance(obj, Enum): return obj.name # Fallback for any enums not in model output + # Handle Pydantic models + if hasattr(obj, "model_dump"): + return obj.model_dump() raise TypeError(f"Type {type(obj)} not serializable") +def format_energy_usage(energy_response: Any) -> str: + """ + Format energy usage response as a human-readable table. + + Args: + energy_response: EnergyUsageResponse object + + Returns: + Formatted string with energy usage data in tabular form + """ + lines = [] + + # Add header + lines.append("=" * 90) + lines.append("ENERGY USAGE REPORT") + lines.append("=" * 90) + + # Total summary + total = energy_response.total + total_usage_wh = total.total_usage + total_time_hours = total.total_time + + lines.append("") + lines.append("TOTAL SUMMARY") + lines.append("-" * 90) + lines.append( + f"Total Energy Used: {total_usage_wh:,} Wh ({total_usage_wh / 1000:.2f} kWh)" # noqa: E501 + ) + lines.append( + f" Heat Pump: {total.heat_pump_usage:,} Wh ({total.heat_pump_percentage:.1f}%)" # noqa: E501 + ) + lines.append( + f" Heat Element: {total.heat_element_usage:,} Wh ({total.heat_element_percentage:.1f}%)" # noqa: E501 + ) + lines.append(f"Total Time Running: {total_time_hours} hours") + lines.append(f" Heat Pump: {total.heat_pump_time} hours") + lines.append(f" Heat Element: {total.heat_element_time} hours") + + # Monthly data + if energy_response.usage: + lines.append("") + lines.append("MONTHLY BREAKDOWN") + lines.append("-" * 90) + lines.append( + f"{'Month':<20} {'Energy (Wh)':<18} {'HP (Wh)':<15} {'HE (Wh)':<15} {'HP Time (h)':<15}" # noqa: E501 + ) + lines.append("-" * 90) + + 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_time = sum(d.heat_pump_time for d in month_data.data) + + lines.append( + f"{month_name_str:<20} {total_wh:>16,} {hp_wh:>13,} {he_wh:>13,} {hp_time:>13}" # noqa: E501 + ) + + lines.append("=" * 90) + return "\n".join(lines) + + +def print_energy_usage(energy_response: Any) -> None: + """ + Print energy usage data in human-readable tabular format. + + Args: + energy_response: EnergyUsageResponse object + """ + print(format_energy_usage(energy_response)) + + def write_status_to_csv(file_path: str, status: DeviceStatus) -> None: """ Append device status to a CSV file. @@ -94,3 +185,680 @@ def print_json(data: Any, indent: int = 2) -> None: indent: Number of spaces for indentation (default: 2) """ print(format_json_output(data, indent)) + + +def print_device_status(device_status: Any) -> None: + """ + Print device status with aligned columns and dynamic width calculation. + + Args: + device_status: DeviceStatus object + """ + # Collect all items with their categories + all_items = [] + + # Operation Status + if hasattr(device_status, "operation_mode"): + mode = getattr( + device_status.operation_mode, "name", device_status.operation_mode + ) + all_items.append(("OPERATION STATUS", "Mode", mode)) + if hasattr(device_status, "operation_busy"): + all_items.append( + ( + "OPERATION STATUS", + "Busy", + "Yes" if device_status.operation_busy else "No", + ) + ) + if hasattr(device_status, "current_statenum"): + 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", + ) + ) + + # 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), + ) + ) + + # 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", + ) + ) + + # 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), + ) + ) + + # 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", + ) + ) + + # 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", + ) + ) + + # Power & Energy + if hasattr(device_status, "wh_total_power_consumption"): + all_items.append( + ( + "POWER & ENERGY", + "Total Consumption", + f"{_format_number(device_status.wh_total_power_consumption)}Wh", + ) + ) + if hasattr(device_status, "wh_heat_pump_power"): + all_items.append( + ( + "POWER & ENERGY", + "Heat Pump Power", + f"{_format_number(device_status.wh_heat_pump_power)}Wh", + ) + ) + if hasattr(device_status, "wh_electric_heater_power"): + all_items.append( + ( + "POWER & ENERGY", + "Electric Heater Power", + 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), + ) + ) + + # 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)) + 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, + ) + ) + + # Compressor & Valve + if hasattr(device_status, "mixing_rate"): + mixing = _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) + 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), + ) + ) + + # Recirculation + if hasattr(device_status, "recirc_operation_mode"): + all_items.append( + ( + "RECIRCULATION", + "Operation Mode", + device_status.recirc_operation_mode, + ) + ) + if hasattr(device_status, "recirc_pump_operation_status"): + all_items.append( + ( + "RECIRCULATION", + "Pump Status", + 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", + ) + ) + + # Status & Alerts + if hasattr(device_status, "error_code"): + all_items.append( + ("STATUS & ALERTS", "Error Code", device_status.error_code) + ) + if hasattr(device_status, "sub_error_code"): + all_items.append( + ("STATUS & ALERTS", "Sub Error Code", device_status.sub_error_code) + ) + if hasattr(device_status, "fault_status1"): + all_items.append( + ("STATUS & ALERTS", "Fault Status 1", device_status.fault_status1) + ) + if hasattr(device_status, "fault_status2"): + all_items.append( + ("STATUS & ALERTS", "Fault Status 2", device_status.fault_status2) + ) + if hasattr(device_status, "error_buzzer_use"): + all_items.append( + ( + "STATUS & ALERTS", + "Error Buzzer", + "Yes" if device_status.error_buzzer_use else "No", + ) + ) + + # 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, + ) + ) + + # 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", + ) + ) + + # 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)) + + # Demand Response & TOU + if hasattr(device_status, "dr_event_status"): + all_items.append( + ( + "DEMAND RESPONSE & TOU", + "DR Event Status", + 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, + ) + ) + if hasattr(device_status, "tou_status"): + all_items.append( + ("DEMAND RESPONSE & TOU", "TOU Status", device_status.tou_status) + ) + if hasattr(device_status, "tou_override_status"): + all_items.append( + ( + "DEMAND RESPONSE & TOU", + "TOU Override Status", + device_status.tou_override_status, + ) + ) + + # Anti-Legionella + if hasattr(device_status, "anti_legionella_period"): + all_items.append( + ( + "ANTI-LEGIONELLA", + "Period", + f"{device_status.anti_legionella_period}h", + ) + ) + if hasattr(device_status, "anti_legionella_operation_busy"): + all_items.append( + ( + "ANTI-LEGIONELLA", + "Operation Busy", + "Yes" if device_status.anti_legionella_operation_busy else "No", + ) + ) + + # Calculate widths dynamically + max_label_len = max((len(label) for _, label, _ in all_items), default=20) + 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 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) + + +def print_device_info(device_feature: Any) -> None: + """ + Print device information with aligned columns and dynamic width calculation. + + Args: + device_feature: DeviceFeature object + """ + # Serialize to dict to get enum names from model_dump() + if hasattr(device_feature, "model_dump"): + device_dict = device_feature.model_dump() + else: + device_dict = device_feature + + # Collect all items with their categories + all_items = [] + + # Device Identity + if "controller_serial_number" in device_dict: + all_items.append( + ( + "DEVICE IDENTITY", + "Serial Number", + device_dict["controller_serial_number"], + ) + ) + if "country_code" in device_dict: + all_items.append( + ("DEVICE IDENTITY", "Country Code", device_dict["country_code"]) + ) + if "model_type_code" in device_dict: + all_items.append( + ("DEVICE IDENTITY", "Model Type", device_dict["model_type_code"]) + ) + if "control_type_code" in device_dict: + all_items.append( + ( + "DEVICE IDENTITY", + "Control Type", + device_dict["control_type_code"], + ) + ) + if "volume_code" in device_dict: + all_items.append( + ("DEVICE IDENTITY", "Volume Code", device_dict["volume_code"]) + ) + + # Firmware Versions + if "controller_sw_version" in device_dict: + all_items.append( + ( + "FIRMWARE VERSIONS", + "Controller Version", + f"v{device_dict['controller_sw_version']}", + ) + ) + if "controller_sw_code" in device_dict: + all_items.append( + ( + "FIRMWARE VERSIONS", + "Controller Code", + device_dict["controller_sw_code"], + ) + ) + if "panel_sw_version" in device_dict: + all_items.append( + ( + "FIRMWARE VERSIONS", + "Panel Version", + f"v{device_dict['panel_sw_version']}", + ) + ) + if "panel_sw_code" in device_dict: + all_items.append( + ("FIRMWARE VERSIONS", "Panel Code", device_dict["panel_sw_code"]) + ) + if "wifi_sw_version" in device_dict: + all_items.append( + ( + "FIRMWARE VERSIONS", + "WiFi Version", + f"v{device_dict['wifi_sw_version']}", + ) + ) + if "wifi_sw_code" in device_dict: + all_items.append( + ("FIRMWARE VERSIONS", "WiFi Code", device_dict["wifi_sw_code"]) + ) + if ( + hasattr(device_feature, "recirc_sw_version") + and device_dict["recirc_sw_version"] > 0 + ): + all_items.append( + ( + "FIRMWARE VERSIONS", + "Recirculation Version", + f"v{device_dict['recirc_sw_version']}", + ) + ) + if "recirc_model_type_code" in device_dict: + all_items.append( + ( + "FIRMWARE VERSIONS", + "Recirculation Model", + device_dict["recirc_model_type_code"], + ) + ) + + # Configuration + if "temperature_type" in device_dict: + temp_type = getattr( + device_dict["temperature_type"], + "name", + device_dict["temperature_type"], + ) + all_items.append(("CONFIGURATION", "Temperature Unit", temp_type)) + if "temp_formula_type" in device_dict: + all_items.append( + ( + "CONFIGURATION", + "Temperature Formula", + device_dict["temp_formula_type"], + ) + ) + if "dhw_temperature_min" in device_dict: + all_items.append( + ( + "CONFIGURATION", + "DHW Temp Range", + f"{device_dict['dhw_temperature_min']}°F - {device_dict['dhw_temperature_max']}°F", # noqa: E501 + ) + ) + if "freeze_protection_temp_min" in device_dict: + all_items.append( + ( + "CONFIGURATION", + "Freeze Protection Range", + f"{device_dict['freeze_protection_temp_min']}°F - {device_dict['freeze_protection_temp_max']}°F", # noqa: E501 + ) + ) + if "recirc_temperature_min" in device_dict: + all_items.append( + ( + "CONFIGURATION", + "Recirculation Temp Range", + f"{device_dict['recirc_temperature_min']}°F - {device_dict['recirc_temperature_max']}°F", # noqa: E501 + ) + ) + + # Supported Features + features_list = [ + ("Power Control", "power_use"), + ("DHW Control", "dhw_use"), + ("Heat Pump Mode", "heatpump_use"), + ("Electric Mode", "electric_use"), + ("Energy Saver", "energy_saver_use"), + ("High Demand", "high_demand_use"), + ("Eco Mode", "eco_use"), + ("Holiday Mode", "holiday_use"), + ("Program Reservation", "program_reservation_use"), + ("Recirculation", "recirculation_use"), + ("Recirculation Reservation", "recirc_reservation_use"), + ("Smart Diagnostic", "smart_diagnostic_use"), + ("WiFi RSSI", "wifi_rssi_use"), + ("Energy Usage", "energy_usage_use"), + ("Freeze Protection", "freeze_protection_use"), + ("Mixing Valve", "mixing_valve_use"), + ("DR Settings", "dr_setting_use"), + ("Anti-Legionella", "anti_legionella_setting_use"), + ("HPWH", "hpwh_use"), + ("DHW Refill", "dhw_refill_use"), + ("Title 24", "title24_use"), + ] + + for label, attr in features_list: + if hasattr(device_feature, attr): + value = getattr(device_feature, attr) + status = "Yes" if value else "No" + all_items.append(("SUPPORTED FEATURES", label, status)) + + # Calculate widths dynamically + max_label_len = max((len(label) for _, label, _ in all_items), default=20) + 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) diff --git a/src/nwp500/command_decorators.py b/src/nwp500/command_decorators.py new file mode 100644 index 0000000..f92fcdc --- /dev/null +++ b/src/nwp500/command_decorators.py @@ -0,0 +1,132 @@ +"""Decorators for device command validation and capability checking. + +This module provides decorators that automatically validate device capabilities +before command execution, preventing unsupported commands from being sent. +""" + +import functools +import inspect +import logging +from collections.abc import Callable +from typing import Any, TypeVar + +from .device_capabilities import DeviceCapabilityChecker +from .exceptions import DeviceCapabilityError + +__author__ = "Emmanuel Levijarvi" + +_logger = logging.getLogger(__name__) + +# Type variable for async functions +F = TypeVar("F", bound=Callable[..., Any]) + + +def requires_capability(feature: str) -> Callable[[F], F]: + """Decorator that validates device capability before executing command. + + This decorator automatically checks if a device supports a specific + controllable feature before allowing the command to execute. If the + device doesn't support the feature, a DeviceCapabilityError is raised. + + The decorator automatically caches device info on first call using + _get_device_features(), which internally calls ensure_device_info_cached(). + This means capability validation is transparent to the caller - no manual + caching is required. + + The decorator expects the command method to: + 1. Have 'self' (controller instance with _device_info_cache) + 2. Have 'device' parameter (Device object with mac_address) + + Args: + feature: Name of the required capability (e.g., "recirculation_mode") + + Returns: + Decorator function + + Raises: + DeviceCapabilityError: If device doesn't support the feature + ValueError: If feature name is not recognized + + Example: + >>> class MyController: + ... def __init__(self, cache): + ... self._device_info_cache = cache + ... + ... @requires_capability("recirculation_mode") + ... async def set_recirculation_mode(self, device, mode): + ... # Device info automatically cached on first call + ... # Capability automatically validated before execution + ... return await self._publish(...) + """ + + def decorator(func: F) -> F: + # Determine if this is an async function + is_async = inspect.iscoroutinefunction(func) + + if is_async: + + @functools.wraps(func) + async def async_wrapper( + self: Any, device: Any, *args: Any, **kwargs: Any + ) -> Any: + # Get cached features, auto-requesting if necessary + _logger.info( + f"Checking capability '{feature}' for {func.__name__}" + ) + try: + cached_features = await self._get_device_features(device) + except DeviceCapabilityError: + # Re-raise capability errors as-is (don't mask them) + raise + except Exception as e: + # Wrap other errors (timeouts, connection issues, etc) + raise DeviceCapabilityError( + feature, + f"Cannot execute {func.__name__}: {str(e)}", + ) from e + + if cached_features is None: + raise DeviceCapabilityError( + feature, + f"Cannot execute {func.__name__}: " + f"Device info could not be obtained.", + ) + + # Validate capability if feature is defined in DeviceFeature + if hasattr(cached_features, feature): + supported = DeviceCapabilityChecker.supports( + feature, cached_features + ) + _logger.debug( + f"Cap '{feature}': {'OK' if supported else 'FAIL'}" + ) + DeviceCapabilityChecker.assert_supported( + feature, cached_features + ) + else: + raise DeviceCapabilityError( + feature, f"Feature '{feature}' missing. Prevented." + ) + + # Execute command + return await func(self, device, *args, **kwargs) + + return async_wrapper # type: ignore + + else: + + @functools.wraps(func) + def sync_wrapper( + self: Any, device: Any, *args: Any, **kwargs: Any + ) -> Any: + # Sync functions cannot support capability checking + # as it requires async device info lookup + raise TypeError( + f"{func.__name__} must be async to use " + f"@requires_capability decorator. Capability checking " + f"requires async device info cache access." + ) + + return sync_wrapper # type: ignore + + return decorator diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py new file mode 100644 index 0000000..037600f --- /dev/null +++ b/src/nwp500/device_capabilities.py @@ -0,0 +1,129 @@ +"""Device capability checking for MQTT commands. + +This module provides a generalized framework for checking device capabilities +before executing MQTT commands. It uses a mapping-based approach to validate +that a device supports specific controllable features without requiring +individual checker functions. +""" + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from .exceptions import DeviceCapabilityError + +if TYPE_CHECKING: + from .models import DeviceFeature + +__author__ = "Emmanuel Levijarvi" + + +# Type for capability check functions +CapabilityCheckFn = Callable[["DeviceFeature"], bool] + + +class DeviceCapabilityChecker: + """Generalized 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, + extensible way without requiring individual methods for each control. + """ + + # Map of controllable features to their check functions + # Capability names MUST match DeviceFeature attribute names exactly + # for traceability: capability name -> DeviceFeature.{name} + _CAPABILITY_MAP: dict[str, CapabilityCheckFn] = { + "power_use": lambda f: bool(f.power_use), + "dhw_use": lambda f: bool(f.dhw_use), + "dhw_temperature_setting_use": lambda f: _check_dhw_temperature_control( + f + ), + "holiday_use": lambda f: bool(f.holiday_use), + "program_reservation_use": lambda f: bool(f.program_reservation_use), + "recirculation_use": lambda f: bool(f.recirculation_use), + "recirc_reservation_use": lambda f: bool(f.recirc_reservation_use), + } + + @classmethod + def supports(cls, feature: str, device_features: "DeviceFeature") -> bool: + """Check if device supports control of a specific feature. + + Args: + feature: Name of the controllable feature to check + device_features: Device feature information + + Returns: + True if feature control is supported, False otherwise + + Raises: + ValueError: If feature is not recognized + """ + if feature not in cls._CAPABILITY_MAP: + valid_features = ", ".join(sorted(cls._CAPABILITY_MAP.keys())) + raise ValueError( + f"Unknown controllable feature: {feature}. " + f"Valid features: {valid_features}" + ) + return cls._CAPABILITY_MAP[feature](device_features) + + @classmethod + def assert_supported( + cls, feature: str, device_features: "DeviceFeature" + ) -> None: + """Assert that device supports control of a feature. + + Args: + feature: Name of the controllable feature to check + device_features: Device feature information + + Raises: + DeviceCapabilityError: If feature control is not supported + ValueError: If feature is not recognized + """ + if not cls.supports(feature, device_features): + raise DeviceCapabilityError(feature) + + @classmethod + def register_capability( + cls, name: str, check_fn: CapabilityCheckFn + ) -> None: + """Register a custom controllable feature check. + + This allows extensions or applications to define custom capability + checks without modifying the core library. + + Args: + name: Feature name + check_fn: Function that takes DeviceFeature and returns bool + """ + cls._CAPABILITY_MAP[name] = check_fn + + @classmethod + def get_available_controls( + cls, device_features: "DeviceFeature" + ) -> dict[str, bool]: + """Get all controllable features available on a device. + + Args: + device_features: Device feature information + + Returns: + Dictionary mapping feature names to whether they can be controlled + """ + return { + feature: cls.supports(feature, device_features) + for feature in cls._CAPABILITY_MAP + } + + +def _check_dhw_temperature_control(features: "DeviceFeature") -> bool: + """Check if device supports DHW temperature control. + + Returns True if temperature control is enabled (not UNKNOWN or DISABLE). + """ + from .enums import DHWControlTypeFlag + + return features.dhw_temperature_setting_use not in ( + DHWControlTypeFlag.UNKNOWN, + DHWControlTypeFlag.DISABLE, + ) diff --git a/src/nwp500/device_info_cache.py b/src/nwp500/device_info_cache.py new file mode 100644 index 0000000..16f5177 --- /dev/null +++ b/src/nwp500/device_info_cache.py @@ -0,0 +1,183 @@ +"""Device information caching with periodic updates. + +This module manages caching of device information (features, capabilities) +with automatic periodic updates to keep data synchronized with the device. +""" + +import asyncio +import logging +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 + +__author__ = "Emmanuel Levijarvi" + +_logger = logging.getLogger(__name__) + + +class CachedDeviceInfo(TypedDict): + """Cached device information metadata.""" + + mac: str + cached_at: str + expires_at: str | None + is_expired: bool + + +class CacheInfoResult(TypedDict): + """Result of get_cache_info() call.""" + + device_count: int + update_interval_minutes: float + devices: list[CachedDeviceInfo] + + +class DeviceInfoCache: + """Manages caching of device information with periodic updates. + + This cache stores device features (capabilities, firmware info, etc.) + and automatically refreshes them at regular intervals to keep data + synchronized with the actual device state. + + The cache is keyed by device MAC address, allowing support for + multiple devices connected to the same MQTT client. + """ + + def __init__(self, update_interval_minutes: int = 30) -> None: + """Initialize the device info cache. + + Args: + update_interval_minutes: How often to refresh device info + (default: 30 minutes). Set to 0 to disable auto-updates. + """ + self.update_interval = timedelta(minutes=update_interval_minutes) + # Cache: {mac_address: (feature, timestamp)} + self._cache: dict[str, tuple[DeviceFeature, datetime]] = {} + self._lock = asyncio.Lock() + + async def get(self, device_mac: str) -> "DeviceFeature | None": + """Get cached device features if available and not expired. + + Args: + device_mac: Device MAC address + + Returns: + Cached DeviceFeature if available, None otherwise + """ + async with self._lock: + if device_mac not in self._cache: + return None + + features, timestamp = self._cache[device_mac] + + # Check if cache is still fresh + if self.is_expired(timestamp): + del self._cache[device_mac] + return None + + return features + + async def set(self, device_mac: str, features: "DeviceFeature") -> None: + """Cache device features with current timestamp. + + Args: + device_mac: Device MAC address + features: Device feature information to cache + """ + async with self._lock: + self._cache[device_mac] = (features, datetime.now(UTC)) + _logger.debug("Device info cached") + + async def invalidate(self, device_mac: str) -> None: + """Invalidate cache entry for a device. + + Forces a refresh on next request. + + Args: + device_mac: Device MAC address + """ + async with self._lock: + if device_mac in self._cache: + del self._cache[device_mac] + redacted = redact_mac(device_mac) + _logger.debug(f"Invalidated cache for {redacted}") + + async def clear(self) -> None: + """Clear all cached device information.""" + async with self._lock: + self._cache.clear() + _logger.debug("Cleared device info cache") + + def is_expired(self, timestamp: datetime) -> bool: + """Check if a cache entry is expired. + + Args: + timestamp: When the cache entry was created + + Returns: + True if expired, False if still fresh + """ + if self.update_interval.total_seconds() == 0: + # Auto-updates disabled + return False + + age = datetime.now(UTC) - timestamp + return age > self.update_interval + + async def get_all_cached(self) -> dict[str, "DeviceFeature"]: + """Get all currently cached device features. + + Returns: + Dictionary mapping MAC addresses to DeviceFeature objects + """ + async with self._lock: + # Filter out expired entries + return { + mac: features + for mac, (features, timestamp) in self._cache.items() + if not self.is_expired(timestamp) + } + + async def get_cache_info( + self, + ) -> CacheInfoResult: + """Get cache statistics and metadata. + + Returns: + Dictionary with cache info including: + - device_count: Number of cached devices + - update_interval_minutes: Cache update interval in minutes + - devices: List of device cache metadata + """ + async with self._lock: + devices: list[CachedDeviceInfo] = [] + for mac, (_features, timestamp) in self._cache.items(): + expires_at = ( + timestamp + self.update_interval + if self.update_interval.total_seconds() > 0 + else None + ) + device_info: CachedDeviceInfo = { + "mac": mac, + "cached_at": timestamp.isoformat(), + "expires_at": expires_at.isoformat() + if expires_at + else None, + "is_expired": self.is_expired(timestamp), + } + devices.append(device_info) + + result: CacheInfoResult = { + "device_count": len(devices), + "update_interval_minutes": ( + self.update_interval.total_seconds() / 60 + if self.update_interval.total_seconds() > 0 + else 0 + ), + "devices": devices, + } + return result diff --git a/src/nwp500/enums.py b/src/nwp500/enums.py index f66d42e..a5c788a 100644 --- a/src/nwp500/enums.py +++ b/src/nwp500/enums.py @@ -122,6 +122,21 @@ class RecirculationMode(IntEnum): TEMPERATURE = 4 # Activates when pipe temp drops +class DHWControlTypeFlag(IntEnum): + """DHW temperature control precision setting. + + Controls the granularity of temperature adjustments available for DHW + (Domestic Hot Water) control. Different models support different precision + levels. + """ + + UNKNOWN = 0 + DISABLE = 1 # Temperature control disabled (OFF) + ENABLE_DOT_5_DEGREE = 2 # 0.5°C precision + ENABLE_1_DEGREE = 3 # 1°C precision + ENABLE_3_STAGE = 4 # 3-stage discrete levels + + # ============================================================================ # Time of Use (TOU) Enumerations # ============================================================================ @@ -380,6 +395,14 @@ class FirmwareType(IntEnum): FilterChange.UNKNOWN: "Unknown", } +DHW_CONTROL_TYPE_FLAG_TEXT = { + DHWControlTypeFlag.UNKNOWN: "Unknown", + DHWControlTypeFlag.DISABLE: "OFF", + DHWControlTypeFlag.ENABLE_DOT_5_DEGREE: "0.5°C", + DHWControlTypeFlag.ENABLE_1_DEGREE: "1°C", + DHWControlTypeFlag.ENABLE_3_STAGE: "3 Stage", +} + # ============================================================================ # Error Code Enumerations diff --git a/src/nwp500/events.py b/src/nwp500/events.py index 81140ca..0add1e2 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -148,7 +148,7 @@ def once( ) def off( - self, event: str, callback: Callable[..., Any | None] = None + self, event: str, callback: Callable[..., Any | None] | None = None ) -> int: """ Remove event listener(s). diff --git a/src/nwp500/exceptions.py b/src/nwp500/exceptions.py index ab3f2cd..eacccf9 100644 --- a/src/nwp500/exceptions.py +++ b/src/nwp500/exceptions.py @@ -24,7 +24,8 @@ └── DeviceError ├── DeviceNotFoundError ├── DeviceOfflineError - └── DeviceOperationError + ├── DeviceOperationError + └── DeviceCapabilityError Migration from v4.x ------------------- @@ -35,14 +36,14 @@ # Old code (v4.x) try: - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) except RuntimeError as e: if "Not connected" in str(e): # handle connection error # New code (v5.0+) try: - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) except MqttNotConnectedError: # handle connection error except MqttError: @@ -90,7 +91,7 @@ def __init__( message: str, *, error_code: str | None = None, - details: dict[str, Any | None] = None, + details: dict[str, Any | None] | None = None, retriable: bool = False, ): """Initialize base exception. @@ -152,7 +153,7 @@ def __init__( self, message: str, status_code: int | None = None, - response: dict[str, Any | None] = None, + response: dict[str, Any | None] | None = None, **kwargs: Any, ): """Initialize authentication error. @@ -219,7 +220,7 @@ def __init__( self, message: str, code: int | None = None, - response: dict[str, Any | None] = None, + response: dict[str, Any | None] | None = None, **kwargs: Any, ): """Initialize API error. @@ -272,7 +273,7 @@ class MqttNotConnectedError(MqttError): mqtt_client = NavienMqttClient(auth_client) # Must connect first await mqtt_client.connect() - await mqtt_client.request_device_status(device) + await mqtt_client.control.request_device_status(device) """ pass @@ -443,3 +444,27 @@ class DeviceOperationError(DeviceError): """ pass + + +class DeviceCapabilityError(DeviceError): + """Device does not support a requested capability. + + Raised when an MQTT command requires a device capability that the device + does not support. This may occur when trying to use features that are not + available on specific device models or hardware revisions. + + Attributes: + feature_name: Name of the unsupported feature + """ + + def __init__(self, feature_name: str, message: str | None = None) -> None: + """Initialize capability error. + + Args: + feature_name: Name of the missing/unsupported feature + message: Optional custom error message + """ + self.feature_name = feature_name + if message is None: + message = f"Device does not support {feature_name} capability" + super().__init__(message) diff --git a/src/nwp500/field_factory.py b/src/nwp500/field_factory.py new file mode 100644 index 0000000..26cc0d8 --- /dev/null +++ b/src/nwp500/field_factory.py @@ -0,0 +1,172 @@ +"""Field factory for creating typed Pydantic fields with metadata templates. + +This module provides convenience functions for creating Pydantic fields with +standard metadata (device_class, unit_of_measurement, etc.) pre-configured, +reducing boilerplate in models while maintaining type safety. + +Each factory function creates a Pydantic Field with metadata for Home Assistant +integration: +- temperature_field: Adds unit_of_measurement, device_class='temperature', + suggested_display_precision +- signal_strength_field: Adds unit_of_measurement, + device_class='signal_strength' +- energy_field: Adds unit_of_measurement, device_class='energy' +- power_field: Adds unit_of_measurement, device_class='power' + +Example: + >>> from nwp500.field_factory import temperature_field + >>> class MyModel(BaseModel): + ... temp: float = temperature_field("DHW Temperature", unit="°F") +""" + +from typing import Any, cast + +from pydantic import Field + +__all__ = [ + "temperature_field", + "signal_strength_field", + "energy_field", + "power_field", +] + + +def temperature_field( + description: str, + *, + unit: str = "°F", + default: Any = None, + **kwargs: Any, +) -> Any: + """Create a temperature field with standard Home Assistant metadata. + + Args: + description: Field description + unit: Temperature unit (default: °F) + default: Default value or Pydantic default + **kwargs: Additional Pydantic Field arguments + + Returns: + Pydantic Field with temperature metadata + """ + json_schema_extra: dict[str, Any] = { + "unit_of_measurement": unit, + "device_class": "temperature", + "suggested_display_precision": 1, + } + if "json_schema_extra" in kwargs: + extra = kwargs.pop("json_schema_extra") + if isinstance(extra, dict): + json_schema_extra.update(extra) + + return Field( + default=default, + description=description, + json_schema_extra=cast(Any, json_schema_extra), + **kwargs, + ) + + +def signal_strength_field( + description: str, + *, + unit: str = "dBm", + default: Any = None, + **kwargs: Any, +) -> Any: + """Create a signal strength field with standard Home Assistant metadata. + + Args: + description: Field description + unit: Signal unit (default: dBm) + default: Default value or Pydantic default + **kwargs: Additional Pydantic Field arguments + + Returns: + Pydantic Field with signal strength metadata + """ + json_schema_extra: dict[str, Any] = { + "unit_of_measurement": unit, + "device_class": "signal_strength", + } + if "json_schema_extra" in kwargs: + extra = kwargs.pop("json_schema_extra") + if isinstance(extra, dict): + json_schema_extra.update(extra) + + return Field( + default=default, + description=description, + json_schema_extra=cast(Any, json_schema_extra), + **kwargs, + ) + + +def energy_field( + description: str, + *, + unit: str = "kWh", + default: Any = None, + **kwargs: Any, +) -> Any: + """Create an energy field with standard Home Assistant metadata. + + Args: + description: Field description + unit: Energy unit (default: kWh) + default: Default value or Pydantic default + **kwargs: Additional Pydantic Field arguments + + Returns: + Pydantic Field with energy metadata + """ + json_schema_extra: dict[str, Any] = { + "unit_of_measurement": unit, + "device_class": "energy", + } + if "json_schema_extra" in kwargs: + extra = kwargs.pop("json_schema_extra") + if isinstance(extra, dict): + json_schema_extra.update(extra) + + return Field( + default=default, + description=description, + json_schema_extra=cast(Any, json_schema_extra), + **kwargs, + ) + + +def power_field( + description: str, + *, + unit: str = "W", + default: Any = None, + **kwargs: Any, +) -> Any: + """Create a power field with standard Home Assistant metadata. + + Args: + description: Field description + unit: Power unit (default: W) + default: Default value or Pydantic default + **kwargs: Additional Pydantic Field arguments + + Returns: + Pydantic Field with power metadata + """ + json_schema_extra: dict[str, Any] = { + "unit_of_measurement": unit, + "device_class": "power", + } + if "json_schema_extra" in kwargs: + extra = kwargs.pop("json_schema_extra") + if isinstance(extra, dict): + json_schema_extra.update(extra) + + return Field( + default=default, + description=description, + json_schema_extra=cast(Any, json_schema_extra), + **kwargs, + ) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 83822bb..6d31f19 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -7,7 +7,7 @@ """ import logging -from typing import Annotated, Any +from typing import Annotated, Any, Self from pydantic import BaseModel, BeforeValidator, ConfigDict, Field from pydantic.alias_generators import to_camel @@ -15,14 +15,20 @@ from .enums import ( CurrentOperationMode, DeviceType, + DHWControlTypeFlag, DhwOperationSetting, DREvent, ErrorCode, HeatSource, + RecirculationMode, TemperatureType, TempFormulaType, UnitType, ) +from .field_factory import ( + signal_strength_field, + temperature_field, +) _logger = logging.getLogger(__name__) @@ -33,53 +39,24 @@ def _device_bool_validator(v: Any) -> bool: - """Convert device boolean (2=True, 0/1=False).""" - return bool(v == 2) - - -def _capability_flag_validator(v: Any) -> bool: - """Convert capability flag (2=True/supported, 1=False/not supported). - - Uses same pattern as OnOffFlag: 1=OFF/not supported, 2=ON/supported. - """ + """Convert device boolean flag (2=True, 1=False).""" return bool(v == 2) def _div_10_validator(v: Any) -> float: """Divide by 10.""" - if isinstance(v, (int, float)): - return float(v) / 10.0 - return float(v) + 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)): - celsius = float(v) / 2.0 - return (celsius * 9 / 5) + 32 + return (float(v) / 2.0 * 9 / 5) + 32 return float(v) def fahrenheit_to_half_celsius(fahrenheit: float) -> int: - """Convert Fahrenheit to half-degrees Celsius (for device commands). - - This is the inverse of the HalfCelsiusToF conversion used for reading. - Use this when sending temperature values to the device (e.g., reservations). - - Args: - fahrenheit: Temperature in Fahrenheit (e.g., 140.0) - - Returns: - Integer value in half-degrees Celsius for device param field - - Examples: - >>> fahrenheit_to_half_celsius(140.0) - 120 - >>> fahrenheit_to_half_celsius(120.0) - 98 - >>> fahrenheit_to_half_celsius(95.0) - 70 - """ + """Convert Fahrenheit to half-degrees Celsius (for device commands).""" celsius = (fahrenheit - 32) * 5 / 9 return round(celsius * 2) @@ -87,30 +64,23 @@ def fahrenheit_to_half_celsius(fahrenheit: float) -> int: def _deci_celsius_to_fahrenheit(v: Any) -> float: """Convert decicelsius (tenths of Celsius) to Fahrenheit.""" if isinstance(v, (int, float)): - celsius = float(v) / 10.0 - return (celsius * 9 / 5) + 32 + return (float(v) / 10.0 * 9 / 5) + 32 return float(v) def _tou_status_validator(v: Any) -> bool: - """Convert TOU status (0=False/disabled, 1=True/enabled).""" + """Convert TOU status (0=False, 1=True).""" return bool(v == 1) def _tou_override_validator(v: Any) -> bool: - """Convert TOU override status (1=True/override active, 2=False/normal). - - Note: This field uses OnOffFlag pattern (1=OFF, 2=ON) but represents - whether TOU schedule operation is enabled, not whether override is active. - So: 2 (ON) = TOU operating normally = override NOT active = False - 1 (OFF) = TOU not operating = override IS active = True - """ + """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(_capability_flag_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)] @@ -144,49 +114,42 @@ def model_dump(self, **kwargs: Any) -> dict[str, Any]: @staticmethod def _convert_enums_to_names( - data: Any, visited: set[int | None] = None + data: Any, visited: set[int] | None = None ) -> Any: """Recursively convert Enum values to their names. Args: - data: Data to convert - visited: Set of visited object ids to detect cycles - - Returns: - Data with enums converted to their names + data: The data structure to convert. + visited: Set of object IDs already visited to prevent infinite + recursion. None indicates uninitialized/first call. """ from enum import Enum - if visited is None: - visited = set() - if isinstance(data, Enum): return data.name - elif isinstance(data, dict): - # Check for circular reference - data_id = id(data) - if data_id in visited: - return data - visited.add(data_id) - result: dict[Any, Any] = { - key: NavienBaseModel._convert_enums_to_names(value, visited) - for key, value in data.items() + if not isinstance(data, (dict, list, tuple)): + return data + + visited = visited or set() + if id(data) in visited: + return data + visited.add(id(data)) + + if isinstance(data, dict): + res: dict[Any, Any] | list[Any] | tuple[Any, ...] = { + k: NavienBaseModel._convert_enums_to_names(v, visited) + for k, v in data.items() } - visited.discard(data_id) - return result - elif isinstance(data, (list, tuple)): - # Check for circular reference - data_id = id(data) - if data_id in visited: - return data - visited.add(data_id) - converted = [ - NavienBaseModel._convert_enums_to_names(item, visited) - for item in data - ] - visited.discard(data_id) - return type(data)(converted) - return data + else: + res = type(data)( + [ + NavienBaseModel._convert_enums_to_names(i, visited) + for i in data + ] + ) + + visited.discard(id(data)) + return res class DeviceInfo(NavienBaseModel): @@ -218,6 +181,10 @@ class Device(NavienBaseModel): device_info: DeviceInfo location: Location + def with_info(self, info: DeviceInfo) -> Self: + """Return a new Device instance with updated DeviceInfo.""" + return self.model_copy(update={"device_info": info}) + class FirmwareInfo(NavienBaseModel): """Firmware information for a device.""" @@ -259,7 +226,7 @@ def model_validate( *, strict: bool | None = None, from_attributes: bool | None = None, - context: dict[str, Any | None] = None, + context: dict[str, Any | None] | None = None, **kwargs: Any, ) -> "TOUInfo": # Handle nested structure where fields are in 'touInfo' @@ -289,12 +256,8 @@ class DeviceStatus(NavienBaseModel): command: int = Field( description="The command that triggered this status update" ) - outside_temperature: float = Field( - description="The outdoor/ambient temperature measured by the heat pump", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + outside_temperature: float = temperature_field( + "The outdoor/ambient temperature measured by the heat pump" ) special_function_status: int = Field( description=( @@ -319,15 +282,9 @@ class DeviceStatus(NavienBaseModel): ) fault_status1: int = Field(description="Fault status register 1") fault_status2: int = Field(description="Fault status register 2") - wifi_rssi: int = Field( - description=( - "WiFi signal strength in dBm. " - "Typical values: -30 (excellent) to -90 (poor)" - ), - json_schema_extra={ - "unit_of_measurement": "dBm", - "device_class": "signal_strength", - }, + wifi_rssi: int = signal_strength_field( + "WiFi signal strength in dBm. " + "Typical values: -30 (excellent) to -90 (poor)" ) dhw_charge_per: float = Field( description=( @@ -454,7 +411,7 @@ class DeviceStatus(NavienBaseModel): "device_class": "energy", }, ) - recirc_operation_mode: int = Field( + recirc_operation_mode: RecirculationMode = Field( description="Recirculation operation mode" ) recirc_pump_operation_status: int = Field( @@ -504,6 +461,13 @@ class DeviceStatus(NavienBaseModel): "Sustained DHW usage status - indicates prolonged hot water usage" ) ) + dhw_operation_busy: DeviceBool = Field( + default=False, + description=( + "DHW operation busy status - " + "indicates if the device is currently heating water to meet demand" + ), + ) program_reservation_use: DeviceBool = Field( description=( "Whether a program reservation (scheduled operation) is in use" @@ -601,144 +565,64 @@ class DeviceStatus(NavienBaseModel): ) # Temperature fields - encoded in half-degrees Celsius - dhw_temperature: HalfCelsiusToF = Field( - description="Current Domestic Hot Water (DHW) outlet temperature", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + dhw_temperature: HalfCelsiusToF = temperature_field( + "Current Domestic Hot Water (DHW) outlet temperature" ) - dhw_temperature_setting: HalfCelsiusToF = Field( - description=( - "User-configured target DHW temperature. " - "Range: 95°F (35°C) to 150°F (65.5°C). Default: 120°F (49°C)" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + dhw_temperature_setting: HalfCelsiusToF = temperature_field( + "User-configured target DHW temperature. " + "Range: 95°F (35°C) to 150°F (65.5°C). Default: 120°F (49°C)" ) - dhw_target_temperature_setting: HalfCelsiusToF = Field( - description=( - "Duplicate of dhw_temperature_setting for legacy API compatibility" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + dhw_target_temperature_setting: HalfCelsiusToF = temperature_field( + "Duplicate of dhw_temperature_setting for legacy API compatibility" ) - freeze_protection_temperature: HalfCelsiusToF = Field( - description=( - "Freeze protection temperature setpoint. " - "Range: 43-50°F (6-10°C), Default: 43°F" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + freeze_protection_temperature: HalfCelsiusToF = temperature_field( + "Freeze protection temperature setpoint. " + "Range: 43-50°F (6-10°C), Default: 43°F" ) - dhw_temperature2: HalfCelsiusToF = Field( - description="Second DHW temperature reading", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + dhw_temperature2: HalfCelsiusToF = temperature_field( + "Second DHW temperature reading" ) - hp_upper_on_temp_setting: HalfCelsiusToF = Field( - description="Heat pump upper on temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + hp_upper_on_temp_setting: HalfCelsiusToF = temperature_field( + "Heat pump upper on temperature setting" ) - hp_upper_off_temp_setting: HalfCelsiusToF = Field( - description="Heat pump upper off temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + hp_upper_off_temp_setting: HalfCelsiusToF = temperature_field( + "Heat pump upper off temperature setting" ) - hp_lower_on_temp_setting: HalfCelsiusToF = Field( - description="Heat pump lower on temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + hp_lower_on_temp_setting: HalfCelsiusToF = temperature_field( + "Heat pump lower on temperature setting" ) - hp_lower_off_temp_setting: HalfCelsiusToF = Field( - description="Heat pump lower off temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + hp_lower_off_temp_setting: HalfCelsiusToF = temperature_field( + "Heat pump lower off temperature setting" ) - he_upper_on_temp_setting: HalfCelsiusToF = Field( - description="Heater element upper on temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + he_upper_on_temp_setting: HalfCelsiusToF = temperature_field( + "Heater element upper on temperature setting" ) - he_upper_off_temp_setting: HalfCelsiusToF = Field( - description="Heater element upper off temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + he_upper_off_temp_setting: HalfCelsiusToF = temperature_field( + "Heater element upper off temperature setting" ) - he_lower_on_temp_setting: HalfCelsiusToF = Field( - description="Heater element lower on temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + he_lower_on_temp_setting: HalfCelsiusToF = temperature_field( + "Heater element lower on temperature setting" ) - he_lower_off_temp_setting: HalfCelsiusToF = Field( - description="Heater element lower off temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + he_lower_off_temp_setting: HalfCelsiusToF = temperature_field( + "Heater element lower off temperature setting" ) - heat_min_op_temperature: HalfCelsiusToF = Field( - description=( - "Minimum heat pump operation temperature. " - "Lowest tank setpoint allowed (95-113°F, default 95°F)" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + heat_min_op_temperature: HalfCelsiusToF = temperature_field( + "Minimum heat pump operation temperature. " + "Lowest tank setpoint allowed (95-113°F, default 95°F)" ) - recirc_temp_setting: HalfCelsiusToF = Field( - description="Recirculation temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + recirc_temp_setting: HalfCelsiusToF = temperature_field( + "Recirculation temperature setting" ) - recirc_temperature: HalfCelsiusToF = Field( - description="Recirculation temperature", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + recirc_temperature: HalfCelsiusToF = temperature_field( + "Recirculation temperature" ) - recirc_faucet_temperature: HalfCelsiusToF = Field( - description="Recirculation faucet temperature", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + recirc_faucet_temperature: HalfCelsiusToF = temperature_field( + "Recirculation faucet temperature" ) # Fields with scale division (raw / 10.0) - current_inlet_temperature: HalfCelsiusToF = Field( - description="Cold water inlet temperature", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + current_inlet_temperature: HalfCelsiusToF = temperature_field( + "Cold water inlet temperature" ) current_dhw_flow_rate: Div10 = Field( description="Current DHW flow rate in Gallons Per Minute", @@ -807,78 +691,34 @@ class DeviceStatus(NavienBaseModel): ) # Temperature fields with decicelsius to Fahrenheit conversion - tank_upper_temperature: DeciCelsiusToF = Field( - description="Temperature of the upper part of the tank", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + tank_upper_temperature: DeciCelsiusToF = temperature_field( + "Temperature of the upper part of the tank" ) - tank_lower_temperature: DeciCelsiusToF = Field( - description="Temperature of the lower part of the tank", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + tank_lower_temperature: DeciCelsiusToF = temperature_field( + "Temperature of the lower part of the tank" ) - discharge_temperature: DeciCelsiusToF = Field( - description=( - "Compressor discharge temperature - " - "temperature of refrigerant leaving the compressor" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + discharge_temperature: DeciCelsiusToF = temperature_field( + "Compressor discharge temperature - " + "temperature of refrigerant leaving the compressor" ) - suction_temperature: DeciCelsiusToF = Field( - description=( - "Compressor suction temperature - " - "temperature of refrigerant entering the compressor" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + suction_temperature: DeciCelsiusToF = temperature_field( + "Compressor suction temperature - " + "temperature of refrigerant entering the compressor" ) - evaporator_temperature: DeciCelsiusToF = Field( - description=( - "Evaporator temperature - " - "temperature where heat is absorbed from ambient air" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + evaporator_temperature: DeciCelsiusToF = temperature_field( + "Evaporator temperature - " + "temperature where heat is absorbed from ambient air" ) - ambient_temperature: DeciCelsiusToF = Field( - description=( - "Ambient air temperature measured at the heat pump air intake" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + ambient_temperature: DeciCelsiusToF = temperature_field( + "Ambient air temperature measured at the heat pump air intake" ) - target_super_heat: DeciCelsiusToF = Field( - description=( - "Target superheat value - desired temperature difference " - "ensuring complete refrigerant vaporization" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + target_super_heat: DeciCelsiusToF = temperature_field( + "Target superheat value - desired temperature difference " + "ensuring complete refrigerant vaporization" ) - current_super_heat: DeciCelsiusToF = Field( - description=( - "Current superheat value - actual temperature difference " - "between suction and evaporator temperatures" - ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + current_super_heat: DeciCelsiusToF = temperature_field( + "Current superheat value - actual temperature difference " + "between suction and evaporator temperatures" ) # Enum fields @@ -894,21 +734,12 @@ class DeviceStatus(NavienBaseModel): default=TemperatureType.FAHRENHEIT, description="Type of temperature unit", ) - freeze_protection_temp_min: HalfCelsiusToF = Field( + freeze_protection_temp_min: HalfCelsiusToF = temperature_field( + "Active freeze protection lower limit. Default: 43°F (6°C)", default=43.0, - description="Active freeze protection lower limit. Default: 43°F (6°C)", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, ) - freeze_protection_temp_max: HalfCelsiusToF = Field( - default=65.0, - description="Active freeze protection upper limit. Default: 65°F", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + freeze_protection_temp_max: HalfCelsiusToF = temperature_field( + "Active freeze protection upper limit. Default: 65°F", default=65.0 ) @classmethod @@ -975,6 +806,18 @@ class DeviceFeature(NavienBaseModel): "for communication protocol version" ) ) + recirc_sw_version: int = Field( + description=( + "Recirculation module firmware version - " + "controls recirculation pump operation and temperature loop" + ) + ) + recirc_model_type_code: int = Field( + description=( + "Recirculation module model identifier - " + "specifies installed recirculation system variant" + ) + ) controller_serial_number: str = Field( description=( "Unique serial number of the main controller board " @@ -982,163 +825,182 @@ class DeviceFeature(NavienBaseModel): ) ) power_use: CapabilityFlag = Field( - description=("Power control capability (2=supported, 1=not supported)") + default=False, + description=("Power control capability (2=supported, 1=not supported)"), ) holiday_use: CapabilityFlag = Field( + default=False, description=( "Vacation mode support (2=supported, 1=not supported) - " "energy-saving mode for 0-99 days" - ) + ), ) program_reservation_use: CapabilityFlag = Field( + default=False, description=( "Scheduled operation support (2=supported, 1=not supported) - " "programmable heating schedules" - ) + ), ) dhw_use: CapabilityFlag = Field( + default=False, description=( "Domestic hot water functionality (2=supported, 1=not supported) - " "primary function of water heater" - ) + ), ) - dhw_temperature_setting_use: CapabilityFlag = Field( + dhw_temperature_setting_use: DHWControlTypeFlag = Field( description=( - "Temperature adjustment capability " - "(2=supported, 1=not supported) - " - "user can modify target temperature" + "DHW temperature control precision setting: " + "granularity of temperature adjustments available for DHW control" ) ) smart_diagnostic_use: CapabilityFlag = Field( + default=False, description=( "Self-diagnostic capability (2=supported, 1=not supported) - " "10-minute startup diagnostic, error code system" - ) + ), ) wifi_rssi_use: CapabilityFlag = Field( + default=False, description=( "WiFi signal monitoring (2=supported, 1=not supported) - " "reports signal strength in dBm" - ) + ), ) temp_formula_type: TempFormulaType = Field( + default=TempFormulaType.ASYMMETRIC, description=( "Temperature calculation method identifier " "for internal sensor calibration" - ) + ), ) energy_usage_use: CapabilityFlag = Field( - description=( - "Energy monitoring support (1=available) - tracks kWh consumption" - ) + default=False, + description=("Energy monitoring support (2=supp, 1=not) - tracks kWh"), ) freeze_protection_use: CapabilityFlag = Field( + default=False, description=( - "Freeze protection capability (1=available) - " + "Freeze protection capability (2=supported, 1=not supported) - " "automatic heating when tank drops below threshold" - ) + ), ) - mixing_value_use: CapabilityFlag = Field( - description=( - "Thermostatic mixing valve support (1=available) - " - "for temperature limiting at point of use" - ) + mixing_valve_use: CapabilityFlag = Field( + alias="mixingValveUse", + default=False, + description=("Thermostatic mixing valve support (2=supp, 1=not)"), ) dr_setting_use: CapabilityFlag = Field( + default=False, description=( - "Demand Response support (1=available) - " + "Demand Response support (2=supported, 1=not supported) - " "CTA-2045 compliance for utility load management" - ) + ), ) anti_legionella_setting_use: CapabilityFlag = Field( + default=False, description=( - "Anti-Legionella function (1=available) - " + "Anti-Legionella function (2=supported, 1=not supported) - " "periodic heating to 140°F (60°C) to prevent bacteria" - ) + ), ) hpwh_use: CapabilityFlag = Field( + default=False, description=( - "Heat Pump Water Heater mode (1=supported) - " + "Heat Pump Water Heater mode (2=supported, 1=not supported) - " "primary efficient heating using refrigeration cycle" - ) + ), ) dhw_refill_use: CapabilityFlag = Field( + default=False, description=( - "Tank refill detection (1=supported) - " + "Tank refill detection (2=supported, 1=not supported) - " "monitors for dry fire conditions during refill" - ) + ), ) eco_use: CapabilityFlag = Field( + default=False, description=( - "ECO safety switch capability (1=available) - " + "ECO safety switch capability (2=supported, 1=not supported) - " "Energy Cut Off high-temperature limit protection" - ) + ), ) electric_use: CapabilityFlag = Field( + default=False, description=( - "Electric-only mode (1=supported) - " + "Electric-only mode (2=supported, 1=not supported) - " "heating element only for maximum recovery speed" - ) + ), ) heatpump_use: CapabilityFlag = Field( + default=False, description=( - "Heat pump only mode (1=supported) - " + "Heat pump only mode (2=supported, 1=not supported) - " "most efficient operation using only refrigeration cycle" - ) + ), ) energy_saver_use: CapabilityFlag = Field( + default=False, description=( - "Energy Saver mode (1=supported) - " + "Energy Saver mode (2=supported, 1=not supported) - " "hybrid efficiency mode balancing speed and efficiency (default)" - ) + ), ) high_demand_use: CapabilityFlag = Field( + default=False, description=( - "High Demand mode (1=supported) - " + "High Demand mode (2=supported, 1=not supported) - " "hybrid boost mode prioritizing fast recovery" - ) - ) - - # Temperature limit fields with half-degree Celsius scaling - dhw_temperature_min: HalfCelsiusToF = Field( - description=( - "Minimum DHW temperature setting: 95°F (35°C) - " - "safety and efficiency lower limit" ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, ) - dhw_temperature_max: HalfCelsiusToF = Field( + recirculation_use: CapabilityFlag = Field( + default=False, description=( - "Maximum DHW temperature setting: 150°F (65.5°C) - " - "scald protection upper limit" + "Recirculation pump support (2=supported, 1=not supported) - " + "instant hot water delivery via dedicated loop" ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, ) - freeze_protection_temp_min: HalfCelsiusToF = Field( + recirc_reservation_use: CapabilityFlag = Field( + default=False, description=( - "Minimum freeze protection threshold: 43°F (6°C) - " - "factory default activation temperature" + "Recirculation schedule support (2=supported, 1=not supported) - " + "programmable recirculation on specified schedule" ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, ) - freeze_protection_temp_max: HalfCelsiusToF = Field( + title24_use: CapabilityFlag = Field( + default=False, description=( - "Maximum freeze protection threshold: 65°F - " - "user-adjustable upper limit" + "Title 24 compliance (2=supported, 1=not supported) - " + "California energy code compliance for recirculation systems" ), - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, + ) + + # Temperature limit fields with half-degree Celsius scaling + dhw_temperature_min: HalfCelsiusToF = temperature_field( + "Minimum DHW temperature setting: 95°F (35°C) - " + "safety and efficiency lower limit" + ) + dhw_temperature_max: HalfCelsiusToF = temperature_field( + "Maximum DHW temperature setting: 150°F (65.5°C) - " + "scald protection upper limit" + ) + freeze_protection_temp_min: HalfCelsiusToF = temperature_field( + "Minimum freeze protection threshold: 43°F (6°C) - " + "factory default activation temperature" + ) + freeze_protection_temp_max: HalfCelsiusToF = temperature_field( + "Maximum freeze protection threshold: 65°F - " + "user-adjustable upper limit" + ) + recirc_temperature_min: HalfCelsiusToF = temperature_field( + "Minimum recirculation temperature setting - " + "lower limit for recirculation loop temperature control" + ) + recirc_temperature_max: HalfCelsiusToF = temperature_field( + "Maximum recirculation temperature setting - " + "upper limit for recirculation loop temperature control" ) # Enum field @@ -1181,8 +1043,8 @@ class MqttCommand(NavienBaseModel): protocol_version: int = 2 -class EnergyUsageTotal(NavienBaseModel): - """Total energy usage data.""" +class EnergyUsageBase(NavienBaseModel): + """Base energy usage fields common to daily and total responses.""" heat_pump_usage: int = Field(default=0, alias="hpUsage") heat_element_usage: int = Field(default=0, alias="heUsage") @@ -1191,44 +1053,37 @@ class EnergyUsageTotal(NavienBaseModel): @property def total_usage(self) -> int: - """Total energy usage (heat pump + heat element).""" return self.heat_pump_usage + self.heat_element_usage + +class EnergyUsageTotal(EnergyUsageBase): + """Total energy usage data.""" + @property def heat_pump_percentage(self) -> float: - if self.total_usage == 0: - return 0.0 - return (self.heat_pump_usage / self.total_usage) * 100.0 + return ( + (self.heat_pump_usage / self.total_usage * 100.0) + if self.total_usage > 0 + else 0.0 + ) @property def heat_element_percentage(self) -> float: - if self.total_usage == 0: - return 0.0 - return (self.heat_element_usage / self.total_usage) * 100.0 + return ( + (self.heat_element_usage / self.total_usage * 100.0) + if self.total_usage > 0 + else 0.0 + ) @property def total_time(self) -> int: - """Total operating time (heat pump + heat element).""" return self.heat_pump_time + self.heat_element_time -class EnergyUsageDay(NavienBaseModel): - """Daily energy usage data. - - Note: The API returns a fixed-length array (30 elements) for each month, - with unused days having all zeros. The day number is implicit from the - array index (0-based). - """ - - heat_pump_usage: int = Field(alias="hpUsage") - heat_element_usage: int = Field(alias="heUsage") - heat_pump_time: int = Field(alias="hpTime") - heat_element_time: int = Field(alias="heTime") +class EnergyUsageDay(EnergyUsageBase): + """Daily energy usage data.""" - @property - def total_usage(self) -> int: - """Total energy usage (heat pump + heat element).""" - return self.heat_pump_usage + self.heat_element_usage + pass class MonthlyEnergyData(NavienBaseModel): diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt_client.py index 0623935..19ed187 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt_client.py @@ -15,8 +15,8 @@ import json import logging import uuid -from collections.abc import Callable, Sequence -from typing import TYPE_CHECKING, Any +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, cast from awscrt import mqtt from awscrt.exceptions import AwsCrtError @@ -532,23 +532,37 @@ async def connect(self) -> bool: ) self._reconnection_handler.enable() - # Initialize subscription manager + # Initialize shared device info cache and client_id + from .device_info_cache import DeviceInfoCache + client_id = self.config.client_id or "" + device_info_cache = DeviceInfoCache(update_interval_minutes=30) + + # Initialize subscription manager with cache self._subscription_manager = MqttSubscriptionManager( connection=self._connection, client_id=client_id, event_emitter=self, schedule_coroutine=self._schedule_coroutine, + device_info_cache=device_info_cache, ) - # Initialize device controller + # Initialize device controller with cache self._device_controller = MqttDeviceController( client_id=client_id, session_id=self._session_id, publish_func=self._connection_manager.publish, + device_info_cache=device_info_cache, ) - # Initialize periodic request manager + # Set the auto-request callback on the controller + # Wrap ensure_device_info_cached to match callback signature + async def ensure_callback(device: Device) -> bool: + return await self.ensure_device_info_cached(device) + + self._device_controller._ensure_device_info_callback = ( + ensure_callback + ) # Note: These will be implemented later when we # delegate device control methods self._periodic_manager = MqttPeriodicRequestManager( @@ -834,442 +848,119 @@ async def subscribe_device( device, callback ) - async def subscribe_device_status( - self, device: Device, callback: Callable[[DeviceStatus], None] - ) -> int: - """ - Subscribe to device status messages with automatic parsing. - - This method wraps the standard subscription with automatic parsing - of status messages into DeviceStatus objects. The callback will only - be invoked when a status message is received and successfully parsed. - - Additionally, the client emits granular events for state changes: - - 'status_received': Every status update (DeviceStatus) - - 'temperature_changed': Temperature changed (old_temp, new_temp) - - 'mode_changed': Operation mode changed (old_mode, new_mode) - - 'power_changed': Power consumption changed (old_power, new_power) - - 'heating_started': Device started heating (status) - - 'heating_stopped': Device stopped heating (status) - - 'error_detected': Error code detected (error_code, status) - - 'error_cleared': Error code cleared (error_code) - - Args: - device: Device object - callback: Callback function that receives DeviceStatus objects - - Returns: - Subscription packet ID - - Example (Traditional Callback):: - - >>> def on_status(status: DeviceStatus): - ... print(f"Temperature: {status.dhw_temperature}°F") - ... print(f"Mode: {status.operation_mode}") - >>> - >>> await mqtt_client.subscribe_device_status(device, on_status) - - Example (Event Emitter):: - - >>> # Multiple handlers for same event - >>> mqtt_client.on('temperature_changed', log_temperature) - >>> mqtt_client.on('temperature_changed', update_ui) - >>> - >>> # State change events - >>> mqtt_client.on('heating_started', lambda s: print("Heating ON")) - >>> mqtt_client.on( - ... 'heating_stopped', lambda s: print("Heating OFF") - ... ) - >>> - >>> # Subscribe to start receiving events - >>> await mqtt_client.subscribe_device_status( - ... device, lambda s: None - ... ) - """ + async def _delegate_subscription(self, method_name: str, *args: Any) -> int: + """Helper to delegate subscription to subscription manager.""" if not self._connected or not self._subscription_manager: raise MqttNotConnectedError("Not connected to MQTT broker") + method = getattr(self._subscription_manager, method_name) + return cast(int, await method(*args)) - # Delegate to subscription manager (it handles state change - # detection and events) - return await self._subscription_manager.subscribe_device_status( - device, callback + async def subscribe_device_status( + self, device: Device, callback: Callable[[DeviceStatus], None] + ) -> int: + """Subscribe to device status messages with automatic parsing.""" + return await self._delegate_subscription( + "subscribe_device_status", device, callback ) async def subscribe_device_feature( self, device: Device, callback: Callable[[DeviceFeature], None] ) -> int: - """ - Subscribe to device feature/info messages with automatic parsing. - - This method wraps the standard subscription with automatic parsing - of feature messages into DeviceFeature objects. The callback will only - be invoked when a feature message is received and successfully parsed. - - Feature messages contain device capabilities, firmware versions, - serial numbers, and configuration limits. - - Additionally emits: 'feature_received' event with DeviceFeature object. - - Args: - device: Device object - callback: Callback function that receives DeviceFeature objects - - Returns: - Subscription packet ID - - Example:: - - >>> def on_feature(feature: DeviceFeature): - ... print(f"Serial: {feature.controllerSerialNumber}") - ... print(f"FW Version: {feature.controllerSwVersion}") - ... print( - ... f"Temp Range: {feature.dhwTemperatureMin}-" - ... f"{feature.dhwTemperatureMax}°F" - ... ) - >>> - >>> await mqtt_client.subscribe_device_feature(device, on_feature) - - >>> # Or use event emitter - >>> mqtt_client.on( - ... 'feature_received', - ... lambda f: print(f"FW: {f.controllerSwVersion}") - ... ) - >>> await mqtt_client.subscribe_device_feature( - ... device, lambda f: None - ... ) - """ - if not self._connected or not self._subscription_manager: - raise MqttNotConnectedError("Not connected to MQTT broker") - - # Delegate to subscription manager - return await self._subscription_manager.subscribe_device_feature( - device, callback + """Subscribe to device feature/info messages with automatic parsing.""" + return await self._delegate_subscription( + "subscribe_device_feature", device, callback ) - async def request_device_status(self, device: Device) -> int: - """ - Request general device status. - - Args: - device: Device object - - Returns: - Publish packet ID - """ - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.request_device_status(device) - - async def request_device_info(self, device: Device) -> int: - """ - Request device information. - - Returns: - Publish packet ID - """ - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.request_device_info(device) - - async def set_power(self, device: Device, power_on: bool) -> int: - """ - Turn device on or off. - - Args: - device: Device object - power_on: True to turn on, False to turn off - device_type: Device type (52 for NWP500) - additional_value: Additional value from device info - - Returns: - Publish packet ID - """ - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.set_power(device, power_on) - - async def set_dhw_mode( + async def subscribe_energy_usage( self, device: Device, - mode_id: int, - vacation_days: int | None = None, - ) -> int: - """ - Set DHW (Domestic Hot Water) operation mode. - - Args: - device: Device object - mode_id: Mode ID (1=Heat Pump Only, 2=Electric Only, 3=Energy Saver, - 4=High Demand, 5=Vacation) - vacation_days: Number of vacation days (required for Vacation mode) - - Returns: - Publish packet ID - - Note: - Valid selectable mode IDs are 1, 2, 3, 4, and 5 (vacation). - Additional modes may appear in status responses: - - 0: Standby (device in idle state) - - 6: Power Off (device is powered off) - - Mode descriptions: - - 1: Heat Pump Only (most efficient, slowest recovery) - - 2: Electric Only (least efficient, fastest recovery) - - 3: Energy Saver (balanced, good default) - - 4: High Demand (maximum heating capacity) - - 5: Vacation Mode (requires vacation_days parameter) - """ - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.set_dhw_mode( - device, mode_id, vacation_days - ) - - async def enable_anti_legionella( - self, device: Device, period_days: int + callback: Callable[[EnergyUsageResponse], None], ) -> int: - """Enable Anti-Legionella disinfection with a 1-30 day cycle. - - This command has been confirmed through HAR analysis of the - official Navien app. - When sent, the device responds with antiLegionellaUse=2 (enabled) and - antiLegionellaPeriod set to the specified value. - - See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" for the - authoritative - command code (33554472) and expected payload format: - {"mode": "anti-leg-on", "param": [], "paramStr": ""} - - Args: - device: The device to control - period_days: Days between disinfection cycles (1-30) - - Returns: - The message ID of the published command - - Raises: - ValueError: If period_days is not in the valid range [1, 30] - """ - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.enable_anti_legionella( - device, period_days + """Subscribe to energy usage query responses with automatic parsing.""" + return await self._delegate_subscription( + "subscribe_energy_usage", device, callback ) - async def disable_anti_legionella(self, device: Device) -> int: - """Disable the Anti-Legionella disinfection cycle. - - This command has been confirmed through HAR analysis of the - official Navien app. - When sent, the device responds with antiLegionellaUse=1 (disabled) while - antiLegionellaPeriod retains its previous value. - - The correct command code is 33554471 (not 33554473 as - previously assumed). - - See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" section - for details. - - Returns: - The message ID of the published command + async def ensure_device_info_cached( + self, device: Device, timeout: float = 30.0 + ) -> bool: """ - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") + Ensure device info is cached, requesting if necessary. - return await self._device_controller.disable_anti_legionella(device) - - async def set_dhw_temperature( - self, device: Device, temperature_f: float - ) -> int: - """ - Set DHW target temperature. + Called by control commands and CLI to ensure device + capabilities are available before execution. Args: - device: Device object - temperature_f: Target temperature in Fahrenheit (95-150°F). - Automatically converted to the device's internal format. + device: Device to ensure info for + timeout: Maximum time to wait for response (default: 30 seconds) Returns: - Publish packet ID + True if device info was successfully cached, False on timeout Raises: - MqttNotConnectedError: If not connected to broker - RangeValidationError: If temperature is outside 95-150°F range - - Example: - await client.set_dhw_temperature(device, 140.0) + MqttNotConnectedError: If not connected """ if not self._connected or not self._device_controller: raise MqttNotConnectedError("Not connected to MQTT broker") - return await self._device_controller.set_dhw_temperature( - device, temperature_f - ) - - async def update_reservations( - self, - device: Device, - reservations: Sequence[dict[str, Any]], - *, - enabled: bool = True, - ) -> int: - """Update programmed reservations for temperature/mode changes.""" - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.update_reservations( - device, reservations, enabled=enabled - ) - - async def request_reservations(self, device: Device) -> int: - """Request the current reservation program from the device.""" - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") + from .mqtt_utils import redact_mac - return await self._device_controller.request_reservations(device) - - async def configure_tou_schedule( - self, - device: Device, - controller_serial_number: str, - periods: Sequence[dict[str, Any]], - *, - enabled: bool = True, - ) -> int: - """Configure Time-of-Use pricing schedule via MQTT.""" - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.configure_tou_schedule( - device, controller_serial_number, periods, enabled=enabled - ) - - async def request_tou_settings( - self, - device: Device, - controller_serial_number: str, - ) -> int: - """Request current Time-of-Use schedule from the device.""" - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") + mac = device.device_info.mac_address + redacted_mac = redact_mac(mac) + cached = await self._device_controller._device_info_cache.get(mac) + if cached is not None: + return True - return await self._device_controller.request_tou_settings( - device, controller_serial_number + # Not cached, request and wait + future: asyncio.Future[DeviceFeature] = ( + asyncio.get_running_loop().create_future() ) - async def set_tou_enabled(self, device: Device, enabled: bool) -> int: - """Quickly toggle Time-of-Use functionality without - modifying the schedule.""" - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.set_tou_enabled(device, enabled) - - async def request_energy_usage( - self, device: Device, year: int, months: list[int] - ) -> int: - """ - Request daily energy usage data for specified month(s). - - This retrieves historical energy usage data showing heat pump and - electric heating element consumption broken down by day. The response - includes both energy usage (Wh) and operating time (hours) for each - component. - - Args: - device: Device object - year: Year to query (e.g., 2025) - months: List of months to query (1-12). Can request multiple months. + def on_feature(feature: DeviceFeature) -> None: + if not future.done(): + _logger.info(f"Device feature received for {redacted_mac}") + future.set_result(feature) - Returns: - Publish packet ID - - Example:: - - # Request energy usage for September 2025 - await mqtt_client.request_energy_usage( - device, - year=2025, - months=[9] - ) - - # Request multiple months - await mqtt_client.request_energy_usage( - device, - year=2025, - months=[7, 8, 9] + _logger.info(f"Ensuring device info cached for {redacted_mac}") + await self.subscribe_device_feature(device, on_feature) + try: + _logger.info(f"Requesting device info from {redacted_mac}") + await self.control.request_device_info(device) + _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) + return True + except TimeoutError: + _logger.error( + f"Timed out waiting for device info after {timeout}s for " + f"{redacted_mac}" ) - """ - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.request_energy_usage( - device, year, months - ) - - async def subscribe_energy_usage( - self, - device: Device, - callback: Callable[[EnergyUsageResponse], None], - ) -> int: - """ - Subscribe to energy usage query responses with automatic parsing. - - This method wraps the standard subscription with automatic parsing - of energy usage responses into EnergyUsageResponse objects. - - Args: - device: Device object - callback: Callback function that receives - EnergyUsageResponse objects - - Returns: - Subscription packet ID - - Example: - >>> def on_energy_usage(energy: EnergyUsageResponse): - ... print(f"Total Usage: {energy.total.total_usage} Wh") - ... print( - ... f"Heat Pump: " - ... f"{energy.total.heat_pump_percentage:.1f}%" - ... ) - ... print( - ... f"Electric: " - ... f"{energy.total.heat_element_percentage:.1f}%" - ... ) - >>> - >>> await mqtt_client.subscribe_energy_usage( - ... device, on_energy_usage - ... ) - >>> await mqtt_client.request_energy_usage(device, 2025, [9]) - """ - if not self._connected or not self._subscription_manager: - raise MqttNotConnectedError("Not connected to MQTT broker") - - # Delegate to subscription manager - return await self._subscription_manager.subscribe_energy_usage( - device, callback - ) + return False + finally: + # Note: We don't unsubscribe token here because it might + # interfere with other subscribers if we're not careful. + # But the subscription manager handles multiple callbacks. + pass - async def signal_app_connection(self, device: Device) -> int: + @property + def control(self) -> MqttDeviceController: """ - Signal that the app has connected. + Get the device controller for sending commands. - Args: - device: Device object + The control property enforces that the client must be connected before + accessing any control methods. This is by design to ensure device + commands are only sent when MQTT connection is established and active. + Commands like request_device_info that populate the cache are not + accessible through this property and must be called separately if + needed before connection is fully established. - Returns: - Publish packet ID + Raises: + MqttNotConnectedError: If client is not connected """ if not self._connected or not self._device_controller: raise MqttNotConnectedError("Not connected to MQTT broker") - - return await self._device_controller.signal_app_connection(device) + return self._device_controller async def start_periodic_requests( self, @@ -1279,37 +970,7 @@ async def start_periodic_requests( ) -> None: """ Start sending periodic requests for device information or status. - - This optional helper continuously sends requests at a - specified interval. - It can be used to keep device information or status up-to-date. - - Args: - device: Device object - request_type: Type of request (DEVICE_INFO or DEVICE_STATUS) - period_seconds: Time between requests in seconds - (default: 300 = 5 minutes) - - Example: - >>> # Start periodic status requests (default) - >>> await mqtt_client.start_periodic_requests(device) - >>> - >>> # Start periodic device info requests - >>> await mqtt_client.start_periodic_requests( - ... device, - ... request_type=PeriodicRequestType.DEVICE_INFO - ... ) - >>> - >>> # Custom period: request every 60 seconds - >>> await mqtt_client.start_periodic_requests( - ... device, - ... period_seconds=60 - ... ) - - Note: - - Only one periodic task per request type per device - - Call stop_periodic_requests() to stop a task - - All tasks automatically stop when client disconnects + ... """ if not self._periodic_manager: raise MqttConnectionError( @@ -1327,21 +988,7 @@ async def stop_periodic_requests( ) -> None: """ Stop sending periodic requests for a device. - - Args: - device: Device object - request_type: Type of request to stop. If None, stops all types - for this device. - - Example: - >>> # Stop specific request type - >>> await mqtt_client.stop_periodic_requests( - ... device, - ... PeriodicRequestType.DEVICE_STATUS - ... ) - >>> - >>> # Stop all periodic requests for device - >>> await mqtt_client.stop_periodic_requests(device) + ... """ if not self._periodic_manager: raise MqttConnectionError( @@ -1355,107 +1002,15 @@ async def stop_periodic_requests( async def _stop_all_periodic_tasks(self) -> None: """ Stop all periodic tasks. - - This is called internally when reconnection fails permanently - to reduce log noise from tasks trying to send requests while - disconnected. + ... """ # Delegate to public method with specific reason await self.stop_all_periodic_tasks(_reason="connection failure") - # Convenience methods - async def start_periodic_device_info_requests( - self, device: Device, period_seconds: float = 300.0 - ) -> None: - """ - Start sending periodic device info requests. - - This is a convenience wrapper around start_periodic_requests(). - - Args: - device: Device object - period_seconds: Time between requests in seconds - (default: 300 = 5 minutes) - """ - if not self._periodic_manager: - raise MqttConnectionError( - "Periodic request manager not initialized" - ) - - await self._periodic_manager.start_periodic_device_info_requests( - device, period_seconds - ) - - async def start_periodic_device_status_requests( - self, device: Device, period_seconds: float = 300.0 - ) -> None: - """ - Start sending periodic device status requests. - - This is a convenience wrapper around start_periodic_requests(). - - Args: - device: Device object - period_seconds: Time between requests in seconds - (default: 300 = 5 minutes) - """ - if not self._periodic_manager: - raise MqttConnectionError( - "Periodic request manager not initialized" - ) - - await self._periodic_manager.start_periodic_device_status_requests( - device, period_seconds - ) - - async def stop_periodic_device_info_requests(self, device: Device) -> None: - """ - Stop sending periodic device info requests for a device. - - This is a convenience wrapper around stop_periodic_requests(). - - Args: - device: Device object - """ - if not self._periodic_manager: - raise MqttConnectionError( - "Periodic request manager not initialized" - ) - - await self._periodic_manager.stop_periodic_device_info_requests(device) - - async def stop_periodic_device_status_requests( - self, device: Device - ) -> None: - """ - Stop sending periodic device status requests for a device. - - This is a convenience wrapper around stop_periodic_requests(). - - Args: - device: Device object - """ - if not self._periodic_manager: - raise MqttConnectionError( - "Periodic request manager not initialized" - ) - - await self._periodic_manager.stop_periodic_device_status_requests( - device - ) - async def stop_all_periodic_tasks(self, _reason: str | None = None) -> None: """ Stop all periodic request tasks. - - This is automatically called when disconnecting. - - Args: - _reason: Internal parameter for logging context - (e.g., "connection failure") - - Example: - >>> await mqtt_client.stop_all_periodic_tasks() + ... """ if not self._periodic_manager: raise MqttConnectionError( @@ -1503,9 +1058,7 @@ def session_id(self) -> str: def clear_command_queue(self) -> int: """ Clear all queued commands. - - Returns: - Number of commands that were cleared + ... """ if self._command_queue: count = self._command_queue.count @@ -1518,18 +1071,7 @@ def clear_command_queue(self) -> int: async def reset_reconnect(self) -> None: """ Reset reconnection state and trigger a new reconnection attempt. - - This method resets the reconnection attempt counter and initiates - a new reconnection cycle. Useful for implementing custom recovery - logic after max reconnection attempts have been exhausted. - - Example: - >>> # In a reconnection_failed event handler - >>> await mqtt_client.reset_reconnect() - - Note: - This should typically only be called after a reconnection_failed - event, not during normal operation. + ... """ if self._reconnection_handler: self._reconnection_handler.reset() diff --git a/src/nwp500/mqtt_connection.py b/src/nwp500/mqtt_connection.py index dea8255..cee5c80 100644 --- a/src/nwp500/mqtt_connection.py +++ b/src/nwp500/mqtt_connection.py @@ -321,7 +321,7 @@ async def unsubscribe(self, topic: str) -> int: async def publish( self, topic: str, - payload: str | dict[str, Any, Any], + payload: str | dict[str, Any], qos: mqtt.QoS = mqtt.QoS.AT_LEAST_ONCE, ) -> int: """ @@ -347,13 +347,9 @@ async def publish( # Convert payload to bytes if needed if isinstance(payload, dict): payload_bytes = json.dumps(payload).encode("utf-8") - elif isinstance(payload, str): - payload_bytes = payload.encode("utf-8") - elif isinstance(payload, bytes): - payload_bytes = payload else: - # Try to JSON encode other types - payload_bytes = json.dumps(payload).encode("utf-8") + # payload is str + payload_bytes = payload.encode("utf-8") # Publish and get the concurrent.futures.Future publish_future, packet_id = self._connection.publish( diff --git a/src/nwp500/mqtt_device_control.py b/src/nwp500/mqtt_device_control.py index fd9b7e5..284af18 100644 --- a/src/nwp500/mqtt_device_control.py +++ b/src/nwp500/mqtt_device_control.py @@ -11,6 +11,10 @@ - Time-of-Use (TOU) configuration - Energy usage queries - App connection signaling +- Demand response control +- Air filter maintenance +- Vacation mode configuration +- Recirculation pump control and scheduling """ import logging @@ -18,9 +22,18 @@ from datetime import 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 ParameterValidationError, RangeValidationError -from .models import Device, fahrenheit_to_half_celsius +from .exceptions import ( + DeviceCapabilityError, + ParameterValidationError, + RangeValidationError, +) +from .models import Device, DeviceFeature, fahrenheit_to_half_celsius __author__ = "Emmanuel Levijarvi" @@ -33,6 +46,16 @@ 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 + 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: + + Example: + >>> controller.assert_support("recirculation_mode", device_features) + >>> # Will raise DeviceCapabilityError if not supported + >>> msg_id = await controller.set_recirculation_mode(device, mode) """ def __init__( @@ -40,6 +63,7 @@ def __init__( client_id: str, session_id: str, publish_func: Callable[..., Awaitable[int]], + device_info_cache: DeviceInfoCache | None = None, ) -> None: """ Initialize device controller. @@ -48,10 +72,106 @@ def __init__( client_id: MQTT client ID session_id: Session ID for commands publish_func: Function to publish MQTT messages (async callable) + device_info_cache: Optional device info cache. If not provided, + a new cache with 30-minute update interval is created. """ 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( + update_interval_minutes=30 + ) + # Callback for auto-requesting device info when needed + self._ensure_device_info_callback: ( + Callable[[Device], Awaitable[bool]] | None + ) = None + + async def _ensure_device_info_cached( + self, device: Device, timeout: float = 5.0 + ) -> None: + """ + Ensure device info is cached, requesting if necessary. + + Automatically requests device info if not already cached. + Used internally by control commands. + + Args: + device: Device to ensure info for + timeout: Timeout for waiting for device info response + + Raises: + DeviceCapabilityError: If device info cannot be obtained + """ + mac = device.device_info.mac_address + + # Check if already cached + cached = await self._device_info_cache.get(mac) + if cached is not None: + return # Already cached + + raise DeviceCapabilityError( + "device_info", + ( + f"Device info not cached for {mac}. " + "Ensure device info request has been made." + ), + ) + + async def _auto_request_device_info(self, device: Device) -> None: + """ + Auto-request device info and wait for response. + + Called by decorator when device info is not cached. + + Args: + device: Device to request info for + + Raises: + RuntimeError: If auto-request callback not set or request fails + """ + if self._ensure_device_info_callback is None: + raise RuntimeError( + "Auto-request not available. " + "Ensure MQTT client has set the callback." + ) + success = await self._ensure_device_info_callback(device) + if not success: + raise RuntimeError( + "Failed to obtain device info: " + "Device did not respond with feature data within timeout" + ) + + def check_support( + self, feature: str, device_features: DeviceFeature + ) -> bool: + """Check if device supports a controllable feature. + + Args: + feature: Name of the controllable feature + device_features: Device feature information + + Returns: + True if feature is supported, False otherwise + + Raises: + ValueError: If feature is not recognized + """ + return DeviceCapabilityChecker.supports(feature, device_features) + + def assert_support( + self, feature: str, device_features: DeviceFeature + ) -> None: + """Assert that device supports a controllable feature. + + Args: + feature: Name of the controllable feature + device_features: Device feature information + + Raises: + DeviceCapabilityError: If feature is not supported + ValueError: If feature is not recognized + """ + DeviceCapabilityChecker.assert_supported(feature, device_features) def _build_command( self, @@ -97,307 +217,182 @@ def _build_command( ), } - async def request_device_status(self, device: Device) -> int: - """ - Request general device status. + async def _mode_command( + self, + device: Device, + code: int, + mode: str, + param: list[Any] | None = None, + ) -> int: + """Helper for standard mode-based commands.""" + return await self._send_command( + device, code, mode=mode, param=param or [], paramStr="" + ) - Args: - device: Device object + def _validate_range( + self, field: str, val: float, min_val: float, max_val: float + ) -> None: + """Helper to validate parameter ranges.""" + if not min_val <= val <= max_val: + raise RangeValidationError( + f"{field} must be between {min_val} and {max_val}", + field, + val, + min_val, + max_val, + ) - Returns: - Publish packet ID + async def _get_device_features( + self, device: Device + ) -> DeviceFeature | None: """ - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value + Get cached device features, auto-requesting if necessary. - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/st" - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.STATUS_REQUEST, - additional_value=additional_value, - ) - command["requestTopic"] = topic + Internal helper used by decorators and status requests. + """ + mac = device.device_info.mac_address + cached_features = await self._device_info_cache.get(mac) - return await self._publish(topic, command) + if cached_features is None: + _logger.info("Device info not cached, auto-requesting...") + await self._auto_request_device_info(device) + cached_features = await self._device_info_cache.get(mac) - async def request_device_info(self, device: Device) -> int: + return cached_features + + async def _send_command( + self, + device: Device, + command_code: int, + topic_suffix: str = "ctrl", + response_topic_suffix: str | None = None, + **payload_kwargs: Any, + ) -> int: """ - Request device information (features, firmware, etc.). + Internal helper to build and send a device command. Args: - device: Device object + device: Device to send command to + command_code: Command code to use + topic_suffix: Suffix for the command topic + response_topic_suffix: Optional suffix for custom response topic + **payload_kwargs: Additional fields for the request payload Returns: Publish packet ID """ device_id = device.device_info.mac_address - device_type = device.device_info.device_type + device_type_int = device.device_info.device_type + device_type_str = str(device_type_int) additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/st/did" + topic = MqttTopicBuilder.command_topic( + device_type_str, device_id, topic_suffix + ) + command = self._build_command( - device_type=device_type, + device_type=device_type_int, device_id=device_id, - command=CommandCode.DEVICE_INFO_REQUEST, + command=command_code, additional_value=additional_value, + **payload_kwargs, ) command["requestTopic"] = topic + if response_topic_suffix: + command["responseTopic"] = MqttTopicBuilder.response_topic( + device_type_str, self._client_id, response_topic_suffix + ) + return await self._publish(topic, command) - async def set_power(self, device: Device, power_on: bool) -> int: + async def request_device_status(self, device: Device) -> int: """ - Turn device on or off. + Request general device status. Args: device: Device object - power_on: True to turn on, False to turn off Returns: Publish packet ID """ - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/ctrl" - mode = "power-on" if power_on else "power-off" - command_code = ( - CommandCode.POWER_ON if power_on else CommandCode.POWER_OFF + return await self._send_command( + device=device, + command_code=CommandCode.STATUS_REQUEST, + topic_suffix="st", ) - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=command_code, - additional_value=additional_value, - mode=mode, - param=[], - paramStr="", - ) - command["requestTopic"] = topic - - return await self._publish(topic, command) - - async def set_dhw_mode( - self, - device: Device, - mode_id: int, - vacation_days: int | None = None, - ) -> int: + async def request_device_info(self, device: Device) -> int: """ - Set DHW (Domestic Hot Water) operation mode. + Request device information (features, firmware, etc.). Args: device: Device object - mode_id: Mode ID (1=Heat Pump Only, 2=Electric Only, 3=Energy Saver, - 4=High Demand, 5=Vacation) - vacation_days: Number of vacation days (required for Vacation mode) Returns: Publish packet ID + """ + return await self._send_command( + device=device, + command_code=CommandCode.DEVICE_INFO_REQUEST, + topic_suffix="st/did", + ) - Note: - Valid selectable mode IDs are 1, 2, 3, 4, and 5 (vacation). - Additional modes may appear in status responses: - - 0: Standby (device in idle state) - - 6: Power Off (device is powered off) + @requires_capability("power_use") + async def set_power(self, device: Device, power_on: bool) -> int: + """Turn device on or off.""" + return await self._mode_command( + device, + CommandCode.POWER_ON if power_on else CommandCode.POWER_OFF, + "power-on" if power_on else "power-off", + ) - Mode descriptions: - - 1: Heat Pump Only (most efficient, slowest recovery) - - 2: Electric Only (least efficient, fastest recovery) - - 3: Energy Saver (balanced, good default) - - 4: High Demand (maximum heating capacity) - - 5: Vacation Mode (requires vacation_days parameter) - """ + @requires_capability("dhw_use") + async def set_dhw_mode( + self, device: Device, mode_id: int, vacation_days: int | None = None + ) -> int: + """Set DHW operation mode.""" if mode_id == DhwOperationSetting.VACATION.value: if vacation_days is None: raise ParameterValidationError( - "Vacation mode requires vacation_days (1-30)", + "Vacation mode requires vacation_days", parameter="vacation_days", ) - if not 1 <= vacation_days <= 30: - raise RangeValidationError( - "vacation_days must be between 1 and 30", - field="vacation_days", - value=vacation_days, - min_value=1, - max_value=30, - ) + self._validate_range("vacation_days", vacation_days, 1, 30) param = [mode_id, vacation_days] else: - if vacation_days is not None: - raise ParameterValidationError( - "vacation_days is only valid for vacation mode (mode 5)", - parameter="vacation_days", - ) param = [mode_id] - - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/ctrl" - - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.DHW_MODE, - additional_value=additional_value, - mode="dhw-mode", - param=param, - paramStr="", + return await self._mode_command( + device, CommandCode.DHW_MODE, "dhw-mode", param ) - command["requestTopic"] = topic - - return await self._publish(topic, command) async def enable_anti_legionella( self, device: Device, period_days: int ) -> int: - """ - Enable Anti-Legionella disinfection with a 1-30 day cycle. - - This command has been confirmed through HAR analysis of the official - Navien app. - When sent, the device responds with antiLegionellaUse=2 (enabled) and - antiLegionellaPeriod set to the specified value. - - See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" for the - authoritative - command code (33554472) and expected payload format: - {"mode": "anti-leg-on", "param": [], "paramStr": ""} - - Args: - device: The device to control - period_days: Days between disinfection cycles (1-30) - - Returns: - The message ID of the published command - - Raises: - ValueError: If period_days is not in the valid range [1, 30] - """ - if not 1 <= period_days <= 30: - raise RangeValidationError( - "period_days must be between 1 and 30", - field="period_days", - value=period_days, - min_value=1, - max_value=30, - ) - - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/ctrl" - - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.ANTI_LEGIONELLA_ON, - additional_value=additional_value, - mode="anti-leg-on", - param=[period_days], - paramStr="", + """Enable Anti-Legionella disinfection.""" + self._validate_range("period_days", period_days, 1, 30) + return await self._mode_command( + device, CommandCode.ANTI_LEGIONELLA_ON, "anti-leg-on", [period_days] ) - command["requestTopic"] = topic - - return await self._publish(topic, command) async def disable_anti_legionella(self, device: Device) -> int: - """ - Disable the Anti-Legionella disinfection cycle. - - This command has been confirmed through HAR analysis of the official - Navien app. - When sent, the device responds with antiLegionellaUse=1 (disabled) while - antiLegionellaPeriod retains its previous value. - - The correct command code is 33554471 (not 33554473 as previously - assumed). - See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" section for - details. - - Args: - device: The device to control - - Returns: - The message ID of the published command - """ - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/ctrl" - - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.ANTI_LEGIONELLA_OFF, - additional_value=additional_value, - mode="anti-leg-off", - param=[], - paramStr="", + """Disable the Anti-Legionella disinfection cycle.""" + return await self._mode_command( + device, CommandCode.ANTI_LEGIONELLA_OFF, "anti-leg-off" ) - command["requestTopic"] = topic - - return await self._publish(topic, command) + @requires_capability("dhw_temperature_setting_use") async def set_dhw_temperature( self, device: Device, temperature_f: float ) -> int: - """ - Set DHW target temperature. - - Args: - device: Device object - temperature_f: Target temperature in Fahrenheit (95-150°F). - Automatically converted to the device's internal format. - - Returns: - Publish packet ID - - Raises: - RangeValidationError: If temperature is outside 95-150°F range - - Example: - await controller.set_dhw_temperature(device, 140.0) - """ - if not 95 <= temperature_f <= 150: - raise RangeValidationError( - "temperature_f must be between 95 and 150°F", - field="temperature_f", - value=temperature_f, - min_value=95, - max_value=150, - ) - - param = fahrenheit_to_half_celsius(temperature_f) - - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/ctrl" - - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.DHW_TEMPERATURE, - additional_value=additional_value, - mode="dhw-temperature", - param=[param], - paramStr="", + """Set DHW target temperature (95-150°F).""" + self._validate_range("temperature_f", temperature_f, 95, 150) + return await self._mode_command( + device, + CommandCode.DHW_TEMPERATURE, + "dhw-temperature", + [fahrenheit_to_half_celsius(temperature_f)], ) - command["requestTopic"] = topic - - return await self._publish(topic, command) async def update_reservations( self, @@ -420,29 +415,17 @@ async def update_reservations( # See docs/MQTT_MESSAGES.rst "Reservation Management" for the # command code (16777226) and the reservation object fields # (enable, week, hour, min, mode, param). - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/ctrl/rsv/rd" - reservation_use = 1 if enabled else 2 reservation_payload = [dict(entry) for entry in reservations] - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.RESERVATION_MANAGEMENT, - additional_value=additional_value, + return await self._send_command( + device=device, + command_code=CommandCode.RESERVATION_MANAGEMENT, + topic_suffix="ctrl/rsv/rd", + response_topic_suffix="rsv/rd", reservationUse=reservation_use, reservation=reservation_payload, ) - command["requestTopic"] = topic - command["responseTopic"] = ( - f"cmd/{device_type}/{self._client_id}/res/rsv/rd" - ) - - return await self._publish(topic, command) async def request_reservations(self, device: Device) -> int: """ @@ -454,25 +437,14 @@ async def request_reservations(self, device: Device) -> int: Returns: Publish packet ID """ - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/st/rsv/rd" - - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.RESERVATION_READ, - additional_value=additional_value, + return await self._send_command( + device=device, + command_code=CommandCode.RESERVATION_READ, + topic_suffix="st/rsv/rd", + response_topic_suffix="rsv/rd", ) - command["requestTopic"] = topic - command["responseTopic"] = ( - f"cmd/{device_type}/{self._client_id}/res/rsv/rd" - ) - - return await self._publish(topic, command) + @requires_capability("program_reservation_use") async def configure_tou_schedule( self, device: Device, @@ -510,30 +482,18 @@ async def configure_tou_schedule( "At least one TOU period must be provided", parameter="periods" ) - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/ctrl/tou/rd" - reservation_use = 1 if enabled else 2 reservation_payload = [dict(period) for period in periods] - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.TOU_RESERVATION, - additional_value=additional_value, + return await self._send_command( + device=device, + command_code=CommandCode.TOU_RESERVATION, + topic_suffix="ctrl/tou/rd", + response_topic_suffix="tou/rd", controllerSerialNumber=controller_serial_number, reservationUse=reservation_use, reservation=reservation_payload, ) - command["requestTopic"] = topic - command["responseTopic"] = ( - f"cmd/{device_type}/{self._client_id}/res/tou/rd" - ) - - return await self._publish(topic, command) async def request_tou_settings( self, @@ -559,58 +519,22 @@ async def request_tou_settings( parameter="controller_serial_number", ) - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/ctrl/tou/rd" - - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.TOU_RESERVATION, - additional_value=additional_value, + return await self._send_command( + device=device, + command_code=CommandCode.TOU_RESERVATION, + topic_suffix="ctrl/tou/rd", + response_topic_suffix="tou/rd", controllerSerialNumber=controller_serial_number, ) - command["requestTopic"] = topic - command["responseTopic"] = ( - f"cmd/{device_type}/{self._client_id}/res/tou/rd" - ) - - return await self._publish(topic, command) + @requires_capability("program_reservation_use") async def set_tou_enabled(self, device: Device, enabled: bool) -> int: - """ - Quickly toggle Time-of-Use functionality without modifying the schedule. - - Args: - device: Device object - enabled: True to enable TOU, False to disable - - Returns: - Publish packet ID - """ - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - device_topic = f"navilink-{device_id}" - topic = f"cmd/{device_type}/{device_topic}/ctrl" - - command_code = CommandCode.TOU_ON if enabled else CommandCode.TOU_OFF - mode = "tou-on" if enabled else "tou-off" - - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=command_code, - additional_value=additional_value, - mode=mode, - param=[], - paramStr="", + """Toggle Time-of-Use functionality.""" + return await self._mode_command( + device, + CommandCode.TOU_ON if enabled else CommandCode.TOU_OFF, + "tou-on" if enabled else "tou-off", ) - command["requestTopic"] = topic - - return await self._publish(topic, command) async def request_energy_usage( self, device: Device, year: int, months: list[int] @@ -647,47 +571,88 @@ async def request_energy_usage( months=[7, 8, 9] ) """ - device_id = device.device_info.mac_address - device_type = device.device_info.device_type - additional_value = device.device_info.additional_value - - device_topic = f"navilink-{device_id}" - topic = ( - f"cmd/{device_type}/{device_topic}/st/energy-usage-daily-query/rd" - ) - - command = self._build_command( - device_type=device_type, - device_id=device_id, - command=CommandCode.ENERGY_USAGE_QUERY, - additional_value=additional_value, + return await self._send_command( + device=device, + command_code=CommandCode.ENERGY_USAGE_QUERY, + topic_suffix="st/energy-usage-daily-query/rd", + response_topic_suffix="energy-usage-daily-query/rd", month=months, year=year, ) - command["requestTopic"] = topic - command["responseTopic"] = ( - f"cmd/{device_type}/{self._client_id}/res/energy-usage-daily-query/rd" - ) - - return await self._publish(topic, command) async def signal_app_connection(self, device: Device) -> int: """ Signal that the app has connected. - - Args: - device: Device object - - Returns: - Publish packet ID + ... """ device_id = device.device_info.mac_address - device_type = device.device_info.device_type - device_topic = f"navilink-{device_id}" - topic = f"evt/{device_type}/{device_topic}/app-connection" + device_type = str(device.device_info.device_type) + topic = MqttTopicBuilder.event_topic( + device_type, device_id, "app-connection" + ) message = { "clientID": self._client_id, "timestamp": datetime.utcnow().isoformat() + "Z", } return await self._publish(topic, message) + + async def enable_demand_response(self, device: Device) -> int: + """Enable utility demand response participation.""" + return await self._mode_command(device, CommandCode.DR_ON, "dr-on") + + async def disable_demand_response(self, device: Device) -> int: + """Disable utility demand response participation.""" + return await self._mode_command(device, CommandCode.DR_OFF, "dr-off") + + async def reset_air_filter(self, device: Device) -> int: + """Reset air filter maintenance timer.""" + return await self._mode_command( + device, CommandCode.AIR_FILTER_RESET, "air-filter-reset" + ) + + @requires_capability("holiday_use") + async def set_vacation_days(self, device: Device, days: int) -> int: + """Set vacation/away mode duration (1-365 days).""" + self._validate_range("days", days, 1, 365) + return await self._mode_command( + device, CommandCode.GOOUT_DAY, "goout-day", [days] + ) + + @requires_capability("program_reservation_use") + async def configure_reservation_water_program(self, device: Device) -> int: + """Enable/configure water program reservation mode.""" + return await self._mode_command( + device, CommandCode.RESERVATION_WATER_PROGRAM, "reservation-mode" + ) + + @requires_capability("recirc_reservation_use") + async def configure_recirculation_schedule( + self, + device: Device, + schedule: dict[str, Any], + ) -> int: + """ + Configure recirculation pump schedule. + ... + """ + return await self._send_command( + device=device, + command_code=CommandCode.RECIR_RESERVATION, + schedule=schedule, + ) + + @requires_capability("recirculation_use") + async def set_recirculation_mode(self, device: Device, mode: int) -> int: + """Set recirculation pump operation mode (1-4).""" + self._validate_range("mode", mode, 1, 4) + return await self._mode_command( + device, CommandCode.RECIR_MODE, "recirc-mode", [mode] + ) + + @requires_capability("recirculation_use") + async def trigger_recirculation_hot_button(self, device: Device) -> int: + """Manually trigger the recirculation pump hot button.""" + return await self._mode_command( + device, CommandCode.RECIR_HOT_BTN, "recirc-hotbtn", [1] + ) diff --git a/src/nwp500/mqtt_subscriptions.py b/src/nwp500/mqtt_subscriptions.py index 71006e5..d9df732 100644 --- a/src/nwp500/mqtt_subscriptions.py +++ b/src/nwp500/mqtt_subscriptions.py @@ -15,15 +15,20 @@ import json import logging from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any from awscrt import mqtt 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 + +if TYPE_CHECKING: + from .device_info_cache import DeviceInfoCache __author__ = "Emmanuel Levijarvi" @@ -48,6 +53,7 @@ def __init__( client_id: str, event_emitter: EventEmitter, schedule_coroutine: Callable[[Any], None], + device_info_cache: DeviceInfoCache | None = None, ): """ Initialize subscription manager. @@ -57,11 +63,14 @@ 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 + features """ self._connection = connection self._client_id = client_id self._event_emitter = event_emitter self._schedule_coroutine = schedule_coroutine + self._device_info_cache = device_info_cache # Track subscriptions and handlers self._subscriptions: dict[str, mqtt.QoS] = {} @@ -325,130 +334,63 @@ async def subscribe_device( # Subscribe to all command responses from device (broader pattern) # Device responses come on cmd/{device_type}/navilink-{device_id}/# device_id = device.device_info.mac_address - device_type = device.device_info.device_type - device_topic = f"navilink-{device_id}" - response_topic = f"cmd/{device_type}/{device_topic}/#" + device_type = str(device.device_info.device_type) + response_topic = MqttTopicBuilder.command_topic( + device_type, device_id, "#" + ) return await self.subscribe(response_topic, callback) async def subscribe_device_status( self, device: Device, callback: Callable[[DeviceStatus], None] ) -> int: - """ - Subscribe to device status messages with automatic parsing. - - This method wraps the standard subscription with automatic parsing - of status messages into DeviceStatus objects. The callback will only - be invoked when a status message is received and successfully parsed. - - Additionally, the client emits granular events for state changes: - - 'status_received': Every status update (DeviceStatus) - - 'temperature_changed': Temperature changed (old_temp, new_temp) - - 'mode_changed': Operation mode changed (old_mode, new_mode) - - 'power_changed': Power consumption changed (old_power, new_power) - - 'heating_started': Device started heating (status) - - 'heating_stopped': Device stopped heating (status) - - 'error_detected': Error code detected (error_code, status) - - 'error_cleared': Error code cleared (error_code) + """Subscribe to device status messages with automatic parsing.""" - Args: - device: Device object - callback: Callback function that receives DeviceStatus objects - - Returns: - Subscription packet ID + def post_parse(status: DeviceStatus) -> None: + self._schedule_coroutine( + self._event_emitter.emit("status_received", status) + ) + self._schedule_coroutine(self._detect_state_changes(status)) - Example (Traditional Callback):: - - >>> def on_status(status: DeviceStatus): - ... print(f"Temperature: {status.dhw_temperature}°F") - ... print(f"Mode: {status.operation_mode}") - >>> - >>> await mqtt_client.subscribe_device_status(device, on_status) - - Example (Event Emitter):: - - >>> # Multiple handlers for same event - >>> mqtt_client.on('temperature_changed', log_temperature) - >>> mqtt_client.on('temperature_changed', update_ui) - >>> - >>> # State change events - >>> mqtt_client.on('heating_started', lambda s: print("Heating ON")) - >>> mqtt_client.on('heating_stopped', lambda s: print("Heating - OFF")) - >>> - >>> # Subscribe to start receiving events - >>> await mqtt_client.subscribe_device_status(device, lambda s: - None) - """ + handler = self._make_handler( + DeviceStatus, callback, "status", post_parse + ) + return await self.subscribe_device(device=device, callback=handler) - def status_message_handler(topic: str, message: dict[str, Any]) -> None: - """Parse status messages and invoke user callback.""" + def _make_handler( + self, + model: Any, + callback: Callable[[Any], None], + key: str | None = None, + post_parse: Callable[[Any], None] | None = None, + ) -> Callable[[str, dict[str, Any]], None]: + """Generic factory for MQTT message handlers.""" + + def handler(topic: str, message: dict[str, Any]) -> None: try: - # Log all messages received for debugging - _logger.debug( - f"Status handler received message on topic: {topic}" + res = message.get("response", {}) + # Try nested response field, then fallback to top-level + data = (res.get(key) if key else res) or ( + message.get(key) if key else None ) - _logger.debug(f"Message keys: {list(message.keys())}") - - if "response" not in message: - _logger.debug( - "Message does not contain 'response' key, skipping. " - "Keys: %s", - list(message.keys()), - ) - return - - response = message["response"] - _logger.debug(f"Response keys: {list(response.keys())}") - - if "status" not in response: - _logger.debug( - "Response does not contain 'status' key, skipping. " - "Keys: %s", - list(response.keys()), - ) + if not data: return - # Parse status into DeviceStatus object - _logger.info( - f"Parsing device status message from topic: {topic}" - ) - status_data = response["status"] - device_status = DeviceStatus.from_dict(status_data) - - # Emit raw status event - self._schedule_coroutine( - self._event_emitter.emit("status_received", device_status) - ) - - # Detect and emit state changes - self._schedule_coroutine( - self._detect_state_changes(device_status) - ) - - # Invoke user callback with parsed status - _logger.info("Invoking user callback with parsed DeviceStatus") - callback(device_status) - _logger.debug("User callback completed successfully") - - except KeyError as e: + parsed = model.from_dict(data) + if post_parse: + post_parse(parsed) + callback(parsed) + except ( + ValidationError, + KeyError, + ValueError, + TypeError, + AttributeError, + ) as e: _logger.warning( - f"Missing required field in status message: {e}", - exc_info=True, - ) - except ValueError as e: - _logger.warning( - f"Invalid value in status message: {e}", exc_info=True - ) - except (TypeError, AttributeError) as e: - _logger.error( - f"Error parsing device status: {e}", exc_info=True + f"Error parsing {model.__name__} on {topic}: {e}" ) - # Subscribe using the internal handler - return await self.subscribe_device( - device=device, callback=status_message_handler - ) + return handler async def _detect_state_changes(self, status: DeviceStatus) -> None: """ @@ -536,202 +478,37 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: async def subscribe_device_feature( self, device: Device, callback: Callable[[DeviceFeature], None] ) -> int: - """ - Subscribe to device feature/info messages with automatic parsing. - - This method wraps the standard subscription with automatic parsing - of feature messages into DeviceFeature objects. The callback will only - be invoked when a feature message is received and successfully parsed. - - Feature messages contain device capabilities, firmware versions, - serial numbers, and configuration limits. - - Additionally emits: 'feature_received' event with DeviceFeature object. - - Args: - device: Device object - callback: Callback function that receives DeviceFeature objects - - Returns: - Subscription packet ID - - Example:: - - >>> def on_feature(feature: DeviceFeature): - ... print(f"Serial: {feature.controller_serial_number}") - ... print(f"FW Version: {feature.controller_sw_version}") - ... print(f"Temp Range: - {feature.dhw_temperature_min}-{feature.dhw_temperature_max}°F") - >>> - >>> await mqtt_client.subscribe_device_feature(device, on_feature) - - >>> # Or use event emitter - >>> mqtt_client.on('feature_received', lambda f: print(f"FW: - {f.controller_sw_version}")) - >>> await mqtt_client.subscribe_device_feature(device, lambda f: - None) - """ + """Subscribe to device feature/info messages with automatic parsing.""" - def feature_message_handler( - topic: str, message: dict[str, Any] - ) -> None: - """Parse feature messages and invoke user callback.""" - try: - # Log all messages received for debugging - _logger.debug( - f"Feature handler received message on topic: {topic}" - ) - _logger.debug(f"Message keys: {list(message.keys())}") - - # Check if message contains feature data - if "response" not in message: - _logger.debug( - "Message does not contain 'response' key, " - "skipping. Keys: %s", - list(message.keys()), - ) - return - - response = message["response"] - _logger.debug(f"Response keys: {list(response.keys())}") - - if "feature" not in response: - _logger.debug( - "Response does not contain 'feature' key, " - "skipping. Keys: %s", - list(response.keys()), - ) - return - - # Parse feature into DeviceFeature object - _logger.info( - f"Parsing device feature message from topic: {topic}" - ) - feature_data = response["feature"] - device_feature = DeviceFeature.from_dict(feature_data) - - # Emit feature received event + def post_parse(feature: DeviceFeature) -> None: + if self._device_info_cache: self._schedule_coroutine( - self._event_emitter.emit("feature_received", device_feature) - ) - - # Invoke user callback with parsed feature - _logger.info("Invoking user callback with parsed DeviceFeature") - callback(device_feature) - _logger.debug("User callback completed successfully") - - except KeyError as e: - _logger.warning( - f"Missing required field in feature message: {e}", - exc_info=True, - ) - except ValueError as e: - _logger.warning( - f"Invalid value in feature message: {e}", exc_info=True - ) - except (TypeError, AttributeError) as e: - _logger.error( - f"Error parsing device feature: {e}", exc_info=True + self._device_info_cache.set( + device.device_info.mac_address, feature + ) ) + self._schedule_coroutine( + self._event_emitter.emit("feature_received", feature) + ) - # Subscribe using the internal handler - return await self.subscribe_device( - device=device, callback=feature_message_handler + handler = self._make_handler( + DeviceFeature, callback, "feature", post_parse ) + return await self.subscribe_device(device=device, callback=handler) async def subscribe_energy_usage( self, device: Device, callback: Callable[[EnergyUsageResponse], None], ) -> int: - """ - Subscribe to energy usage query responses with automatic parsing. - - This method wraps the standard subscription with automatic parsing - of energy usage responses into EnergyUsageResponse objects. - - Args: - device: Device object - callback: Callback function that receives EnergyUsageResponse - objects - - Returns: - Subscription packet ID - - Example: - >>> def on_energy_usage(energy: EnergyUsageResponse): - ... print(f"Total Usage: {energy.total.total_usage} Wh") - ... print(f"Heat Pump: - {energy.total.heat_pump_percentage:.1f}%") - ... print(f"Electric: - {energy.total.heat_element_percentage:.1f}%") - >>> - >>> await mqtt_client.subscribe_energy_usage(device, - on_energy_usage) - >>> await mqtt_client.request_energy_usage(device, 2025, [9]) - """ - device_type = device.device_info.device_type - - def energy_message_handler(topic: str, message: dict[str, Any]) -> None: - """Parse and route energy usage responses to user callback. - - Args: - topic: MQTT topic the message was received on - message: Parsed message dictionary - """ - try: - _logger.debug( - "Energy handler received message on topic: %s", topic - ) - _logger.debug("Message keys: %s", list(message.keys())) - - if "response" not in message: - _logger.debug( - "Message does not contain 'response' key, " - "skipping. Keys: %s", - list(message.keys()), - ) - return - - response_data = message["response"] - _logger.debug("Response keys: %s", list(response_data.keys())) - - if "typeOfUsage" not in response_data: - _logger.debug( - "Response does not contain 'typeOfUsage' key, " - "skipping. Keys: %s", - list(response_data.keys()), - ) - return - - _logger.info( - "Parsing energy usage response from topic: %s", topic - ) - energy_response = EnergyUsageResponse.from_dict(response_data) - - _logger.info( - "Invoking user callback with parsed EnergyUsageResponse" - ) - callback(energy_response) - _logger.debug("User callback completed successfully") - - except KeyError as e: - _logger.warning( - "Failed to parse energy usage message - missing key: %s", e - ) - except (TypeError, ValueError, AttributeError) as e: - _logger.error( - "Error in energy usage message handler: %s", - e, - exc_info=True, - ) - - response_topic = ( - f"cmd/{device_type}/{self._client_id}/res/" - f"energy-usage-daily-query/rd" + """Subscribe to energy usage responses with automatic parsing.""" + handler = self._make_handler(EnergyUsageResponse, callback) + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "energy-usage-daily-query/rd", ) - - return await self.subscribe(response_topic, energy_message_handler) + return await self.subscribe(topic, handler) def clear_subscriptions(self) -> None: """Clear all subscription tracking (called on disconnect).""" diff --git a/src/nwp500/mqtt_utils.py b/src/nwp500/mqtt_utils.py index c77983a..ae205b6 100644 --- a/src/nwp500/mqtt_utils.py +++ b/src/nwp500/mqtt_utils.py @@ -142,6 +142,51 @@ def redact_topic(topic: str) -> str: return topic +def redact_mac(mac: str | None) -> str: + """Mask a MAC address or device ID for safe logging. + + Args: + mac: The MAC address or device ID to redact + (e.g., 'navilink-0123456789ab') + + Returns: + A redacted string like 'navilink-01...89ab' or '' + """ + if not mac: + return "" + + # Handle navilink- prefix + prefix = "" + if mac.startswith("navilink-"): + prefix = "navilink-" + mac = mac[len("navilink-") :] + + if len(mac) <= 4: + return f"{prefix}" + + # Mask central part, keeping first 2 and last 4 + return f"{prefix}{mac[:2]}...{mac[-4:]}" + + +def redact_serial(serial: str | None) -> str: + """Mask a serial number for safe logging. + + Args: + serial: Serial number to redact + + Returns: + Redacted serial like 'AB...1234' + """ + if not serial: + return "" + + if len(serial) <= 6: + return "" + + # Mask central part, keeping first 2 and last 4 + return f"{serial[:2]}...{serial[-4:]}" + + @dataclass class MqttConnectionConfig: """Configuration for MQTT connection. diff --git a/src/nwp500/topic_builder.py b/src/nwp500/topic_builder.py new file mode 100644 index 0000000..e5dbd3a --- /dev/null +++ b/src/nwp500/topic_builder.py @@ -0,0 +1,40 @@ +""" +MQTT topic building utilities for Navien devices. +""" + + +class MqttTopicBuilder: + """Helper to construct standard MQTT topics for Navien devices.""" + + @staticmethod + def device_topic(mac_address: str) -> str: + """Get the base device topic from MAC address.""" + return f"navilink-{mac_address}" + + @staticmethod + def command_topic( + device_type: str, mac_address: str, suffix: str = "ctrl" + ) -> str: + """ + Build a command topic. + Format: cmd/{device_type}/navilink-{mac}/{suffix} + """ + dt = MqttTopicBuilder.device_topic(mac_address) + return f"cmd/{device_type}/{dt}/{suffix}" + + @staticmethod + def response_topic(device_type: str, client_id: str, suffix: str) -> str: + """ + Build a response topic. + Format: cmd/{device_type}/{client_id}/res/{suffix} + """ + return f"cmd/{device_type}/{client_id}/res/{suffix}" + + @staticmethod + def event_topic(device_type: str, mac_address: str, suffix: str) -> str: + """ + Build an event topic. + Format: evt/{device_type}/navilink-{mac}/{suffix} + """ + dt = MqttTopicBuilder.device_topic(mac_address) + return f"evt/{device_type}/{dt}/{suffix}" diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 31639a2..6ad9160 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -24,13 +24,16 @@ def mock_device(): @pytest.fixture def mock_mqtt(): mqtt = MagicMock() - # Async methods need to be AsyncMock + # Control attribute contains device control methods + mqtt.control = MagicMock() + mqtt.control.request_device_info = AsyncMock() + mqtt.control.request_device_status = AsyncMock() + mqtt.control.set_dhw_mode = AsyncMock() + mqtt.control.set_dhw_temperature = AsyncMock() + + # Async methods on mqtt itself mqtt.subscribe_device_feature = AsyncMock() - mqtt.request_device_info = AsyncMock() mqtt.subscribe_device_status = AsyncMock() - mqtt.request_device_status = AsyncMock() - mqtt.set_dhw_mode = AsyncMock() - mqtt.set_dhw_temperature = AsyncMock() return mqtt @@ -53,7 +56,7 @@ async def side_effect_subscribe(device, callback): ) assert serial == "TEST_SERIAL_123" - mock_mqtt.request_device_info.assert_called_once_with(mock_device) + mock_mqtt.control.request_device_info.assert_called_once_with(mock_device) @pytest.mark.asyncio @@ -68,7 +71,7 @@ async def test_get_controller_serial_number_timeout(mock_mqtt, mock_device): ) assert serial is None - mock_mqtt.request_device_info.assert_called_once_with(mock_device) + mock_mqtt.control.request_device_info.assert_called_once_with(mock_device) @pytest.mark.asyncio @@ -85,10 +88,11 @@ async def side_effect_subscribe(device, callback): await handle_status_request(mock_mqtt, mock_device) - mock_mqtt.request_device_status.assert_called_once_with(mock_device) + mock_mqtt.control.request_device_status.assert_called_once_with(mock_device) captured = capsys.readouterr() - assert "some" in captured.out - assert "data" in captured.out + # Check for human-readable format output + assert "DEVICE STATUS" in captured.out + assert "STATUS" in captured.out @pytest.mark.asyncio @@ -110,9 +114,8 @@ async def side_effect_subscribe(device, callback): await handle_set_mode_request(mock_mqtt, mock_device, "heat-pump") - mock_mqtt.set_dhw_mode.assert_called_once_with( - mock_device, 1 - ) # 1 = Heat Pump + # 1 = Heat Pump + mock_mqtt.control.set_dhw_mode.assert_called_once_with(mock_device, 1) @pytest.mark.asyncio @@ -120,7 +123,7 @@ async def test_handle_set_mode_request_invalid_mode(mock_mqtt, mock_device): """Test setting an invalid mode.""" await handle_set_mode_request(mock_mqtt, mock_device, "invalid-mode") - mock_mqtt.set_dhw_mode.assert_not_called() + mock_mqtt.control.set_dhw_mode.assert_not_called() @pytest.mark.asyncio @@ -138,14 +141,6 @@ async def side_effect_subscribe(device, callback): await handle_set_dhw_temp_request(mock_mqtt, mock_device, 120.0) - mock_mqtt.set_dhw_temperature.assert_called_once_with(mock_device, 120.0) - - -@pytest.mark.asyncio -async def test_handle_set_dhw_temp_request_out_of_range(mock_mqtt, mock_device): - """Test setting temperature out of range.""" - await handle_set_dhw_temp_request(mock_mqtt, mock_device, 160.0) # > 150 - mock_mqtt.set_dhw_temperature.assert_not_called() - - await handle_set_dhw_temp_request(mock_mqtt, mock_device, 90.0) # < 95 - mock_mqtt.set_dhw_temperature.assert_not_called() + mock_mqtt.control.set_dhw_temperature.assert_called_once_with( + mock_device, 120.0 + ) diff --git a/tests/test_command_decorators.py b/tests/test_command_decorators.py new file mode 100644 index 0000000..24df735 --- /dev/null +++ b/tests/test_command_decorators.py @@ -0,0 +1,333 @@ +"""Tests for command decorators.""" + +from typing import Any +from unittest.mock import Mock + +import pytest + +from nwp500.command_decorators import requires_capability +from nwp500.device_info_cache import DeviceInfoCache +from nwp500.exceptions import DeviceCapabilityError + + +class BaseMockController: + """Base class for mock controllers to avoid duplication.""" + + def __init__(self, cache: DeviceInfoCache) -> None: + self._device_info_cache = cache + + async def _get_device_features(self, device: Any) -> Any: + """Get device features, helper for the decorator.""" + features = await self._device_info_cache.get( + device.device_info.mac_address + ) + if features is None and hasattr(self, "_auto_request_device_info"): + try: + await self._auto_request_device_info(device) + features = await self._device_info_cache.get( + device.device_info.mac_address + ) + except Exception: + pass + return features + + +class TestRequiresCapabilityDecorator: + """Tests for requires_capability decorator.""" + + @pytest.mark.asyncio + async def test_decorator_allows_supported_capability(self) -> None: + """Test decorator allows execution when capability is supported.""" + cache = DeviceInfoCache() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + # Create mock features that supports power_use + mock_features = Mock() + mock_features.power_use = True + + await cache.set(mock_device.device_info.mac_address, mock_features) + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + self.command_called = False + + @requires_capability("power_use") + async def set_power(self, device: Mock, power_on: bool) -> None: + self.command_called = True + + controller = MockController() + await controller.set_power(mock_device, True) + assert controller.command_called + + @pytest.mark.asyncio + async def test_decorator_blocks_unsupported_capability(self) -> None: + """Test decorator blocks execution when capability is not supported.""" + cache = DeviceInfoCache() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + # Create mock features that does not support power_use + mock_features = Mock() + mock_features.power_use = False + + await cache.set(mock_device.device_info.mac_address, mock_features) + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + self.command_called = False + + @requires_capability("power_use") + async def set_power(self, device: Mock, power_on: bool) -> None: + self.command_called = True + + controller = MockController() + with pytest.raises(DeviceCapabilityError): + await controller.set_power(mock_device, True) + assert not controller.command_called + + @pytest.mark.asyncio + async def test_decorator_auto_requests_device_info(self) -> None: + """Test decorator auto-requests device info when not cached.""" + cache = DeviceInfoCache() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + # Create mock features that support power_use + mock_features = Mock() + mock_features.power_use = True + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + self.command_called = False + self.auto_request_called = False + + @requires_capability("power_use") + async def set_power(self, device: Mock, power_on: bool) -> None: + self.command_called = True + + async def _auto_request_device_info(self, device: Mock) -> None: + self.auto_request_called = True + await self._device_info_cache.set( + device.device_info.mac_address, mock_features + ) + + controller = MockController() + await controller.set_power(mock_device, True) + assert controller.auto_request_called + assert controller.command_called + + @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() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + + @requires_capability("power_use") + async def set_power(self, device: Mock, power_on: bool) -> None: + pass + + async def _auto_request_device_info(self, device: Mock) -> None: + # Simulate failure to get device info + pass + + controller = MockController() + with pytest.raises(DeviceCapabilityError): + await controller.set_power(mock_device, True) + + @pytest.mark.asyncio + async def test_decorator_with_multiple_arguments(self) -> None: + """Test decorator works with multiple function arguments.""" + cache = DeviceInfoCache() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + mock_features = Mock() + mock_features.power_use = True + + await cache.set(mock_device.device_info.mac_address, mock_features) + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + self.received_args = None + + @requires_capability("power_use") + async def command( + self, + device: Mock, + arg1: str, + arg2: int, + kwarg1: str = "default", + ) -> None: + self.received_args = (arg1, arg2, kwarg1) + + controller = MockController() + await controller.command(mock_device, "value1", 42, kwarg1="custom") + assert controller.received_args == ("value1", 42, "custom") + + @pytest.mark.asyncio + async def test_decorator_preserves_function_name(self) -> None: + """Test decorator preserves function name.""" + cache = DeviceInfoCache() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + mock_features = Mock() + mock_features.power_use = True + + await cache.set(mock_device.device_info.mac_address, mock_features) + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + + @requires_capability("power_use") + async def my_special_command(self, device: Mock) -> None: + pass + + controller = MockController() + assert controller.my_special_command.__name__ == "my_special_command" + + @pytest.mark.asyncio + async def test_decorator_with_different_capabilities(self) -> None: + """Test decorator works with different capability requirements.""" + cache = DeviceInfoCache() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + # Device supports only power_use + mock_features = Mock() + mock_features.power_use = True + mock_features.dhw_use = False + + await cache.set(mock_device.device_info.mac_address, mock_features) + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + self.power_called = False + self.dhw_called = False + + @requires_capability("power_use") + async def set_power(self, device: Mock) -> None: + self.power_called = True + + @requires_capability("dhw_use") + async def set_dhw(self, device: Mock) -> None: + self.dhw_called = True + + controller = MockController() + + # power_use should succeed + await controller.set_power(mock_device) + assert controller.power_called + + # dhw_use should fail + with pytest.raises(DeviceCapabilityError): + await controller.set_dhw(mock_device) + assert not controller.dhw_called + + @pytest.mark.asyncio + async def test_decorator_with_sync_function_logs_warning(self) -> None: + """Test decorator with sync function raises TypeError.""" + cache = DeviceInfoCache() + mock_device = Mock() + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + self.command_called = False + + @requires_capability("power_use") + def set_power_sync(self, device: Mock, power_on: bool) -> None: + self.command_called = True + + controller = MockController() + + with pytest.raises( + TypeError, + match="must be async to use @requires_capability decorator", + ): + controller.set_power_sync(mock_device, True) + + @pytest.mark.asyncio + async def test_decorator_handles_auto_request_exception(self) -> None: + """Test decorator handles exception during auto-request.""" + cache = DeviceInfoCache() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + + @requires_capability("power_use") + async def set_power(self, device: Mock, power_on: bool) -> None: + pass + + async def _auto_request_device_info(self, device: Mock) -> None: + # Simulate exception during auto-request + raise RuntimeError("Connection failed") + + controller = MockController() + + with pytest.raises(DeviceCapabilityError): + await controller.set_power(mock_device, True) + + @pytest.mark.asyncio + async def test_decorator_returns_function_result(self) -> None: + """Test decorator properly returns function result.""" + cache = DeviceInfoCache() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + mock_features = Mock() + mock_features.power_use = True + + await cache.set(mock_device.device_info.mac_address, mock_features) + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + + @requires_capability("power_use") + async def get_status(self, device: Mock) -> str: + return "status_ok" + + controller = MockController() + result = await controller.get_status(mock_device) + assert result == "status_ok" + + @pytest.mark.asyncio + async def test_decorator_with_exception_in_decorated_function(self) -> None: + """Test decorator propagates exceptions from decorated function.""" + cache = DeviceInfoCache() + mock_device = Mock() + mock_device.device_info.mac_address = "AA:BB:CC:DD:EE:FF" + + mock_features = Mock() + mock_features.power_use = True + + await cache.set(mock_device.device_info.mac_address, mock_features) + + class MockController(BaseMockController): + def __init__(self) -> None: + super().__init__(cache) + + @requires_capability("power_use") + async def failing_command(self, device: Mock) -> None: + raise RuntimeError("Command failed") + + controller = MockController() + + with pytest.raises(RuntimeError, match="Command failed"): + await controller.failing_command(mock_device) diff --git a/tests/test_device_capabilities.py b/tests/test_device_capabilities.py new file mode 100644 index 0000000..d8bde3c --- /dev/null +++ b/tests/test_device_capabilities.py @@ -0,0 +1,130 @@ +"""Tests for device capability checking.""" + +from unittest.mock import Mock + +import pytest + +from nwp500.device_capabilities import DeviceCapabilityChecker +from nwp500.enums import DHWControlTypeFlag +from nwp500.exceptions import DeviceCapabilityError + + +class TestDeviceCapabilityChecker: + """Tests for DeviceCapabilityChecker.""" + + 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) + + 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) + + 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) + + 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) + + 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) + + def test_dhw_temperature_control_enabled(self) -> None: + """Test DHW temperature control detection when enabled.""" + mock_feature = Mock() + mock_feature.dhw_temperature_setting_use = ( + DHWControlTypeFlag.ENABLE_1_DEGREE + ) + assert DeviceCapabilityChecker.supports( + "dhw_temperature_setting_use", mock_feature + ) + + 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( + "dhw_temperature_setting_use", mock_feature + ) + + 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( + "dhw_temperature_setting_use", mock_feature + ) + + def test_get_available_controls(self) -> None: + """Test get_available_controls returns all feature statuses.""" + mock_feature = Mock() + mock_feature.power_use = True + mock_feature.dhw_use = False + mock_feature.dhw_temperature_setting_use = ( + DHWControlTypeFlag.ENABLE_1_DEGREE + ) + mock_feature.holiday_use = True + mock_feature.program_reservation_use = False + mock_feature.recirculation_use = True + mock_feature.recirc_reservation_use = False + + controls = DeviceCapabilityChecker.get_available_controls(mock_feature) + + assert controls["power_use"] is True + assert controls["dhw_use"] is False + assert controls["dhw_temperature_setting_use"] is True + assert controls["holiday_use"] is True + assert controls["program_reservation_use"] is False + assert controls["recirculation_use"] is True + assert controls["recirc_reservation_use"] is False + assert len(controls) == 7 + + def test_register_capability(self) -> None: + """Test custom capability registration.""" + mock_feature = Mock() + custom_check = lambda f: True # noqa: E731 + + DeviceCapabilityChecker.register_capability( + "custom_feature", custom_check + ) + + try: + assert DeviceCapabilityChecker.supports( + "custom_feature", mock_feature + ) + finally: + # Clean up + del DeviceCapabilityChecker._CAPABILITY_MAP["custom_feature"] + + def test_register_capability_override(self) -> None: + """Test overriding an existing capability.""" + original = DeviceCapabilityChecker._CAPABILITY_MAP["power_use"] + mock_feature = Mock() + + try: + # Override to always return False + DeviceCapabilityChecker.register_capability( + "power_use", lambda f: False + ) + mock_feature.power_use = True + assert not DeviceCapabilityChecker.supports( + "power_use", mock_feature + ) + finally: + # Restore original + DeviceCapabilityChecker._CAPABILITY_MAP["power_use"] = original diff --git a/tests/test_device_info_cache.py b/tests/test_device_info_cache.py new file mode 100644 index 0000000..d298ba6 --- /dev/null +++ b/tests/test_device_info_cache.py @@ -0,0 +1,269 @@ +"""Tests for device information caching.""" + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest + +from nwp500.device_info_cache import DeviceInfoCache + + +@pytest.fixture +def device_feature() -> dict: + """Create a mock device feature.""" + return {"mac": "AA:BB:CC:DD:EE:FF", "data": "feature_data"} + + +@pytest.fixture +def cache_with_updates() -> DeviceInfoCache: + """Create a cache with 30-minute update interval.""" + return DeviceInfoCache(update_interval_minutes=30) + + +@pytest.fixture +def cache_no_updates() -> DeviceInfoCache: + """Create a cache with auto-updates disabled.""" + return DeviceInfoCache(update_interval_minutes=0) + + +class TestDeviceInfoCache: + """Tests for DeviceInfoCache.""" + + @pytest.mark.asyncio + async def test_cache_get_returns_none_when_empty( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test that get returns None for uncached device.""" + result = await cache_with_updates.get("AA:BB:CC:DD:EE:FF") + assert result is None + + @pytest.mark.asyncio + async def test_cache_set_and_get( + self, cache_with_updates: DeviceInfoCache, device_feature: dict + ) -> None: + """Test basic set and get operations.""" + mac = "AA:BB:CC:DD:EE:FF" + await cache_with_updates.set(mac, device_feature) + result = await cache_with_updates.get(mac) + assert result is device_feature + + @pytest.mark.asyncio + async def test_cache_set_overwrites_previous( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test that set overwrites previous cache entry.""" + mac = "AA:BB:CC:DD:EE:FF" + feature1 = {"data": "first"} + feature2 = {"data": "second"} + + await cache_with_updates.set(mac, feature1) + result1 = await cache_with_updates.get(mac) + assert result1 is feature1 + + await cache_with_updates.set(mac, feature2) + result2 = await cache_with_updates.get(mac) + assert result2 is feature2 + + @pytest.mark.asyncio + async def test_cache_multiple_devices( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test caching multiple devices.""" + mac1 = "AA:BB:CC:DD:EE:FF" + mac2 = "11:22:33:44:55:66" + + feature1 = {"data": "device1"} + feature2 = {"data": "device2"} + + await cache_with_updates.set(mac1, feature1) + await cache_with_updates.set(mac2, feature2) + + result1 = await cache_with_updates.get(mac1) + result2 = await cache_with_updates.get(mac2) + + assert result1 is feature1 + assert result2 is feature2 + + @pytest.mark.asyncio + async def test_cache_expiration(self) -> None: + """Test that cache entries expire.""" + cache_exp = DeviceInfoCache(update_interval_minutes=1) + mac = "AA:BB:CC:DD:EE:FF" + feature = {"data": "test"} + old_time = datetime.now(UTC) - timedelta(minutes=2) + cache_exp._cache[mac] = (feature, old_time) + + # Get after expiry should return None + result = await cache_exp.get(mac) + assert result is None + + @pytest.mark.asyncio + async def test_is_expired_with_zero_interval( + self, cache_no_updates: DeviceInfoCache + ) -> None: + """Test is_expired returns False when interval is 0 (no updates).""" + old_time = datetime.now(UTC) - timedelta(hours=1) + assert not cache_no_updates.is_expired(old_time) + + @pytest.mark.asyncio + async def test_is_expired_with_fresh_entry( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test is_expired returns False for fresh entries.""" + recent_time = datetime.now(UTC) - timedelta(minutes=5) + assert not cache_with_updates.is_expired(recent_time) + + @pytest.mark.asyncio + async def test_is_expired_with_old_entry( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test is_expired returns True for old entries.""" + old_time = datetime.now(UTC) - timedelta(minutes=60) + assert cache_with_updates.is_expired(old_time) + + @pytest.mark.asyncio + async def test_cache_invalidate( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test cache invalidation.""" + mac = "AA:BB:CC:DD:EE:FF" + feature = {"data": "test"} + await cache_with_updates.set(mac, feature) + assert await cache_with_updates.get(mac) is not None + + await cache_with_updates.invalidate(mac) + assert await cache_with_updates.get(mac) is None + + @pytest.mark.asyncio + async def test_cache_invalidate_nonexistent( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test invalidating nonexistent entry doesn't raise.""" + # Should not raise + await cache_with_updates.invalidate("AA:BB:CC:DD:EE:FF") + + @pytest.mark.asyncio + async def test_cache_clear( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test clearing entire cache.""" + mac1 = "AA:BB:CC:DD:EE:FF" + mac2 = "11:22:33:44:55:66" + feature = {"data": "test"} + + await cache_with_updates.set(mac1, feature) + await cache_with_updates.set(mac2, feature) + + assert await cache_with_updates.get(mac1) is not None + assert await cache_with_updates.get(mac2) is not None + + await cache_with_updates.clear() + + assert await cache_with_updates.get(mac1) is None + assert await cache_with_updates.get(mac2) is None + + @pytest.mark.asyncio + async def test_get_all_cached( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test get_all_cached returns all cached devices.""" + mac1 = "AA:BB:CC:DD:EE:FF" + mac2 = "11:22:33:44:55:66" + + feature1 = {"data": "device1"} + feature2 = {"data": "device2"} + + await cache_with_updates.set(mac1, feature1) + await cache_with_updates.set(mac2, feature2) + + all_cached = await cache_with_updates.get_all_cached() + + assert len(all_cached) == 2 + assert all_cached[mac1] is feature1 + assert all_cached[mac2] is feature2 + + @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) + mac1 = "AA:BB:CC:DD:EE:FF" + mac2 = "11:22:33:44:55:66" + feature = {"data": "test"} + + # Set one fresh and one expired + await cache.set(mac1, feature) + + old_time = datetime.now(UTC) - timedelta(minutes=2) + cache._cache[mac2] = (feature, old_time) + + all_cached = await cache.get_all_cached() + + assert len(all_cached) == 1 + assert mac1 in all_cached + assert mac2 not in all_cached + + @pytest.mark.asyncio + async def test_get_cache_info( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test get_cache_info returns correct information.""" + mac = "AA:BB:CC:DD:EE:FF" + feature = {"data": "test"} + await cache_with_updates.set(mac, feature) + + info = await cache_with_updates.get_cache_info() + + assert info["device_count"] == 1 + assert info["update_interval_minutes"] == 30 + assert len(info["devices"]) == 1 + assert info["devices"][0]["mac"] == mac + assert info["devices"][0]["is_expired"] is False + assert info["devices"][0]["cached_at"] is not None + assert info["devices"][0]["expires_at"] is not None + + @pytest.mark.asyncio + async def test_get_cache_info_with_no_updates( + self, cache_no_updates: DeviceInfoCache + ) -> None: + """Test get_cache_info with auto-updates disabled.""" + mac = "AA:BB:CC:DD:EE:FF" + feature = {"data": "test"} + await cache_no_updates.set(mac, feature) + + info = await cache_no_updates.get_cache_info() + + assert info["update_interval_minutes"] == 0 + assert info["devices"][0]["expires_at"] is None + assert info["devices"][0]["is_expired"] is False + + @pytest.mark.asyncio + async def test_cache_thread_safety( + self, cache_with_updates: DeviceInfoCache + ) -> None: + """Test concurrent cache operations.""" + macs = [f"AA:BB:CC:DD:EE:{i:02X}" for i in range(10)] + feature = {"data": "test"} + + # Concurrent sets + await asyncio.gather( + *[cache_with_updates.set(mac, feature) for mac in macs] + ) + + # Concurrent gets + results = await asyncio.gather( + *[cache_with_updates.get(mac) for mac in macs] + ) + + assert all(r is not None for r in results) + assert len([r for r in results if r is not None]) == 10 + + @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) + + assert cache_60.update_interval == timedelta(minutes=60) + assert cache_5.update_interval == timedelta(minutes=5) + assert cache_0.update_interval == timedelta(minutes=0)