diff --git a/.agent/workflows/pre-completion-testing.md b/.agent/workflows/pre-completion-testing.md new file mode 100644 index 0000000..39396bd --- /dev/null +++ b/.agent/workflows/pre-completion-testing.md @@ -0,0 +1,73 @@ +--- +description: Run linting and testing before completing tasks +--- + +# Pre-Completion Testing Workflow + +Before marking any code-related task as complete, you MUST run the following checks: + +## 1. Linting with Ruff + +Run ruff to check for code style and quality issues: + +```bash +ruff check src/ tests/ examples/ +``` + +If there are any errors, fix them before proceeding. You can auto-fix many issues with: + +```bash +ruff check --fix src/ tests/ examples/ +``` + +## 2. Format Check with Ruff + +Verify code formatting is correct: + +```bash +ruff format --check src/ tests/ examples/ +``` + +If formatting issues are found, apply formatting: + +```bash +ruff format src/ tests/ examples/ +``` + +## 3. Run Unit Tests + +Execute the test suite to ensure no regressions: + +```bash +pytest tests/ +``` + +All tests must pass before completing the task. + +## 4. Type Checking (Optional but Recommended) + +If you've modified type annotations or core logic, run mypy: + +```bash +mypy src/ +``` + +## Summary + +**Required before task completion:** +- ✅ Ruff linting passes (no errors) +- ✅ Ruff formatting check passes +- ✅ All pytest tests pass + +**Recommended:** +- ✅ Mypy type checking passes (if types were modified) + +## Quick Command + +You can run all checks with: + +```bash +ruff check src/ tests/ examples/ && ruff format --check src/ tests/ examples/ && pytest tests/ +``` + +**IMPORTANT**: Do not claim a task is complete without running these checks. If any check fails, fix the issues and re-run the checks. diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a1794cc..b964f34 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,89 @@ Changelog ========= +Version 6.0.3 (2025-11-20) +========================== + +**BREAKING CHANGES**: Migration from custom dataclass-based models to Pydantic BaseModel implementations with automatic field validation and alias handling. + +Removed +------- + +- Removed legacy dataclass implementations for models (DeviceInfo, Location, Device, FirmwareInfo, DeviceStatus, DeviceFeature, EnergyUsage*). All models now inherit from ``NavienBaseModel`` (Pydantic). +- Removed manual ``from_dict`` constructors relying on camelCase key mapping logic. +- Removed field metadata conversion system (``meta()`` + ``apply_field_conversions()``) in favor of Pydantic ``BeforeValidator`` pipeline. + +Changed +------- + +- Models now use snake_case attribute names consistently; camelCase keys from API/MQTT are mapped automatically via Pydantic ``alias_generator=to_camel``. +- Boolean device fields now validated via ``DeviceBool`` Annotated type (device value 2 -> True, 0/1 -> False) replacing manual conversion code. +- Temperature offset (+20), scale division (/10) and decicelsius-to-Fahrenheit conversions implemented with lightweight ``BeforeValidator`` functions (``Add20``, ``Div10``, ``DeciCelsiusToF``) instead of post-processing. +- Enum parsing now handled directly by Pydantic; unknown values default safely via explicit Field defaults instead of try/except conversion loops. +- Field names updated (examples & docs) to snake_case: e.g. ``operationMode`` -> ``operation_mode``, ``dhwTemperatureSetting`` -> ``dhw_temperature_setting``. +- API typo handled using Field alias (``heLowerOnTDiffempSetting`` -> ``he_lower_on_diff_temp_setting``) rather than custom dictionary mutation. +- DeviceStatus conversion now performed on parse instead of separate transformation step, improving performance and reducing memory copies. +- Improved validation error messages from Pydantic on malformed payloads. +- Simplified energy usage model accessors; removed manual percentage methods duplication. + +Added +----- + +- Introduced ``NavienBaseModel`` configuring alias generation, population by name, and ignoring unknown fields for forward compatibility. +- Added structured Annotated types: ``DeviceBool``, ``Add20``, ``Div10``, ``DeciCelsiusToF`` for declarative conversion definitions. +- Added consistent default enum values directly in field declarations (e.g. ``operation_mode=STANDBY``). + +Migration Guide (v6.0.2 -> v6.0.3) +---------------------------------- + +1. Replace any imports of dataclass models with Pydantic versions (paths unchanged). No code change required if you only accessed attributes. +2. Remove calls to ``Model.from_dict(data)``: Either use ``Model.model_validate(data)`` or continue calling ``from_dict`` where still provided (thin wrapper for backward compatibility on some classes). Preferred: ``DeviceStatus.model_validate(raw_payload)``. +3. Update attribute access to snake_case. Common mappings: + - ``deviceInfo.macAddress`` -> ``device.device_info.mac_address`` + - ``deviceStatus.operationMode`` -> ``status.operation_mode`` + - ``deviceStatus.dhwTemperatureSetting`` -> ``status.dhw_temperature_setting`` + - ``deviceStatus.currentInletTemperature`` -> ``status.current_inlet_temperature`` +4. Remove manual conversion code. Raw numeric values are converted automatically; stop adding +20 or dividing by 10 in user code. +5. Stop performing boolean normalization (``value == 2``) manually; attributes already return proper bools. +6. For enum handling, remove try/except wrappers; rely on defaulted fields (e.g. ``operation_mode`` defaults to ``STANDBY``). +7. If you previously mutated raw payload keys to snake_case, eliminate that transformation step. +8. If you logged intermediate converted dictionaries, you can access ``model.model_dump()`` for a fully converted representation. +9. Replace any custom validation logic with Pydantic validators or continue using existing patterns; most prior validation code is now unnecessary. +10. Energy usage: Access percentages via properties unchanged; object types now Pydantic models. + +Quick Example +~~~~~~~~~~~~~ + +.. code-block:: python + + # OLD (v6.0.2) + raw = mqtt_payload["deviceStatus"] + converted = apply_field_conversions(DeviceStatus, raw) + status = DeviceStatus(**converted) + print(converted["dhwTemperatureSetting"] + 20) # manual offset + + # NEW (v6.0.3) + status = DeviceStatus.model_validate(mqtt_payload["deviceStatus"]) + print(status.dhw_temperature_setting) # already includes +20 offset + + # OLD boolean and enum handling + is_heating = converted["currentHeatUse"] == 2 + mode = CurrentOperationMode(converted["operationMode"]) if converted["operationMode"] in (0,32,64,96) else CurrentOperationMode.STANDBY + + # NEW simplified + is_heating = status.current_heat_use + mode = status.operation_mode + +Benefits +~~~~~~~~ + +- Declarative conversions reduce 400+ lines of imperative transformation logic. +- Improved performance (single parse vs copy + transform). +- Automatic camelCase key mapping; less brittle than manual dict key copying. +- Rich validation errors for debugging malformed device messages. +- Cleaner, shorter model definitions with clearer intent. +- Easier extension: add new fields with conversion by combining Annotated + validator. + Version 6.0.2 (2025-11-15) ========================== diff --git a/README.rst b/README.rst index 6cb5dc7..df122d1 100644 --- a/README.rst +++ b/README.rst @@ -13,19 +13,15 @@ A Python library for monitoring and controlling the Navien NWP500 Heat Pump Wate Features ======== +* Monitor status (temperature, power, charge %) +* Set target water temperature +* Change operation mode +* Optional scheduling (reservations) +* Optional time-of-use settings +* Periodic high-temp cycle info +* Access detailed status fields -* **Device Monitoring**: Access real-time status information including temperatures, power consumption, and tank charge level -* **Temperature Control**: Set target water temperature (90-151°F) -* **Operation Mode Control**: Switch between Heat Pump, Energy Saver, High Demand, Electric, and Vacation modes -* **Reservation Management**: Schedule automatic temperature and mode changes -* **Time of Use (TOU)**: Configure energy pricing schedules for demand response -* **Anti-Legionella Protection**: Monitor periodic disinfection cycles (140°F heating) -* **Comprehensive Status Data**: Access to 70+ device status fields including compressor status, heater status, flow rates, and more -* **MQTT Protocol Support**: Low-level MQTT communication with Navien devices -* **Non-Blocking Async Operations**: Fully compatible with async event loops (Home Assistant safe) -* **Automatic Reconnection**: Reconnects automatically with exponential backoff during network interruptions -* **Command Queuing**: Commands sent while disconnected are queued and sent automatically when reconnected -* **Data Models**: Type-safe data classes with automatic unit conversions +* Async friendly Quick Start =========== @@ -119,8 +115,8 @@ The library includes a command line interface for quick monitoring and device in **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) via MQTT as JSON and exit -* ``--device-feature``: Print device capabilities and feature settings via MQTT as JSON and exit +* ``--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 @@ -161,63 +157,10 @@ The library provides access to comprehensive device status information: * Cumulative operation time * Flow rates -Operation Modes -=============== - -.. list-table:: Operation Modes - :header-rows: 1 - :widths: 25 10 65 - - * - Mode - - ID - - Description - * - Heat Pump Mode - - 1 - - Most energy-efficient mode using only the heat pump. Longest recovery time. - * - Electric Mode - - 2 - - Fastest recovery using only electric heaters. Least energy-efficient. - * - Energy Saver Mode - - 3 - - Default mode. Balances efficiency and recovery time using both heat pump and electric heater. - * - High Demand Mode - - 4 - - Uses electric heater more frequently for faster recovery time. - * - Vacation Mode - - 5 - - Suspends heating to save energy during extended absences. - -**Important:** When you set a mode, you're configuring the ``dhwOperationSetting`` (what mode to use when heating). The device's current operational state is reported in ``operationMode`` (0=Standby, 32=Heat Pump active, 64=Energy Saver active, 96=High Demand active). - -MQTT Protocol -============= - -The library supports low-level MQTT communication with Navien devices: - -**Control Topics** - * ``cmd/{deviceType}/{deviceId}/ctrl`` - Send control commands - * ``cmd/{deviceType}/{deviceId}/ctrl/rsv/rd`` - Manage reservations - * ``cmd/{deviceType}/{deviceId}/ctrl/tou/rd`` - Time of Use settings - * ``cmd/{deviceType}/{deviceId}/st`` - Request status updates - -**Control Commands** - * Power control (on/off) - * DHW mode changes (including vacation mode) - * Temperature settings - * Reservation management (scheduled mode/temperature changes) - * Time of Use (TOU) pricing schedules - -**Status Requests** - * Device information - * General device status - * Energy usage queries - * Reservation information - * TOU settings - Documentation ============= -For detailed information on device status fields, MQTT protocol, authentication, and more, see the complete documentation at https://nwp500-python.readthedocs.io/ +Full docs: https://nwp500-python.readthedocs.io/ Data Models =========== @@ -228,12 +171,6 @@ The library includes type-safe data models with automatic unit conversions: * **DeviceFeature**: Device capabilities, firmware versions, and configuration limits * **OperationMode**: Enumeration of available operation modes * **TemperatureUnit**: Celsius/Fahrenheit handling -* **MqttRequest/MqttCommand**: MQTT message structures - -Temperature conversions are handled automatically: - * DHW temperatures: ``raw_value + 20`` (°F) - * Heat pump temperatures: ``raw_value / 10.0`` (°F) - * Ambient temperature: ``(raw_value * 9/5) + 32`` (°F) Requirements ============ @@ -245,37 +182,6 @@ Requirements * pydantic >= 2.0.0 * awsiotsdk >= 1.21.0 -Development -=========== -To set up a development environment: - -.. code-block:: bash - - # Clone the repository - git clone https://github.com/eman/nwp500-python.git - cd nwp500-python - - # Install in development mode - pip install -e . - - # Run tests - pytest - -**Linting and CI Consistency** - -To ensure your local linting matches CI exactly: - -.. code-block:: bash - - # Install tox (recommended) - pip install tox - - # Run linting exactly as CI does - tox -e lint - - # Auto-fix and format - tox -e format - License ======= diff --git a/docs/api/nwp500.rst b/docs/api/nwp500.rst new file mode 100644 index 0000000..5e2f5cd --- /dev/null +++ b/docs/api/nwp500.rst @@ -0,0 +1,157 @@ +nwp500 package +============== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + nwp500.cli + +Submodules +---------- + +nwp500.api\_client module +------------------------- + +.. automodule:: nwp500.api_client + :members: + :show-inheritance: + :undoc-members: + +nwp500.auth module +------------------ + +.. automodule:: nwp500.auth + :members: + :show-inheritance: + :undoc-members: + +nwp500.config module +-------------------- + +.. automodule:: nwp500.config + :members: + :show-inheritance: + :undoc-members: + +nwp500.constants module +----------------------- + +.. automodule:: nwp500.constants + :members: + :show-inheritance: + :undoc-members: + +nwp500.encoding module +---------------------- + +.. automodule:: nwp500.encoding + :members: + :show-inheritance: + :undoc-members: + +nwp500.events module +-------------------- + +.. automodule:: nwp500.events + :members: + :show-inheritance: + :undoc-members: + +nwp500.exceptions module +------------------------ + +.. automodule:: nwp500.exceptions + :members: + :show-inheritance: + :undoc-members: + +nwp500.models module +-------------------- + +.. automodule:: nwp500.models + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_client module +-------------------------- + +.. automodule:: nwp500.mqtt_client + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_command\_queue module +---------------------------------- + +.. automodule:: nwp500.mqtt_command_queue + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_connection module +------------------------------ + +.. automodule:: nwp500.mqtt_connection + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_device\_control module +----------------------------------- + +.. automodule:: nwp500.mqtt_device_control + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_periodic module +---------------------------- + +.. automodule:: nwp500.mqtt_periodic + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_reconnection module +-------------------------------- + +.. automodule:: nwp500.mqtt_reconnection + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_subscriptions module +--------------------------------- + +.. automodule:: nwp500.mqtt_subscriptions + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_utils module +------------------------- + +.. automodule:: nwp500.mqtt_utils + :members: + :show-inheritance: + :undoc-members: + +nwp500.utils module +------------------- + +.. automodule:: nwp500.utils + :members: + :show-inheritance: + :undoc-members: + +Module contents +--------------- + +.. automodule:: nwp500 + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/configuration.rst b/docs/configuration.rst index 790e804..c5228c3 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -140,53 +140,15 @@ MQTT Configuration ================== The MQTT client supports various configuration options through -``MqttConnectionConfig``: +``MqttConnectionConfig``. -Basic Configuration -------------------- +For detailed configuration guides, see: -.. code-block:: python - - from nwp500 import NavienMqttClient, MqttConnectionConfig - from nwp500.mqtt_utils import MqttConnectionConfig - - config = MqttConnectionConfig( - client_id="my-custom-client", # or None for auto-generated - clean_session=True, - keep_alive_secs=1200 - ) - - mqtt = NavienMqttClient(auth, config=config) - -Reconnection Settings ---------------------- - -Configure automatic reconnection behavior: - -.. code-block:: python - - config = MqttConnectionConfig( - auto_reconnect=True, - max_reconnect_attempts=15, - initial_reconnect_delay=1.0, # seconds - max_reconnect_delay=120.0, # seconds - reconnect_backoff_multiplier=2.0 - ) - -Command Queue Settings ----------------------- - -Configure command queueing when disconnected: - -.. code-block:: python +* :doc:`guides/auto_recovery` - Connection recovery settings +* :doc:`guides/command_queue` - Offline command queuing - config = MqttConnectionConfig( - enable_command_queue=True, - max_queued_commands=100 - ) - -Complete Example ----------------- +Basic Example +------------- .. code-block:: python @@ -194,23 +156,13 @@ Complete Example from nwp500.mqtt_utils import MqttConnectionConfig config = MqttConnectionConfig( - # Connection - endpoint="a1t30mldyslmuq-ats.iot.us-east-1.amazonaws.com", - region="us-east-1", - client_id="my-app-client", - clean_session=True, + # Connection settings + client_id="my-custom-client", keep_alive_secs=1200, - # Reconnection + # Enable features (see guides for details) auto_reconnect=True, - max_reconnect_attempts=10, - initial_reconnect_delay=1.0, - max_reconnect_delay=120.0, - reconnect_backoff_multiplier=2.0, - - # Command queue - enable_command_queue=True, - max_queued_commands=100 + enable_command_queue=True ) mqtt = NavienMqttClient(auth, config=config) diff --git a/docs/guides/advanced_features_explained.rst b/docs/guides/advanced_features_explained.rst index e6634ed..9ac041b 100644 --- a/docs/guides/advanced_features_explained.rst +++ b/docs/guides/advanced_features_explained.rst @@ -16,12 +16,12 @@ Weather-Responsive Heating ========================== Feature Overview -^^^^^^^^^^^^^^^^ +---------------- The device continuously monitors ambient air temperature to optimize heat pump performance and adjust heating strategies. This enables the system to maintain comfort while adapting to seasonal conditions automatically. Technical Implementation -^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------ **Data Sources**: @@ -31,7 +31,7 @@ Technical Implementation How It Works -^^^^^^^^^^^^ +------------ **Temperature Thresholds and Heating Adjustments**: @@ -78,7 +78,7 @@ The device maintains internal target superheat values that adjust based on ambie - **Recovery Override**: Pre-charging during known demand periods (morning peak) Practical Applications -^^^^^^^^^^^^^^^^^^^^^^^ +---------------------- **Morning Peak Scenario (40°F Ambient)**: @@ -102,7 +102,7 @@ Practical Applications 4. Achieves 3.5+ COP (for every 1 kW electrical, 3.5 kW of heat) Integration with MQTT Status Message -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------------------ The ``outsideTemperature`` field is transmitted in the device status update. Python clients can monitor this field: @@ -120,7 +120,7 @@ Demand Response Integration (CTA-2045) ====================================== Feature Overview -^^^^^^^^^^^^^^^^ +---------------- The NWP500 supports demand response signals per the CTA-2045 (Consumer Technology Association) standard, enabling integration with smart grid programs and demand response events. @@ -129,9 +129,9 @@ The NWP500 supports demand response signals per the CTA-2045 (Consumer Technolog A protocol that allows utilities to send control signals to networked devices (like water heaters) to manage demand during peak periods or grid stress conditions. Technical Implementation -^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------ DR Event Status Field -^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~ **Field**: ``drEventStatus`` (bitfield) @@ -186,7 +186,7 @@ DR Event Status Field 4:30 PM Normal operation restored 0b00000000 Resume standard schedule DR Override Status Field -^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~ **Field**: ``drOverrideStatus`` (integer flag) @@ -207,7 +207,7 @@ DR Override Status Field 7. After override period expires, device returns to DR command compliance Implementation in Device Firmware -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Decision Tree** (inferred from status fields): @@ -231,13 +231,13 @@ Implementation in Device Firmware 4. **Cost Reduction**: Shift heating to low-price periods automatically Utility Integration Requirements -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To use demand response with your NWP500: Tank Temperature Sensors -^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------ **Upper Tank Sensor** (``tankUpperTemperature``) @@ -256,7 +256,7 @@ Tank Temperature Sensors - **Control Target**: Used to trigger lower electric heating element and lower heat pump stage Tank Stratification Explained -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **What Is Stratification?** @@ -282,7 +282,7 @@ In a vertical tank, naturally occurring density differences create layers: 4. **User Comfort**: Upper zone always available at target temperature for draw Practical Stratification Scenarios -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Scenario 1: Excellent Stratification (Efficient)** @@ -325,7 +325,7 @@ Practical Stratification Scenarios → Device may alert or switch to safety mode Device Control Strategy Based on Stratification -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Two-Stage Heating with Stratification**: @@ -365,7 +365,7 @@ Device Control Strategy Based on Stratification - **Optimal**: ~25-30°F differential maximizes recovery time vs. efficiency tradeoff Heat Pump Integration with Stratification -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The two-stage control extends to heat pump operation: @@ -379,7 +379,7 @@ Modern control systems may use "superheat modulation" where: - Looser superheat (safer operation) when stratification poor Monitoring Stratification from Python -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -416,7 +416,7 @@ Monitoring Stratification from Python } Factors Affecting Stratification -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Positive Factors** (Preserve Stratification): diff --git a/docs/guides/auto_recovery.rst b/docs/guides/auto_recovery.rst index 71872f5..026fba7 100644 --- a/docs/guides/auto_recovery.rst +++ b/docs/guides/auto_recovery.rst @@ -236,7 +236,7 @@ Use exponential backoff between recovery attempts with token refresh. device = await api_client.get_first_device() def on_status(status): - print(f"Temperature: {status.dhwTemperature}°F") + print(f"Temperature: {status.dhw_temperature}°F") # Create resilient client mqtt_config = MqttConnectionConfig( diff --git a/docs/guides/energy_monitoring.rst b/docs/guides/energy_monitoring.rst index c10c026..d3d879a 100644 --- a/docs/guides/energy_monitoring.rst +++ b/docs/guides/energy_monitoring.rst @@ -26,7 +26,7 @@ The most important metric for energy monitoring: def on_status(status: DeviceStatus): # Total power consumption in Watts - power_watts = status.currentInstPower + power_watts = status.current_inst_power print(f"Current Power: {power_watts} W") | **Field:** ``currentInstPower`` @@ -43,13 +43,13 @@ Know which heating components are currently active: .. code:: python def on_status(status: DeviceStatus): - if status.compUse: + if status.comp_use: print("Heat pump compressor is running") - if status.heatUpperUse: + if status.heat_upper_use: print("Upper electric heater is running") - if status.heatLowerUse: + if status.heat_lower_use: print("Lower electric heater is running") | **Fields:** - ``compUse`` (bool): Heat pump compressor status - @@ -65,9 +65,9 @@ Track total runtime for each heating component: def on_status(status: DeviceStatus): # Convert minutes to hours - comp_hours = status.compRunningMinuteTotal / 60 - heater1_hours = status.heater1RunningMinuteTotal / 60 - heater2_hours = status.heater2RunningMinuteTotal / 60 + comp_hours = status.comp_running_minute_total / 60 + heater1_hours = status.heater1_running_minute_total / 60 + heater2_hours = status.heater2_running_minute_total / 60 print(f"Heat Pump Runtime: {comp_hours:.1f} hours") print(f"Upper Heater Runtime: {heater1_hours:.1f} hours") @@ -126,7 +126,7 @@ Monitor available stored energy: .. code:: python def on_status(status: DeviceStatus): - capacity = status.availableEnergyCapacity + capacity = status.available_energy_capacity print(f"Energy Capacity: {capacity}%") if capacity < 20: @@ -150,8 +150,8 @@ Water Temperature def on_status(status: DeviceStatus): # Current water temperature - current_temp = status.dhwTemperature - target_temp = status.dhwTemperatureSetting + current_temp = status.dhw_temperature + target_temp = status.dhw_temperature_setting print(f"Water Temperature: {current_temp}°F (Target: {target_temp}°F)") @@ -168,10 +168,10 @@ Monitor individual heating component temperatures: .. code:: python def on_status(status: DeviceStatus): - print(f"Compressor Temp: {status.compTemp}°F") - print(f"Upper Tank Temp: {status.dhwTankUpperTemp}°F") - print(f"Lower Tank Temp: {status.dhwTankLowerTemp}°F") - print(f"Heat Exchanger Out: {status.dhwHeatexOutTemp}°F") + print(f"Compressor Temp: {status.comp_temp}°F") + print(f"Upper Tank Temp: {status.dhw_tank_upper_temp}°F") + print(f"Lower Tank Temp: {status.dhw_tank_lower_temp}°F") + print(f"Heat Exchanger Out: {status.dhw_heatex_out_temp}°F") Complete Energy Monitoring Example ---------------------------------- @@ -204,15 +204,15 @@ Complete Energy Monitoring Example print("="*50) # Real-time power - print(f"\nCurrent Power: {status.currentInstPower} W") + print(f"\nCurrent Power: {status.current_inst_power} W") # Active components components = [] - if status.compUse: + if status.comp_use: components.append("Heat Pump") - if status.heatUpperUse: + if status.heat_upper_use: components.append("Upper Heater") - if status.heatLowerUse: + if status.heat_lower_use: components.append("Lower Heater") if components: @@ -222,18 +222,18 @@ Complete Energy Monitoring Example # Cumulative runtime print(f"\nCumulative Runtime:") - print(f" Heat Pump: {status.compRunningMinuteTotal / 60:.1f} hours") - print(f" Upper Heater: {status.heater1RunningMinuteTotal / 60:.1f} hours") - print(f" Lower Heater: {status.heater2RunningMinuteTotal / 60:.1f} hours") + print(f" Heat Pump: {status.comp_running_minute_total / 60:.1f} hours") + print(f" Upper Heater: {status.heater1_running_minute_total / 60:.1f} hours") + print(f" Lower Heater: {status.heater2_running_minute_total / 60:.1f} hours") # Energy capacity and temperature - print(f"\nEnergy Capacity: {status.availableEnergyCapacity}%") - print(f"Water Temp: {status.dhwTemperature}°F " - f"(Target: {status.dhwTemperatureSetting}°F)") + print(f"\nEnergy Capacity: {status.available_energy_capacity}%") + print(f"Water Temp: {status.dhw_temperature}°F " + f"(Target: {status.dhw_temperature_setting}°F)") # Estimated hourly cost (if running continuously at current power) - if status.currentInstPower > 0: - hourly_cost = calculate_power_cost(status.currentInstPower, 1.0) + if status.current_inst_power > 0: + hourly_cost = calculate_power_cost(status.current_inst_power, 1.0) print(f"\nEstimated Cost (if sustained): ${hourly_cost:.3f}/hour") # Subscribe to device status diff --git a/docs/guides/event_system.rst b/docs/guides/event_system.rst index 6ee4e05..9cce974 100644 --- a/docs/guides/event_system.rst +++ b/docs/guides/event_system.rst @@ -54,8 +54,8 @@ Simple Event Handler # Define event handler def on_status_update(status): - print(f"Temperature: {status.dhwTemperature}°F") - print(f"Power: {status.currentInstPower}W") + print(f"Temperature: {status.dhw_temperature}°F") + print(f"Power: {status.current_inst_power}W") # Subscribe to status updates await mqtt.subscribe_device_status(device, on_status_update) @@ -93,14 +93,14 @@ Track state changes and react only when values change significantly. def on_status(self, status): # Temperature changed by more than 2°F - if self.last_temp is None or abs(status.dhwTemperature - self.last_temp) >= 2: - print(f"Temperature changed: {self.last_temp}°F → {status.dhwTemperature}°F") - self.last_temp = status.dhwTemperature + if self.last_temp is None or abs(status.dhw_temperature - self.last_temp) >= 2: + print(f"Temperature changed: {self.last_temp}°F → {status.dhw_temperature}°F") + self.last_temp = status.dhw_temperature # Power changed by more than 100W - if self.last_power is None or abs(status.currentInstPower - self.last_power) >= 100: - print(f"Power changed: {self.last_power}W → {status.currentInstPower}W") - self.last_power = status.currentInstPower + if self.last_power is None or abs(status.current_inst_power - self.last_power) >= 100: + print(f"Power changed: {self.last_power}W → {status.current_inst_power}W") + self.last_power = status.current_inst_power # Usage async def main(): @@ -150,8 +150,8 @@ Monitor multiple devices with individual callbacks. device_name = device_data['device'].device_info.device_name print(f"[{device_name}]") - print(f" Temperature: {status.dhwTemperature}°F") - print(f" Power: {status.currentInstPower}W") + print(f" Temperature: {status.dhw_temperature}°F") + print(f" Power: {status.current_inst_power}W") print() device_data['last_status'] = status @@ -245,26 +245,26 @@ Build an alert system that triggers on specific conditions. # Define alert rules alerts.add_rule(AlertRule( name="Low Temperature", - condition=lambda s: s.dhwTemperature < 110, + condition=lambda s: s.dhw_temperature < 110, action=lambda s: send_email( "Low Water Temperature", - f"Temperature dropped to {s.dhwTemperature}°F" + f"Temperature dropped to {s.dhw_temperature}°F" ) )) alerts.add_rule(AlertRule( name="High Power", - condition=lambda s: s.currentInstPower > 2000, + condition=lambda s: s.current_inst_power > 2000, action=lambda s: log_alert( - f"High power usage: {s.currentInstPower}W" + f"High power usage: {s.current_inst_power}W" ) )) alerts.add_rule(AlertRule( name="Error Detected", - condition=lambda s: s.errorCode != 0, + condition=lambda s: s.error_code != 0, action=lambda s: send_sms( - f"Device error: {s.errorCode}" + f"Device error: {s.error_code}" ) )) @@ -330,12 +330,12 @@ Log device data to a database or file. """, ( timestamp, device_mac, - status.dhwTemperature, - status.dhwTemperatureSetting, - status.currentInstPower, - status.dhwOperationSetting.name, - status.operationMode.name, - status.errorCode + status.dhw_temperature, + status.dhw_temperature_setting, + status.current_inst_power, + status.dhw_operation_setting.name, + status.operation_mode.name, + status.error_code )) conn.commit() conn.close() @@ -390,12 +390,12 @@ Integrate with Home Assistant, OpenHAB, or custom systems. # Prepare state data state_data = { - 'temperature': status.dhwTemperature, - 'target_temperature': status.dhwTemperatureSetting, - 'power': status.currentInstPower, - 'mode': status.dhwOperationSetting.name, - 'state': status.operationMode.name, - 'error': status.errorCode + 'temperature': status.dhw_temperature, + 'target_temperature': status.dhw_temperature_setting, + 'power': status.current_inst_power, + 'mode': status.dhw_operation_setting.name, + 'state': status.operation_mode.name, + 'error': status.error_code } # Publish to HA @@ -408,7 +408,7 @@ Integrate with Home Assistant, OpenHAB, or custom systems. url = f"{self.ha_url}/api/states/sensor.navien_{device_mac}" async with session.post(url, headers=headers, json={ - 'state': status.dhwTemperature, + 'state': status.dhw_temperature, 'attributes': state_data }) as resp: if resp.status == 200: @@ -470,7 +470,7 @@ Best Practices .. code-block:: python # Track callback references - callback = lambda s: print(s.dhwTemperature) + callback = lambda s: print(s.dhw_temperature) await mqtt.subscribe_device_status(device, callback) diff --git a/docs/index.rst b/docs/index.rst index d2a701e..8524436 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -67,8 +67,8 @@ Basic Example # Monitor device status def on_status(status): - print(f"Temp: {status.dhwTemperature}°F") - print(f"Power: {status.currentInstPower}W") + print(f"Temp: {status.dhw_temperature}°F") + print(f"Power: {status.current_inst_power}W") await mqtt.subscribe_device_status(device, on_status) await mqtt.request_device_status(device) @@ -112,17 +112,7 @@ Documentation Index api/modules -.. toctree:: - :maxdepth: 2 - :caption: Protocol Reference - protocol/rest_api - protocol/mqtt_protocol - protocol/device_status - protocol/data_conversions - protocol/device_features - protocol/error_codes - protocol/firmware_tracking .. toctree:: :maxdepth: 1 @@ -135,6 +125,19 @@ Documentation Index guides/event_system guides/command_queue guides/auto_recovery + guides/advanced_features_explained + +.. toctree:: + :maxdepth: 2 + :caption: Advanced: Protocol Reference + + protocol/rest_api + protocol/mqtt_protocol + protocol/device_status + protocol/data_conversions + protocol/device_features + protocol/error_codes + protocol/firmware_tracking .. toctree:: :maxdepth: 1 diff --git a/docs/protocol/data_conversions.rst b/docs/protocol/data_conversions.rst index 9888942..702abad 100644 --- a/docs/protocol/data_conversions.rst +++ b/docs/protocol/data_conversions.rst @@ -3,6 +3,11 @@ Data Conversions and Units Reference This document provides comprehensive details on all data conversions applied to device status messages, field units, and the meaning of various data structures. +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/models`) instead of implementing + conversions manually. + Overview of Conversion Types ---------------------------- diff --git a/docs/protocol/device_features.rst b/docs/protocol/device_features.rst index 2538947..64c02e7 100644 --- a/docs/protocol/device_features.rst +++ b/docs/protocol/device_features.rst @@ -1,7 +1,12 @@ Device Feature Fields ===================== -This document lists the fields found in the ``DeviceFeature`` object returned by MQTT device info requests. +This document lists the fields found in the ``feature`` object (also known as + +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/mqtt_client`) instead of implementing + the protocol directly.by MQTT device info requests. The DeviceFeature data contains comprehensive device capabilities, configuration, and firmware information received via MQTT when calling ``request_device_info()``. This data is much more detailed than the basic device information available through the REST API and corresponds to the actual device specifications and capabilities as documented in the official Navien NWP500 Installation and User manuals. diff --git a/docs/protocol/device_status.rst b/docs/protocol/device_status.rst index 22228fc..c3d3eb9 100644 --- a/docs/protocol/device_status.rst +++ b/docs/protocol/device_status.rst @@ -4,6 +4,11 @@ Device Status Fields This document lists the fields found in the ``status`` object of device status messages. +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/models`) instead of implementing + the protocol directly. + .. list-table:: :header-rows: 1 :widths: 10 10 10 36 35 @@ -667,7 +672,7 @@ For user-facing applications, follow these guidelines: """Format mode and status for UI display.""" # Check if device is powered off first - if status.dhwOperationSetting == DhwOperationSetting.POWER_OFF: + if status.dhw_operation_setting == DhwOperationSetting.POWER_OFF: return { 'configured_mode': 'Off', 'operational_state': 'Powered Off', @@ -677,19 +682,19 @@ For user-facing applications, follow these guidelines: } # User's configured mode (what they selected) - configured_mode = status.dhwOperationSetting.name.replace('_', ' ').title() + configured_mode = status.dhw_operation_setting.name.replace('_', ' ').title() # Current operational state - if status.operationMode == CurrentOperationMode.STANDBY: + if status.operation_mode == CurrentOperationMode.STANDBY: operational_state = "Idle" is_heating = False - elif status.operationMode == CurrentOperationMode.HEAT_PUMP_MODE: + elif status.operation_mode == CurrentOperationMode.HEAT_PUMP_MODE: operational_state = "Heating (Heat Pump)" is_heating = True - elif status.operationMode == CurrentOperationMode.HYBRID_EFFICIENCY_MODE: + elif status.operation_mode == CurrentOperationMode.HYBRID_EFFICIENCY_MODE: operational_state = "Heating (Energy Saver)" is_heating = True - elif status.operationMode == CurrentOperationMode.HYBRID_BOOST_MODE: + elif status.operation_mode == CurrentOperationMode.HYBRID_BOOST_MODE: operational_state = "Heating (High Demand)" is_heating = True else: diff --git a/docs/protocol/error_codes.rst b/docs/protocol/error_codes.rst index 5237602..c5ed665 100644 --- a/docs/protocol/error_codes.rst +++ b/docs/protocol/error_codes.rst @@ -1,7 +1,11 @@ Error Codes =========== -This document provides a comprehensive reference for NWP500 heat pump water heater error codes. When an error occurs, the front panel display flashes red and shows the error code. For Level 1 errors, operation continues while displaying the error. +This document provides a comprehensive reference for NWP500 heat pump water heater error codes. + +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/models`) which handles error parsing automatically. When an error occurs, the front panel display flashes red and shows the error code. For Level 1 errors, operation continues while displaying the error. Error Code Reference -------------------- diff --git a/docs/protocol/firmware_tracking.rst b/docs/protocol/firmware_tracking.rst index afdf35d..20d083c 100644 --- a/docs/protocol/firmware_tracking.rst +++ b/docs/protocol/firmware_tracking.rst @@ -4,6 +4,11 @@ Firmware Version Tracking This document tracks firmware versions and the device status fields they introduce or modify. +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/mqtt_client`) instead of implementing + the protocol directly. + Purpose ------- diff --git a/docs/protocol/mqtt_protocol.rst b/docs/protocol/mqtt_protocol.rst index 7020351..7d35b0b 100644 --- a/docs/protocol/mqtt_protocol.rst +++ b/docs/protocol/mqtt_protocol.rst @@ -5,11 +5,10 @@ MQTT Protocol This document describes the MQTT protocol used for real-time communication with Navien NWP500 devices via AWS IoT Core. -.. note:: - Most users should use the Python :doc:`../python_api/mqtt_client` rather than - implementing the protocol directly. This documentation is for - understanding the underlying protocol or implementing clients in other - languages. +.. warning:: + This document describes the underlying MQTT protocol. Most users should use the + Python client library (:doc:`../python_api/mqtt_client`) instead of implementing + the protocol directly. Overview ======== @@ -306,9 +305,9 @@ Status Response "deviceType": 52, "macAddress": "...", "status": { - "dhwTemperature": 120, - "dhwTemperatureSetting": 120, - "currentInstPower": 450, + "dhw_temperature": 120, + "dhw_temperature_setting": 120, + "current_inst_power": 450, "operationMode": 64, "dhwOperationSetting": 3, "operationBusy": 2, @@ -336,11 +335,11 @@ Feature/Info Response { "response": { "feature": { - "controllerSerialNumber": "ABC123", - "controllerSwVersion": 184614912, - "dhwTemperatureMin": 75, - "dhwTemperatureMax": 130, - "energyUsageUse": 1, + "controller_serial_number": "ABC123", + "controller_sw_version": 184614912, + "dhw_temperature_min": 75, + "dhw_temperature_max": 130, + "energy_usage_use": 1, ... } } diff --git a/docs/protocol/rest_api.rst b/docs/protocol/rest_api.rst index 23ecca1..31efb48 100644 --- a/docs/protocol/rest_api.rst +++ b/docs/protocol/rest_api.rst @@ -5,6 +5,10 @@ REST API Protocol This document describes the Navien Smart Control REST API protocol based on the OpenAPI 3.1 specification. +.. warning:: + This document describes the underlying REST API protocol. Most users should use the + Python client library (:doc:`../python_api/api_client`) instead of using the API directly. + Base URL ======== @@ -339,8 +343,8 @@ All error responses follow this format: .. code-block:: json { - "code": , - "msg": "", + "code": 404, + "msg": "NOT_FOUND", "data": null } diff --git a/docs/python_api/events.rst b/docs/python_api/events.rst index f645f78..c9ae218 100644 --- a/docs/python_api/events.rst +++ b/docs/python_api/events.rst @@ -141,8 +141,8 @@ Emitted when device status update is received. .. code-block:: python def handle_status(status): - print(f"Temperature: {status.dhwTemperature}°F") - print(f"Power: {status.currentInstPower}W") + print(f"Temperature: {status.dhw_temperature}°F") + print(f"Power: {status.current_inst_power}W") mqtt.on('status_received', handle_status) @@ -267,11 +267,11 @@ Example 3: Temperature Alerts mqtt = NavienMqttClient(auth) def check_temp(status): - if status.dhwTemperature < 110: + if status.dhw_temperature < 110: print("WARNING: Temperature below 110°F") send_alert("Low water temperature") - if status.dhwTemperature > 145: + if status.dhw_temperature > 145: print("WARNING: Temperature above 145°F") send_alert("High water temperature") diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index 25735c6..7c11d3f 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -85,7 +85,7 @@ preference. # Check current mode from status def on_status(status): - if status.dhwOperationSetting == DhwOperationSetting.ENERGY_SAVER: + if status.dhw_operation_setting == DhwOperationSetting.ENERGY_SAVER: print("Running in Energy Saver mode") CurrentOperationMode @@ -112,16 +112,16 @@ Current real-time operational state - what the device is doing **right now**. from nwp500 import CurrentOperationMode def on_status(status): - mode = status.operationMode + mode = status.operation_mode if mode == CurrentOperationMode.IDLE: print("Device idle") elif mode == CurrentOperationMode.HEAT_PUMP: - print(f"Heat pump running at {status.currentInstPower}W") + print(f"Heat pump running at {status.current_inst_power}W") elif mode == CurrentOperationMode.ELECTRIC_HEATER: - print(f"Electric heater at {status.currentInstPower}W") + print(f"Electric heater at {status.current_inst_power}W") elif mode == CurrentOperationMode.HEAT_PUMP_AND_HEATER: - print(f"Both running at {status.currentInstPower}W") + print(f"Both running at {status.current_inst_power}W") TemperatureUnit --------------- @@ -140,10 +140,10 @@ Temperature scale enumeration. .. code-block:: python def on_status(status): - if status.temperatureType == TemperatureUnit.FAHRENHEIT: - print(f"Temperature: {status.dhwTemperature}°F") + if status.temperature_type == TemperatureUnit.FAHRENHEIT: + print(f"Temperature: {status.dhw_temperature}°F") else: - print(f"Temperature: {status.dhwTemperature}°C") + print(f"Temperature: {status.dhw_temperature}°C") Device Models ============= @@ -283,14 +283,14 @@ Complete real-time device status with 100+ fields. **Key Temperature Fields:** - * ``dhwTemperature`` (float) - Current water temperature (°F or °C) - * ``dhwTemperatureSetting`` (float) - Target temperature setting - * ``dhwTargetTemperatureSetting`` (float) - Target with offsets applied - * ``tankUpperTemperature`` (float) - Upper tank sensor - * ``tankLowerTemperature`` (float) - Lower tank sensor - * ``currentInletTemperature`` (float) - Cold water inlet temperature - * ``outsideTemperature`` (float) - Outdoor temperature - * ``ambientTemperature`` (float) - Ambient air temperature + * ``dhw_temperature`` (float) - Current water temperature (°F or °C) + * ``dhw_temperature_setting`` (float) - Target temperature setting + * ``dhw_target_temperature_setting`` (float) - Target with offsets applied + * ``tank_upper_temperature`` (float) - Upper tank sensor + * ``tank_lower_temperature`` (float) - Lower tank sensor + * ``current_inlet_temperature`` (float) - Cold water inlet temperature + * ``outside_temperature`` (float) - Outdoor temperature + * ``ambient_temperature`` (float) - Ambient air temperature .. note:: Temperature display values are 20°F higher than message values. @@ -298,66 +298,66 @@ Complete real-time device status with 100+ fields. **Key Power/Energy Fields:** - * ``currentInstPower`` (float) - Current power consumption (Watts) - * ``totalEnergyCapacity`` (float) - Total energy capacity (%) - * ``availableEnergyCapacity`` (float) - Available energy (%) - * ``dhwChargePer`` (float) - DHW charge percentage + * ``current_inst_power`` (float) - Current power consumption (Watts) + * ``total_energy_capacity`` (float) - Total energy capacity (%) + * ``available_energy_capacity`` (float) - Available energy (%) + * ``dhw_charge_per`` (float) - DHW charge percentage **Operation Mode Fields:** - * ``operationMode`` (CurrentOperationMode) - Current operational state - * ``dhwOperationSetting`` (DhwOperationSetting) - User's mode preference - * ``temperatureType`` (TemperatureUnit) - Temperature unit + * ``operation_mode`` (CurrentOperationMode) - Current operational state + * ``dhw_operation_setting`` (DhwOperationSetting) - User's mode preference + * ``temperature_type`` (TemperatureUnit) - Temperature unit **Boolean Status Fields:** - * ``operationBusy`` (bool) - Device actively heating water - * ``dhwUse`` (bool) - Water being used (short-term detection) - * ``dhwUseSustained`` (bool) - Water being used (sustained) - * ``compUse`` (bool) - Compressor/heat pump running - * ``heatUpperUse`` (bool) - Upper electric heater active - * ``heatLowerUse`` (bool) - Lower electric heater active - * ``evaFanUse`` (bool) - Evaporator fan running - * ``antiLegionellaUse`` (bool) - Anti-Legionella enabled - * ``antiLegionellaOperationBusy`` (bool) - Anti-Legionella cycle active - * ``programReservationUse`` (bool) - Reservation schedule enabled - * ``freezeProtectionUse`` (bool) - Freeze protection enabled + * ``operation_busy`` (bool) - Device actively heating water + * ``dhw_use`` (bool) - Water being used (short-term detection) + * ``dhw_use_sustained`` (bool) - Water being used (sustained) + * ``comp_use`` (bool) - Compressor/heat pump running + * ``heat_upper_use`` (bool) - Upper electric heater active + * ``heat_lower_use`` (bool) - Lower electric heater active + * ``eva_fan_use`` (bool) - Evaporator fan running + * ``anti_legionella_use`` (bool) - Anti-Legionella enabled + * ``anti_legionella_operation_busy`` (bool) - Anti-Legionella cycle active + * ``program_reservation_use`` (bool) - Reservation schedule enabled + * ``freeze_protection_use`` (bool) - Freeze protection enabled **Error/Diagnostic Fields:** - * ``errorCode`` (int) - Error code (0 = no error) - * ``subErrorCode`` (int) - Sub-error code - * ``smartDiagnostic`` (int) - Smart diagnostic status - * ``faultStatus1`` (int) - Fault status flags - * ``faultStatus2`` (int) - Additional fault flags + * ``error_code`` (int) - Error code (0 = no error) + * ``sub_error_code`` (int) - Sub-error code + * ``smart_diagnostic`` (int) - Smart diagnostic status + * ``fault_status1`` (int) - Fault status flags + * ``fault_status2`` (int) - Additional fault flags **Network/Communication:** - * ``wifiRssi`` (int) - WiFi signal strength (dBm) + * ``wifi_rssi`` (int) - WiFi signal strength (dBm) **Vacation/Schedule:** - * ``vacationDaySetting`` (int) - Vacation days configured - * ``vacationDayElapsed`` (int) - Vacation days elapsed - * ``antiLegionellaPeriod`` (int) - Anti-Legionella cycle period + * ``vacation_day_setting`` (int) - Vacation days configured + * ``vacation_day_elapsed`` (int) - Vacation days elapsed + * ``anti_legionella_period`` (int) - Anti-Legionella cycle period **Time-of-Use (TOU):** - * ``touStatus`` (int) - TOU status - * ``touOverrideStatus`` (int) - TOU override status + * ``tou_status`` (int) - TOU status + * ``tou_override_status`` (int) - TOU override status **Heat Pump Detailed Status:** - * ``targetFanRpm`` (int) - Target fan RPM - * ``currentFanRpm`` (int) - Current fan RPM - * ``fanPwm`` (int) - Fan PWM duty cycle - * ``mixingRate`` (float) - Mixing valve rate - * ``eevStep`` (int) - Electronic expansion valve position - * ``dischargeTemperature`` (float) - Compressor discharge temp - * ``suctionTemperature`` (float) - Compressor suction temp - * ``evaporatorTemperature`` (float) - Evaporator temperature - * ``targetSuperHeat`` (float) - Target superheat - * ``currentSuperHeat`` (float) - Current superheat + * ``target_fan_rpm`` (int) - Target fan RPM + * ``current_fan_rpm`` (int) - Current fan RPM + * ``fan_pwm`` (int) - Fan PWM duty cycle + * ``mixing_rate`` (float) - Mixing valve rate + * ``eev_step`` (int) - Electronic expansion valve position + * ``discharge_temperature`` (float) - Compressor discharge temp + * ``suction_temperature`` (float) - Compressor suction temp + * ``evaporator_temperature`` (float) - Evaporator temperature + * ``target_super_heat`` (float) - Target superheat + * ``current_super_heat`` (float) - Current superheat **Example:** @@ -365,40 +365,40 @@ Complete real-time device status with 100+ fields. def on_status(status): # Temperature monitoring - print(f"Water: {status.dhwTemperature}°F") - print(f"Target: {status.dhwTemperatureSetting}°F") - print(f"Upper Tank: {status.tankUpperTemperature}°F") - print(f"Lower Tank: {status.tankLowerTemperature}°F") + print(f"Water: {status.dhw_temperature}°F") + print(f"Target: {status.dhw_temperature_setting}°F") + print(f"Upper Tank: {status.tank_upper_temperature}°F") + print(f"Lower Tank: {status.tank_lower_temperature}°F") # Power consumption - print(f"Power: {status.currentInstPower}W") - print(f"Energy: {status.availableEnergyCapacity}%") + print(f"Power: {status.current_inst_power}W") + print(f"Energy: {status.available_energy_capacity}%") # Operation mode - print(f"Mode: {status.dhwOperationSetting.name}") - print(f"State: {status.operationMode.name}") + print(f"Mode: {status.dhw_operation_setting.name}") + print(f"State: {status.operation_mode.name}") # Active heating - if status.operationBusy: + if status.operation_busy: print("Heating water:") - if status.compUse: + if status.comp_use: print(" - Heat pump running") - if status.heatUpperUse: + if status.heat_upper_use: print(" - Upper heater active") - if status.heatLowerUse: + if status.heat_lower_use: print(" - Lower heater active") # Water usage detection - if status.dhwUse: + if status.dhw_use: print("Water usage detected (short-term)") - if status.dhwUseSustained: + if status.dhw_useSustained: print("Water usage detected (sustained)") # Errors - if status.errorCode != 0: - print(f"ERROR: {status.errorCode}") - if status.subErrorCode != 0: - print(f" Sub-error: {status.subErrorCode}") + if status.error_code != 0: + print(f"ERROR: {status.error_code}") + if status.sub_error_code != 0: + print(f" Sub-error: {status.sub_error_code}") DeviceFeature ------------- @@ -409,78 +409,78 @@ Device capabilities, features, and firmware information. **Firmware Version Fields:** - * ``controllerSwVersion`` (int) - Controller firmware version - * ``panelSwVersion`` (int) - Panel firmware version - * ``wifiSwVersion`` (int) - WiFi module firmware version - * ``controllerSwCode`` (int) - Controller software code - * ``panelSwCode`` (int) - Panel software code - * ``wifiSwCode`` (int) - WiFi software code - * ``controllerSerialNumber`` (str) - Controller serial number + * ``controller_sw_version`` (int) - Controller firmware version + * ``panel_sw_version`` (int) - Panel firmware version + * ``wifi_sw_version`` (int) - WiFi module firmware version + * ``controller_sw_code`` (int) - Controller software code + * ``panel_sw_code`` (int) - Panel software code + * ``wifi_sw_code`` (int) - WiFi software code + * ``controller_serial_number`` (str) - Controller serial number **Device Configuration:** - * ``countryCode`` (int) - Country code - * ``modelTypeCode`` (int) - Model type - * ``controlTypeCode`` (int) - Control type - * ``volumeCode`` (int) - Tank volume code - * ``tempFormulaType`` (int) - Temperature formula type - * ``temperatureType`` (TemperatureUnit) - Temperature unit + * ``country_code`` (int) - Country code + * ``model_type_code`` (int) - Model type + * ``control_type_code`` (int) - Control type + * ``volume_code`` (int) - Tank volume code + * ``temp_formula_type`` (int) - Temperature formula type + * ``temperature_type`` (TemperatureUnit) - Temperature unit **Temperature Limits:** - * ``dhwTemperatureMin`` (int) - Minimum DHW temperature - * ``dhwTemperatureMax`` (int) - Maximum DHW temperature - * ``freezeProtectionTempMin`` (int) - Min freeze protection temp - * ``freezeProtectionTempMax`` (int) - Max freeze protection temp + * ``dhw_temperature_min`` (int) - Minimum DHW temperature + * ``dhw_temperature_max`` (int) - Maximum DHW temperature + * ``freeze_protection_temp_min`` (int) - Min freeze protection temp + * ``freeze_protection_temp_max`` (int) - Max freeze protection temp **Feature Flags (all int, 0=disabled, 1=enabled):** - * ``powerUse`` - Power control supported - * ``dhwUse`` - DHW functionality - * ``dhwTemperatureSettingUse`` - Temperature control - * ``energyUsageUse`` - Energy monitoring supported - * ``antiLegionellaSettingUse`` - Anti-Legionella supported - * ``programReservationUse`` - Reservation scheduling supported - * ``freezeProtectionUse`` - Freeze protection available - * ``heatpumpUse`` - Heat pump mode available - * ``electricUse`` - Electric mode available - * ``energySaverUse`` - Energy Saver mode available - * ``highDemandUse`` - High Demand mode available - * ``smartDiagnosticUse`` - Smart diagnostics available - * ``wifiRssiUse`` - WiFi signal strength available - * ``holidayUse`` - Holiday/vacation mode - * ``mixingValueUse`` - Mixing valve - * ``drSettingUse`` - Demand response - * ``dhwRefillUse`` - DHW refill - * ``ecoUse`` - Eco mode + * ``power_use`` - Power control supported + * ``dhw_use`` - DHW functionality + * ``dhw_temperature_setting_use`` - Temperature control + * ``energy_usage_use`` - Energy monitoring supported + * ``anti_legionella_setting_use`` - Anti-Legionella supported + * ``program_reservation_use`` - Reservation scheduling supported + * ``freeze_protection_use`` - Freeze protection available + * ``heatpump_use`` - Heat pump mode available + * ``electric_use`` - Electric mode available + * ``energy_saver_use`` - Energy Saver mode available + * ``high_demand_use`` - High Demand mode available + * ``smart_diagnostic_use`` - Smart diagnostics available + * ``wifi_rssi_use`` - WiFi signal strength available + * ``holiday_use`` - Holiday/vacation mode + * ``mixing_value_use`` - Mixing valve + * ``dr_setting_use`` - Demand response + * ``dhw_refill_use`` - DHW refill + * ``eco_use`` - Eco mode **Example:** .. code-block:: python def on_feature(feature): - print(f"Serial: {feature.controllerSerialNumber}") - print(f"Firmware: {feature.controllerSwVersion}") - print(f"WiFi: {feature.wifiSwVersion}") + print(f"Serial: {feature.controller_serial_number}") + print(f"Firmware: {feature.controller_sw_version}") + print(f"WiFi: {feature.wifi_sw_version}") print(f"\nTemperature Range:") - print(f" Min: {feature.dhwTemperatureMin}°F") - print(f" Max: {feature.dhwTemperatureMax}°F") + print(f" Min: {feature.dhw_temperature_min}°F") + print(f" Max: {feature.dhw_temperature_max}°F") print(f"\nSupported Features:") - if feature.energyUsageUse: + if feature.energy_usage_use: print(" [OK] Energy monitoring") - if feature.antiLegionellaSettingUse: + if feature.anti_legionella_setting_use: print(" [OK] Anti-Legionella") - if feature.programReservationUse: + if feature.program_reservation_use: print(" [OK] Reservations") - if feature.heatpumpUse: + if feature.heatpump_use: print(" [OK] Heat pump mode") - if feature.electricUse: + if feature.electric_use: print(" [OK] Electric mode") - if feature.energySaverUse: + if feature.energy_saver_use: print(" [OK] Energy Saver mode") - if feature.highDemandUse: + if feature.high_demand_use: print(" [OK] High Demand mode") Energy Models @@ -495,10 +495,10 @@ Complete energy usage response with daily breakdown. **Fields:** - * ``deviceType`` (int) - Device type - * ``macAddress`` (str) - Device MAC - * ``additionalValue`` (str) - Additional identifier - * ``typeOfUsage`` (int) - Usage type code + * ``device_type`` (int) - Device type + * ``mac_address`` (str) - Device MAC + * ``additional_value`` (str) - Additional identifier + * ``type_of_usage`` (int) - Usage type code * ``total`` (EnergyUsageTotal) - Total usage summary * ``usage`` (list[MonthlyEnergyData]) - Monthly data with daily breakdown @@ -521,8 +521,8 @@ Complete energy usage response with daily breakdown. for day_num, day in enumerate(month_data.data, 1): if day.total_usage > 0: print(f" Day {day_num}: {day.total_usage} Wh") - print(f" HP: {day.hpUsage} Wh ({day.hpTime}h)") - print(f" HE: {day.heUsage} Wh ({day.heTime}h)") + print(f" HP: {day.heat_pump_usage} Wh ({day.heat_pump_time}h)") + print(f" HE: {day.heat_element_usage} Wh ({day.heat_element_time}h)") EnergyUsageTotal ---------------- @@ -533,16 +533,16 @@ Summary totals for energy usage. **Fields:** - * ``heUsage`` (int) - Total heat element usage (Wh) - * ``hpUsage`` (int) - Total heat pump usage (Wh) - * ``heTime`` (int) - Total heat element time (hours) - * ``hpTime`` (int) - Total heat pump time (hours) + * ``heat_element_usage`` (int) - Total heat element usage (Wh) + * ``heat_pump_usage`` (int) - Total heat pump usage (Wh) + * ``heat_element_time`` (int) - Total heat element time (hours) + * ``heat_pump_time`` (int) - Total heat pump time (hours) **Computed Properties:** - * ``total_usage`` (int) - heUsage + hpUsage - * ``heat_pump_percentage`` (float) - (hpUsage / total) × 100 - * ``heat_element_percentage`` (float) - (heUsage / total) × 100 + * ``total_usage`` (int) - heat_element_usage + heat_pump_usage + * ``heat_pump_percentage`` (float) - (heat_pump_usage / total) × 100 + * ``heat_element_percentage`` (float) - (heat_element_usage / total) × 100 MonthlyEnergyData ----------------- @@ -566,14 +566,14 @@ Energy data for a single day. **Fields:** - * ``heUsage`` (int) - Heat element usage (Wh) - * ``hpUsage`` (int) - Heat pump usage (Wh) - * ``heTime`` (int) - Heat element time (hours) - * ``hpTime`` (int) - Heat pump time (hours) + * ``heat_element_usage`` (int) - Heat element usage (Wh) + * ``heat_pump_usage`` (int) - Heat pump usage (Wh) + * ``heat_element_time`` (int) - Heat element time (hours) + * ``heat_pump_time`` (int) - Heat pump time (hours) **Computed Properties:** - * ``total_usage`` (int) - heUsage + hpUsage + * ``total_usage`` (int) - heat_element_usage + heat_pump_usage Time-of-Use Models ================== @@ -635,12 +635,12 @@ Complete MQTT command message. **Fields:** - * ``clientID`` (str) - MQTT client ID - * ``sessionID`` (str) - Session ID - * ``requestTopic`` (str) - Request topic - * ``responseTopic`` (str) - Response topic + * ``client_id`` (str) - MQTT client ID + * ``session_id`` (str) - Session ID + * ``request_topic`` (str) - Request topic + * ``response_topic`` (str) - Response topic * ``request`` (MqttRequest) - Request payload - * ``protocolVersion`` (int) - Protocol version (default: 2) + * ``protocol_version`` (int) - Protocol version (default: 2) MqttRequest ----------- @@ -652,12 +652,12 @@ MQTT request payload. **Fields:** * ``command`` (int) - Command code (see CommandCode) - * ``deviceType`` (int) - Device type - * ``macAddress`` (str) - Device MAC - * ``additionalValue`` (str) - Additional identifier + * ``device_type`` (int) - Device type + * ``mac_address`` (str) - Device MAC + * ``additional_value`` (str) - Additional identifier * ``mode`` (str, optional) - Mode parameter * ``param`` (list[int | float]) - Numeric parameters - * ``paramStr`` (str) - String parameters + * ``param_str`` (str) - String parameters * ``month`` (list[int], optional) - Month list for energy queries * ``year`` (int, optional) - Year for energy queries @@ -680,7 +680,7 @@ Best Practices .. code-block:: python def on_feature(feature): - if feature.energyUsageUse: + if feature.energy_usage_use: # Device supports energy monitoring await mqtt.request_energy_usage(device, year, months) @@ -701,10 +701,10 @@ Best Practices def on_status(status): # User's mode preference - user_mode = status.dhwOperationSetting + user_mode = status.dhw_operation_setting # Current real-time state - current_state = status.operationMode + current_state = status.operation_mode # These can differ! # User sets ENERGY_SAVER, device might be in HEAT_PUMP state diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index aa03982..6a50186 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -49,10 +49,10 @@ Basic Monitoring # Subscribe to status updates def on_status(status): - print(f"Water Temp: {status.dhwTemperature}°F") - print(f"Target: {status.dhwTemperatureSetting}°F") - print(f"Power: {status.currentInstPower}W") - print(f"Mode: {status.dhwOperationSetting.name}") + print(f"Water Temp: {status.dhw_temperature}°F") + print(f"Target: {status.dhw_temperature_setting}°F") + print(f"Power: {status.current_inst_power}W") + print(f"Mode: {status.dhw_operation_setting.name}") await mqtt.subscribe_device_status(device, on_status) await mqtt.request_device_status(device) @@ -203,31 +203,31 @@ subscribe_device_status() def on_status(status): """Called every time device status updates.""" - print(f"Temperature: {status.dhwTemperature}°F") - print(f"Target: {status.dhwTemperatureSetting}°F") - print(f"Mode: {status.dhwOperationSetting.name}") - print(f"Power: {status.currentInstPower}W") - print(f"Energy: {status.availableEnergyCapacity}%") + print(f"Temperature: {status.dhw_temperature}°F") + print(f"Target: {status.dhw_temperature_setting}°F") + print(f"Mode: {status.dhw_operation_setting.name}") + print(f"Power: {status.current_inst_power}W") + print(f"Energy: {status.available_energy_capacity}%") # Check if actively heating - if status.operationBusy: + if status.operation_busy: print("Device is heating water") - if status.compUse: + if status.comp_use: print(" - Heat pump running") - if status.heatUpperUse: + if status.heat_upper_use: print(" - Upper heater active") - if status.heatLowerUse: + if status.heat_lower_use: print(" - Lower heater active") # Check water usage - if status.dhwUse: + if status.dhw_use: print("Water is being used (short-term)") - if status.dhwUseSustained: + if status.dhw_use_sustained: print("Water is being used (sustained)") # Check for errors - if status.errorCode != 0: - print(f"ERROR: {status.errorCode}") + if status.error_code != 0: + print(f"ERROR: {status.error_code}") await mqtt.subscribe_device_status(device, on_status) await mqtt.request_device_status(device) @@ -282,17 +282,17 @@ subscribe_device_feature() def on_feature(feature): """Called when device features/info received.""" - print(f"Serial: {feature.controllerSerialNumber}") - print(f"Firmware: {feature.controllerSwVersion}") - print(f"Temp Range: {feature.dhwTemperatureMin}°F - " - f"{feature.dhwTemperatureMax}°F") + print(f"Serial: {feature.controller_serial_number}") + print(f"Firmware: {feature.controller_sw_version}") + print(f"Temp Range: {feature.dhw_temperature_min}°F - " + f"{feature.dhw_temperature_max}°F") # Check capabilities - if feature.energyUsageUse: + if feature.energy_usage_use: print("Energy monitoring: Supported") - if feature.antiLegionellaSettingUse: + if feature.anti_legionella_setting_use: print("Anti-Legionella: Supported") - if feature.reservationUse: + if feature.reservation_use: print("Reservations: Supported") await mqtt.subscribe_device_feature(device, on_feature) @@ -879,24 +879,24 @@ Example 1: Complete Monitoring Application now = datetime.now().strftime("%H:%M:%S") # Temperature changed - if last_temp != status.dhwTemperature: - print(f"[{now}] Temperature: {status.dhwTemperature}°F " - f"(Target: {status.dhwTemperatureSetting}°F)") - last_temp = status.dhwTemperature + if last_temp != status.dhw_temperature: + print(f"[{now}] Temperature: {status.dhw_temperature}°F " + f"(Target: {status.dhw_temperatureSetting}°F)") + last_temp = status.dhw_temperature # Power changed - if last_power != status.currentInstPower: - print(f"[{now}] Power: {status.currentInstPower}W") - last_power = status.currentInstPower + if last_power != status.current_inst_power: + print(f"[{now}] Power: {status.current_inst_power}W") + last_power = status.current_inst_power # Heating state - if status.operationBusy: + if status.operation_busy: components = [] - if status.compUse: + if status.comp_use: components.append("HP") - if status.heatUpperUse: + if status.heat_upper_use: components.append("Upper") - if status.heatLowerUse: + if status.heat_lower_use: components.append("Lower") print(f"[{now}] Heating: {', '.join(components)}") @@ -935,11 +935,11 @@ Example 2: Automatic Temperature Control nonlocal last_use_time # Water is being used - if status.dhwUse or status.dhwUseSustained: + if status.dhw_use or status.dhw_use_sustained: last_use_time = datetime.now() # If temp dropped below 130°F, boost to high demand - if status.dhwTemperature < 130: + if status.dhw_temperature < 130: asyncio.create_task( mqtt.set_dhw_mode(device, 4) # High Demand ) @@ -978,9 +978,9 @@ Example 3: Multi-Device Monitoring # Create callback for each device def create_callback(device_name): def callback(status): - print(f"[{device_name}] {status.dhwTemperature}°F, " - f"{status.currentInstPower}W, " - f"{status.dhwOperationSetting.name}") + print(f"[{device_name}] {status.dhw_temperature}°F, " + f"{status.current_inst_power}W, " + f"{status.dhw_operation_setting.name}") return callback # Subscribe to all devices diff --git a/docs/quickstart.rst b/docs/quickstart.rst index cc574ce..f3cbbe4 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -119,10 +119,10 @@ Connect to MQTT for real-time device monitoring: # Define status callback def on_status(status): print(f"\nDevice Status:") - print(f" Water Temp: {status.dhwTemperature}°F") - print(f" Target: {status.dhwTemperatureSetting}°F") - print(f" Power: {status.currentInstPower}W") - print(f" Mode: {status.dhwOperationSetting.name}") + print(f" Water Temp: {status.dhw_temperature}°F") + print(f" Target: {status.dhw_temperature_setting}°F") + print(f" Power: {status.current_inst_power}W") + print(f" Mode: {status.dhw_operation_setting.name}") # Subscribe and request status await mqtt.subscribe_device_status(device, on_status) diff --git a/examples/combined_callbacks.py b/examples/combined_callbacks.py index 2b41c58..5d42b79 100644 --- a/examples/combined_callbacks.py +++ b/examples/combined_callbacks.py @@ -85,22 +85,24 @@ async def main(): def on_status(status: DeviceStatus): counts["status"] += 1 print(f"\n📊 Status Update #{counts['status']}") - print(f" Mode: {status.operationMode.name}") - print(f" DHW Temp: {status.dhwTemperature:.1f}°F") - print(f" DHW Charge: {status.dhwChargePer:.1f}%") - print(f" Compressor: {'On' if status.compUse else 'Off'}") + print(f" Mode: {status.operation_mode.name}") + print(f" DHW Temp: {status.dhw_temperature:.1f}°F") + print(f" DHW Charge: {status.dhw_charge_per:.1f}%") + print(f" Compressor: {'On' if status.comp_use else 'Off'}") # Callback for feature/capability info def on_feature(feature: DeviceFeature): counts["feature"] += 1 print(f"\n📋 Feature Info #{counts['feature']}") - print(f" Serial: {feature.controllerSerialNumber}") - print(f" FW Version: {feature.controllerSwVersion}") + print(f" Serial: {feature.controller_serial_number}") + print(f" FW Version: {feature.controller_sw_version}") print( - f" Temp Range: {feature.dhwTemperatureMin}-{feature.dhwTemperatureMax}°F" + f" Temp Range: {feature.dhw_temperature_min}-{feature.dhw_temperature_max}°F" ) - print(f" Heat Pump: {'Yes' if feature.heatpumpUse == 2 else 'No'}") - print(f" Electric: {'Yes' if feature.electricUse == 2 else 'No'}") + print( + f" Heat Pump: {'Yes' if feature.heatpump_use == 2 else 'No'}" + ) + print(f" Electric: {'Yes' if feature.electric_use == 2 else 'No'}") # Subscribe to broader topics to catch all messages print("Subscribing to status and feature callbacks...") diff --git a/examples/device_feature_callback.py b/examples/device_feature_callback.py index dd39416..44e4ae9 100644 --- a/examples/device_feature_callback.py +++ b/examples/device_feature_callback.py @@ -119,31 +119,31 @@ def on_device_feature(feature: DeviceFeature): # Access typed feature fields directly print("Device Identity:") - print(f" Serial Number: {feature.controllerSerialNumber}") - print(f" Country Code: {feature.countryCode}") - print(f" Model Type: {feature.modelTypeCode}") - print(f" Control Type: {feature.controlTypeCode}") - print(f" Volume Code: {feature.volumeCode}") + print(f" Serial Number: {feature.controller_serial_number}") + print(f" Country Code: {feature.country_code}") + print(f" Model Type: {feature.model_type_code}") + print(f" Control Type: {feature.control_type_code}") + print(f" Volume Code: {feature.volume_code}") print("\nFirmware Versions:") print( - f" Controller SW: {feature.controllerSwVersion} (code: {feature.controllerSwCode})" + f" Controller SW: {feature.controller_sw_version} (code: {feature.controller_sw_code})" ) print( - f" Panel SW: {feature.panelSwVersion} (code: {feature.panelSwCode})" + f" Panel SW: {feature.panel_sw_version} (code: {feature.panel_sw_code})" ) print( - f" WiFi SW: {feature.wifiSwVersion} (code: {feature.wifiSwCode})" + f" WiFi SW: {feature.wifi_sw_version} (code: {feature.wifi_sw_code})" ) print("\nConfiguration:") print(f" Temperature Unit: {feature.temperatureType.name}") print(f" Temp Formula Type: {feature.tempFormulaType}") print( - f" DHW Temp Range: {feature.dhwTemperatureMin}°F - {feature.dhwTemperatureMax}°F" + f" DHW Temp Range: {feature.dhw_temperature_min}°F - {feature.dhw_temperature_max}°F" ) print( - f" Freeze Prot Range: {feature.freezeProtectionTempMin}°F - {feature.freezeProtectionTempMax}°F" + f" Freeze Prot Range: {feature.freeze_protection_temp_min}°F - {feature.freeze_protection_temp_max}°F" ) print("\nFeature Support:") @@ -151,16 +151,16 @@ def on_device_feature(feature: DeviceFeature): f" Power Control: {'Supported' if feature.powerUse == 2 else 'Not Available'}" ) print( - f" DHW Control: {'Supported' if feature.dhwUse == 2 else 'Not Available'}" + f" DHW Control: {'Supported' if feature.dhw_use == 2 else 'Not Available'}" ) print( - f" DHW Temp Setting: Level {feature.dhwTemperatureSettingUse}" + f" DHW Temp Setting: Level {feature.dhw_temperature_settingUse}" ) print( - f" Heat Pump Mode: {'Supported' if feature.heatpumpUse == 2 else 'Not Available'}" + f" Heat Pump Mode: {'Supported' if feature.heatpump_use == 2 else 'Not Available'}" ) print( - f" Electric Mode: {'Supported' if feature.electricUse == 2 else 'Not Available'}" + f" Electric Mode: {'Supported' if feature.electric_use == 2 else 'Not Available'}" ) print( f" Energy Saver: {'Supported' if feature.energySaverUse == 2 else 'Not Available'}" @@ -169,7 +169,7 @@ def on_device_feature(feature: DeviceFeature): f" High Demand: {'Supported' if feature.highDemandUse == 2 else 'Not Available'}" ) print( - f" Eco Mode: {'Supported' if feature.ecoUse == 2 else 'Not Available'}" + f" Eco Mode: {'Supported' if feature.eco_use == 2 else 'Not Available'}" ) print("\nAdvanced Features:") @@ -177,19 +177,19 @@ def on_device_feature(feature: DeviceFeature): f" Holiday Mode: {'Supported' if feature.holidayUse == 2 else 'Not Available'}" ) print( - f" Program Schedule: {'Supported' if feature.programReservationUse == 2 else 'Not Available'}" + f" Program Schedule: {'Supported' if feature.program_reservation_use == 2 else 'Not Available'}" ) print( - f" Smart Diagnostic: {'Supported' if feature.smartDiagnosticUse == 1 else 'Not Available'}" + f" Smart Diagnostic: {'Supported' if feature.smart_diagnosticUse == 1 else 'Not Available'}" ) print( - f" WiFi RSSI: {'Supported' if feature.wifiRssiUse == 2 else 'Not Available'}" + f" WiFi RSSI: {'Supported' if feature.wifi_rssiUse == 2 else 'Not Available'}" ) print( f" Energy Usage: {'Supported' if feature.energyUsageUse == 2 else 'Not Available'}" ) print( - f" Freeze Protection: {'Supported' if feature.freezeProtectionUse == 2 else 'Not Available'}" + f" Freeze Protection: {'Supported' if feature.freeze_protection_use == 2 else 'Not Available'}" ) print( f" Mixing Valve: {'Supported' if feature.mixingValueUse == 1 else 'Not Available'}" diff --git a/examples/device_status_callback.py b/examples/device_status_callback.py index 2634f27..2e426f7 100755 --- a/examples/device_status_callback.py +++ b/examples/device_status_callback.py @@ -137,44 +137,46 @@ def on_device_status(status: DeviceStatus): # Access typed status fields directly print("Temperatures:") - print(f" DHW Temperature: {status.dhwTemperature:.1f}°F") + print(f" DHW Temperature: {status.dhw_temperature:.1f}°F") print( - f" DHW Target Setting: {status.dhwTargetTemperatureSetting:.1f}°F" + f" DHW Target Setting: {status.dhw_target_temperature_setting:.1f}°F" ) print( - f" Tank Upper: {status.tankUpperTemperature:.1f}°F" + f" Tank Upper: {status.tank_upper_temperature:.1f}°F" ) print( - f" Tank Lower: {status.tankLowerTemperature:.1f}°F" + f" Tank Lower: {status.tank_lower_temperature:.1f}°F" ) print( - f" Discharge: {status.dischargeTemperature:.1f}°F" + f" Discharge: {status.discharge_temperature:.1f}°F" ) print( - f" Ambient: {status.ambientTemperature:.1f}°F" + f" Ambient: {status.ambient_temperature:.1f}°F" ) print("\nOperation:") - print(f" Mode: {status.operationMode.name}") - print(f" Operation Busy: {status.operationBusy}") - print(f" DHW Active: {status.dhwUse}") - print(f" Compressor Active: {status.compUse}") - print(f" Evaporator Fan Active: {status.evaFanUse}") - print(f" Current Power: {status.currentInstPower:.1f}W") + print(f" Mode: {status.operation_mode.name}") + print(f" Operation Busy: {status.operation_busy}") + print(f" DHW Active: {status.dhw_use}") + print(f" Compressor Active: {status.comp_use}") + print(f" Evaporator Fan Active: {status.eva_fan_use}") + print(f" Current Power: {status.current_inst_power:.1f}W") print("\nSystem Status:") - print(f" Error Code: {status.errorCode}") - print(f" WiFi RSSI: {status.wifiRssi} dBm") - print(f" DHW Charge: {status.dhwChargePer:.1f}%") - print(f" Eco Mode: {status.ecoUse}") - print(f" Freeze Protection: {status.freezeProtectionUse}") + print(f" Error Code: {status.error_code}") + print(f" WiFi RSSI: {status.wifi_rssi} dBm") + print(f" DHW Charge: {status.dhw_charge_per:.1f}%") + print(f" Eco Mode: {status.eco_use}") + print(f" Freeze Protection: {status.freeze_protection_use}") print("\nAdvanced:") print( - f" Fan RPM: {status.currentFanRpm}/{status.targetFanRpm}" + f" Fan RPM: {status.current_fan_rpm}/{status.target_fan_rpm}" + ) + print(f" EEV Step: {status.eev_step}") + print( + f" Super Heat: {status.current_super_heat:.1f}°F" ) - print(f" EEV Step: {status.eevStep}") - print(f" Super Heat: {status.currentSuperHeat:.1f}°F") print( f" Flow Rate: {status.currentDhwFlowRate:.1f} GPM" ) diff --git a/examples/device_status_callback_debug.py b/examples/device_status_callback_debug.py index 9eb67a2..46855e5 100644 --- a/examples/device_status_callback_debug.py +++ b/examples/device_status_callback_debug.py @@ -131,9 +131,9 @@ def on_device_status(status: DeviceStatus): print( f"\n[SUCCESS] PARSED Status Update #{message_count['status']}" ) - print(f" DHW Temperature: {status.dhwTemperature:.1f}°F") - print(f" Operation Mode: {status.operationMode.name}") - print(f" Compressor: {status.compUse}") + print(f" DHW Temperature: {status.dhw_temperature:.1f}°F") + print(f" Operation Mode: {status.operation_mode.name}") + print(f" Compressor: {status.comp_use}") # Subscribe with raw handler first print("Subscribing to raw messages...") diff --git a/examples/energy_usage_example.py b/examples/energy_usage_example.py index e8aface..d6aaa5e 100755 --- a/examples/energy_usage_example.py +++ b/examples/energy_usage_example.py @@ -50,17 +50,17 @@ def on_energy_usage(energy: EnergyUsageResponse): # Heat pump details print("🔵 HEAT PUMP") print( - f" Energy Usage: {energy.total.hpUsage:,} Wh ({energy.total.heat_pump_percentage:.1f}%)" + f" Energy Usage: {energy.total.heat_pump_usage:,} Wh ({energy.total.heat_pump_percentage:.1f}%)" ) - print(f" Operating Time: {energy.total.hpTime} hours") + print(f" Operating Time: {energy.total.heat_pump_time} hours") print() # Electric heater details print("🔴 ELECTRIC HEATER") print( - f" Energy Usage: {energy.total.heUsage:,} Wh ({energy.total.heat_element_percentage:.1f}%)" + f" Energy Usage: {energy.total.heat_element_usage:,} Wh ({energy.total.heat_element_percentage:.1f}%)" ) - print(f" Operating Time: {energy.total.heTime} hours") + print(f" Operating Time: {energy.total.heat_element_time} hours") print() # Efficiency analysis @@ -91,14 +91,14 @@ def on_energy_usage(energy: EnergyUsageResponse): if day_data.total_usage > 0: # Only show days with usage date_str = f"{month_data.year}-{month_data.month:02d}-{day_num:02d}" hp_pct_day = ( - (day_data.hpUsage / day_data.total_usage * 100) + (day_data.heat_pump_usage / day_data.total_usage * 100) if day_data.total_usage > 0 else 0 ) print( f" {date_str}: {day_data.total_usage:5,} Wh " - f"(HP: {day_data.hpUsage:5,} Wh, HE: {day_data.heUsage:4,} Wh, " + f"(HP: {day_data.heat_pump_usage:5,} Wh, HE: {day_data.heat_element_usage:4,} Wh, " f"HP%: {hp_pct_day:4.1f}%)" ) diff --git a/examples/event_emitter_demo.py b/examples/event_emitter_demo.py index 9508f98..b727f50 100644 --- a/examples/event_emitter_demo.py +++ b/examples/event_emitter_demo.py @@ -70,7 +70,7 @@ def optimize_on_mode_change( # Example 3: Power state handlers def on_heating_started(status: DeviceStatus): """Handler for when heating starts.""" - print(f"🔥 [Power] Heating STARTED - Power: {status.currentInstPower}W") + print(f"🔥 [Power] Heating STARTED - Power: {status.current_inst_power}W") def on_heating_stopped(status: DeviceStatus): @@ -82,8 +82,8 @@ def on_heating_stopped(status: DeviceStatus): def on_error_detected(error_code: str, status: DeviceStatus): """Handler for error detection.""" print(f"[ERROR] [Error] ERROR DETECTED: {error_code}") - print(f" Temperature: {status.dhwTemperature}°F") - print(f" Mode: {status.operationMode}") + print(f" Temperature: {status.dhw_temperature}°F") + print(f" Mode: {status.operation_mode}") def on_error_cleared(error_code: str): @@ -179,7 +179,7 @@ async def main(): # One-time listener example mqtt_client.once( "status_received", - lambda s: print(f" 🎉 First status received: {s.dhwTemperature}°F"), + lambda s: print(f" 🎉 First status received: {s.dhw_temperature}°F"), ) print(" [SUCCESS] Registered one-time status handler") print() diff --git a/examples/improved_auth_pattern.py b/examples/improved_auth_pattern.py index 5c64898..577a4b8 100644 --- a/examples/improved_auth_pattern.py +++ b/examples/improved_auth_pattern.py @@ -45,9 +45,9 @@ async def main(): # Step 4: Monitor device status def on_status(status): print("\n📊 Device Status:") - print(f" Temperature: {status.dhwTemperature}°F") - print(f" Target: {status.dhwTemperatureSetting}°F") - print(f" Power: {status.currentInstPower}W") + print(f" Temperature: {status.dhw_temperature}°F") + print(f" Target: {status.dhw_temperature_setting}°F") + print(f" Power: {status.current_inst_power}W") await mqtt.subscribe_device_status(device, on_status) await mqtt.request_device_status(device) diff --git a/examples/mqtt_client_example.py b/examples/mqtt_client_example.py index e0d070e..08a2217 100755 --- a/examples/mqtt_client_example.py +++ b/examples/mqtt_client_example.py @@ -149,12 +149,12 @@ def on_device_status(status: DeviceStatus): print( f"\n📊 Status Update #{message_count['status']} (Message #{message_count['count']})" ) - print(f" - DHW Temperature: {status.dhwTemperature:.1f}°F") - print(f" - Tank Upper: {status.tankUpperTemperature:.1f}°F") - print(f" - Tank Lower: {status.tankLowerTemperature:.1f}°F") - print(f" - Operation Mode: {status.operationMode}") - print(f" - DHW Active: {status.dhwUse}") - print(f" - Compressor: {status.compUse}") + print(f" - DHW Temperature: {status.dhw_temperature:.1f}°F") + print(f" - Tank Upper: {status.tank_upper_temperature:.1f}°F") + print(f" - Tank Lower: {status.tank_lower_temperature:.1f}°F") + print(f" - Operation Mode: {status.operation_mode}") + print(f" - DHW Active: {status.dhw_use}") + print(f" - Compressor: {status.comp_use}") def on_device_feature(feature: DeviceFeature): """Typed callback for device features.""" @@ -163,9 +163,9 @@ def on_device_feature(feature: DeviceFeature): print( f"\n📋 Device Info #{message_count['feature']} (Message #{message_count['count']})" ) - print(f" - Serial: {feature.controllerSerialNumber}") - print(f" - SW Version: {feature.controllerSwVersion}") - print(f" - Heat Pump: {feature.heatpumpUse}") + print(f" - Serial: {feature.controller_serial_number}") + print(f" - SW Version: {feature.controller_sw_version}") + print(f" - Heat Pump: {feature.heatpump_use}") # Subscribe with typed parsing wrappers diff --git a/examples/periodic_device_info.py b/examples/periodic_device_info.py index 7ada6b6..d48ca00 100755 --- a/examples/periodic_device_info.py +++ b/examples/periodic_device_info.py @@ -77,11 +77,11 @@ def on_device_feature(feature: DeviceFeature): info_count += 1 print(f"\n--- Device Info Response #{info_count} ---") - print(f"Controller Serial: {feature.controllerSerialNumber}") - print(f"Controller SW Version: {feature.controllerSwVersion}") - print(f"Heat Pump Use: {feature.heatpumpUse}") + print(f"Controller Serial: {feature.controller_serial_number}") + print(f"Controller SW Version: {feature.controller_sw_version}") + print(f"Heat Pump Use: {feature.heatpump_use}") print( - f"DHW Temp Min/Max: {feature.dhwTemperatureMin}/{feature.dhwTemperatureMax}°F" + f"DHW Temp Min/Max: {feature.dhw_temperature_min}/{feature.dhw_temperature_max}°F" ) # Subscribe with typed parsing diff --git a/examples/periodic_requests.py b/examples/periodic_requests.py index 00d31f1..631f4fd 100755 --- a/examples/periodic_requests.py +++ b/examples/periodic_requests.py @@ -73,9 +73,9 @@ def on_device_status(status: DeviceStatus): status_count += 1 print(f"\n--- Status Response #{status_count} ---") - print(f" Temperature: {status.dhwTemperature:.1f}°F") - print(f" Power: {status.currentInstPower:.1f}W") - print(f" Available Energy: {status.availableEnergyCapacity:.0f} Wh") + print(f" Temperature: {status.dhw_temperature:.1f}°F") + print(f" Power: {status.current_inst_power:.1f}W") + print(f" Available Energy: {status.available_energy_capacity:.0f} Wh") def on_device_feature(feature: DeviceFeature): """Callback receives parsed DeviceFeature objects.""" @@ -83,9 +83,9 @@ def on_device_feature(feature: DeviceFeature): info_count += 1 print(f"\n--- Device Info Response #{info_count} ---") - print(f" Serial: {feature.controllerSerialNumber}") - print(f" FW Version: {feature.controllerSwVersion}") - print(f" Heat Pump: {feature.heatpumpUse}") + print(f" Serial: {feature.controller_serial_number}") + print(f" FW Version: {feature.controller_sw_version}") + print(f" Heat Pump: {feature.heatpump_use}") # Subscribe using typed callbacks await mqtt.subscribe_device_status(device, on_device_status) diff --git a/examples/power_control_example.py b/examples/power_control_example.py index 4a0fdfd..e43cfe6 100644 --- a/examples/power_control_example.py +++ b/examples/power_control_example.py @@ -47,8 +47,8 @@ async def power_control_example(): def on_current_status(status): nonlocal current_status current_status = status - logger.info(f"Current operation mode: {status.operationMode.name}") - logger.info(f"Current DHW temperature: {status.dhwTemperature}°F") + logger.info(f"Current operation mode: {status.operation_mode.name}") + 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) @@ -62,7 +62,7 @@ def on_current_status(status): def on_power_off_response(status): nonlocal power_off_complete logger.info("Power OFF response received!") - logger.info(f"Operation mode: {status.operationMode.name}") + logger.info(f"Operation mode: {status.operation_mode.name}") logger.info(f"DHW Operation Setting: {status.dhwOperationSetting.name}") power_off_complete = True @@ -90,9 +90,9 @@ def on_power_off_response(status): def on_power_on_response(status): nonlocal power_on_complete logger.info("Power ON response received!") - logger.info(f"Operation mode: {status.operationMode.name}") + logger.info(f"Operation mode: {status.operation_mode.name}") logger.info(f"DHW Operation Setting: {status.dhwOperationSetting.name}") - logger.info(f"Tank charge: {status.dhwChargePer}%") + logger.info(f"Tank charge: {status.dhw_charge_per}%") power_on_complete = True await mqtt_client.subscribe_device_status(device, on_power_on_response) diff --git a/examples/reconnection_demo.py b/examples/reconnection_demo.py index 5055747..090102a 100644 --- a/examples/reconnection_demo.py +++ b/examples/reconnection_demo.py @@ -89,7 +89,7 @@ def on_status(status): nonlocal status_count status_count += 1 print(f"\n📊 Status update #{status_count}:") - print(f" Temperature: {status.dhwTemperature}°F") + print(f" Temperature: {status.dhw_temperature}°F") print(f" Connected: {mqtt_client.is_connected}") if mqtt_client.is_reconnecting: print(f" Reconnecting: attempt {mqtt_client.reconnect_attempts}...") diff --git a/examples/reservation_schedule_example.py b/examples/reservation_schedule_example.py index 56bb9d8..636c9f2 100644 --- a/examples/reservation_schedule_example.py +++ b/examples/reservation_schedule_example.py @@ -7,6 +7,7 @@ from typing import Any from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient +from nwp500.encoding import decode_week_bitfield from nwp500.encoding import build_reservation_entry @@ -50,7 +51,7 @@ def on_reservation_update(topic: str, message: dict[str, Any]) -> None: ) print(f" entries: {len(reservations)}") for idx, entry in enumerate(reservations, start=1): - week_days = NavienAPIClient.decode_week_bitfield(entry.get("week", 0)) + week_days = decode_week_bitfield(entry.get("week", 0)) display_temp = entry.get("param", 0) + 20 print( " - #{idx}: {time:02d}:{minute:02d} mode={mode} display_temp={temp}F days={days}".format( diff --git a/examples/set_dhw_temperature_example.py b/examples/set_dhw_temperature_example.py index a1f8984..f44ab56 100644 --- a/examples/set_dhw_temperature_example.py +++ b/examples/set_dhw_temperature_example.py @@ -48,9 +48,9 @@ def on_current_status(status): nonlocal current_status current_status = status logger.info( - f"Current DHW target temperature: {status.dhwTargetTemperatureSetting}°F" + f"Current DHW target temperature: {status.dhw_target_temperature_setting}°F" ) - logger.info(f"Current DHW temperature: {status.dhwTemperature}°F") + 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) @@ -67,11 +67,11 @@ def on_temp_change_response(status): nonlocal temp_changed logger.info("Temperature change response received!") logger.info( - f"New target temperature: {status.dhwTargetTemperatureSetting}°F" + f"New target temperature: {status.dhw_target_temperature_setting}°F" ) - logger.info(f"Current DHW temperature: {status.dhwTemperature}°F") - logger.info(f"Operation mode: {status.operationMode.name}") - logger.info(f"Tank charge: {status.dhwChargePer}%") + logger.info(f"Current DHW temperature: {status.dhw_temperature}°F") + logger.info(f"Operation mode: {status.operation_mode.name}") + logger.info(f"Tank charge: {status.dhw_charge_per}%") temp_changed = True await mqtt_client.subscribe_device_status(device, on_temp_change_response) diff --git a/examples/set_mode_example.py b/examples/set_mode_example.py index 3cec4ff..393e14f 100644 --- a/examples/set_mode_example.py +++ b/examples/set_mode_example.py @@ -47,7 +47,7 @@ async def set_mode_example(): def on_current_status(status): nonlocal current_status current_status = status - logger.info(f"Current mode: {status.operationMode.name}") + 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) @@ -62,9 +62,9 @@ def on_current_status(status): def on_mode_change_response(status): nonlocal mode_changed logger.info("Mode change response received!") - logger.info(f"New mode: {status.operationMode.name}") - logger.info(f"DHW Temperature: {status.dhwTemperature}°F") - logger.info(f"Tank Charge: {status.dhwChargePer}%") + logger.info(f"New mode: {status.operation_mode.name}") + logger.info(f"DHW Temperature: {status.dhw_temperature}°F") + logger.info(f"Tank Charge: {status.dhw_charge_per}%") mode_changed = True await mqtt_client.subscribe_device_status(device, on_mode_change_response) diff --git a/examples/simple_auto_recovery.py b/examples/simple_auto_recovery.py index 8e8b0d8..ce4e421 100644 --- a/examples/simple_auto_recovery.py +++ b/examples/simple_auto_recovery.py @@ -200,8 +200,8 @@ def on_status(status): nonlocal status_count status_count += 1 logger.info( - f"Status #{status_count}: Temp={status.dhwTemperature}°F, " - f"Mode={status.operationMode}" + f"Status #{status_count}: Temp={status.dhw_temperature}°F, " + f"Mode={status.operation_mode}" ) # Create resilient MQTT client diff --git a/examples/simple_periodic_info.py b/examples/simple_periodic_info.py index a58ea77..0578e70 100644 --- a/examples/simple_periodic_info.py +++ b/examples/simple_periodic_info.py @@ -39,7 +39,7 @@ async def main(): # Typed callback def on_feature(feature: DeviceFeature): print( - f"Device info: Serial {feature.controllerSerialNumber}, FW {feature.controllerSwVersion}" + f"Device info: Serial {feature.controller_serial_number}, FW {feature.controller_sw_version}" ) # Subscribe with typed parsing diff --git a/examples/simple_periodic_status.py b/examples/simple_periodic_status.py index 01876f1..3e0a7cf 100755 --- a/examples/simple_periodic_status.py +++ b/examples/simple_periodic_status.py @@ -39,7 +39,9 @@ async def main(): # Typed callback def on_status(status: DeviceStatus): - print(f"Status: {status.dhwTemperature:.1f}°F, {status.currentInstPower:.1f}W") + print( + f"Status: {status.dhw_temperature:.1f}°F, {status.current_inst_power:.1f}W" + ) # Subscribe with typed parsing await mqtt.subscribe_device_status(device, on_status) diff --git a/examples/test_periodic_minimal.py b/examples/test_periodic_minimal.py index d0a235f..bdb67c3 100755 --- a/examples/test_periodic_minimal.py +++ b/examples/test_periodic_minimal.py @@ -54,8 +54,8 @@ def on_device_status(status: DeviceStatus): message_count += 1 timestamp = datetime.now().strftime("%H:%M:%S") print(f"[{timestamp}] Status #{message_count}") - print(f" Temperature: {status.dhwTemperature:.1f}°F") - print(f" Power: {status.currentInstPower:.1f}W") + print(f" Temperature: {status.dhw_temperature:.1f}°F") + print(f" Power: {status.current_inst_power:.1f}W") # Subscribe with typed parsing print("Subscribing...") diff --git a/examples/token_restoration_example.py b/examples/token_restoration_example.py index 67e21b2..8c7ef2f 100644 --- a/examples/token_restoration_example.py +++ b/examples/token_restoration_example.py @@ -126,11 +126,11 @@ async def main(): parser = argparse.ArgumentParser( description="Token restoration example for nwp500-python" ) - group = parser.add_mutually_exclusive_group(required=True) + group = parser.add_mutually_exclusive_group(required=False) group.add_argument( "--save", action="store_true", - help="Authenticate and save tokens for future use", + help="Authenticate and save tokens for future use (default)", ) group.add_argument( "--restore", @@ -141,10 +141,11 @@ async def main(): args = parser.parse_args() try: - if args.save: - await save_tokens_example() - else: + if args.restore: await restore_tokens_example() + else: + # Default to save mode if --save is specified or no args provided + await save_tokens_example() except Exception as e: logger.error(f"Error: {e}") raise diff --git a/examples/tou_openei_example.py b/examples/tou_openei_example.py index e8492b9..0bf2fb2 100755 --- a/examples/tou_openei_example.py +++ b/examples/tou_openei_example.py @@ -214,7 +214,7 @@ def capture_feature(feature) -> None: await mqtt_client.subscribe_device_feature(device, capture_feature) await mqtt_client.request_device_info(device) feature = await asyncio.wait_for(feature_future, timeout=15) - return feature.controllerSerialNumber + return feature.controller_serial_number async def main() -> None: diff --git a/examples/tou_schedule_example.py b/examples/tou_schedule_example.py index 0d91fc5..2d3579d 100644 --- a/examples/tou_schedule_example.py +++ b/examples/tou_schedule_example.py @@ -6,7 +6,8 @@ import sys from typing import Any -from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient, build_tou_period +from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient +from nwp500.encoding import decode_week_bitfield, decode_price, build_tou_period async def _wait_for_controller_serial(mqtt_client: NavienMqttClient, device) -> str: @@ -25,7 +26,7 @@ def capture_feature(feature) -> None: # Wait for the response feature = await asyncio.wait_for(feature_future, timeout=15) - return feature.controllerSerialNumber + return feature.controller_serial_number async def main() -> None: @@ -88,11 +89,11 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None: print("\nTOU response received:") print(f" reservationUse: {response.get('reservationUse')}") for idx, entry in enumerate(reservation, start=1): - week_days = NavienAPIClient.decode_week_bitfield(entry.get("week", 0)) - price_min_value = NavienAPIClient.decode_price( + week_days = decode_week_bitfield(entry.get("week", 0)) + price_min_value = decode_price( entry.get("priceMin", 0), entry.get("decimalPoint", 0) ) - price_max_value = NavienAPIClient.decode_price( + price_max_value = decode_price( entry.get("priceMax", 0), entry.get("decimalPoint", 0) ) print( diff --git a/scripts/bump_version.py b/scripts/bump_version.py index ef7f033..800ee0f 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -22,7 +22,6 @@ import re import subprocess import sys -from typing import Tuple def run_git_command(args: list) -> str: @@ -44,36 +43,38 @@ def run_git_command(args: list) -> str: def get_current_version() -> str: """Get the current version from git tags.""" # Get all tags sorted by version - tags_output = run_git_command(["tag", "-l", "v*", "--sort=-version:refname"]) - + tags_output = run_git_command( + ["tag", "-l", "v*", "--sort=-version:refname"] + ) + if not tags_output: print("No version tags found. Starting from v0.0.0") return "0.0.0" - + # Get the most recent tag latest_tag = tags_output.split("\n")[0] - + # Remove the 'v' prefix version = latest_tag[1:] if latest_tag.startswith("v") else latest_tag - + return version -def parse_version(version_str: str) -> Tuple[int, int, int]: +def parse_version(version_str: str) -> tuple[int, int, int]: """Parse a version string into (major, minor, patch) tuple.""" match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str) if not match: print(f"Error: Invalid version format: {version_str}", file=sys.stderr) print("Version must be in format: X.Y.Z", file=sys.stderr) sys.exit(1) - + return int(match.group(1)), int(match.group(2)), int(match.group(3)) def bump_version(version_str: str, bump_type: str) -> str: """Bump a version string according to the bump type.""" major, minor, patch = parse_version(version_str) - + if bump_type == "major": return f"{major + 1}.0.0" elif bump_type == "minor": @@ -90,30 +91,40 @@ def validate_version_progression(current: str, new: str) -> None: """Validate that the new version is a proper progression from current.""" curr_major, curr_minor, curr_patch = parse_version(current) new_major, new_minor, new_patch = parse_version(new) - + # Check if new version is greater than current curr_tuple = (curr_major, curr_minor, curr_patch) new_tuple = (new_major, new_minor, new_patch) - + if new_tuple <= curr_tuple: - print(f"Error: New version {new} is not greater than current version {current}", file=sys.stderr) + print( + f"Error: New version {new} is not greater than current version " + f"{current}", + file=sys.stderr, + ) sys.exit(1) - - # Check for unreasonable jumps (more than 1 major version or unusual patterns) + + # Check for unreasonable jumps major_jump = new_major - curr_major minor_jump = new_minor - curr_minor patch_jump = new_patch - curr_patch - + if major_jump > 1: - print(f"Warning: Large major version jump detected ({current} -> {new})") + print( + f"Warning: Large major version jump detected ({current} -> {new})" + ) print(f"This will jump from {curr_major}.x.x to {new_major}.x.x") - + if major_jump == 0 and minor_jump > 5: - print(f"Warning: Large minor version jump detected ({current} -> {new})") + print( + f"Warning: Large minor version jump detected ({current} -> {new})" + ) print(f"This will jump from x.{curr_minor}.x to x.{new_minor}.x") - + if major_jump == 0 and minor_jump == 0 and patch_jump > 10: - print(f"Warning: Large patch version jump detected ({current} -> {new})") + print( + f"Warning: Large patch version jump detected ({current} -> {new})" + ) print(f"This will jump from x.x.{curr_patch} to x.x.{new_patch}") @@ -122,14 +133,17 @@ def check_working_directory_clean() -> None: status = run_git_command(["status", "--porcelain"]) if status: print("Error: Working directory is not clean.", file=sys.stderr) - print("Please commit or stash your changes before bumping version.", file=sys.stderr) + print( + "Please commit or stash your changes before bumping version.", + file=sys.stderr, + ) sys.exit(1) def create_tag(version: str, message: str = None) -> None: """Create a git tag for the version.""" tag_name = f"v{version}" - + # Check if tag already exists try: subprocess.run( @@ -142,29 +156,46 @@ def create_tag(version: str, message: str = None) -> None: except subprocess.CalledProcessError: # Tag doesn't exist, which is what we want pass - + # Create the tag if message: run_git_command(["tag", "-a", tag_name, "-m", message]) else: - run_git_command(["tag", "-a", tag_name, "-m", f"Release version {version}"]) - + run_git_command( + ["tag", "-a", tag_name, "-m", f"Release version {version}"] + ) + print(f"[OK] Created tag: {tag_name}") def main() -> None: """Main entry point.""" if len(sys.argv) != 2: - print("Usage: python scripts/bump_version.py [major|minor|patch|X.Y.Z]", file=sys.stderr) + print( + "Usage: python scripts/bump_version.py [major|minor|patch|X.Y.Z]", + file=sys.stderr, + ) print("\nExamples:", file=sys.stderr) - print(" python scripts/bump_version.py patch # Bump patch version", file=sys.stderr) - print(" python scripts/bump_version.py minor # Bump minor version", file=sys.stderr) - print(" python scripts/bump_version.py major # Bump major version", file=sys.stderr) - print(" python scripts/bump_version.py 3.1.5 # Set explicit version", file=sys.stderr) + print( + " python scripts/bump_version.py patch # Bump patch version", + file=sys.stderr, + ) + print( + " python scripts/bump_version.py minor # Bump minor version", + file=sys.stderr, + ) + print( + " python scripts/bump_version.py major # Bump major version", + file=sys.stderr, + ) + print( + " python scripts/bump_version.py 3.1.5 # Set explicit version", + file=sys.stderr, + ) sys.exit(1) - + bump_type = sys.argv[1] - + # Validate bump type if bump_type not in ["major", "minor", "patch"]: # Check if it's a valid version number @@ -172,27 +203,30 @@ def main() -> None: parse_version(bump_type) except SystemExit: print(f"Error: Invalid bump type: {bump_type}", file=sys.stderr) - print("Must be one of: major, minor, patch, or X.Y.Z", file=sys.stderr) + print( + "Must be one of: major, minor, patch, or X.Y.Z", + file=sys.stderr, + ) sys.exit(1) - + # Check working directory is clean check_working_directory_clean() - + # Get current version current_version = get_current_version() print(f"Current version: {current_version}") - + # Calculate new version new_version = bump_version(current_version, bump_type) print(f"New version: {new_version}") - + # Validate version progression validate_version_progression(current_version, new_version) - + # Create the tag print(f"\nCreating tag v{new_version}...") create_tag(new_version) - + print("\n[OK] Version bump complete!") print("\nNext steps:") print(f" 1. Push the tag: git push origin v{new_version}") diff --git a/scripts/format.py b/scripts/format.py index 75ebb13..3430d0c 100644 --- a/scripts/format.py +++ b/scripts/format.py @@ -4,16 +4,17 @@ Auto-fixes linting issues and formats code consistently with CI. """ +import os import subprocess import sys -import os from pathlib import Path + def run_command(cmd, description): """Run a command and return success status.""" print(f"\n🔧 {description}") print(f"Command: {' '.join(cmd)}") - + try: result = subprocess.run(cmd, check=True, capture_output=True, text=True) print(f"[OK] {description} - COMPLETED") @@ -32,36 +33,54 @@ def run_command(cmd, description): print("Install ruff with: python3 -m pip install ruff>=0.1.0") return False + def main(): """Main formatting function that mirrors tox format environment.""" - + # Change to project root project_root = Path(__file__).parent.parent os.chdir(project_root) - + print("[START] Running local formatting (mirroring tox format environment)") print(f"Working directory: {project_root}") - + # Define the same commands used in tox.ini format environment format_commands = [ ( - ["python3", "-m", "ruff", "check", "--fix", "src/", "tests/", "examples/"], - "Auto-fixing linting issues" + [ + "python3", + "-m", + "ruff", + "check", + "--fix", + "src/", + "tests/", + "examples/", + ], + "Auto-fixing linting issues", ), ( - ["python3", "-m", "ruff", "format", "src/", "tests/", "examples/"], - "Formatting code" - ) + [ + "python3", + "-m", + "ruff", + "format", + "src/", + "tests/", + "examples/", + ], + "Formatting code", + ), ] - + all_passed = True - + for cmd, description in format_commands: success = run_command(cmd, description) if not success: all_passed = False - - print("\n" + "="*50) + + print("\n" + "=" * 50) if all_passed: print("🎉 All formatting COMPLETED successfully!") print("Your code is now formatted consistently with CI requirements.") @@ -71,5 +90,6 @@ def main(): print("Check the output above for details.") return 1 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/scripts/lint.py b/scripts/lint.py index cbc3c03..89bbeb9 100644 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -4,16 +4,17 @@ This ensures local and CI linting results are identical. """ +import os import subprocess import sys -import os from pathlib import Path + def run_command(cmd, description): """Run a command and return success status.""" print(f"\n🔍 {description}") print(f"Command: {' '.join(cmd)}") - + try: result = subprocess.run(cmd, check=True, capture_output=True, text=True) print(f"[OK] {description} - PASSED") @@ -32,36 +33,54 @@ def run_command(cmd, description): print("Install ruff with: python3 -m pip install ruff>=0.1.0") return False + def main(): """Main linting function that mirrors tox lint environment.""" - + # Change to project root project_root = Path(__file__).parent.parent os.chdir(project_root) - + print("[START] Running local linting (mirroring CI environment)") print(f"Working directory: {project_root}") - + # Define the same commands used in tox.ini lint_commands = [ ( - ["python3", "-m", "ruff", "check", "src/", "tests/", "examples/"], - "Ruff linting check" + [ + "python3", + "-m", + "ruff", + "check", + "src/", + "tests/", + "examples/", + ], + "Ruff linting check", ), ( - ["python3", "-m", "ruff", "format", "--check", "src/", "tests/", "examples/"], - "Ruff format check" - ) + [ + "python3", + "-m", + "ruff", + "format", + "--check", + "src/", + "tests/", + "examples/", + ], + "Ruff format check", + ), ] - + all_passed = True - + for cmd, description in lint_commands: success = run_command(cmd, description) if not success: all_passed = False - - print("\n" + "="*50) + + print("\n" + "=" * 50) if all_passed: print("🎉 All linting checks PASSED!") print("Your code matches the CI environment requirements.") @@ -73,5 +92,6 @@ def main(): print(" python3 -m ruff format src/ tests/ examples/") return 1 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/scripts/setup-dev.py b/scripts/setup-dev.py index 8fe7952..7dce27d 100644 --- a/scripts/setup-dev.py +++ b/scripts/setup-dev.py @@ -4,16 +4,17 @@ Installs the minimal dependencies needed for local linting that matches CI. """ +import os import subprocess import sys -import os from pathlib import Path + def run_command(cmd, description): """Run a command and return success status.""" print(f"\n🔧 {description}") print(f"Command: {' '.join(cmd)}") - + try: result = subprocess.run(cmd, check=True, capture_output=True, text=True) print(f"[OK] {description} - SUCCESS") @@ -31,38 +32,39 @@ def run_command(cmd, description): print(f"[ERROR] {description} - FAILED (command not found)") return False + def main(): """Set up development environment.""" - + # Change to project root project_root = Path(__file__).parent.parent os.chdir(project_root) - + print("[START] Setting up development environment") print(f"Working directory: {project_root}") - + # Install ruff for linting (matches CI requirement) install_commands = [ ( [sys.executable, "-m", "pip", "install", "--user", "ruff>=0.1.0"], - "Installing ruff (linter/formatter)" + "Installing ruff (linter/formatter)", ) ] - + all_passed = True - + for cmd, description in install_commands: success = run_command(cmd, description) if not success: all_passed = False - - print("\n" + "="*50) + + print("\n" + "=" * 50) if all_passed: print("🎉 Development environment setup COMPLETED!") print() print("Next steps:") print(" 1. Run linting: make ci-lint") - print(" 2. Auto-format: make ci-format") + print(" 2. Auto-format: make ci-format") print(" 3. Full check: make ci-check") print() print("Or use the scripts directly:") @@ -74,5 +76,6 @@ def main(): print("Check the output above for details.") return 1 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/setup.cfg b/setup.cfg index f323453..2f0e1a3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,6 +57,7 @@ python_requires = >=3.9 install_requires = aiohttp>=3.8.0 awsiotsdk>=1.26.0 + pydantic>=2.0.0 [options.packages.find] diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index d68f026..9e3e37e 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -72,7 +72,7 @@ DeviceInfo, DeviceStatus, DhwOperationSetting, - EnergyUsageData, + EnergyUsageDay, EnergyUsageResponse, EnergyUsageTotal, FirmwareInfo, @@ -106,9 +106,9 @@ "TemperatureUnit", "MqttRequest", "MqttCommand", - "EnergyUsageData", - "MonthlyEnergyData", "EnergyUsageTotal", + "EnergyUsageDay", + "MonthlyEnergyData", "EnergyUsageResponse", # Authentication "NavienAuthClient", diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index c917bd0..93d58dc 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -70,9 +70,11 @@ def __init__( self.base_url = base_url.rstrip("/") self._auth_client = auth_client - self._session: aiohttp.ClientSession = session or auth_client._session - if self._session is None: + 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 ) @@ -220,7 +222,7 @@ async def list_devices( ) devices_data = response.get("data", []) - devices = [Device.from_dict(d) for d in devices_data] + devices = [Device.model_validate(d) for d in devices_data] _logger.info(f"Retrieved {len(devices)} device(s)") return devices @@ -256,7 +258,7 @@ async def get_device_info( ) data = response.get("data", {}) - device = Device.from_dict(data) + device = Device.model_validate(data) _logger.info( f"Retrieved info for device: {device.device_info.device_name}" @@ -295,7 +297,7 @@ async def get_firmware_info( data = response.get("data", {}) firmwares_data = data.get("firmwares", []) - firmwares = [FirmwareInfo.from_dict(f) for f in firmwares_data] + firmwares = [FirmwareInfo.model_validate(f) for f in firmwares_data] _logger.info(f"Retrieved firmware info: {len(firmwares)} firmware(s)") return firmwares @@ -339,7 +341,7 @@ async def get_tou_info( ) data = response.get("data", {}) - tou_info = TOUInfo.from_dict(data) + tou_info = TOUInfo.model_validate(data) _logger.info("Retrieved TOU info for device") return tou_info diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index fadd188..1e79fe6 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -13,11 +13,12 @@ import json import logging -from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any, Optional import aiohttp +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator +from pydantic.alias_generators import to_camel from . import __version__ from .config import API_BASE_URL, REFRESH_ENDPOINT, SIGN_IN_ENDPOINT @@ -34,26 +35,27 @@ _logger = logging.getLogger(__name__) -@dataclass -class UserInfo: +class NavienBaseModel(BaseModel): + """Base model for Navien authentication models.""" + + model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, extra="ignore" + ) + + +class UserInfo(NavienBaseModel): """User information returned from authentication.""" - user_type: str - user_first_name: str - user_last_name: str - user_status: str - user_seq: int + user_type: str = "" + user_first_name: str = "" + user_last_name: str = "" + user_status: str = "" + user_seq: int = 0 @classmethod def from_dict(cls, data: dict[str, Any]) -> "UserInfo": - """Create UserInfo from API response dictionary.""" - return cls( - user_type=data.get("userType", ""), - user_first_name=data.get("userFirstName", ""), - user_last_name=data.get("userLastName", ""), - user_status=data.get("userStatus", ""), - user_seq=data.get("userSeq", 0), - ) + """Create UserInfo from API response dictionary (compatibility).""" + return cls.model_validate(data) @property def full_name(self) -> str: @@ -61,29 +63,48 @@ def full_name(self) -> str: return f"{self.user_first_name} {self.user_last_name}".strip() -@dataclass -class AuthTokens: +class AuthTokens(NavienBaseModel): """Authentication tokens and AWS credentials returned from the API.""" - id_token: str - access_token: str - refresh_token: str - authentication_expires_in: int + id_token: str = "" + access_token: str = "" + refresh_token: str = "" + authentication_expires_in: int = 3600 access_key_id: Optional[str] = None secret_key: Optional[str] = None session_token: Optional[str] = None authorization_expires_in: Optional[int] = None # Calculated fields - issued_at: datetime = field(default_factory=datetime.now) - _expires_at: datetime = field( - default=datetime.now(), init=False, repr=False - ) - _aws_expires_at: Optional[datetime] = field( - default=None, init=False, repr=False - ) + issued_at: datetime = Field(default_factory=datetime.now) + + _expires_at: datetime = PrivateAttr() + _aws_expires_at: Optional[datetime] = PrivateAttr(default=None) - def __post_init__(self) -> None: + @model_validator(mode="before") + @classmethod + def handle_empty_aliases(cls, data: Any) -> Any: + """Handle empty camelCase aliases with snake_case fallbacks.""" + if isinstance(data, dict): + # Fields to check for fallback + fields_to_check = [ + ("accessToken", "access_token"), + ("accessKeyId", "access_key_id"), + ("secretKey", "secret_key"), + ("refreshToken", "refresh_token"), + ("sessionToken", "session_token"), + ("authenticationExpiresIn", "authentication_expires_in"), + ("authorizationExpiresIn", "authorization_expires_in"), + ("idToken", "id_token"), + ] + + for camel, snake in fields_to_check: + # If camel exists but is empty/None, and snake exists, use snake + if camel in data and not data[camel] and snake in data: + data[camel] = data[snake] + return data + + def model_post_init(self, __context: Any) -> None: """Cache the expiration timestamp after initialization.""" # Pre-calculate and cache the expiration time self._expires_at = self.issued_at + timedelta( @@ -94,6 +115,8 @@ def __post_init__(self) -> None: self._aws_expires_at = self.issued_at + timedelta( seconds=self.authorization_expires_in ) + else: + self._aws_expires_at = None @classmethod def from_dict(cls, data: dict[str, Any]) -> "AuthTokens": @@ -106,84 +129,20 @@ def from_dict(cls, data: dict[str, Any]) -> "AuthTokens": Returns: AuthTokens instance - - Example: - # From API response - >>> tokens = AuthTokens.from_dict({ - ... "idToken": "...", - ... "accessToken": "...", - ... "refreshToken": "...", - ... "authenticationExpiresIn": 3600 - ... }) - - # From stored data (after to_dict()) - >>> stored = tokens.to_dict() - >>> restored = AuthTokens.from_dict(stored) """ - - # Helper to get value from either camelCase or snake_case key - def get_value( - camel_key: str, snake_key: str, default: Any = None - ) -> Any: - """Get value, checking camelCase first, then snake_case.""" - value = data.get(camel_key) - if value is not None and value != "": - return value - value = data.get(snake_key) - if value is not None and value != "": - return value - return default - - # Support both camelCase (API) and snake_case (stored) keys - return cls( - id_token=get_value("idToken", "id_token", ""), - access_token=get_value("accessToken", "access_token", ""), - refresh_token=get_value("refreshToken", "refresh_token", ""), - authentication_expires_in=get_value( - "authenticationExpiresIn", "authentication_expires_in", 3600 - ), - access_key_id=get_value("accessKeyId", "access_key_id"), - secret_key=get_value("secretKey", "secret_key"), - session_token=get_value("sessionToken", "session_token"), - authorization_expires_in=get_value( - "authorizationExpiresIn", "authorization_expires_in" - ), - issued_at=datetime.fromisoformat(data["issued_at"]) - if "issued_at" in data - else datetime.now(), - ) + # Pydantic with populate_by_name=True handles both snake_case (stored) + # and camelCase (API alias) automatically. + return cls.model_validate(data) def to_dict(self) -> dict[str, Any]: """Convert AuthTokens to a dictionary for storage. - Returns a dictionary with all token data including the issued_at - timestamp, which is essential for correctly calculating expiration - times when restoring tokens. - Returns: - Dictionary with snake_case keys suitable for JSON serialization - - Example: - >>> tokens = auth_client.current_tokens - >>> stored_data = tokens.to_dict() - >>> # Save to file/database - >>> import json - >>> json.dump(stored_data, file) - >>> - >>> # Later, restore tokens - >>> restored_tokens = AuthTokens.from_dict(json.load(file)) + Dictionary with snake_case keys suitable for JSON serialization. + DateTime fields are serialized to ISO 8601 format strings + (e.g., "2025-11-19T08:51:00") for backward compatibility. """ - return { - "id_token": self.id_token, - "access_token": self.access_token, - "refresh_token": self.refresh_token, - "authentication_expires_in": self.authentication_expires_in, - "access_key_id": self.access_key_id, - "secret_key": self.secret_key, - "session_token": self.session_token, - "authorization_expires_in": self.authorization_expires_in, - "issued_at": self.issued_at.isoformat(), - } + return self.model_dump(mode="json") @property def expires_at(self) -> datetime: @@ -229,36 +188,34 @@ def bearer_token(self) -> str: return f"Bearer {self.access_token}" -@dataclass -class AuthenticationResponse: +class AuthenticationResponse(NavienBaseModel): """Complete authentication response including user info and tokens.""" user_info: UserInfo tokens: AuthTokens - legal: list[dict[str, Any]] = field(default_factory=list) + legal: list[dict[str, Any]] = Field(default_factory=list) code: int = 200 - message: str = "SUCCESS" + message: str = Field(default="SUCCESS", alias="msg") @classmethod def from_dict( cls, response_data: dict[str, Any] ) -> "AuthenticationResponse": """Create AuthenticationResponse from API response.""" - code = response_data.get("code", 200) - message = response_data.get("msg", "SUCCESS") + # Map nested API response to flat model structure + # API response: { "code": ..., "msg": ..., "data": { ... } } data = response_data.get("data", {}) - user_info = UserInfo.from_dict(data.get("userInfo", {})) - tokens = AuthTokens.from_dict(data.get("token", {})) - legal = data.get("legal", []) + # Construct a dict that matches the model structure + model_data = { + "code": response_data.get("code", 200), + "msg": response_data.get("msg", "SUCCESS"), + "userInfo": data.get("userInfo", {}), + "tokens": data.get("token", {}), + "legal": data.get("legal", []), + } - return cls( - user_info=user_info, - tokens=tokens, - legal=legal, - code=code, - message=message, - ) + return cls.model_validate(model_data) __all__ = [ @@ -353,13 +310,7 @@ def __init__( # Create a minimal AuthenticationResponse with stored tokens # UserInfo will be populated on first API call if needed self._auth_response = AuthenticationResponse( - user_info=UserInfo( - user_type="", - user_first_name="", - user_last_name="", - user_status="", - user_seq=0, - ), + user_info=UserInfo(), tokens=stored_tokens, ) self._user_email = user_id diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py index 24a8b48..1bab777 100644 --- a/src/nwp500/cli/commands.py +++ b/src/nwp500/cli/commands.py @@ -31,7 +31,7 @@ async def get_controller_serial_number( def on_feature(feature: DeviceFeature) -> None: if not future.done(): - future.set_result(feature.controllerSerialNumber) + future.set_result(feature.controller_serial_number) await mqtt.subscribe_device_feature(device, on_feature) _logger.info("Requesting controller serial number...") @@ -232,7 +232,7 @@ def on_status_response(status: DeviceStatus) -> None: ) _logger.info( f"Mode change successful. New mode: " - f"{status.operationMode.name}" + f"{status.operation_mode.name}" ) else: _logger.warning( @@ -308,7 +308,7 @@ def on_status_response(status: DeviceStatus) -> None: ) _logger.info( f"Temperature change successful. New target: " - f"{status.dhwTargetTemperatureSetting}°F" + f"{status.dhw_target_temperature_setting}°F" ) else: _logger.warning( diff --git a/src/nwp500/cli/monitoring.py b/src/nwp500/cli/monitoring.py index 5f48fde..548e169 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -31,8 +31,8 @@ async def handle_monitoring( def on_status_update(status: DeviceStatus) -> None: _logger.info( - f"Received status update: Temp={status.dhwTemperature}°F, " - f"Power={'ON' if status.dhwUse else 'OFF'}" + f"Received status update: Temp={status.dhw_temperature}°F, " + f"Power={'ON' if status.dhw_use else 'OFF'}" ) write_status_to_csv(output_file, status) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 5d2af30..2f4d342 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -7,255 +7,105 @@ """ import logging -from dataclasses import dataclass, field from enum import Enum -from typing import Any, Optional, Union +from typing import Annotated, Any, Optional, Union -from . import constants +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field +from pydantic.alias_generators import to_camel _logger = logging.getLogger(__name__) # ============================================================================ -# Field Conversion Helpers +# Conversion Helpers & Validators # ============================================================================ -def meta(**kwargs: Any) -> dict[str, Any]: - """ - Create metadata for dataclass fields with conversion information. +def _device_bool_validator(v: Any) -> bool: + """Convert device boolean (2=True, 0/1=False).""" + return bool(v == 2) - Args: - conversion: Conversion type ('device_bool', 'add_20', 'div_10', - 'decicelsius_to_f', 'enum') - enum_class: For enum conversions, the enum class to use - default_value: For enum conversions, the default value on error - Returns: - Metadata dict for use with field(metadata=...) - """ - return kwargs +def _add_20_validator(v: Any) -> float: + """Add 20 to the value (temperature offset).""" + if isinstance(v, (int, float)): + return float(v) + 20.0 + return float(v) -def apply_field_conversions( - cls: type[Any], data: dict[str, Any] -) -> dict[str, Any]: - """ - Apply conversions to data based on field metadata. +def _div_10_validator(v: Any) -> float: + """Divide by 10.""" + if isinstance(v, (int, float)): + return float(v) / 10.0 + return float(v) - This function reads conversion metadata from dataclass fields and applies - the appropriate transformations. This eliminates duplicate field lists and - makes conversion logic self-documenting. - Args: - cls: The dataclass with field metadata - data: Raw data dictionary to convert +def _decicelsius_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) - Returns: - Converted data dictionary - """ - converted_data = data.copy() - - # Iterate through all fields and apply conversions based on metadata - for field_info in cls.__dataclass_fields__.values(): - field_name = field_info.name - if field_name not in converted_data: - continue - - metadata = field_info.metadata - conversion = metadata.get("conversion") - - if not conversion: - continue - - value = converted_data[field_name] - - # Apply the appropriate conversion - if conversion == "device_bool": - # Device encoding: 0 or 1 = false, 2 = true - converted_data[field_name] = value == 2 - - elif conversion == "add_20": - # Temperature offset conversion - converted_data[field_name] = value + 20 - - elif conversion == "div_10": - # Scale down by factor of 10 - converted_data[field_name] = value / 10.0 - - elif conversion == "decicelsius_to_f": - # Convert decicelsius (tenths of Celsius) to Fahrenheit - converted_data[field_name] = _decicelsius_to_fahrenheit(value) - - elif conversion == "enum": - # Convert to enum with error handling - enum_class = metadata.get("enum_class") - default_value = metadata.get("default_value") - - if enum_class: - try: - converted_data[field_name] = enum_class(value) - except ValueError: - if default_value is not None: - _logger.warning( - "Unknown %s value: %s. Defaulting to %s.", - field_name, - value, - default_value.name - if hasattr(default_value, "name") - else default_value, - ) - converted_data[field_name] = default_value - else: - # Re-raise if no default provided - raise - - return converted_data - - -def _decicelsius_to_fahrenheit(raw_value: float) -> float: - """ - Convert a raw decicelsius value to Fahrenheit. - Args: - raw_value: Raw value in decicelsius (tenths of degrees Celsius) +# Reusable Annotated types for conversions +DeviceBool = Annotated[bool, BeforeValidator(_device_bool_validator)] +Add20 = Annotated[float, BeforeValidator(_add_20_validator)] +Div10 = Annotated[float, BeforeValidator(_div_10_validator)] +DeciCelsiusToF = Annotated[float, BeforeValidator(_decicelsius_to_fahrenheit)] - Returns: - Temperature in Fahrenheit - Example: - >>> _decicelsius_to_fahrenheit(250) # 25.0°C - 77.0 - """ - celsius = raw_value / 10.0 - return (celsius * 9 / 5) + 32 +class NavienBaseModel(BaseModel): + """Base model for all Navien models.""" + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", # Ignore unknown fields by default + ) class DhwOperationSetting(Enum): - """DHW operation setting modes (user-configured heating preferences). - - This enum represents the user's configured mode preference - what heating - mode - the device should use when it needs to heat water. These values appear in - the - dhwOperationSetting field and are set via user commands. - - These modes balance energy efficiency and recovery time based on user needs: - - Higher efficiency = longer recovery time, lower operating costs - - Lower efficiency = faster recovery time, higher operating costs - - Values are based on the MQTT protocol dhw-mode command parameter as - documented - in MQTT_MESSAGES.rst. - - Attributes: - HEAT_PUMP: Heat Pump Only - most efficient, slowest recovery - ELECTRIC: Electric Only - least efficient, fastest recovery - ENERGY_SAVER: Hybrid: Efficiency - balanced, good default - HIGH_DEMAND: Hybrid: Boost - maximum heating capacity - VACATION: Vacation mode - suspends heating to save energy - POWER_OFF: Device powered off - appears when device is turned off - """ + """DHW operation setting modes (user-configured heating preferences).""" - HEAT_PUMP = 1 # Heat Pump Only - most efficient, slowest recovery - ELECTRIC = 2 # Electric Only - least efficient, fastest recovery - ENERGY_SAVER = 3 # Hybrid: Efficiency - balanced, good default - HIGH_DEMAND = 4 # Hybrid: Boost - maximum heating capacity - VACATION = 5 # Vacation mode - suspends heating to save energy - POWER_OFF = 6 # Device powered off - appears when device is turned off + HEAT_PUMP = 1 + ELECTRIC = 2 + ENERGY_SAVER = 3 + HIGH_DEMAND = 4 + VACATION = 5 + POWER_OFF = 6 class CurrentOperationMode(Enum): - """Current operation mode (real-time operational state). - - This enum represents the device's current actual operational state - what - the device is doing RIGHT NOW. These values appear in the operationMode - field and change automatically based on heating demand. + """Current operation mode (real-time operational state).""" - Unlike DhwOperationSetting (user preference), this reflects real-time - operation and changes dynamically as the device starts/stops heating. - - Values are based on device status responses in MQTT messages as documented - in DEVICE_STATUS_FIELDS.rst. - - Attributes: - STANDBY: Device is idle, not actively heating - HEAT_PUMP_MODE: Heat pump is actively running to heat water - HYBRID_EFFICIENCY_MODE: Device actively heating in Energy Saver mode - HYBRID_BOOST_MODE: Device actively heating in High Demand mode - """ - - STANDBY = 0 # Device is idle, not actively heating - HEAT_PUMP_MODE = 32 # Heat pump is actively running to heat water - HYBRID_EFFICIENCY_MODE = 64 # Device actively heating in Energy Saver mode - HYBRID_BOOST_MODE = 96 # Device actively heating in High Demand mode + STANDBY = 0 + HEAT_PUMP_MODE = 32 + HYBRID_EFFICIENCY_MODE = 64 + HYBRID_BOOST_MODE = 96 class TemperatureUnit(Enum): - """Temperature unit enumeration. - - Attributes: - CELSIUS: Celsius temperature scale (°C) - FAHRENHEIT: Fahrenheit temperature scale (°F) - """ + """Temperature unit enumeration.""" CELSIUS = 1 FAHRENHEIT = 2 -@dataclass -class DeviceInfo: - """Device information from API. - - Contains basic device identification and network status information - retrieved from the Navien Smart Control REST API. +class DeviceInfo(NavienBaseModel): + """Device information from API.""" - Attributes: - home_seq: Home sequence identifier - mac_address: Device MAC address (unique identifier) - additional_value: Additional device identifier value - device_type: Device type code (52 for NWP500) - device_name: User-assigned device name - connected: Connection status (1=offline, 2=online) - install_type: Installation type (optional) - """ - - home_seq: int - mac_address: str - additional_value: str - device_type: int - device_name: str - connected: int + home_seq: int = 0 + mac_address: str = "" + additional_value: str = "" + device_type: int = 52 + device_name: str = "Unknown" + connected: int = 0 install_type: Optional[str] = None - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "DeviceInfo": - """Create DeviceInfo from API response dictionary.""" - return cls( - home_seq=data.get("homeSeq", 0), - mac_address=data.get("macAddress", ""), - additional_value=data.get("additionalValue", ""), - device_type=data.get("deviceType", 52), - device_name=data.get("deviceName", "Unknown"), - connected=data.get("connected", 0), - install_type=data.get("installType"), - ) - - -@dataclass -class Location: - """Location information for a device. - Contains geographic and address information for a Navien device. - - Attributes: - state: State or province - city: City name - address: Street address - latitude: GPS latitude coordinate - longitude: GPS longitude coordinate - altitude: Altitude/elevation - """ +class Location(NavienBaseModel): + """Location information for a device.""" state: Optional[str] = None city: Optional[str] = None @@ -264,691 +114,352 @@ class Location: longitude: Optional[float] = None altitude: Optional[float] = None - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "Location": - """Create Location from API response dictionary.""" - return cls( - state=data.get("state"), - city=data.get("city"), - address=data.get("address"), - latitude=data.get("latitude"), - longitude=data.get("longitude"), - altitude=data.get("altitude"), - ) - -@dataclass -class Device: - """Complete device information including location. - - Represents a complete Navien device with both identification/status - information and geographic location data. - - Attributes: - device_info: Device identification and status - location: Geographic location information - """ +class Device(NavienBaseModel): + """Complete device information including location.""" device_info: DeviceInfo location: Location - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "Device": - """Create Device from API response dictionary.""" - device_info_data = data.get("deviceInfo", {}) - location_data = data.get("location", {}) - - return cls( - device_info=DeviceInfo.from_dict(device_info_data), - location=Location.from_dict(location_data), - ) - - -@dataclass -class FirmwareInfo: - """Firmware information for a device. - Contains version and update information for device firmware. - See FIRMWARE_TRACKING.rst for details on firmware version tracking. +class FirmwareInfo(NavienBaseModel): + """Firmware information for a device.""" - Attributes: - mac_address: Device MAC address - additional_value: Additional device identifier - device_type: Device type code - cur_sw_code: Current software code - cur_version: Current firmware version - downloaded_version: Downloaded firmware version (if available) - device_group: Device group identifier (optional) - """ - - mac_address: str - additional_value: str - device_type: int - cur_sw_code: int - cur_version: int + mac_address: str = "" + additional_value: str = "" + device_type: int = 52 + cur_sw_code: int = 0 + cur_version: int = 0 downloaded_version: Optional[int] = None device_group: Optional[str] = None - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "FirmwareInfo": - """Create FirmwareInfo from API response dictionary.""" - return cls( - mac_address=data.get("macAddress", ""), - additional_value=data.get("additionalValue", ""), - device_type=data.get("deviceType", 52), - cur_sw_code=data.get("curSwCode", 0), - cur_version=data.get("curVersion", 0), - downloaded_version=data.get("downloadedVersion"), - device_group=data.get("deviceGroup"), - ) - - -@dataclass -class TOUSchedule: - """Time of Use schedule information. - - Represents a Time-of-Use (TOU) pricing schedule for energy optimization. - See TIME_OF_USE.rst for detailed information about TOU configuration. - - Attributes: - season: Season bitfield (months when schedule applies) - intervals: List of time intervals with pricing information - """ - - season: int - intervals: list[dict[str, Any]] - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "TOUSchedule": - """Create TOUSchedule from API response dictionary.""" - return cls( - season=data.get("season", 0), intervals=data.get("interval", []) - ) +class TOUSchedule(NavienBaseModel): + """Time of Use schedule information.""" -@dataclass -class TOUInfo: - """Time of Use information. + season: int = 0 + intervals: list[dict[str, Any]] = Field( + default_factory=list, alias="interval" + ) - Contains complete Time-of-Use (TOU) configuration including utility - information and pricing schedules. See TIME_OF_USE.rst for details - on configuring TOU optimization. - Attributes: - register_path: Registration path - source_type: Source type identifier - controller_id: Controller identifier - manufacture_id: Manufacturer identifier - name: TOU schedule name - utility: Utility company name - zip_code: ZIP code for utility area - schedule: List of TOU schedules by season - """ +class TOUInfo(NavienBaseModel): + """Time of Use information.""" - register_path: str - source_type: str - controller_id: str - manufacture_id: str - name: str - utility: str - zip_code: int - schedule: list[TOUSchedule] + register_path: str = "" + source_type: str = "" + controller_id: str = "" + manufacture_id: str = "" + name: str = "" + utility: str = "" + zip_code: int = 0 + schedule: list[TOUSchedule] = Field(default_factory=list) @classmethod - def from_dict(cls, data: dict[str, Any]) -> "TOUInfo": - """Create TOUInfo from API response dictionary.""" - tou_info_data = data.get("touInfo", {}) - schedule_data = tou_info_data.get("schedule", []) - - return cls( - register_path=data.get("registerPath", ""), - source_type=data.get("sourceType", ""), - controller_id=tou_info_data.get("controllerId", ""), - manufacture_id=tou_info_data.get("manufactureId", ""), - name=tou_info_data.get("name", ""), - utility=tou_info_data.get("utility", ""), - zip_code=tou_info_data.get("zipCode", 0), - schedule=[TOUSchedule.from_dict(s) for s in schedule_data], + def model_validate( + cls, + obj: Any, + *, + strict: Optional[bool] = None, + from_attributes: Optional[bool] = None, + context: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> "TOUInfo": + # Handle nested structure where fields are in 'touInfo' + if isinstance(obj, dict): + data = obj.copy() + if "touInfo" in data: + tou_data = data.pop("touInfo") + data.update(tou_data) + return super().model_validate( + data, + strict=strict, + from_attributes=from_attributes, + context=context, + ) + return super().model_validate( + obj, + strict=strict, + from_attributes=from_attributes, + context=context, ) -@dataclass -class DeviceStatus: - """ - Represents the status of the Navien water heater device. - - This data is typically found in the 'status' object of MQTT response - messages. This class provides a factory method `from_dict` to - create an instance from a raw dictionary, applying necessary data - conversions. - - Field metadata indicates conversion types: - - device_bool: Device-specific boolean encoding (0/1=false, 2=true) - - add_20: Temperature offset conversion (raw + 20) - - div_10: Scale division (raw / 10.0) - - decicelsius_to_f: Decicelsius to Fahrenheit conversion - - enum: Enum conversion with default fallback - """ +class DeviceStatus(NavienBaseModel): + """Represents the status of the Navien water heater device.""" - # Basic status fields (no conversion needed) + # Basic status fields command: int - outsideTemperature: float - specialFunctionStatus: int - errorCode: int - subErrorCode: int - smartDiagnostic: int - faultStatus1: int - faultStatus2: int - wifiRssi: int - dhwChargePer: float - drEventStatus: int - vacationDaySetting: int - vacationDayElapsed: int - antiLegionellaPeriod: int - programReservationType: int - tempFormulaType: str - currentStatenum: int - targetFanRpm: int - currentFanRpm: int - fanPwm: int - mixingRate: float - eevStep: int - airFilterAlarmPeriod: int - airFilterAlarmElapsed: int - cumulatedOpTimeEvaFan: int - cumulatedDhwFlowRate: float - touStatus: int - drOverrideStatus: int - touOverrideStatus: int - totalEnergyCapacity: float - availableEnergyCapacity: float - recircOperationMode: int - recircPumpOperationStatus: int - recircHotBtnReady: int - recircOperationReason: int - recircErrorStatus: int - currentInstPower: float - - # Boolean fields with device-specific encoding (0/1=false, 2=true) - didReload: bool = field(metadata=meta(conversion="device_bool")) - operationBusy: bool = field(metadata=meta(conversion="device_bool")) - freezeProtectionUse: bool = field(metadata=meta(conversion="device_bool")) - dhwUse: bool = field(metadata=meta(conversion="device_bool")) - dhwUseSustained: bool = field(metadata=meta(conversion="device_bool")) - programReservationUse: bool = field(metadata=meta(conversion="device_bool")) - ecoUse: bool = field(metadata=meta(conversion="device_bool")) - compUse: bool = field(metadata=meta(conversion="device_bool")) - eevUse: bool = field(metadata=meta(conversion="device_bool")) - evaFanUse: bool = field(metadata=meta(conversion="device_bool")) - shutOffValveUse: bool = field(metadata=meta(conversion="device_bool")) - conOvrSensorUse: bool = field(metadata=meta(conversion="device_bool")) - wtrOvrSensorUse: bool = field(metadata=meta(conversion="device_bool")) - antiLegionellaUse: bool = field(metadata=meta(conversion="device_bool")) - antiLegionellaOperationBusy: bool = field( - metadata=meta(conversion="device_bool") - ) - errorBuzzerUse: bool = field(metadata=meta(conversion="device_bool")) - currentHeatUse: bool = field(metadata=meta(conversion="device_bool")) - heatUpperUse: bool = field(metadata=meta(conversion="device_bool")) - heatLowerUse: bool = field(metadata=meta(conversion="device_bool")) - scaldUse: bool = field(metadata=meta(conversion="device_bool")) - airFilterAlarmUse: bool = field(metadata=meta(conversion="device_bool")) - recircOperationBusy: bool = field(metadata=meta(conversion="device_bool")) - recircReservationUse: bool = field(metadata=meta(conversion="device_bool")) + outside_temperature: float + special_function_status: int + error_code: int + sub_error_code: int + smart_diagnostic: int + fault_status1: int + fault_status2: int + wifi_rssi: int + dhw_charge_per: float + dr_event_status: int + vacation_day_setting: int + vacation_day_elapsed: int + anti_legionella_period: int + program_reservation_type: int + temp_formula_type: Union[int, str] + current_statenum: int + target_fan_rpm: int + current_fan_rpm: int + fan_pwm: int + mixing_rate: float + eev_step: int + air_filter_alarm_period: int + air_filter_alarm_elapsed: int + cumulated_op_time_eva_fan: int + cumulated_dhw_flow_rate: float + tou_status: int + dr_override_status: int + tou_override_status: int + total_energy_capacity: float + available_energy_capacity: float + recirc_operation_mode: int + recirc_pump_operation_status: int + recirc_hot_btn_ready: int + recirc_operation_reason: int + recirc_error_status: int + current_inst_power: float + + # Boolean fields with device-specific encoding + did_reload: DeviceBool + operation_busy: DeviceBool + freeze_protection_use: DeviceBool + dhw_use: DeviceBool + dhw_use_sustained: DeviceBool + program_reservation_use: DeviceBool + eco_use: DeviceBool + comp_use: DeviceBool + eev_use: DeviceBool + eva_fan_use: DeviceBool + shut_off_valve_use: DeviceBool + con_ovr_sensor_use: DeviceBool + wtr_ovr_sensor_use: DeviceBool + anti_legionella_use: DeviceBool + anti_legionella_operation_busy: DeviceBool + error_buzzer_use: DeviceBool + current_heat_use: DeviceBool + heat_upper_use: DeviceBool + heat_lower_use: DeviceBool + scald_use: DeviceBool + air_filter_alarm_use: DeviceBool + recirc_operation_busy: DeviceBool + recirc_reservation_use: DeviceBool # Temperature fields with offset (raw + 20) - dhwTemperature: float = field(metadata=meta(conversion="add_20")) - dhwTemperatureSetting: float = field(metadata=meta(conversion="add_20")) - dhwTargetTemperatureSetting: float = field( - metadata=meta(conversion="add_20") - ) - freezeProtectionTemperature: float = field( - metadata=meta(conversion="add_20") - ) - dhwTemperature2: float = field(metadata=meta(conversion="add_20")) - hpUpperOnTempSetting: float = field(metadata=meta(conversion="add_20")) - hpUpperOffTempSetting: float = field(metadata=meta(conversion="add_20")) - hpLowerOnTempSetting: float = field(metadata=meta(conversion="add_20")) - hpLowerOffTempSetting: float = field(metadata=meta(conversion="add_20")) - heUpperOnTempSetting: float = field(metadata=meta(conversion="add_20")) - heUpperOffTempSetting: float = field(metadata=meta(conversion="add_20")) - heLowerOnTempSetting: float = field(metadata=meta(conversion="add_20")) - heLowerOffTempSetting: float = field(metadata=meta(conversion="add_20")) - heatMinOpTemperature: float = field(metadata=meta(conversion="add_20")) - recircTempSetting: float = field(metadata=meta(conversion="add_20")) - recircTemperature: float = field(metadata=meta(conversion="add_20")) - recircFaucetTemperature: float = field(metadata=meta(conversion="add_20")) + dhw_temperature: Add20 + dhw_temperature_setting: Add20 + dhw_target_temperature_setting: Add20 + freeze_protection_temperature: Add20 + dhw_temperature2: Add20 + hp_upper_on_temp_setting: Add20 + hp_upper_off_temp_setting: Add20 + hp_lower_on_temp_setting: Add20 + hp_lower_off_temp_setting: Add20 + he_upper_on_temp_setting: Add20 + he_upper_off_temp_setting: Add20 + he_lower_on_temp_setting: Add20 + he_lower_off_temp_setting: Add20 + heat_min_op_temperature: Add20 + recirc_temp_setting: Add20 + recirc_temperature: Add20 + recirc_faucet_temperature: Add20 # Fields with scale division (raw / 10.0) - currentInletTemperature: float = field(metadata=meta(conversion="div_10")) - currentDhwFlowRate: float = field(metadata=meta(conversion="div_10")) - hpUpperOnDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - hpUpperOffDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - hpLowerOnDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - hpLowerOffDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - heUpperOnDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - heUpperOffDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - heLowerOnDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - heLowerOffDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - recircDhwFlowRate: float = field(metadata=meta(conversion="div_10")) + current_inlet_temperature: Div10 + current_dhw_flow_rate: Div10 + hp_upper_on_diff_temp_setting: Div10 + hp_upper_off_diff_temp_setting: Div10 + hp_lower_on_diff_temp_setting: Div10 + hp_lower_off_diff_temp_setting: Div10 + he_upper_on_diff_temp_setting: Div10 + he_upper_off_diff_temp_setting: Div10 + he_lower_on_diff_temp_setting: Div10 = Field( + alias="heLowerOnTDiffempSetting" + ) # Handle API typo: heLowerOnTDiffempSetting -> heLowerOnDiffTempSetting + he_lower_off_diff_temp_setting: Div10 + recirc_dhw_flow_rate: Div10 # Temperature fields with decicelsius to Fahrenheit conversion - tankUpperTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") + tank_upper_temperature: DeciCelsiusToF + tank_lower_temperature: DeciCelsiusToF + discharge_temperature: DeciCelsiusToF + suction_temperature: DeciCelsiusToF + evaporator_temperature: DeciCelsiusToF + ambient_temperature: DeciCelsiusToF + target_super_heat: DeciCelsiusToF + current_super_heat: DeciCelsiusToF + + # Enum fields + operation_mode: CurrentOperationMode = Field( + default=CurrentOperationMode.STANDBY ) - tankLowerTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") + dhw_operation_setting: DhwOperationSetting = Field( + default=DhwOperationSetting.ENERGY_SAVER ) - dischargeTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - suctionTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - evaporatorTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - ambientTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - targetSuperHeat: float = field(metadata=meta(conversion="decicelsius_to_f")) - currentSuperHeat: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - - # Enum fields with default fallbacks - operationMode: CurrentOperationMode = field( - metadata=meta( - conversion="enum", - enum_class=CurrentOperationMode, - default_value=CurrentOperationMode.STANDBY, - ) - ) - dhwOperationSetting: DhwOperationSetting = field( - metadata=meta( - conversion="enum", - enum_class=DhwOperationSetting, - default_value=DhwOperationSetting.ENERGY_SAVER, - ) - ) - temperatureType: TemperatureUnit = field( - metadata=meta( - conversion="enum", - enum_class=TemperatureUnit, - default_value=TemperatureUnit.FAHRENHEIT, - ) - ) - freezeProtectionTempMin: float = field( - default=43.0, metadata=meta(conversion="add_20") - ) - freezeProtectionTempMax: float = field( - default=65.0, metadata=meta(conversion="add_20") + temperature_type: TemperatureUnit = Field( + default=TemperatureUnit.FAHRENHEIT ) + freeze_protection_temp_min: Add20 = 43.0 + freeze_protection_temp_max: Add20 = 65.0 @classmethod def from_dict(cls, data: dict[str, Any]) -> "DeviceStatus": - """Create a DeviceStatus object from a raw dictionary. - - Applies conversions based on field metadata, eliminating duplicate - field lists and making the code more maintainable. - - Args: - data: Raw status dictionary from MQTT or API response - - Returns: - DeviceStatus object with all conversions applied - """ - # Copy data to avoid modifying the original dictionary - converted_data = data.copy() - - # Get valid field names for this class - valid_fields = {f.name for f in cls.__dataclass_fields__.values()} - - # Handle key typo from documentation/API - if "heLowerOnTDiffempSetting" in converted_data: - converted_data["heLowerOnDiffTempSetting"] = converted_data.pop( - "heLowerOnTDiffempSetting" - ) - - # Apply all conversions based on field metadata - converted_data = apply_field_conversions(cls, converted_data) - - # Filter out any unknown fields not defined in the dataclass - # This handles new fields added by firmware updates gracefully - unknown_fields = set(converted_data.keys()) - valid_fields - if unknown_fields: - # Check if any unknown fields are documented in constants - known_firmware_fields = set( - constants.KNOWN_FIRMWARE_FIELD_CHANGES.keys() - ) - known_new_fields = unknown_fields & known_firmware_fields - truly_unknown = unknown_fields - known_firmware_fields - - if known_new_fields: - _logger.info( - "Ignoring known new fields from recent firmware: %s. " - "These fields are documented but not yet implemented " - "in DeviceStatus. Please report this with your " - "firmware version to help us track field changes.", - known_new_fields, - ) - - if truly_unknown: - _logger.warning( - "Discovered new unknown fields from device status: %s. " - "This may indicate a firmware update. Please report " - "this issue with your device firmware version " - "(controllerSwVersion, panelSwVersion, wifiSwVersion) " - "so we can update the library. See " - "constants.KNOWN_FIRMWARE_FIELD_CHANGES.", - truly_unknown, - ) - - converted_data = { - k: v for k, v in converted_data.items() if k in valid_fields - } - - return cls(**converted_data) - - -@dataclass -class DeviceFeature: - """ - Represents device capabilities, configuration, and firmware information. - - This data is found in the 'feature' object of MQTT response messages, - typically received in response to device info requests. It contains - device model information, firmware versions, capabilities, and limits. - - Field metadata indicates conversion types (same as DeviceStatus). - """ - - # Basic feature fields (no conversion needed) - countryCode: int - modelTypeCode: int - controlTypeCode: int - volumeCode: int - controllerSwVersion: int - panelSwVersion: int - wifiSwVersion: int - controllerSwCode: int - panelSwCode: int - wifiSwCode: int - controllerSerialNumber: str - powerUse: int - holidayUse: int - programReservationUse: int - dhwUse: int - dhwTemperatureSettingUse: int - smartDiagnosticUse: int - wifiRssiUse: int - tempFormulaType: int - energyUsageUse: int - freezeProtectionUse: int - mixingValueUse: int - drSettingUse: int - antiLegionellaSettingUse: int - hpwhUse: int - dhwRefillUse: int - ecoUse: int - electricUse: int - heatpumpUse: int - energySaverUse: int - highDemandUse: int + """Compatibility method for existing code.""" + return cls.model_validate(data) + + +class DeviceFeature(NavienBaseModel): + """Device capabilities, configuration, and firmware info.""" + + country_code: int + model_type_code: int + control_type_code: int + volume_code: int + controller_sw_version: int + panel_sw_version: int + wifi_sw_version: int + controller_sw_code: int + panel_sw_code: int + wifi_sw_code: int + controller_serial_number: str + power_use: int + holiday_use: int + program_reservation_use: int + dhw_use: int + dhw_temperature_setting_use: int + smart_diagnostic_use: int + wifi_rssi_use: int + temp_formula_type: int + energy_usage_use: int + freeze_protection_use: int + mixing_value_use: int + dr_setting_use: int + anti_legionella_setting_use: int + hpwh_use: int + dhw_refill_use: int + eco_use: int + electric_use: int + heatpump_use: int + energy_saver_use: int + high_demand_use: int # Temperature limit fields with offset (raw + 20) - dhwTemperatureMin: int = field(metadata=meta(conversion="add_20")) - dhwTemperatureMax: int = field(metadata=meta(conversion="add_20")) - freezeProtectionTempMin: int = field(metadata=meta(conversion="add_20")) - freezeProtectionTempMax: int = field(metadata=meta(conversion="add_20")) - - # Enum field with default fallback - temperatureType: TemperatureUnit = field( - metadata=meta( - conversion="enum", - enum_class=TemperatureUnit, - default_value=TemperatureUnit.FAHRENHEIT, - ) + dhw_temperature_min: Add20 + dhw_temperature_max: Add20 + freeze_protection_temp_min: Add20 + freeze_protection_temp_max: Add20 + + # Enum field + temperature_type: TemperatureUnit = Field( + default=TemperatureUnit.FAHRENHEIT ) @classmethod def from_dict(cls, data: dict[str, Any]) -> "DeviceFeature": - """Create a DeviceFeature object from a raw dictionary. - - Applies conversions based on field metadata. + """Compatibility method.""" + return cls.model_validate(data) - Args: - data: Raw feature dictionary from MQTT or API response - Returns: - DeviceFeature object with all conversions applied - """ - # Copy data to avoid modifying the original dictionary - converted_data = data.copy() - - # Get valid field names for this class - valid_fields = {f.name for f in cls.__dataclass_fields__.values()} - - # Apply all conversions based on field metadata - converted_data = apply_field_conversions(cls, converted_data) - - # Filter out any unknown fields (similar to DeviceStatus) - unknown_fields = set(converted_data.keys()) - valid_fields - if unknown_fields: - _logger.info( - "Ignoring unknown fields from device feature: %s. " - "This may indicate new device capabilities from a " - "firmware update.", - unknown_fields, - ) - converted_data = { - k: v for k, v in converted_data.items() if k in valid_fields - } - - return cls(**converted_data) - - -@dataclass -class MqttRequest: - """MQTT command request payload. - - Represents the 'request' object within an MQTT command payload. This is a - flexible structure that accommodates various command types including status - requests, control commands, and queries. - - See MQTT_MESSAGES.rst for detailed documentation of all command types - and their required fields. - - Attributes: - command: Command code (from CommandCode enum) - deviceType: Device type code (52 for NWP500) - macAddress: Device MAC address - additionalValue: Additional device identifier - mode: Operation mode for control commands - param: Parameter list for control commands - paramStr: Parameter string for control commands - month: Month list for energy usage queries - year: Year for energy usage queries - """ +class MqttRequest(NavienBaseModel): + """MQTT command request payload.""" command: int - deviceType: int - macAddress: str - additionalValue: str = "..." - # Fields for control commands + device_type: int + mac_address: str + additional_value: str = "..." mode: Optional[str] = None - param: list[Union[int, float]] = field(default_factory=list) - paramStr: str = "" - # Fields for energy usage query + param: list[Union[int, float]] = Field(default_factory=list) + param_str: str = "" month: Optional[list[int]] = None year: Optional[int] = None -@dataclass -class MqttCommand: - """Represents an MQTT command message sent to a Navien device. - - This class structures the complete MQTT message including routing - information (topics), session tracking, and the actual command request. - - Attributes: - clientID: MQTT client identifier - sessionID: Session identifier for tracking requests/responses - requestTopic: MQTT topic to publish the command to - responseTopic: MQTT topic to subscribe for responses - request: The actual command request payload - protocolVersion: MQTT protocol version (default: 2) - """ - - clientID: str - sessionID: str - requestTopic: str - responseTopic: str - request: MqttRequest - protocolVersion: int = 2 +class MqttCommand(NavienBaseModel): + """Represents an MQTT command message.""" + client_id: str = Field(alias="clientID") + session_id: str = Field(alias="sessionID") + request_topic: str + response_topic: str + request: Union[MqttRequest, dict[str, Any]] + protocol_version: int = 2 -@dataclass -class EnergyUsageData: - """Daily or monthly energy usage data for a single period. - This data shows the energy consumption and operating time for both - the heat pump and electric heating elements. See ENERGY_MONITORING.rst - for details on querying and interpreting energy usage data. - - Attributes: - heUsage: Heat Element usage in Watt-hours (Wh) - hpUsage: Heat Pump usage in Watt-hours (Wh) - heTime: Heat Element operating time in hours - hpTime: Heat Pump operating time in hours - """ +class EnergyUsageTotal(NavienBaseModel): + """Total energy usage data.""" - heUsage: int # Heat Element usage in Watt-hours (Wh) - hpUsage: int # Heat Pump usage in Watt-hours (Wh) - heTime: int # Heat Element operating time in hours - hpTime: int # Heat Pump operating time in hours + heat_pump_usage: int = Field(default=0, alias="hpUsage") + heat_element_usage: int = Field(default=0, alias="heUsage") + heat_pump_time: int = Field(default=0, alias="hpTime") + heat_element_time: int = Field(default=0, alias="heTime") @property def total_usage(self) -> int: - """Calculate total energy usage. - - Returns: - Total energy usage (heat element + heat pump) in Watt-hours - """ - return self.heUsage + self.hpUsage + """Total energy usage (heat pump + heat element).""" + return self.heat_pump_usage + self.heat_element_usage @property - def total_time(self) -> int: - """Calculate total operating time. - - Returns: - Total operating time (heat element + heat pump) in hours - """ - return self.heTime + self.hpTime - - -@dataclass -class MonthlyEnergyData: - """ - Represents energy usage data for a specific month. - - Contains daily breakdown of energy usage with one entry per day. - Days are indexed starting from 0 (day 1 is index 0). - """ - - year: int - month: int - data: list[EnergyUsageData] - - def get_day_usage(self, day: int) -> Optional[EnergyUsageData]: - """ - Get energy usage for a specific day of the month. - - Args: - day: Day of the month (1-31) - - Returns: - EnergyUsageData for that day, or None if invalid day - """ - if 1 <= day <= len(self.data): - return self.data[day - 1] - return None - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "MonthlyEnergyData": - """Create MonthlyEnergyData from a raw dictionary.""" - converted_data = data.copy() + def heat_pump_percentage(self) -> float: + if self.total_usage == 0: + return 0.0 + return (self.heat_pump_usage / self.total_usage) * 100.0 - # Convert list of dictionaries to EnergyUsageData objects - if "data" in converted_data: - converted_data["data"] = [ - EnergyUsageData(**day_data) - for day_data in converted_data["data"] - ] + @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 cls(**converted_data) + @property + def total_time(self) -> int: + """Total operating time (heat pump + heat element).""" + return self.heat_pump_time + self.heat_element_time -@dataclass -class EnergyUsageTotal: - """Represents total energy usage across the queried period. +class EnergyUsageDay(NavienBaseModel): + """Daily energy usage data. - Attributes: - heUsage: Total Heat Element usage in Watt-hours (Wh) - hpUsage: Total Heat Pump usage in Watt-hours (Wh) + 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). """ - heUsage: int # Total Heat Element usage in Watt-hours (Wh) - hpUsage: int # Total Heat Pump usage in Watt-hours (Wh) - heTime: int # Total Heat Element operating time in hours - hpTime: int # Total Heat Pump operating time in hours + 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") @property def total_usage(self) -> int: - """Total energy usage (heat element + heat pump) in Wh.""" - return self.heUsage + self.hpUsage + """Total energy usage (heat pump + heat element).""" + return self.heat_pump_usage + self.heat_element_usage - @property - def total_time(self) -> int: - """Total operating time (heat element + heat pump) in hours.""" - return self.heTime + self.hpTime - - @property - def heat_pump_percentage(self) -> float: - """Percentage of energy from heat pump (0-100).""" - if self.total_usage == 0: - return 0.0 - return (self.hpUsage / self.total_usage) * 100 - @property - def heat_element_percentage(self) -> float: - """Percentage of energy from electric heating elements (0-100).""" - if self.total_usage == 0: - return 0.0 - return (self.heUsage / self.total_usage) * 100 +class MonthlyEnergyData(NavienBaseModel): + """Monthly energy usage data grouping.""" + year: int + month: int + data: list[EnergyUsageDay] -@dataclass -class EnergyUsageResponse: - """ - Represents the response to an energy usage query. - This contains historical energy usage data broken down by day - for the requested month(s), plus totals for the entire period. - """ +class EnergyUsageResponse(NavienBaseModel): + """Response for energy usage query.""" - deviceType: int - macAddress: str - additionalValue: str - typeOfUsage: int # 1 for daily data total: EnergyUsageTotal usage: list[MonthlyEnergyData] def get_month_data( self, year: int, month: int ) -> Optional[MonthlyEnergyData]: - """ - Get energy usage data for a specific month. + """Get energy usage data for a specific month. Args: year: Year (e.g., 2025) @@ -964,20 +475,5 @@ def get_month_data( @classmethod def from_dict(cls, data: dict[str, Any]) -> "EnergyUsageResponse": - """Create EnergyUsageResponse from a raw dictionary.""" - converted_data = data.copy() - - # Convert total to EnergyUsageTotal - if "total" in converted_data: - converted_data["total"] = EnergyUsageTotal( - **converted_data["total"] - ) - - # Convert usage list to MonthlyEnergyData objects - if "usage" in converted_data: - converted_data["usage"] = [ - MonthlyEnergyData.from_dict(month_data) - for month_data in converted_data["usage"] - ] - - return cls(**converted_data) + """Compatibility method.""" + return cls.model_validate(data) diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt_client.py index b539a37..1143925 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt_client.py @@ -9,12 +9,14 @@ the authentication flow. """ +from __future__ import annotations + import asyncio import json import logging import uuid from collections.abc import Sequence -from typing import Any, Callable, Optional +from typing import TYPE_CHECKING, Any, Callable from awscrt import mqtt from awscrt.exceptions import AwsCrtError @@ -29,12 +31,14 @@ MqttPublishError, TokenRefreshError, ) -from .models import ( - Device, - DeviceFeature, - DeviceStatus, - EnergyUsageResponse, -) + +if TYPE_CHECKING: + from .models import ( + Device, + DeviceFeature, + DeviceStatus, + EnergyUsageResponse, + ) from .mqtt_command_queue import MqttCommandQueue from .mqtt_connection import MqttConnection from .mqtt_device_control import MqttDeviceController @@ -123,7 +127,7 @@ class NavienMqttClient(EventEmitter): def __init__( self, auth_client: NavienAuthClient, - config: Optional[MqttConnectionConfig] = None, + config: MqttConnectionConfig | None = None, ): """ Initialize the MQTT client. @@ -162,22 +166,22 @@ def __init__( self._session_id = uuid.uuid4().hex # Store event loop reference for thread-safe coroutine scheduling - self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop: asyncio.AbstractEventLoop | None = None # Initialize specialized components # Command queue (independent, can be created immediately) self._command_queue = MqttCommandQueue(config=self.config) # Components that depend on connection (initialized in connect()) - self._connection_manager: Optional[MqttConnection] = None - self._reconnection_handler: Optional[MqttReconnectionHandler] = None - self._subscription_manager: Optional[MqttSubscriptionManager] = None - self._device_controller: Optional[MqttDeviceController] = None - self._reconnect_task: Optional[asyncio.Task[None]] = None - self._periodic_manager: Optional[MqttPeriodicRequestManager] = None + self._connection_manager: MqttConnection | None = None + self._reconnection_handler: MqttReconnectionHandler | None = None + self._subscription_manager: MqttSubscriptionManager | None = None + self._device_controller: MqttDeviceController | None = None + self._reconnect_task: asyncio.Task[None] | None = None + self._periodic_manager: MqttPeriodicRequestManager | None = None # Connection state (simpler than checking _connection_manager) - self._connection: Optional[mqtt.Connection] = None + self._connection: mqtt.Connection | None = None self._connected = False _logger.info( @@ -588,44 +592,6 @@ def _on_message_received( except (AttributeError, KeyError, TypeError) as e: _logger.error(f"Error processing message: {e}") - def _topic_matches_pattern(self, topic: str, pattern: str) -> bool: - """Check if a topic matches a subscription pattern with wildcards.""" - # Handle exact match - if topic == pattern: - return True - - # Handle wildcards - topic_parts = topic.split("/") - pattern_parts = pattern.split("/") - - # Multi-level wildcard # matches everything after - if "#" in pattern_parts: - hash_idx = pattern_parts.index("#") - # Must be at the end - if hash_idx != len(pattern_parts) - 1: - return False - # Topic must have at least as many parts as before the # - if len(topic_parts) < hash_idx: - return False - # Check parts before # with + wildcard support - for i in range(hash_idx): - if ( - pattern_parts[i] != "+" - and topic_parts[i] != pattern_parts[i] - ): - return False - return True - - # Single-level wildcard + matches one level - if len(topic_parts) != len(pattern_parts): - return False - - for topic_part, pattern_part in zip(topic_parts, pattern_parts): - if pattern_part != "+" and topic_part != pattern_part: - return False - - return True - async def subscribe( self, topic: str, @@ -792,8 +758,8 @@ async def subscribe_device_status( Example (Traditional Callback):: >>> def on_status(status: DeviceStatus): - ... print(f"Temperature: {status.dhwTemperature}°F") - ... print(f"Mode: {status.operationMode}") + ... print(f"Temperature: {status.dhw_temperature}°F") + ... print(f"Mode: {status.operation_mode}") >>> >>> await mqtt_client.subscribe_device_status(device, on_status) @@ -923,7 +889,7 @@ async def set_dhw_mode( self, device: Device, mode_id: int, - vacation_days: Optional[int] = None, + vacation_days: int | None = None, ) -> int: """ Set DHW (Domestic Hot Water) operation mode. @@ -1283,7 +1249,7 @@ async def start_periodic_requests( async def stop_periodic_requests( self, device: Device, - request_type: Optional[PeriodicRequestType] = None, + request_type: PeriodicRequestType | None = None, ) -> None: """ Stop sending periodic requests for a device. @@ -1404,9 +1370,7 @@ async def stop_periodic_device_status_requests( device ) - async def stop_all_periodic_tasks( - self, _reason: Optional[str] = None - ) -> None: + async def stop_all_periodic_tasks(self, _reason: str | None = None) -> None: """ Stop all periodic request tasks. diff --git a/src/nwp500/mqtt_subscriptions.py b/src/nwp500/mqtt_subscriptions.py index e4e37a2..6de2b71 100644 --- a/src/nwp500/mqtt_subscriptions.py +++ b/src/nwp500/mqtt_subscriptions.py @@ -20,7 +20,7 @@ from .events import EventEmitter from .exceptions import MqttNotConnectedError from .models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse -from .mqtt_utils import redact_topic +from .mqtt_utils import redact_topic, topic_matches_pattern __author__ = "Emmanuel Levijarvi" @@ -115,7 +115,7 @@ def _on_message_received( subscription_pattern, handlers, ) in self._message_handlers.items(): - if self._topic_matches_pattern(topic, subscription_pattern): + if topic_matches_pattern(topic, subscription_pattern): for handler in handlers: try: handler(topic, message) @@ -127,65 +127,6 @@ def _on_message_received( except (AttributeError, KeyError, TypeError) as e: _logger.error(f"Error processing message: {e}") - def _topic_matches_pattern(self, topic: str, pattern: str) -> bool: - """ - Check if a topic matches a subscription pattern with wildcards. - - Supports MQTT wildcards: - - '+' matches a single level - - '#' matches multiple levels (must be at end) - - Args: - topic: Actual topic (e.g., "cmd/52/navilink-ABC/status") - pattern: Pattern with wildcards (e.g., "cmd/52/+/#") - - Returns: - True if topic matches pattern - - Examples: - >>> _topic_matches_pattern("cmd/52/device1/status", - "cmd/52/+/status") - True - >>> _topic_matches_pattern("cmd/52/device1/status/extra", - "cmd/52/device1/#") - True - """ - # Handle exact match - if topic == pattern: - return True - - # Handle wildcards - topic_parts = topic.split("/") - pattern_parts = pattern.split("/") - - # Multi-level wildcard # matches everything after - if "#" in pattern_parts: - hash_idx = pattern_parts.index("#") - # Must be at the end - if hash_idx != len(pattern_parts) - 1: - return False - # Topic must have at least as many parts as before the # - if len(topic_parts) < hash_idx: - return False - # Check parts before # with + wildcard support - for i in range(hash_idx): - if ( - pattern_parts[i] != "+" - and topic_parts[i] != pattern_parts[i] - ): - return False - return True - - # Single-level wildcard + matches one level - if len(topic_parts) != len(pattern_parts): - return False - - for topic_part, pattern_part in zip(topic_parts, pattern_parts): - if pattern_part != "+" and topic_part != pattern_part: - return False - - return True - async def subscribe( self, topic: str, @@ -416,8 +357,8 @@ async def subscribe_device_status( Example (Traditional Callback):: >>> def on_status(status: DeviceStatus): - ... print(f"Temperature: {status.dhwTemperature}°F") - ... print(f"Mode: {status.operationMode}") + ... print(f"Temperature: {status.dhw_temperature}°F") + ... print(f"Mode: {status.operation_mode}") >>> >>> await mqtt_client.subscribe_device_status(device, on_status) @@ -525,44 +466,44 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: try: # Temperature change - if status.dhwTemperature != prev.dhwTemperature: + if status.dhw_temperature != prev.dhw_temperature: await self._event_emitter.emit( "temperature_changed", - prev.dhwTemperature, - status.dhwTemperature, + prev.dhw_temperature, + status.dhw_temperature, ) _logger.debug( - f"Temperature changed: {prev.dhwTemperature}°F → " - f"{status.dhwTemperature}°F" + f"Temperature changed: {prev.dhw_temperature}°F → " + f"{status.dhw_temperature}°F" ) # Operation mode change - if status.operationMode != prev.operationMode: + if status.operation_mode != prev.operation_mode: await self._event_emitter.emit( "mode_changed", - prev.operationMode, - status.operationMode, + prev.operation_mode, + status.operation_mode, ) _logger.debug( - f"Mode changed: {prev.operationMode} → " - f"{status.operationMode}" + f"Mode changed: {prev.operation_mode} → " + f"{status.operation_mode}" ) # Power consumption change - if status.currentInstPower != prev.currentInstPower: + if status.current_inst_power != prev.current_inst_power: await self._event_emitter.emit( "power_changed", - prev.currentInstPower, - status.currentInstPower, + prev.current_inst_power, + status.current_inst_power, ) _logger.debug( - f"Power changed: {prev.currentInstPower}W → " - f"{status.currentInstPower}W" + f"Power changed: {prev.current_inst_power}W → " + f"{status.current_inst_power}W" ) # Heating started/stopped - prev_heating = prev.currentInstPower > 0 - curr_heating = status.currentInstPower > 0 + prev_heating = prev.current_inst_power > 0 + curr_heating = status.current_inst_power > 0 if curr_heating and not prev_heating: await self._event_emitter.emit("heating_started", status) @@ -573,15 +514,15 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: _logger.debug("Heating stopped") # Error detection - if status.errorCode and not prev.errorCode: + if status.error_code and not prev.error_code: await self._event_emitter.emit( - "error_detected", status.errorCode, status + "error_detected", status.error_code, status ) - _logger.info(f"Error detected: {status.errorCode}") + _logger.info(f"Error detected: {status.error_code}") - if not status.errorCode and prev.errorCode: - await self._event_emitter.emit("error_cleared", prev.errorCode) - _logger.info(f"Error cleared: {prev.errorCode}") + if not status.error_code and prev.error_code: + await self._event_emitter.emit("error_cleared", prev.error_code) + _logger.info(f"Error cleared: {prev.error_code}") except (TypeError, AttributeError, RuntimeError) as e: _logger.error(f"Error detecting state changes: {e}", exc_info=True) @@ -614,16 +555,16 @@ async def subscribe_device_feature( Example:: >>> def on_feature(feature: DeviceFeature): - ... print(f"Serial: {feature.controllerSerialNumber}") - ... print(f"FW Version: {feature.controllerSwVersion}") + ... print(f"Serial: {feature.controller_serial_number}") + ... print(f"FW Version: {feature.controller_sw_version}") ... print(f"Temp Range: - {feature.dhwTemperatureMin}-{feature.dhwTemperatureMax}°F") + {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.controllerSwVersion}")) + {f.controller_sw_version}")) >>> await mqtt_client.subscribe_device_feature(device, lambda f: None) """ diff --git a/src/nwp500/mqtt_utils.py b/src/nwp500/mqtt_utils.py index 3cdc5db..c6a4570 100644 --- a/src/nwp500/mqtt_utils.py +++ b/src/nwp500/mqtt_utils.py @@ -219,3 +219,60 @@ class PeriodicRequestType(Enum): DEVICE_INFO = "device_info" DEVICE_STATUS = "device_status" + + +def topic_matches_pattern(topic: str, pattern: str) -> bool: + """ + Check if a topic matches a subscription pattern with wildcards. + + Supports MQTT wildcards: + - '+' matches a single level + - '#' matches multiple levels (must be at end) + + Args: + topic: Actual topic (e.g., "cmd/52/navilink-ABC/status") + pattern: Pattern with wildcards (e.g., "cmd/52/+/#") + + Returns: + True if topic matches pattern + + Examples: + >>> topic_matches_pattern("cmd/52/device1/status", "cmd/52/+/status") + True + >>> topic_matches_pattern( + ... "cmd/52/device1/status/extra", "cmd/52/device1/#" + ... ) + True + """ + # Handle exact match + if topic == pattern: + return True + + # Handle wildcards + topic_parts = topic.split("/") + pattern_parts = pattern.split("/") + + # Multi-level wildcard # matches everything after + if "#" in pattern_parts: + hash_idx = pattern_parts.index("#") + # Must be at the end + if hash_idx != len(pattern_parts) - 1: + return False + # Topic must have at least as many parts as before the # + if len(topic_parts) < hash_idx: + return False + # Check parts before # with + wildcard support + for i in range(hash_idx): + if pattern_parts[i] != "+" and topic_parts[i] != pattern_parts[i]: + return False + return True + + # Single-level wildcard + matches one level + if len(topic_parts) != len(pattern_parts): + return False + + for topic_part, pattern_part in zip(topic_parts, pattern_parts): + if pattern_part != "+" and topic_part != pattern_part: + return False + + return True