diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62638f5..81bb087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install tox run: | @@ -37,7 +37,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install ruff run: | @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.13', '3.14'] + python-version: ['3.14'] steps: - uses: actions/checkout@v4 @@ -87,7 +87,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install build dependencies run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fd0a56..c21a83e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install dependencies run: | @@ -53,7 +53,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.14' - name: Install build dependencies run: | @@ -80,7 +80,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.14' - name: Extract changelog for this version id: changelog diff --git a/.readthedocs.yml b/.readthedocs.yml index 4704766..c9d6a1d 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -19,7 +19,7 @@ formats: build: os: ubuntu-22.04 tools: - python: "3.13" + python: "3.14" python: install: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 375c78f..5802079 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,146 @@ Changelog ========= +Version 8.1.1 (2026-05-12) +========================== + +Bug Fixes +--------- +- **Fix MQTT reconnection after unexpected AWS hangup**: The + ``on_connection_resumed`` callback was missing the ``connection`` parameter + required by the AWS IoT SDK callback signature. The SDK calls + ``on_connection_resumed(connection, return_code, session_present, **kwargs)``, + but the handler only accepted ``(return_code, session_present)``. The + mismatched signature caused the callback to fail silently (exception swallowed + by the AWS CRT C layer), so ``self._connected`` was never restored to ``True`` + after an ``AWS_ERROR_MQTT_UNEXPECTED_HANGUP``. As a result, the + ``connection_resumed`` event was never emitted, the reconnection loop ran + indefinitely, and device sensors became permanently unavailable until a manual + restart. (`#85 `_) + +Version 8.1.0 (2026-05-07) +========================== + +Features +-------- +- **Multi-device support enhancements**: Improved support for accounts with multiple + Navilink devices by injecting device identity into models and events. + + - Added ``mac_address`` field to ``DeviceStatus`` and ``DeviceFeature`` models. + - Added ``device_mac`` attribute to all device-specific MQTT events (temperature + changes, mode changes, power updates, errors, etc.). + - Updated ``DeviceStateTracker`` and ``MqttSubscriptionManager`` to propagate + device identity correctly. + +Version 8.0.0 (2026-05-06) +=========================== + +**BREAKING CHANGES**: ``.on()`` event handler callbacks now receive a single typed +event dataclass instead of positional arguments. ``MqttDeviceController`` is no longer +accessible as ``.control`` on ``NavienMqttClient``; all control methods are now +available directly on the client. + +Breaking Changes +---------------- +- **Typed event payloads**: All ``.on()`` event handler callbacks now receive a single + typed event dataclass instance. The dataclasses are exported from + ``nwp500.mqtt_events``. + + .. code-block:: python + + # OLD (removed) + mqtt.on("temperature_changed", lambda old, new: print(old, new)) + mqtt.on("connection_resumed", lambda rc, sp: print(rc, sp)) + + # NEW + mqtt.on("temperature_changed", lambda e: print(e.old_temperature, e.new_temperature)) + mqtt.on("connection_resumed", lambda e: print(e.return_code, e.session_present)) + +- **``MqttDeviceController`` no longer public**: ``NavienMqttClient`` no longer exposes + a ``.control`` attribute. All control methods are now available directly on the + client. + + .. code-block:: python + + # OLD (removed) + await mqtt.control.set_temperature(device, 50) + + # NEW + await mqtt.set_temperature(device, 50) + +Migration Guide (from 7.x.x) +---------------------------- +The following steps are recommended for a smooth migration, particularly for complex +integrations like Home Assistant: + +1. **Update Event Listeners**: Locate all ``mqtt.on()`` or ``mqtt.once()`` calls. + Update the callback signatures to accept a single argument (the event object) and + update the body to access fields via the object (e.g., ``event.new_temperature`` + instead of ``new_val``). +2. **Refactor Control Calls**: Remove ``.control`` from all device command invocations. + Instead of ``await mqtt.control.set_power(...)``, use ``await mqtt.set_power(...)``. +3. **Handle Unit Conversions**: If your integration previously performed its own + conversions or relied on the library's eager conversion, note that + ``DeviceStatus`` fields like ``dhw_temperature`` are now properties. They return + values based on the global unit system context (``us_customary`` by default). + + * **Home Assistant Tip**: To ensure your state tracking is immune to unit system + toggles within the library, use the new ``*_raw`` fields (e.g., + ``status.dhw_temperature_raw``) for comparison logic, and use the properties + only for display or when a converted value is explicitly needed. +4. **Remove ``from_dict()`` Calls**: The ``from_dict()`` method has been removed + from all models. Use ``model_validate()`` instead. + + * **Note**: ``AuthenticationResponse.model_validate()`` now automatically handles + the ``"data": { ... }`` wrapper found in raw API responses. +5. **Subpackage Imports**: While top-level imports from ``nwp500.models`` are + preserved, if you were importing from the internal ``nwp500.models`` module file + directly, you must update your imports to point to the new structured files + (e.g., ``nwp500.models.status``). + +Added +----- +- **New control methods**: Nine previously unimplemented ``CommandCode`` values now have + full implementations: ``check_firmware``, ``commit_firmware``, ``reconnect_wifi``, + ``reset_wifi``, ``set_freeze_protection_temperature``, ``run_smart_diagnostic``, + ``enable_intelligent_reservation``, ``disable_intelligent_reservation``, and + ``set_water_program_reservation``. +- **Typed subscription methods**: ``subscribe_reservation``, + ``subscribe_weekly_reservation``, and ``subscribe_recirculation`` return typed + responses directly without requiring raw MQTT event handlers. +- **New protocol models**: ``WeeklyReservationSchedule``, ``WeeklyReservationEntry``, + ``RecirculationSchedule``, ``RecirculationScheduleEntry``, and ``OtaCommitPayload``. +- **``DeviceStateTracker``**: State change detection extracted into a dedicated class + in ``nwp500.mqtt.state_tracker`` with per-device tracking keyed by MAC address. +- **``MQTT_PROTOCOL_VERSION`` constant**: Protocol version is now a named constant in + ``nwp500.config`` rather than a hardcoded integer in the command payload builder. +- **``response_ack_topic()``**: New method on ``MqttTopicBuilder`` for control command + acknowledgement topics. + +Changed +------- +- **Unit conversion redesign**: Temperature, flow rate, and volume fields in + ``DeviceStatus`` and ``DeviceFeature`` now store raw device values as ``*_raw: int`` + fields and expose converted values via lazy computed properties. Conversion happens at + access time rather than during Pydantic deserialization, preserving the original + device value in all cases. +- **Models split into subpackage**: ``nwp500.models`` is now a package + (``nwp500/models/``) with modules for status, schedule, TOU, and MQTT models. Public + imports from ``nwp500.models`` are unchanged. +- **Topic building centralised**: All MQTT topic construction now goes through + ``MqttTopicBuilder``. Hardcoded topic strings removed from ``control.py``, + ``reservations.py``, and ``cli/handlers.py``. +- **``set_vacation_days`` delegates to ``set_dhw_mode``**: The method now calls + ``set_dhw_mode(device, DhwOperationSetting.VACATION, vacation_days=days)`` directly. +- **Per-device state tracking**: ``_previous_status`` changed from a single + ``DeviceStatus | None`` to a ``dict[str, DeviceStatus]`` keyed by MAC address, + preventing spurious state-change events when multiple devices are connected. +- **Unit system stored as instance variable**: ``NavienAPIClient``, + ``NavienMqttClient``, and ``NavienAuthClient`` no longer call ``set_unit_system()`` + as a constructor side-effect. +- **``NavienBaseModel`` consolidated**: Duplicate definitions in ``auth.py`` and + ``models.py`` merged into a single definition in ``nwp500._base``. + Version 7.4.10 (2026-04-13) =========================== @@ -205,7 +345,7 @@ Breaking Changes .. code-block:: python # OLD (hardcoded 95-150°F) - await mqtt.control.set_dhw_temperature(device, temperature_f=140.0) + await mqtt.set_dhw_temperature(device, temperature_f=140.0) entry = build_reservation_entry( enabled=True, days=["Monday"], @@ -217,7 +357,7 @@ Breaking Changes # NEW (device-provided limits, unit-aware) # Temperature value automatically uses user's preferred unit - await mqtt.control.set_dhw_temperature(device, 140.0) + await mqtt.set_dhw_temperature(device, 140.0) # Device features provide min/max in user's preferred unit features = await device_info_cache.get(device.device_info.mac_address) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 200a990..ec65726 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -135,7 +135,7 @@ Follow a clear exception hierarchy with consistent naming: .. code-block:: python try: - await mqtt_client.control.set_temperature(device, 150) + await mqtt_client.set_temperature(device, 150) except MqttNotConnectedError: # Handle specific case: not connected print("Connect to device first") diff --git a/README.rst b/README.rst index 11981a2..51e6f99 100644 --- a/README.rst +++ b/README.rst @@ -2,260 +2,77 @@ nwp500-python ============= +|PyPI-v| |Python-versions| |CI-status| |Docs-status| |Code-style| |License| + Python library for Navien NWP500 Heat Pump Water Heater ======================================================== -A Python library for monitoring and controlling the Navien NWP500 Heat Pump Water Heater through the Navilink cloud service. - -**Documentation:** https://nwp500-python.readthedocs.io/ +A complete Python library for monitoring and controlling the Navien NWP500 Heat Pump Water Heater through the Navilink cloud service. -**Source Code:** https://github.com/eman/nwp500-python +* **Documentation:** https://nwp500-python.readthedocs.io/ +* **Source:** https://github.com/eman/nwp500-python 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 - -* Async friendly - -Quick Start -=========== - -Installation ------------- - -Basic Installation (Library Only) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For using the library as a Python package: +* **Complete Interface:** Full support for both REST API and real-time MQTT (AWS IoT). +* **Monitoring:** Real-time tracking of temperature, power usage, tank charge, and component status. +* **Control:** Remote control of target temperatures, operation modes, and vacation settings. +* **Advanced Features:** Native support for reservations, time-of-use (TOU) optimization, and anti-legionella cycles. +* **Type-Safe:** Built with Pydantic for robust data validation and unit handling. +* **Async/Await:** Modern asyncio-based implementation for high-performance integration. + +Getting Started +=============== .. code-block:: bash pip install nwp500-python -Installation with CLI Support -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To use the CLI with rich formatting and colors: - -.. code-block:: bash - - pip install nwp500-python[cli] - -Basic Usage ------------ +Quick Example +------------- .. code-block:: python from nwp500 import NavienAuthClient, NavienAPIClient - # Authentication happens automatically when entering the context - async with NavienAuthClient("your_email@example.com", "your_password") as auth_client: - # Create API client - api_client = NavienAPIClient(auth_client=auth_client) - - # Get device data - devices = await api_client.list_devices() - device = devices[0] if devices else None - - if device: - # Access status information - status = device.status - print(f"Water Temperature: {status.dhw_temperature}") - print(f"Tank Charge: {status.dhw_charge_per}%") - print(f"Power Consumption: {status.current_inst_power}W") - - # Set temperature - await api_client.set_device_temperature(device, 130) - - # Change operation mode - await api_client.set_device_mode(device, "heat_pump") - -For more detailed authentication information, see the `Authentication & Session Management `_ guide. - -MQTT Real-Time Monitoring --------------------------- - -Monitor your device in real-time using MQTT: - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienMqttClient - - async with NavienAuthClient("your_email@example.com", "your_password") as auth_client: - # Create MQTT client - mqtt_client = NavienMqttClient(auth_client=auth_client) - await mqtt_client.connect() + async with NavienAuthClient("email@example.com", "password") as auth: + api = NavienAPIClient(auth) + devices = await api.list_devices() - # Subscribe to device status updates - def on_status(status): - print(f"Temperature: {status.dhw_temperature}°F") - print(f"Mode: {status.operation_mode}") - - device = (await api_client.list_devices())[0] - await mqtt_client.subscribe_device_status(device, on_status) - - # Keep the connection alive - await mqtt_client.wait() - - -Command Line Interface -====================== - -Monitor and control your Navien water heater from the terminal. - -**Installation Requirement:** - -.. code-block:: bash - - pip install nwp500-python[cli] - -Quick Reference ---------------- - -.. code-block:: bash - - # Set credentials via environment variables - export NAVIEN_EMAIL="your_email@example.com" - export NAVIEN_PASSWORD="your_password" - - # Get current device status - python3 -m nwp500.cli status - - # Get device information and firmware (via MQTT - DeviceFeature) - python3 -m nwp500.cli info - - # Get basic device info from REST API (DeviceInfo) - python3 -m nwp500.cli device-info - - # Get controller serial number - python3 -m nwp500.cli serial - - # Turn device on/off - python3 -m nwp500.cli power on - python3 -m nwp500.cli power off - - # Set operation mode - python3 -m nwp500.cli mode heat-pump - python3 -m nwp500.cli mode energy-saver - python3 -m nwp500.cli mode high-demand - python3 -m nwp500.cli mode electric - python3 -m nwp500.cli mode vacation - python3 -m nwp500.cli mode standby - - # Set target temperature - python3 -m nwp500.cli temp 140 - - # Set vacation days - python3 -m nwp500.cli vacation 7 - - # Trigger instant hot water - python3 -m nwp500.cli hot-button - - # Set recirculation pump mode (1-4) - python3 -m nwp500.cli recirc 2 - - # Reset air filter timer - python3 -m nwp500.cli reset-filter - - # Enable water program mode - python3 -m nwp500.cli water-program - - # View and update schedules - python3 -m nwp500.cli reservations get - python3 -m nwp500.cli reservations set '[{"hour": 6, "min": 0, ...}]' - - # Time-of-use settings - python3 -m nwp500.cli tou get - python3 -m nwp500.cli tou set on - - # Energy usage data - python3 -m nwp500.cli energy --year 2024 --months 10,11,12 - - # Demand response - python3 -m nwp500.cli dr enable - python3 -m nwp500.cli dr disable - - # Real-time monitoring (logs to CSV) - python3 -m nwp500.cli monitor - python3 -m nwp500.cli monitor -o my_data.csv - -**Global Options:** - -* ``--email EMAIL``: Navien account email (or use ``NAVIEN_EMAIL`` env var) -* ``--password PASSWORD``: Navien account password (or use ``NAVIEN_PASSWORD`` env var) -* ``-v, --verbose``: Enable debug logging -* ``--version``: Show version and exit - -**Available Commands:** - -* ``status``: Show current device status (temperature, mode, power) -* ``info``: Show device information (firmware, capabilities) -* ``serial``: Get controller serial number -* ``power on|off``: Turn device on or off -* ``mode MODE``: Set operation mode (heat-pump, electric, energy-saver, high-demand, vacation, standby) -* ``temp TEMPERATURE``: Set target water temperature in °F -* ``vacation DAYS``: Enable vacation mode for N days -* ``recirc MODE``: Set recirculation pump (1=always, 2=button, 3=schedule, 4=temperature) -* ``hot-button``: Trigger instant hot water -* ``reset-filter``: Reset air filter maintenance timer -* ``water-program``: Enable water program reservation mode -* ``reservations get|set``: View or update schedule -* ``tou get|set STATE``: View or configure time-of-use settings -* ``energy``: Query historical energy usage (requires ``--year`` and ``--months``) -* ``dr enable|disable``: Enable or disable demand response -* ``monitor``: Monitor device status in real-time (logs to CSV with ``-o`` option) - -Device Status Fields -==================== - -The library provides access to comprehensive device status information. See the `full documentation `_ for all available fields. + if devices: + device = devices[0] + print(f"Temperature: {device.status.dhw_temperature}°F") + await api.set_device_temperature(device, 130) Documentation ============= -Full docs: https://nwp500-python.readthedocs.io/ - -Home Assistant Integration -========================== +The documentation follows the `Diátaxis `_ framework: -Use this library with Home Assistant: `ha_nwp500 `_ +* `Tutorials `_: Start here if you're new to the library. +* `How-to Guides `_: Practical step-by-step recipes for specific tasks. +* `Reference `_: Technical descriptions of the API, models, and protocol. +* `Explanation `_: Understanding-oriented deep dives into the library's design and advanced features. -Data Models -=========== - -The library includes type-safe data models with automatic unit conversions: - -* **DeviceStatus**: Complete device status with 70+ fields -* **DeviceFeature**: Device capabilities, firmware versions, and configuration limits -* **OperationMode**: Enumeration of available operation modes -* **TemperatureUnit**: Celsius/Fahrenheit handling - -Requirements +Contributing ============ -* Python 3.13+ -* aiohttp >= 3.8.0 -* pydantic >= 2.0.0 -* awsiotsdk >= 1.27.0 +We welcome contributions! Please see our `Contributing Guide `_ for more details. License ======= -This project is licensed under the MIT License. - -Author -====== - -Emmanuel Levijarvi - -Acknowledgments -=============== - -This project has been set up using PyScaffold 4.6. For details and usage -information on PyScaffold see https://pyscaffold.org/. +This project is licensed under the MIT License. See the `LICENSE.txt `_ file for details. + +.. |PyPI-v| image:: https://img.shields.io/pypi/v/nwp500-python.svg + :target: https://pypi.org/project/nwp500-python/ +.. |Python-versions| image:: https://img.shields.io/pypi/pyversions/nwp500-python.svg + :target: https://pypi.org/project/nwp500-python/ +.. |CI-status| image:: https://github.com/eman/nwp500-python/actions/workflows/ci.yml/badge.svg + :target: https://github.com/eman/nwp500-python/actions/workflows/ci.yml +.. |Docs-status| image:: https://readthedocs.org/projects/nwp500-python/badge/?version=latest + :target: https://nwp500-python.readthedocs.io/en/latest/?badge=latest +.. |Code-style| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff +.. |License| image:: https://img.shields.io/pypi/l/nwp500-python.svg + :target: https://opensource.org/licenses/MIT diff --git a/docs/api/nwp500.rst b/docs/api/nwp500.rst deleted file mode 100644 index 53aae54..0000000 --- a/docs/api/nwp500.rst +++ /dev/null @@ -1,182 +0,0 @@ -nwp500 package -============== - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - nwp500.cli - nwp500.mqtt - -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.command\_decorators module ---------------------------------- - -.. automodule:: nwp500.command_decorators - :members: - :show-inheritance: - :undoc-members: - -nwp500.config module --------------------- - -.. automodule:: nwp500.config - :members: - :show-inheritance: - :undoc-members: - -nwp500.converters module ------------------------- - -.. automodule:: nwp500.converters - :members: - :show-inheritance: - :undoc-members: - -nwp500.device\_capabilities module ----------------------------------- - -.. automodule:: nwp500.device_capabilities - :members: - :show-inheritance: - :undoc-members: - -nwp500.device\_info\_cache module ---------------------------------- - -.. automodule:: nwp500.device_info_cache - :members: - :show-inheritance: - :undoc-members: - -nwp500.encoding module ----------------------- - -.. automodule:: nwp500.encoding - :members: - :show-inheritance: - :undoc-members: - -nwp500.enums module -------------------- - -.. automodule:: nwp500.enums - :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.factory module ---------------------- - -.. automodule:: nwp500.factory - :members: - :show-inheritance: - :undoc-members: - -nwp500.field\_factory module ----------------------------- - -.. automodule:: nwp500.field_factory - :members: - :show-inheritance: - :undoc-members: - -nwp500.models module --------------------- - -.. automodule:: nwp500.models - :members: - :show-inheritance: - :undoc-members: - -nwp500.mqtt\_events module --------------------------- - -.. automodule:: nwp500.mqtt_events - :members: - :show-inheritance: - :undoc-members: - -nwp500.openei module --------------------- - -.. automodule:: nwp500.openei - :members: - :show-inheritance: - :undoc-members: - -nwp500.temperature module -------------------------- - -.. automodule:: nwp500.temperature - :members: - :show-inheritance: - :undoc-members: - -nwp500.topic\_builder module ----------------------------- - -.. automodule:: nwp500.topic_builder - :members: - :show-inheritance: - :undoc-members: - -nwp500.unit\_system module --------------------------- - -.. automodule:: nwp500.unit_system - :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/conf.py b/docs/conf.py index ba6d57b..059a79b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,7 +33,7 @@ except ImportError: from sphinx import apidoc -output_dir = os.path.join(__location__, "api") +output_dir = os.path.join(__location__, "reference", "api") module_dir = os.path.join(__location__, "../src/nwp500") try: shutil.rmtree(output_dir) diff --git a/docs/guides/advanced_features_explained.rst b/docs/explanation/advanced-features.rst similarity index 96% rename from docs/guides/advanced_features_explained.rst rename to docs/explanation/advanced-features.rst index 8b86dea..8c6bfcb 100644 --- a/docs/guides/advanced_features_explained.rst +++ b/docs/explanation/advanced-features.rst @@ -109,7 +109,7 @@ The ``outsideTemperature`` field is transmitted in the device status update. Pyt .. code-block:: python # From device status updates - status = await mqtt_client.control.request_device_status() + status = await mqtt_client.request_device_status() # Access ambient temperature data outdoor_temp = status.outside_temperature # Raw integer value @@ -383,7 +383,7 @@ Monitoring Stratification from Python async def monitor_stratification(mqtt_client: NavienMQTTClient, device_id: str): """Monitor tank stratification quality""" - status = await mqtt_client.control.request_device_status(device_id) + status = await mqtt_client.request_device_status(device_id) upper_temp = status.tank_upper_temperature # float in °F lower_temp = status.tank_lower_temperature # float in °F @@ -464,7 +464,7 @@ The NWP500 uses **half-degrees Celsius** encoding for temperature fields. **Related Documentation**: -See :doc:`../protocol/data_conversions` for complete field conversion reference and formula applications. +See :doc:`../reference/protocol/data_conversions` for complete field conversion reference and formula applications. Summary and Recommendations ============================ @@ -491,7 +491,7 @@ Summary and Recommendations See Also -------- -* :doc:`../protocol/data_conversions` - Temperature field conversions (HalfCelsiusToF, DeciCelsiusToF) -* :doc:`../protocol/device_status` - Complete device status field reference -* :doc:`scheduling` - Scheduling and automation guide -* :doc:`../python_api/models` - DeviceStatus model field definitions +* :doc:`../reference/protocol/data_conversions` - Temperature field conversions (HalfCelsiusToF, DeciCelsiusToF) +* :doc:`../reference/protocol/device_status` - Complete device status field reference +* :doc:`../how-to/schedule-operation` - Scheduling and automation guide +* :doc:`../reference/python_api/models` - DeviceStatus model field definitions diff --git a/docs/explanation/architecture.rst b/docs/explanation/architecture.rst new file mode 100644 index 0000000..b5942e8 --- /dev/null +++ b/docs/explanation/architecture.rst @@ -0,0 +1,81 @@ +============ +Architecture +============ + +This document explains the high-level architecture of the ``nwp500-python`` library and how it interacts with the Navien Smart Control cloud platform. + +System Overview +=============== + +The library acts as a bridge between your Python application and the Navien NWP500 Heat Pump Water Heater. Communication happens through two primary channels: + +1. **REST API**: Used for authentication, account management, and listing devices. +2. **MQTT (AWS IoT)**: Used for real-time monitoring and control of the device. + +Component Diagram +================= + +.. code-block:: text + + +-------------------+ +------------------------+ + | | | | + | Your Application | | Navien Smart Control | + | | | (Cloud) | + +---------+---------+ +-----------+------------+ + | | + | +-------------+ | + +------>| Auth Client |<------+ (Sign-in/Tokens) + | +-------------+ | + | | + | +-------------+ | + +------>| API Client |<------+ (REST API) + | +-------------+ | + | | + | +-------------+ | + +------>| MQTT Client |<------+ (AWS IoT Core) + +-------------+ + +Core Components +=============== + +Authentication Client +--------------------- + +The ``NavienAuthClient`` is responsible for managing credentials and tokens. It performs the initial sign-in to obtain: +* A JWT access token for REST API requests. +* AWS IoT credentials (identity ID, session token, etc.) for MQTT connection. + +REST API Client +--------------- + +The ``NavienAPIClient`` provides methods for "heavy" or infrequent operations, such as: +* Retrieving the list of devices. +* Getting detailed device information. +* Accessing historical energy data. + +MQTT Client +----------- + +The ``NavienMqttClient`` is the heart of real-time interaction. It maintains a persistent connection to AWS IoT Core and handles: +* Subscribing to device status updates. +* Publishing control commands (e.g., setting temperature). +* Parsing incoming hex-encoded payloads into structured data models. + +Data Models +=========== + +The library uses **Pydantic** for all data models. This ensures: +* **Type Safety**: All fields have explicit types. +* **Validation**: Incoming data is validated against expected formats. +* **Unit Handling**: Temperatures and other units are automatically converted to appropriate scales (e.g., Fahrenheit). + +Event System +============ + +The library implements an asynchronous event system. You can subscribe to various events (e.g., status updates, connection changes) and provide callback functions that will be executed when those events occur. + +See Also +======== + +* :doc:`advanced-features` - Deep dive into TOU, reservations, and more. +* :doc:`../reference/protocol/mqtt_protocol` - Low-level details of the MQTT messaging. diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst new file mode 100644 index 0000000..f1fe4c9 --- /dev/null +++ b/docs/explanation/index.rst @@ -0,0 +1,11 @@ +=========== +Explanation +=========== + +Understanding-oriented deep dives into the library's design and advanced features. + +.. toctree:: + :maxdepth: 1 + + advanced-features + architecture diff --git a/docs/guides/authentication.rst b/docs/how-to/authenticate.rst similarity index 100% rename from docs/guides/authentication.rst rename to docs/how-to/authenticate.rst diff --git a/docs/guides/auto_recovery.rst b/docs/how-to/auto-recovery.rst similarity index 100% rename from docs/guides/auto_recovery.rst rename to docs/how-to/auto-recovery.rst diff --git a/docs/guides/mqtt_diagnostics.rst b/docs/how-to/diagnose-mqtt.rst similarity index 94% rename from docs/guides/mqtt_diagnostics.rst rename to docs/how-to/diagnose-mqtt.rst index 1a95cc9..dca3fa7 100644 --- a/docs/guides/mqtt_diagnostics.rst +++ b/docs/how-to/diagnose-mqtt.rst @@ -38,12 +38,12 @@ Quick Start # Hook events mqtt_client.on('connection_interrupted', - lambda e: diagnostics.record_connection_drop(error=e) + lambda event: diagnostics.record_connection_drop(error=event.error) ) mqtt_client.on('connection_resumed', - lambda rc, sp: diagnostics.record_connection_success( - event_type='resumed', session_present=sp + lambda event: diagnostics.record_connection_success( + event_type='resumed', session_present=event.session_present ) ) @@ -233,20 +233,20 @@ Enable diagnostics and collect baseline data. # Hook connection events mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task( - diagnostics.record_connection_drop( - error=e, + lambda event: asyncio.create_task( + diagnostics.record_connection_drop( + error=event.error, queued_commands=mqtt_client.queued_commands_count ) ) ) mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task( - diagnostics.record_connection_success( - event_type='resumed', - session_present=sp, - return_code=rc + lambda event: asyncio.create_task( + diagnostics.record_connection_success( + event_type='resumed', + session_present=event.session_present, + return_code=event.return_code ) ) ) @@ -512,15 +512,15 @@ Basic Monitoring Loop mqtt_client = NavienMqttClient(auth_client, config=config) mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task( - diagnostics.record_connection_drop(error=e) + lambda event: asyncio.create_task( + diagnostics.record_connection_drop(error=event.error) ) ) mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task( - diagnostics.record_connection_success( - event_type='resumed', session_present=sp + lambda event: asyncio.create_task( + diagnostics.record_connection_success( + event_type='resumed', session_present=event.session_present ) ) ) @@ -598,11 +598,11 @@ Class-Based Monitoring self.mqtt_client = NavienMqttClient(self.auth_client, config=config) self.mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task(self._on_drop(e)) + lambda event: asyncio.create_task(self._on_drop(event.error)) ) self.mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task(self._on_resume(rc, sp)) + lambda event: asyncio.create_task(self._on_resume(event.return_code, event.session_present)) ) await self.mqtt_client.connect() @@ -715,7 +715,7 @@ Device Control Integration diagnostics.record_publish(queued=not mqtt_client.is_connected) # Set temperature - await mqtt_client.control.set_dhw_temperature(device, 140.0) + await mqtt_client.set_dhw_temperature(device, 140.0) if not mqtt_client.is_connected: _logger.info( @@ -795,17 +795,17 @@ Integration Pattern def _setup_event_hooks(self): """Hook diagnostics into MQTT client events.""" self.mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task( - self.diagnostics.record_connection_drop(error=e) + lambda event: asyncio.create_task( + self.diagnostics.record_connection_drop(error=event.error) ) ) self.mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task( + lambda event: asyncio.create_task( self.diagnostics.record_connection_success( event_type='resumed', - session_present=sp, - return_code=rc + session_present=event.session_present, + return_code=event.return_code ) ) ) @@ -991,17 +991,17 @@ Example: Minimal HA Component with Diagnostics # Hook diagnostics mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task( - diagnostics.record_connection_drop(error=e) + lambda event: asyncio.create_task( + diagnostics.record_connection_drop(error=event.error) ) ) mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task( + lambda event: asyncio.create_task( diagnostics.record_connection_success( event_type='resumed', - session_present=sp, - return_code=rc, + session_present=event.session_present, + return_code=event.return_code, ) ) ) @@ -1116,8 +1116,8 @@ Investigation Checklist See Also ======== -- :doc:`../protocol/device_status` - Device status field reference -- :doc:`../python_api/mqtt_client` - MQTT client API documentation +- :doc:`../reference/protocol/device_status` - Device status field reference +- :doc:`../reference/python_api/mqtt_client` - MQTT client API documentation External Resources diff --git a/docs/guides/home_assistant_integration.rst b/docs/how-to/home-assistant.rst similarity index 98% rename from docs/guides/home_assistant_integration.rst rename to docs/how-to/home-assistant.rst index 22e608b..ef66a20 100644 --- a/docs/guides/home_assistant_integration.rst +++ b/docs/how-to/home-assistant.rst @@ -365,6 +365,6 @@ Best Practices See Also ======== -- :doc:`../python_api/models` - Complete model reference -- :doc:`../protocol/data_conversions` - Detailed conversion formulas -- :doc:`../enumerations` - TemperatureType and other enumerations +- :doc:`../reference/python_api/models` - Complete model reference +- :doc:`../reference/protocol/data_conversions` - Detailed conversion formulas +- :doc:`../reference/enumerations` - TemperatureType and other enumerations diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst new file mode 100644 index 0000000..60ac6d9 --- /dev/null +++ b/docs/how-to/index.rst @@ -0,0 +1,20 @@ +============= +How-to Guides +============= + +Practical step-by-step recipes for specific tasks. + +.. toctree:: + :maxdepth: 1 + + authenticate + monitor-status + track-energy + schedule-operation + manage-units + queue-commands + auto-recovery + optimize-tou + diagnose-mqtt + maintenance + home-assistant diff --git a/docs/how-to/maintenance.rst b/docs/how-to/maintenance.rst new file mode 100644 index 0000000..bfe04e1 --- /dev/null +++ b/docs/how-to/maintenance.rst @@ -0,0 +1,119 @@ +================== +Device Maintenance +================== + +Maintenance commands let you handle firmware updates, connectivity recovery, +freeze protection, and onboard diagnostics from MQTT. + +.. contents:: On This Page + :local: + :depth: 2 + +Before You Start +================ + +Many maintenance operations are device-specific. Request device features first so +you can inspect capability flags and supported temperature ranges. + +.. code-block:: python + + await mqtt.subscribe_device_feature(device, lambda feature: print(feature)) + await mqtt.request_device_info(device) + +Firmware OTA Updates +==================== + +Use firmware OTA when the device has already downloaded or advertised an update. +The workflow is asynchronous: + +1. Call :meth:`nwp500.mqtt.client.NavienMqttClient.check_firmware_update` +2. Wait for the device's response on its control response topic +3. If an update is available, call + :meth:`nwp500.mqtt.client.NavienMqttClient.commit_firmware_update` + with an :class:`~nwp500.models.OtaCommitPayload` + +.. warning:: + + Committing firmware reboots the device. Heating and MQTT connectivity will be + interrupted until the upgrade completes. + +.. code-block:: python + + from nwp500 import OtaCommitPayload + + def on_message(topic, message): + print(topic) + print(message) + + await mqtt.subscribe_device(device, on_message) + await mqtt.check_firmware_update(device) + + # After confirming the component code/version from the async response: + payload = OtaCommitPayload(swCode=1, swVersion=1234) + await mqtt.commit_firmware_update(device, payload) + +WiFi Management +=============== + +Two commands cover WiFi recovery: + +* :meth:`nwp500.mqtt.client.NavienMqttClient.reconnect_wifi` performs a soft + reconnect using the currently stored credentials. +* :meth:`nwp500.mqtt.client.NavienMqttClient.reset_wifi` clears WiFi settings and + returns the device to an unprovisioned state. + +.. warning:: + + ``reset_wifi()`` is effectively a factory reset for network settings. You will + need to reconfigure the device in the Navien app afterward. + +.. code-block:: python + + # Try this first when the device drops off WiFi + await mqtt.reconnect_wifi(device) + + # Use only when credentials or provisioning are broken + await mqtt.reset_wifi(device) + +Freeze Protection +================= + +Freeze protection is available on devices that expose the +``freeze_protection_use`` capability. The threshold is specified in the user's +preferred temperature unit and converted automatically. + +The implementation documentation describes a typical supported range of +35-45 °F (about 1.7-7.2 °C). You can also inspect +``DeviceFeature.freeze_protection_temp_min`` and +``DeviceFeature.freeze_protection_temp_max`` after requesting device info. + +.. code-block:: python + + # Fahrenheit example + await mqtt.set_freeze_protection_temperature(device, 40.0) + +Smart Diagnostics +================= + +Smart diagnostics are available on devices that expose the +``smart_diagnostic_use`` capability. Triggering the diagnostic tells the device +to run its onboard self-check routine. + +The result is reflected in the next +:class:`~nwp500.models.DeviceStatus` update via the ``smart_diagnostic`` field. + +.. code-block:: python + + def on_status(status): + print(f"Diagnostic status: {status.smart_diagnostic}") + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.run_smart_diagnostic(device) + await mqtt.request_device_status(device) + +Related Documentation +===================== + +* :doc:`../reference/python_api/mqtt_client` - Full MQTT client API reference +* :doc:`schedule-operation` - Reservations, recirculation schedules, and intelligent scheduling +* :doc:`diagnose-mqtt` - Connection troubleshooting and diagnostics diff --git a/docs/guides/unit_conversion.rst b/docs/how-to/manage-units.rst similarity index 98% rename from docs/guides/unit_conversion.rst rename to docs/how-to/manage-units.rst index 245e600..eec13f6 100644 --- a/docs/guides/unit_conversion.rst +++ b/docs/how-to/manage-units.rst @@ -657,7 +657,7 @@ This feature represents a breaking change from previous versions where all value See Also ======== -- :doc:`../python_api/models` - Complete model reference -- :doc:`../enumerations` - TemperatureType and TemperatureFormulaType enumerations -- :doc:`../protocol/data_conversions` - Raw protocol data formats -- :doc:`home_assistant_integration` - Home Assistant integration guide +- :doc:`../reference/python_api/models` - Complete model reference +- :doc:`../reference/enumerations` - TemperatureType and TemperatureFormulaType enumerations +- :doc:`../reference/protocol/data_conversions` - Raw protocol data formats +- :doc:`home-assistant` - Home Assistant integration guide diff --git a/docs/guides/event_system.rst b/docs/how-to/monitor-status.rst similarity index 74% rename from docs/guides/event_system.rst rename to docs/how-to/monitor-status.rst index 0977155..7fb2160 100644 --- a/docs/guides/event_system.rst +++ b/docs/how-to/monitor-status.rst @@ -2,67 +2,54 @@ Event-Driven Programming ======================== -This guide demonstrates how to build event-driven applications using the -nwp500 library's event system. +The nwp500 event system lets you react to device state changes, connection +events, and derived transitions (temperature delta, mode change, etc.) without +polling. -Overview -======== +Two Callback Patterns +===================== -The event system allows you to: +``subscribe_*()`` methods +-------------------------- -* React to device state changes in real-time -* Build responsive, reactive applications -* Separate concerns (monitoring, logging, alerting) -* Handle multiple devices with a unified interface +These deliver parsed model objects directly to your callback: -Benefits --------- +.. code-block:: python -**Compared to polling:** + def on_status(status): + print(status.dhw_temperature) -* Lower latency - react immediately to changes -* More efficient - no wasted requests -* Cleaner code - declarative callbacks vs loops -* Better scalability - handle multiple devices easily + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) -**Use cases:** +``.on()`` event emitter +------------------------ -* Home automation triggers -* Alert systems -* Data logging and analytics -* UI updates -* Integration with other systems +These deliver a single typed event dataclass. Use them for connection events and +derived state transitions (temperature delta, mode change, etc.): -Basic Usage -=========== +.. code-block:: python -Discovering Available Events ------------------------------ + from nwp500 import MqttClientEvents + + def on_status_event(event): + status = event.status + print(f"Temperature: {status.dhw_temperature}°F") + + mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_event) -The :class:`nwp500.mqtt_events.MqttClientEvents` class provides a complete registry -of all events with type-safe constants and full documentation: +See :doc:`../reference/python_api/events` for the full event dataclass reference. + +Available Events +---------------- .. code-block:: python from nwp500 import MqttClientEvents - # List all available events for event_name in MqttClientEvents.get_all_events(): print(f"- {event_name}") - # Output: - # - CONNECTION_INTERRUPTED - # - CONNECTION_RESUMED - # - STATUS_RECEIVED - # - TEMPERATURE_CHANGED - # - MODE_CHANGED - # - POWER_CHANGED - # - HEATING_STARTED - # - HEATING_STOPPED - # - ERROR_DETECTED - # - ERROR_CLEARED - # - FEATURE_RECEIVED - Simple Event Handler -------------------- @@ -79,27 +66,36 @@ Simple Event Handler mqtt = NavienMqttClient(auth) await mqtt.connect() - # Use type-safe event constants with IDE autocomplete - def on_status_update(status): + def on_status_event(event): + status = event.status print(f"Temperature: {status.dhw_temperature}°F") print(f"Power: {status.current_inst_power}W") - # Subscribe using event constants - mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_update) - await mqtt.control.request_device_status(device) + mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_event) + await mqtt.request_device_status(device) - # Monitor for 5 minutes await asyncio.sleep(300) await mqtt.disconnect() asyncio.run(main()) +Raw status subscription +----------------------- + +Use a typed subscription when you want the model object directly: + +.. code-block:: python + + def on_status(status): + print(status.dhw_temperature) + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) + Event Registry -------------- -The :class:`nwp500.mqtt_events.MqttClientEvents` class provides type-safe event -constants and programmatic discovery, so your callbacks use valid event -names and get IDE autocomplete: +Use ``MqttClientEvents`` constants to avoid typos and get IDE autocomplete: .. code-block:: python @@ -107,31 +103,31 @@ names and get IDE autocomplete: mqtt_client = NavienMqttClient(auth) - # Type-safe constants with IDE autocomplete + def on_temp_change(event): + print(f"Temperature: {event.old_temperature} -> {event.new_temperature}") + + def on_heating_start(event): + print(f"Heating started at {event.status.dhw_temperature}") + + def on_error(event): + print(f"Error: {event.error_code}") + mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, on_temp_change) mqtt_client.on(MqttClientEvents.HEATING_STARTED, on_heating_start) mqtt_client.on(MqttClientEvents.ERROR_DETECTED, on_error) - # Programmatically discover all events - print("Available events:") for event_name in MqttClientEvents.get_all_events(): print(f" - {event_name}") - # Get event string value if needed - event_value = MqttClientEvents.get_event_value("TEMPERATURE_CHANGED") - print(f"Event value: {event_value}") # Output: "temperature_changed" - -Each event has full type documentation. See -:class:`nwp500.mqtt_events` for complete details on event data types and -their arguments. +See :doc:`../reference/python_api/events` for the event dataclass reference. Advanced Patterns ================= -Pattern 1: State Tracking --------------------------- +Tracking significant changes +---------------------------- -Track state changes and react only when values change significantly. +Filter callbacks to only act when a value changes by more than a threshold: .. code-block:: python @@ -174,8 +170,8 @@ Track state changes and react only when values change significantly. await asyncio.sleep(3600) # Monitor for 1 hour -Pattern 2: Multi-Device Monitoring ------------------------------------ +Multiple devices +---------------- Monitor multiple devices with individual callbacks. @@ -233,10 +229,10 @@ Monitor multiple devices with individual callbacks. while True: await asyncio.sleep(60) -Pattern 3: Alert System ------------------------- +Alert rules +----------- -Build an alert system that triggers on specific conditions. +Trigger actions when the device crosses a threshold: .. code-block:: python @@ -332,8 +328,8 @@ Build an alert system that triggers on specific conditions. while True: await asyncio.sleep(3600) -Pattern 4: Data Logger ------------------------ +Data logging +------------ Log device data to a database or file. @@ -416,10 +412,10 @@ Log device data to a database or file. while True: await asyncio.sleep(3600) -Pattern 5: Home Automation Integration ---------------------------------------- +Home automation bridge +---------------------- -Integrate with Home Assistant, OpenHAB, or custom systems. +Publish status updates to Home Assistant or similar systems: .. code-block:: python @@ -499,73 +495,64 @@ Integrate with Home Assistant, OpenHAB, or custom systems. Best Practices ============== -1. **Keep handlers lightweight:** - - .. code-block:: python - - # GOOD: Fast handler - def on_status(status): - asyncio.create_task(process_status(status)) - - # BAD: Slow handler (blocks event loop) - def on_status(status): - time.sleep(5) # BAD - process_status(status) +Keep handlers lightweight +-------------------------- -2. **Handle errors in callbacks:** +Offload heavy work with ``asyncio.create_task`` rather than blocking in the callback: - .. code-block:: python +.. code-block:: python - def safe_handler(status): - try: - process_status(status) - except Exception as e: - print(f"Handler error: {e}") - # Don't let errors crash the event loop + def on_status(status): + asyncio.create_task(process_status(status)) -3. **Unsubscribe when done:** +Wrap callbacks in try/except +----------------------------- - .. code-block:: python +An unhandled exception in a callback won't crash the event loop, but it will +silence subsequent events for that subscription: - # Track callback references - callback = lambda s: print(s.dhw_temperature) +.. code-block:: python - await mqtt.subscribe_device_status(device, callback) + def safe_handler(status): + try: + process_status(status) + except Exception as e: + print(f"Handler error: {e}") - # Later, unsubscribe - # (if the MQTT client supports it) +Async callbacks +--------------- -4. **Use async callbacks when possible:** +Callbacks can be async. The client will schedule them as tasks: - .. code-block:: python +.. code-block:: python - async def async_handler(status): - # Can await async operations - await save_to_database(status) - await send_notification(status) + async def async_handler(status): + await save_to_database(status) + await send_notification(status) -5. **Batch updates to reduce overhead:** +Batch processing +---------------- - .. code-block:: python +Buffer updates and flush periodically to reduce I/O overhead: - class BatchProcessor: - def __init__(self): - self.buffer = [] +.. code-block:: python - def on_status(self, status): - self.buffer.append(status) + class BatchProcessor: + def __init__(self): + self.buffer = [] - if len(self.buffer) >= 10: - self.flush() + def on_status(self, status): + self.buffer.append(status) + if len(self.buffer) >= 10: + self.flush() - def flush(self): - # Process batch - save_batch_to_db(self.buffer) - self.buffer.clear() + def flush(self): + save_batch_to_db(self.buffer) + self.buffer.clear() Related Documentation ===================== -* :doc:`../python_api/events` - Event API reference -* :doc:`../python_api/mqtt_client` - MQTT client -* :doc:`../python_api/models` - Data models +* :doc:`../reference/python_api/events` - Event API reference +* :doc:`../reference/python_api/mqtt_client` - MQTT client +* :doc:`../reference/python_api/models` - Data models diff --git a/docs/guides/time_of_use.rst b/docs/how-to/optimize-tou.rst similarity index 97% rename from docs/guides/time_of_use.rst rename to docs/how-to/optimize-tou.rst index fd5ac97..bc23a50 100644 --- a/docs/guides/time_of_use.rst +++ b/docs/how-to/optimize-tou.rst @@ -524,7 +524,7 @@ Configure two rate periods - off-peak and peak pricing: feature_future.set_result(feature) await mqtt_client.subscribe_device_feature(device, capture_feature) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) feature = await asyncio.wait_for(feature_future, timeout=15) controller_serial = feature.controllerSerialNumber @@ -555,7 +555,7 @@ Configure two rate periods - off-peak and peak pricing: ) # Configure TOU schedule - await mqtt_client.control.configure_tou_schedule( + await mqtt_client.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=[off_peak, peak], @@ -636,7 +636,7 @@ Configure different rates for summer and winter: ) # Configure all periods - await mqtt_client.control.configure_tou_schedule( + await mqtt_client.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=[summer_off_peak, summer_peak, winter_off_peak, winter_peak], @@ -697,7 +697,7 @@ Query the device for its current TOU configuration: await mqtt_client.subscribe(response_topic, on_tou_response) # Request current settings - await mqtt_client.control.request_tou_settings(device, controller_serial) + await mqtt_client.request_tou_settings(device, controller_serial) # Wait for response await asyncio.sleep(5) @@ -721,7 +721,7 @@ Enable or disable TOU operation: await mqtt_client.connect() # Enable or disable TOU - await mqtt_client.control.set_tou_enabled(device, enabled=enable) + await mqtt_client.set_tou_enabled(device, enabled=enable) print(f"TOU {'enabled' if enable else 'disabled'}") await mqtt_client.disconnect() @@ -791,7 +791,7 @@ Navien mobile app uses: # 7. Enable TOU via MQTT mqtt_client = NavienMqttClient(auth) await mqtt_client.connect() - await mqtt_client.control.set_tou_enabled(device, enabled=True) + await mqtt_client.set_tou_enabled(device, enabled=True) await mqtt_client.disconnect() asyncio.run(apply_openei_rate_plan()) @@ -910,7 +910,7 @@ The device status includes TOU-related fields: * ``touStatus``: ``1`` if TOU scheduling is enabled/active, ``0`` if disabled/inactive * ``touOverrideStatus``: ``2`` (ON) = TOU schedule is operating normally, ``1`` (OFF) = user has overridden TOU to force immediate heating -See :doc:`../protocol/device_status` for more details. +See :doc:`../reference/protocol/device_status` for more details. Best Practices -------------- @@ -955,10 +955,10 @@ Limitations Further Reading --------------- -* :doc:`../python_api/api_client` - API client documentation and ``get_tou_info()`` method -* :doc:`../python_api/mqtt_client` - MQTT client and TOU configuration methods -* :doc:`../protocol/mqtt_protocol` - MQTT message formats including TOU commands -* :doc:`../protocol/device_status` - Device status fields including ``touStatus`` +* :doc:`../reference/python_api/api_client` - API client documentation and ``get_tou_info()`` method +* :doc:`../reference/python_api/mqtt_client` - MQTT client and TOU configuration methods +* :doc:`../reference/protocol/mqtt_protocol` - MQTT message formats including TOU commands +* :doc:`../reference/protocol/device_status` - Device status fields including ``touStatus`` * `OpenEI Utility Rates API `__ - Official OpenEI API documentation * `OpenEI IURDB `__ - Interactive Utility Rate Database diff --git a/docs/guides/command_queue.rst b/docs/how-to/queue-commands.rst similarity index 93% rename from docs/guides/command_queue.rst rename to docs/how-to/queue-commands.rst index 9d215e1..2e07312 100644 --- a/docs/guides/command_queue.rst +++ b/docs/how-to/queue-commands.rst @@ -162,7 +162,7 @@ Basic Usage (Default Configuration) # Command queue is enabled by default # Commands sent during disconnection are automatically queued - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # If disconnected, command is queued and sent on reconnection # No user action needed @@ -219,7 +219,7 @@ Handle Queue Full Condition # Queue has max size of 100 by default # Oldest commands automatically dropped when full for i in range(150): - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # First 100 queued, remaining 50 replace oldest print(f"Queued: {mqtt_client.queued_commands_count}") # Will be 100 @@ -255,8 +255,8 @@ Reliable Device Control .. code-block:: python # Even during network issues, commands are preserved - await mqtt_client.control.set_dhw_temperature(device, 140.0) - await mqtt_client.control.set_dhw_mode(device, 2) # Energy Saver mode + await mqtt_client.set_dhw_temperature(device, 140.0) + await mqtt_client.set_dhw_mode(device, 2) # Energy Saver mode # Commands queued if disconnected, sent when reconnected @@ -277,8 +277,8 @@ Batch Operations # Send multiple commands without worrying about connection state for device in devices: - await mqtt_client.control.request_device_status(device) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_status(device) + await mqtt_client.request_device_info(device) # All commands reach their destination eventually @@ -297,9 +297,9 @@ Technical Notes See Also ======== -- :doc:`../python_api/mqtt_client` - MQTT client documentation -- :doc:`../python_api/events` - Event emitter documentation -- :doc:`../python_api/auth_client` - Authentication and tokens +- :doc:`../reference/python_api/mqtt_client` - MQTT client documentation +- :doc:`../reference/python_api/events` - Event emitter documentation +- :doc:`../reference/python_api/auth_client` - Authentication and tokens Example Code ============ diff --git a/docs/guides/scheduling.rst b/docs/how-to/schedule-operation.rst similarity index 84% rename from docs/guides/scheduling.rst rename to docs/how-to/schedule-operation.rst index 5f25da0..1287578 100644 --- a/docs/guides/scheduling.rst +++ b/docs/how-to/schedule-operation.rst @@ -86,7 +86,7 @@ Quick Example mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, [entry], enabled=True ) await mqtt.disconnect() @@ -352,7 +352,7 @@ multiple entries at once: mode_id=3, temperature=55.0 ), ] - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, reservations, enabled=True ) @@ -360,7 +360,7 @@ multiple entries at once: .. code-block:: python - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, [], enabled=False ) @@ -368,7 +368,7 @@ multiple entries at once: .. code-block:: python - await mqtt.control.request_reservations(device) + await mqtt.request_reservations(device) **Read the current schedule using models:** @@ -384,8 +384,8 @@ multiple entries at once: f" - {entry.temperature}{entry.unit}" f" - {entry.mode_name}") - await mqtt.subscribe_device_feature(device, on_reservations) - await mqtt.control.request_reservations(device) + await mqtt.subscribe_reservation_response(device, on_reservations) + await mqtt.request_reservations(device) CLI Helpers ^^^^^^^^^^^ @@ -577,12 +577,119 @@ Important Notes * Reservations are suspended when vacation mode or TOU is active. +Weekly Reservations +=================== + +``update_weekly_reservation()`` configures a separate weekly reservation payload +using :class:`~nwp500.models.WeeklyReservationSchedule`. This is useful when you +want to send the whole weekly program as one typed object. + +.. code-block:: python + + from nwp500 import ( + WeeklyReservationEntry, + WeeklyReservationSchedule, + build_reservation_entry, + ) + + morning = WeeklyReservationEntry.model_validate( + build_reservation_entry( + enabled=True, + days=["MO", "TU", "WE", "TH", "FR"], + hour=6, + minute=0, + mode_id=4, + temperature=60.0, + ) + ) + + day = WeeklyReservationEntry.model_validate( + build_reservation_entry( + enabled=True, + days=["MO", "TU", "WE", "TH", "FR"], + hour=9, + minute=0, + mode_id=3, + temperature=50.0, + ) + ) + + schedule = WeeklyReservationSchedule( + reservationUse=2, + reservation=[morning, day], + ) + + await mqtt.update_weekly_reservation(device, schedule) + +You can also subscribe to weekly reservation responses with +:meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_weekly_reservation_response`. + +Recirculation Scheduling +======================== + +Recirculation schedules are represented by +:class:`~nwp500.models.RecirculationSchedule` and +:class:`~nwp500.models.RecirculationScheduleEntry`. + +.. code-block:: python + + from nwp500 import RecirculationSchedule, RecirculationScheduleEntry + + schedule = RecirculationSchedule( + schedule=[ + RecirculationScheduleEntry( + enable=2, + week=124, # Mon-Fri + start_hour=6, + start_min=0, + end_hour=8, + end_min=30, + mode=2, + ), + RecirculationScheduleEntry( + enable=2, + week=130, # Sat-Sun + start_hour=7, + start_min=0, + end_hour=9, + end_min=0, + mode=2, + ), + ] + ) + + await mqtt.configure_recirculation_schedule(device, schedule) + + def on_recirculation(schedule): + for entry in schedule.schedule: + print(entry.start_time, entry.end_time, entry.mode_name) + + await mqtt.subscribe_recirculation_schedule_response(device, on_recirculation) + +Use :meth:`nwp500.mqtt.client.NavienMqttClient.set_recirculation_mode` to switch +between always-on, button, schedule, and temperature modes. + +Intelligent Scheduling +====================== + +Intelligent scheduling enables the device's adaptive heating mode. + +.. code-block:: python + + await mqtt.enable_intelligent_scheduling(device) + + # Later, return to manual scheduling behavior + await mqtt.disable_intelligent_scheduling(device) + +This mode is separate from standard reservations. Use it when you want the +heater to adapt automatically instead of following only fixed time windows. + Time of Use (TOU) ================== TOU scheduling allows price-aware heating optimization based on your utility's electricity rate structure. For the full TOU guide including -OpenEI integration, see :doc:`time_of_use`. +OpenEI integration, see :doc:`optimize-tou`. TOU Period Structure -------------------- @@ -785,9 +892,9 @@ Anti-legionella is especially important when: See Also ======== -* :doc:`time_of_use` — Full TOU guide with OpenEI integration -* :doc:`../python_api/device_control` — Device control API reference -* :doc:`../python_api/mqtt_client` — MQTT client API reference -* :doc:`../protocol/data_conversions` — Temperature and power field +* :doc:`optimize-tou` — Full TOU guide with OpenEI integration +* :doc:`../reference/python_api/mqtt_client` — MQTT client API reference +* :doc:`maintenance` — Maintenance and OTA operations +* :doc:`../reference/protocol/data_conversions` — Temperature and power field conversions -* :doc:`auto_recovery` — Handling temporary connectivity issues +* :doc:`auto-recovery` — Handling temporary connectivity issues diff --git a/docs/guides/energy_monitoring.rst b/docs/how-to/track-energy.rst similarity index 86% rename from docs/guides/energy_monitoring.rst rename to docs/how-to/track-energy.rst index eb097b5..facd2e4 100644 --- a/docs/guides/energy_monitoring.rst +++ b/docs/how-to/track-energy.rst @@ -103,10 +103,10 @@ Request detailed daily energy usage data for specific months: await mqtt_client.subscribe_energy_usage(device, on_energy_usage) # Request energy usage for September 2025 - await mqtt_client.control.request_energy_usage(device, year=2025, months=[9]) + await mqtt_client.request_energy_usage(device, year=2025, months=[9]) # Request multiple months - await mqtt_client.control.request_energy_usage(device, year=2025, months=[7, 8, 9]) + await mqtt_client.request_energy_usage(device, year=2025, months=[7, 8, 9]) **Methods:** @@ -242,7 +242,7 @@ Complete Energy Monitoring Example await mqtt_client.subscribe_device_status(device, on_status) # Request initial status - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # Monitor for 5 minutes print("Monitoring energy consumption for 5 minutes...") @@ -259,21 +259,21 @@ Energy Data Fields Reference Power Consumption ~~~~~~~~~~~~~~~~~ -+----------------------+------------+--------------+---------------------------+ -| Field | Type | Units | Description | -+======================+============+==============+===========================+ -| ``current_inst_power`` | float | W | Total instantaneous power | -| | | | consumption | -+------------------------+-----------+--------------+---------------------------+ -| ``comp_use`` | bool | - | Heat pump compressor | -| | | | active | -+------------------------+-----------+--------------+---------------------------+ -| ``heat_upper_use`` | bool | - | Upper electric heater | -| | | | active | -+------------------------+-----------+--------------+---------------------------+ -| ``heat_lower_use`` | bool | - | Lower electric heater | -| | | | active | -+------------------------+-----------+--------------+---------------------------+ ++------------------------+------------+--------------+---------------------------+ +| Field | Type | Units | Description | ++========================+============+==============+===========================+ +| ``current_inst_power`` | float | W | Total instantaneous power | +| | | | consumption | ++------------------------+------------+--------------+---------------------------+ +| ``comp_use`` | bool | - | Heat pump compressor | +| | | | active | ++------------------------+------------+--------------+---------------------------+ +| ``heat_upper_use`` | bool | - | Upper electric heater | +| | | | active | ++------------------------+------------+--------------+---------------------------+ +| ``heat_lower_use`` | bool | - | Lower electric heater | +| | | | active | ++------------------------+------------+--------------+---------------------------+ Cumulative Usage ~~~~~~~~~~~~~~~~ @@ -328,6 +328,6 @@ Notes See Also -------- -- :doc:`../protocol/device_status` - Complete list of all status fields -- :doc:`../python_api/mqtt_client` - How to connect and subscribe to device updates -- :doc:`../protocol/mqtt_protocol` - Message format reference +- :doc:`../reference/protocol/device_status` - Complete list of all status fields +- :doc:`../reference/python_api/mqtt_client` - How to connect and subscribe to device updates +- :doc:`../reference/protocol/mqtt_protocol` - Message format reference diff --git a/docs/index.rst b/docs/index.rst index 0d5da5d..0f28550 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,94 +19,42 @@ This library provides a complete Python interface to Navien NWP500 heat pump water heaters through the Navien Smart Control cloud platform. It supports both REST API and real-time MQTT communication. -**Features:** - -* **REST API Client** - Complete implementation of Navien Smart Control - API -* **MQTT Client** - Real-time device communication via AWS IoT Core -* **Authentication** - JWT-based auth with automatic token refresh -* **Type Safety** - Comprehensive type-annotated data models -* **Event System** - Subscribe to device state changes with callbacks -* **Energy Monitoring** - Track power consumption and usage statistics -* **Time-of-Use (TOU)** - Optimize for variable electricity pricing -* **Async/Await** - Fully asynchronous, non-blocking operations - -Quick Start -=========== - -Install with ``pip install nwp500-python``, then see the :doc:`quickstart` guide -to connect and control your device in minutes. - -Documentation Index -=================== +Documentation +============= .. toctree:: - :maxdepth: 1 - :caption: Getting Started + :maxdepth: 2 + :caption: Tutorials - quickstart - installation - configuration + tutorials/getting-started .. toctree:: :maxdepth: 2 - :caption: Python API Reference - - python_api/auth_client - python_api/api_client - python_api/mqtt_client - python_api/device_control - python_api/models - enumerations - python_api/events - python_api/exceptions - python_api/cli + :caption: How-to Guides + + how-to/index .. toctree:: :maxdepth: 2 - :caption: Complete Module Reference - - api/modules - + :caption: Reference - -.. toctree:: - :maxdepth: 1 - :caption: User Guides - - guides/authentication - guides/event_system - guides/command_queue - guides/auto_recovery - guides/scheduling - guides/energy_monitoring - guides/time_of_use - guides/unit_conversion - guides/home_assistant_integration - guides/mqtt_diagnostics - guides/advanced_features_explained + reference/index .. toctree:: :maxdepth: 2 - :caption: Advanced: Protocol Reference + :caption: Explanation - protocol/quick_reference - protocol/rest_api - protocol/mqtt_protocol - protocol/device_status - protocol/data_conversions - protocol/device_features - protocol/error_codes + explanation/index .. toctree:: :maxdepth: 1 - :caption: Development + :caption: Project Information - development/contributing - development/history - changelog - license - authors + project/contributing + project/history + project/changelog + project/license + project/authors Indices and tables ================== diff --git a/docs/authors.rst b/docs/project/authors.rst similarity index 100% rename from docs/authors.rst rename to docs/project/authors.rst diff --git a/docs/changelog.rst b/docs/project/changelog.rst similarity index 100% rename from docs/changelog.rst rename to docs/project/changelog.rst diff --git a/docs/development/contributing.rst b/docs/project/contributing.rst similarity index 100% rename from docs/development/contributing.rst rename to docs/project/contributing.rst diff --git a/docs/development/history.rst b/docs/project/history.rst similarity index 93% rename from docs/development/history.rst rename to docs/project/history.rst index afd7e6e..5255886 100644 --- a/docs/development/history.rst +++ b/docs/project/history.rst @@ -7,7 +7,7 @@ decisions made during the development of the nwp500 Python library. Project Overview ---------------- -A comprehensive Python client library for Navien NWP500 water heaters, +A Python client library for Navien NWP500 water heaters, providing: - REST API client for device management @@ -18,7 +18,7 @@ providing: - Automatic reconnection with exponential backoff - Command queuing for reliable communication - Historical energy usage data (EMS API) -- Modern Python 3.13+ codebase with native type hints +- Modern Python 3.14+ codebase with native type hints Current Status -------------- @@ -38,7 +38,7 @@ The library is feature-complete with: - Comprehensive documentation - Working examples for all features - Unit tests with good coverage -- Python 3.13+ with modern type hints +- Python 3.14+ with modern type hints Implementation Milestones ------------------------- @@ -85,8 +85,8 @@ compatibility - Automatic credential handling from authentication API - Session ID generation for connection tracking **Key Files:** - ``src/nwp500/mqtt_client.py`` - MQTT client -implementation - :doc:`../python_api/mqtt_client` - Complete documentation - -:doc:`../protocol/mqtt_protocol` - Message format reference +implementation - :doc:`../reference/python_api/mqtt_client` - Complete documentation - +:doc:`../reference/protocol/mqtt_protocol` - Message format reference Device Status & Feature Callbacks (October 7, 2025) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -265,11 +265,12 @@ Complete event-driven architecture for device state changes: - ``src/nwp500/events.py`` - EventEmitter implementation (370 lines) - ``src/nwp500/mqtt_client.py`` - MQTT integration with event emitter - ``examples/event_emitter_demo.py`` - Comprehensive demonstration -- ``tests/test_events.py`` - Unit tests (19 tests) -- :doc:`../python_api/events` - Feature documentation +- tests/test_events.py - Unit tests (19 tests) +- :doc:`../reference/python_api/events` - Feature documentation **Thread Safety Implementation:** + MQTT callbacks run in separate threads (e.g., 'Dummy-1') created by AWS IoT SDK. To safely emit events: 1. Event loop captured during ``connect()`` via ``asyncio.get_running_loop()`` @@ -289,7 +290,7 @@ interruptions: - Queue processed in FIFO order when connection is restored - Configurable queue size (default: 100 commands) - Enabled by default for best user experience -- Integrates seamlessly with automatic reconnection +- Integrates with automatic reconnection - Properties: ``queued_commands_count`` for monitoring - Methods: ``clear_command_queue()`` for manual management @@ -323,8 +324,8 @@ References ---------- - `OpenAPI Specification `__ - API specification -- :doc:`../protocol/mqtt_protocol` - MQTT message reference -- :doc:`../protocol/device_status` - Device status fields -- :doc:`../python_api/auth_client` - Authentication guide -- :doc:`../python_api/api_client` - API client guide -- :doc:`../python_api/mqtt_client` - MQTT client guide +- :doc:`../reference/protocol/mqtt_protocol` - MQTT message reference +- :doc:`../reference/protocol/device_status` - Device status fields +- :doc:`../reference/python_api/auth_client` - Authentication guide +- :doc:`../reference/python_api/api_client` - API client guide +- :doc:`../reference/python_api/mqtt_client` - MQTT client guide diff --git a/docs/license.rst b/docs/project/license.rst similarity index 100% rename from docs/license.rst rename to docs/project/license.rst diff --git a/docs/python_api/device_control.rst b/docs/python_api/device_control.rst deleted file mode 100644 index 3721536..0000000 --- a/docs/python_api/device_control.rst +++ /dev/null @@ -1,1188 +0,0 @@ -=========================== -Device Control and Commands -=========================== - -The ``MqttDeviceController`` manages all device control operations including status requests, -mode changes, temperature control, scheduling, and energy queries. - -Overview -======== - -The device controller provides: - -* **Status & Info Requests** - Request device status and feature information -* **Power Control** - Turn device on/off -* **Mode Management** - Change DHW operation modes -* **Temperature Control** - Set target water temperature -* **Anti-Legionella** - Enable/disable disinfection cycles -* **Scheduling** - Configure reservations and time-of-use pricing -* **Energy Monitoring** - Query historical energy usage -* **Recirculation** - Control hot water recirculation pump -* **Demand Response** - Participate in utility demand response -* **Capability Checking** - Validate device features before commanding -* **Automatic Capability Checking** - Decorator-based validation with automatic device info requests - -All control methods are fully asynchronous and require device capability information -to be cached before execution. - -Quick Start -=========== - -Basic Control -------------- - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient - import asyncio - - async def control_device(): - async with NavienAuthClient("email@example.com", "password") as auth: - api = NavienAPIClient(auth) - device = await api.get_first_device() - - mqtt = NavienMqttClient(auth) - await mqtt.connect() - - # Request device info to populate capability cache - await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) - - # Now control operations work with automatic capability checking - await mqtt.control.set_power(device, power_on=True) - await mqtt.control.set_dhw_mode(device, mode_id=3) # Energy Saver - await mqtt.control.set_dhw_temperature(device, 140.0) - - await mqtt.disconnect() - - asyncio.run(control_device()) - -Capability Checking -------------------- - -Before executing control commands, check device capabilities: - -.. code-block:: python - - from nwp500 import NavienMqttClient, DeviceCapabilityError - - async def safe_control(): - mqtt = NavienMqttClient(auth) - await mqtt.connect() - - # Request device info first - await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) - - # Wait for device info to be cached, then control - try: - # Control commands automatically check capabilities via decorator - msg_id = await mqtt.control.set_recirculation_mode(device, 1) - print(f"Command sent with ID {msg_id}") - except DeviceCapabilityError as e: - print(f"Device doesn't support: {e}") - -API Reference -============= - -MqttDeviceController --------------------- - -The ``NavienMqttClient`` includes a built-in device controller for all operations. - -Status and Info Methods ------------------------ - -request_device_status() -^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_device_status(device) - - Request current device status. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) - -request_device_info() -^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_device_info(device) - - Request device features and capabilities. - - This populates the device info cache used for capability checking in control commands. - Always call this before using control commands. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) - -Power Control --------------- - -set_power() -^^^^^^^^^^^ - -.. py:method:: set_power(device, power_on) - - Turn device on or off. - - **Capability Required:** ``power_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param power_on: True to turn on, False to turn off - :type power_on: bool - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support power control - - **Example:** - - .. code-block:: python - - # Turn on - await mqtt.control.set_power(device, power_on=True) - - # Turn off - await mqtt.control.set_power(device, power_on=False) - -DHW Mode Control ------------------ - -set_dhw_mode() -^^^^^^^^^^^^^^ - -.. py:method:: set_dhw_mode(device, mode_id, vacation_days=None) - - Set DHW (Domestic Hot Water) operation mode. - - **Capability Required:** ``dhw_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param mode_id: Mode ID (1-5) - :type mode_id: int - :param vacation_days: Number of days for vacation mode (required if mode_id=5, 1-30) - :type vacation_days: int or None - :return: Publish packet ID - :rtype: int - :raises ParameterValidationError: If vacation_days invalid for non-vacation modes - :raises RangeValidationError: If vacation_days not in 1-30 range - :raises DeviceCapabilityError: If device doesn't support DHW mode control - - **Operation Modes:** - - * 1 = Heat Pump Only - Most efficient, uses only heat pump - * 2 = Electric Only - Fast recovery, uses only electric heaters - * 3 = Energy Saver - Balanced, recommended for most users - * 4 = High Demand - Maximum heating capacity - * 5 = Vacation - Low power mode for extended absence - - **Example:** - - .. code-block:: python - - from nwp500 import DhwOperationSetting - - # Set to Energy Saver (balanced, recommended) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) - # or just: - await mqtt.control.set_dhw_mode(device, 3) - - # Set vacation mode for 7 days - await mqtt.control.set_dhw_mode( - device, - DhwOperationSetting.VACATION.value, - vacation_days=7 - ) - -Temperature Control --------------------- - -set_dhw_temperature() -^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: set_dhw_temperature(device, temperature) - - Set DHW target temperature. - - **Capability Required:** ``dhw_temperature_setting_use`` - DHW temperature control enabled - - :param device: Device object - :type device: Device - :param temperature: Target temperature in user's preferred unit (Celsius or Fahrenheit) - :type temperature: float - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If temperature is outside valid range - :raises DeviceCapabilityError: If device doesn't support temperature control - - The temperature is automatically converted to the device's internal format - (half-degrees Celsius). The valid range depends on the device's - temperature preference and configuration. - - **Example:** - - .. code-block:: python - - # Set temperature (interpreted in device's preferred unit) - await mqtt.control.set_dhw_temperature(device, 140.0) - - # Common temperatures (device-dependent units) - await mqtt.control.set_dhw_temperature(device, 120.0) # Standard - await mqtt.control.set_dhw_temperature(device, 130.0) # Medium - await mqtt.control.set_dhw_temperature(device, 140.0) # Hot - await mqtt.control.set_dhw_temperature(device, 150.0) # Maximum - -Anti-Legionella Control ------------------------- - -enable_anti_legionella() -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: enable_anti_legionella(device, period_days) - - Enable anti-Legionella disinfection cycle. - - :param device: Device object - :type device: Device - :param period_days: Cycle period in days (1-30) - :type period_days: int - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If period_days not in 1-30 range - - **Example:** - - .. code-block:: python - - # Enable weekly anti-Legionella cycle - await mqtt.control.enable_anti_legionella(device, period_days=7) - - # Enable bi-weekly cycle - await mqtt.control.enable_anti_legionella(device, period_days=14) - -disable_anti_legionella() -^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: disable_anti_legionella(device) - - Disable anti-Legionella disinfection cycle. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - await mqtt.control.disable_anti_legionella(device) - -Vacation Mode --------------- - -set_vacation_days() -^^^^^^^^^^^^^^^^^^^ - -.. py:method:: set_vacation_days(device, days) - - Set vacation/away mode duration in days. - - **Capability Required:** ``holiday_use`` - Must be present in device features - - Configures the device to operate in energy-saving mode for the specified number - of days during absence. - - :param device: Device object - :type device: Device - :param days: Number of vacation days (1-365 recommended, positive values) - :type days: int - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If days is not positive - :raises DeviceCapabilityError: If device doesn't support vacation mode - - **Example:** - - .. code-block:: python - - # Set vacation for 14 days - await mqtt.control.set_vacation_days(device, 14) - - # Set for full month - await mqtt.control.set_vacation_days(device, 30) - -Recirculation Control ---------------------- - -set_recirculation_mode() -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: set_recirculation_mode(device, mode) - - Set recirculation pump operation mode. - - **Capability Required:** ``recirculation_use`` - Must be present in device features - - Configures how the recirculation pump operates: - - * 1 = Always On - Pump runs continuously - * 2 = Button Only - Pump activates only via button press - * 3 = Schedule - Pump follows configured schedule - * 4 = Temperature - Pump maintains water temperature - - :param device: Device object - :type device: Device - :param mode: Recirculation mode (1-4) - :type mode: int - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If mode not in 1-4 range - :raises DeviceCapabilityError: If device doesn't support recirculation - - **Example:** - - .. code-block:: python - - # Enable always-on recirculation - await mqtt.control.set_recirculation_mode(device, 1) - - # Set to temperature-based control - await mqtt.control.set_recirculation_mode(device, 4) - -trigger_recirculation_hot_button() -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: trigger_recirculation_hot_button(device) - - Manually trigger the recirculation pump hot button. - - **Capability Required:** ``recirculation_use`` - Must be present in device features - - Activates the recirculation pump for immediate hot water delivery. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support recirculation - - **Example:** - - .. code-block:: python - - # Manually activate recirculation for immediate hot water - await mqtt.control.trigger_recirculation_hot_button(device) - -configure_recirculation_schedule() -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: configure_recirculation_schedule(device, schedule) - - Configure recirculation pump schedule. - - **Capability Required:** ``recirc_reservation_use`` - Recirculation scheduling enabled - - Sets up the recirculation pump operating schedule with specified periods and settings. - - :param device: Device object - :type device: Device - :param schedule: Recirculation schedule configuration - :type schedule: dict - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support recirculation scheduling - - **Example:** - - .. code-block:: python - - schedule = { - "enabled": True, - "periods": [ - { - "startHour": 6, - "startMinute": 0, - "endHour": 22, - "endMinute": 0, - "weekDays": [1, 1, 1, 1, 1, 0, 0] # Mon-Fri - } - ] - } - - await mqtt.control.configure_recirculation_schedule(device, schedule) - -Time-of-Use Control --------------------- - -set_tou_enabled() -^^^^^^^^^^^^^^^^^ - -.. py:method:: set_tou_enabled(device, enabled) - - Enable or disable Time-of-Use optimization. - - **Capability Required:** ``program_reservation_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param enabled: True to enable, False to disable - :type enabled: bool - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support TOU - - **Example:** - - .. code-block:: python - - # Enable TOU - await mqtt.control.set_tou_enabled(device, True) - - # Disable TOU - await mqtt.control.set_tou_enabled(device, False) - -configure_tou_schedule() -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: configure_tou_schedule(device, controller_serial_number, periods, enabled=True) - - Configure Time-of-Use pricing schedule via MQTT. - - **Capability Required:** ``program_reservation_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param controller_serial_number: Controller serial number - :type controller_serial_number: str - :param periods: List of TOU period definitions - :type periods: list[dict] - :param enabled: Whether TOU is enabled (default: True) - :type enabled: bool - :return: Publish packet ID - :rtype: int - :raises ParameterValidationError: If controller_serial_number empty or periods empty - :raises DeviceCapabilityError: If device doesn't support TOU - - **Example:** - - .. code-block:: python - - periods = [ - { - "season": 0, - "week": 0, - "startHour": 9, - "startMinute": 0, - "endHour": 17, - "endMinute": 0, - "priceMin": 0.10, - "priceMax": 0.25, - "decimalPoint": 2 - } - ] - - await mqtt.control.configure_tou_schedule( - device, - controller_serial_number="ABC123", - periods=periods - ) - -request_tou_settings() -^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_tou_settings(device, controller_serial_number) - - Request current Time-of-Use schedule from the device. - - :param device: Device object - :type device: Device - :param controller_serial_number: Controller serial number - :type controller_serial_number: str - :return: Publish packet ID - :rtype: int - :raises ParameterValidationError: If controller_serial_number empty - -Reservation Management ----------------------- - -update_reservations() -^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: update_reservations(device, reservations, enabled=True) - - Update device reservation schedule. - - **Capability Required:** ``program_reservation_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param reservations: List of reservation objects - :type reservations: list[dict] - :param enabled: Enable/disable reservation schedule (default: True) - :type enabled: bool - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support reservations - - **Example:** - - .. code-block:: python - - reservations = [ - { - "startHour": 6, - "startMinute": 0, - "endHour": 22, - "endMinute": 0, - "weekDays": [1, 1, 1, 1, 1, 0, 0], # Mon-Fri - "temperature": 120 - }, - { - "startHour": 8, - "startMinute": 0, - "endHour": 20, - "endMinute": 0, - "weekDays": [0, 0, 0, 0, 0, 1, 1], # Sat-Sun - "temperature": 130 - } - ] - - await mqtt.control.update_reservations(device, reservations, enabled=True) - -request_reservations() -^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_reservations(device) - - Request current reservation schedule from the device. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - -configure_reservation_water_program() -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: configure_reservation_water_program(device) - - Enable/configure water program reservation mode. - - **Capability Required:** ``program_reservation_use`` - Must be present in device features - - Enables the water program reservation system for scheduling. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support reservation programs - -Energy Monitoring ------------------- - -request_energy_usage() -^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_energy_usage(device, year, months) - - Request daily energy usage data for specified period. - - Retrieves historical energy usage data showing heat pump and electric heating - element consumption broken down by day. - - :param device: Device object - :type device: Device - :param year: Year to query (e.g., 2024) - :type year: int - :param months: List of months to query (1-12) - :type months: list[int] - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - # Subscribe first - await mqtt.subscribe_energy_usage(device, on_energy) - - # Request current month - from datetime import datetime - now = datetime.now() - await mqtt.control.request_energy_usage(device, now.year, [now.month]) - - # Request multiple months - await mqtt.control.request_energy_usage(device, 2024, [8, 9, 10]) - -Demand Response ----------------- - -enable_demand_response() -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: enable_demand_response(device) - - Enable utility demand response participation. - - Allows the device to respond to utility demand response signals to reduce - consumption (shed) or pre-heat (load up) before peak periods. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - # Enable demand response - await mqtt.control.enable_demand_response(device) - -disable_demand_response() -^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: disable_demand_response(device) - - Disable utility demand response participation. - - Prevents the device from responding to utility demand response signals. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - # Disable demand response - await mqtt.control.disable_demand_response(device) - -Air Filter Maintenance ------------------------ - -reset_air_filter() -^^^^^^^^^^^^^^^^^^ - -.. py:method:: reset_air_filter(device) - - Reset air filter maintenance timer. - - Used for heat pump models to reset the maintenance timer after filter - cleaning or replacement. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - # Reset air filter timer after maintenance - await mqtt.control.reset_air_filter(device) - -Utility Methods ---------------- - -signal_app_connection() -^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: signal_app_connection(device) - - Signal that an application has connected. - - Recommended to call at startup to notify the device of app connection. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - await mqtt.connect() - await mqtt.control.signal_app_connection(device) - -Device Capabilities Module -========================== - -The ``DeviceCapabilityChecker`` provides a mapping-based approach to validate -device capabilities without requiring individual checker functions. - -.. py:class:: DeviceCapabilityChecker - - Generalized device capability checker using a capability map. - - Class Methods - ^^^^^^^^^^^^^ - -supports() ----------- - -.. py:staticmethod:: supports(feature, device_features) - - Check if device supports control of a specific feature. - - :param feature: Name of the controllable feature - :type feature: str - :param device_features: Device feature information - :type device_features: DeviceFeature - :return: True if feature control is supported, False otherwise - :rtype: bool - :raises ValueError: If feature is not recognized - - **Supported Features:** - - * ``power_use`` - Device power on/off control - * ``dhw_use`` - DHW mode changes - * ``dhw_temperature_setting_use`` - DHW temperature control - * ``holiday_use`` - Vacation/away mode - * ``program_reservation_use`` - Reservations and TOU scheduling - * ``recirculation_use`` - Recirculation pump control - * ``recirc_reservation_use`` - Recirculation scheduling - - **Example:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - if DeviceCapabilityChecker.supports("recirculation_use", device_features): - print("Device supports recirculation pump control") - else: - print("Device doesn't support recirculation pump") - -assert_supported() ------------------- - -.. py:staticmethod:: assert_supported(feature, device_features) - - Assert that device supports control of a feature. - - :param feature: Name of the controllable feature - :type feature: str - :param device_features: Device feature information - :type device_features: DeviceFeature - :raises DeviceCapabilityError: If feature control is not supported - :raises ValueError: If feature is not recognized - - **Example:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - from nwp500 import DeviceCapabilityError - - try: - DeviceCapabilityChecker.assert_supported("recirculation_use", features) - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - print(f"Cannot set recirculation: {e}") - -get_available_controls() ------------------------- - -.. py:staticmethod:: get_available_controls(device_features) - - Get all controllable features available on a device. - - :param device_features: Device feature information - :type device_features: DeviceFeature - :return: Dictionary mapping feature names to whether they can be controlled - :rtype: dict[str, bool] - - **Example:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - controls = DeviceCapabilityChecker.get_available_controls(device_features) - for feature, supported in controls.items(): - status = "✓" if supported else "✗" - print(f"{status} {feature}") - -register_capability() ---------------------- - -.. py:staticmethod:: register_capability(name, check_fn) - - Register a custom controllable feature check. - - Allows extensions or applications to define custom capability checks without - modifying the core library. - - :param name: Feature name - :type name: str - :param check_fn: Function that takes DeviceFeature and returns bool - :type check_fn: Callable[[DeviceFeature], bool] - - **Example:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - def check_custom_feature(features): - return features.some_custom_field is not None - - # Register custom capability - DeviceCapabilityChecker.register_capability("custom_feature", check_custom_feature) - - # Now can use it with control methods - if DeviceCapabilityChecker.supports("custom_feature", device_features): - # Execute custom command - pass - -Controller Capability Methods ------------------------------- - -MqttDeviceController also provides direct capability checking methods: - -check_support() -^^^^^^^^^^^^^^^ - -.. py:method:: check_support(feature, device_features) - - Check if device supports a controllable feature. - - :param feature: Name of the controllable feature - :type feature: str - :param device_features: Device feature information - :type device_features: DeviceFeature - :return: True if feature is supported, False otherwise - :rtype: bool - :raises ValueError: If feature is not recognized - - **Example:** - - .. code-block:: python - - if mqtt.check_support("recirculation_use", device_features): - await mqtt.control.set_recirculation_mode(device, 1) - -assert_support() -^^^^^^^^^^^^^^^^ - -.. py:method:: assert_support(feature, device_features) - - Assert that device supports a controllable feature. - - :param feature: Name of the controllable feature - :type feature: str - :param device_features: Device feature information - :type device_features: DeviceFeature - :raises DeviceCapabilityError: If feature is not supported - :raises ValueError: If feature is not recognized - - **Example:** - - .. code-block:: python - - try: - mqtt.assert_support("recirculation_use", device_features) - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - print(f"Device doesn't support: {e}") - -Capability Checking Decorator -============================== - -The ``@requires_capability`` decorator automatically validates device capabilities -before command execution. - -.. py:function:: requires_capability(feature) - - Decorator that validates device capability before executing command. - - This decorator automatically checks if a device supports a specific controllable - feature before allowing the command to execute. If the device doesn't support - the feature, a ``DeviceCapabilityError`` is raised. - - **Requirements:** - - The decorated method must: - - 1. Have ``self`` (controller instance with ``_device_info_cache``) - 2. Have ``device`` parameter (Device object with ``mac_address``) - 3. Be async (sync methods log a warning and bypass checking for backward compatibility) - - The device info must be cached (via ``request_device_info``) before calling - the command, otherwise a ``DeviceCapabilityError`` is raised. The decorator - supports automatic device info requests if the controller callback is configured. - - :param feature: Name of the required capability (e.g., "recirculation_use") - :type feature: str - :return: Decorator function - :rtype: Callable - - :raises DeviceCapabilityError: If device doesn't support the feature - :raises ValueError: If feature name is not recognized - - **How It Works:** - - 1. Extracts device MAC address from ``device`` parameter - 2. Checks if device info is already cached - 3. If not cached, automatically attempts to request it (if callback configured) - 4. Validates the capability using ``DeviceCapabilityChecker`` - 5. Executes command only if capability check passes - 6. Logs all operations for debugging - - **Example Usage:** - - .. code-block:: python - - from nwp500.mqtt_device_control import MqttDeviceController - from nwp500.command_decorators import requires_capability - - class MyController(MqttDeviceController): - @requires_capability("recirculation_use") - async def set_recirculation_mode(self, device, mode): - # Capability automatically checked before this executes - return await self._publish(...) - - **Automatic Device Info Requests:** - - When a control method is called and device info isn't cached, the decorator - attempts to automatically request it: - - .. code-block:: python - - # Device info is automatically requested if not cached - await mqtt.control.set_recirculation_mode(device, 1) - - # This triggers: - # 1. Check cache (not found) - # 2. Auto-request device info - # 3. Wait for response - # 4. Validate capability - # 5. Execute command - -Error Handling --------------- - -**DeviceCapabilityError** is raised when: - -1. Device doesn't support the required feature -2. Device info cannot be obtained (for automatic requests) -3. Feature name is not recognized - -.. code-block:: python - - from nwp500 import DeviceCapabilityError - - try: - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - print(f"Cannot execute command: {e}") - print(f"Missing capability: {e.feature}") - -Best Practices -============== - -1. **Always request device info first:** - - .. code-block:: python - - # Request device info before control commands - await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) - - # Now control commands can proceed - await mqtt.control.set_power(device, True) - -2. **Check capabilities manually for custom logic:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - controls = DeviceCapabilityChecker.get_available_controls(features) - - if controls.get("recirculation_use"): - await mqtt.control.set_recirculation_mode(device, 1) - else: - print("Recirculation not supported") - -3. **Handle capability errors gracefully:** - - .. code-block:: python - - from nwp500 import DeviceCapabilityError - - try: - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - logger.warning(f"Feature not supported: {e.feature}") - # Fallback to alternative command - -4. **Use try/except for error handling:** - - .. code-block:: python - - from nwp500 import DeviceCapabilityError, RangeValidationError - - try: - await mqtt.control.set_dhw_temperature(device, 140.0) - except DeviceCapabilityError as e: - print(f"Device doesn't support temperature control: {e}") - except RangeValidationError as e: - print(f"Invalid temperature {e.value}°F: {e.message}") - -5. **Implement device capability discovery:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - def print_device_capabilities(device_features): - """Print all supported controls.""" - controls = DeviceCapabilityChecker.get_available_controls(device_features) - - print("Available Controls:") - for feature in sorted(controls.keys()): - supported = controls[feature] - status = "✓" if supported else "✗" - print(f" {status} {feature}") - -Examples -======== - -Example 1: Safe Device Control with Capability Checking --------------------------------------------------------- - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient - from nwp500.device_capabilities import DeviceCapabilityChecker - from nwp500 import DeviceCapabilityError - import asyncio - - async def safe_device_control(): - async with NavienAuthClient(email, password) as auth: - api = NavienAPIClient(auth) - device = await api.get_first_device() - - mqtt = NavienMqttClient(auth) - await mqtt.connect() - - # Store features from device info - features = None - - def on_feature(f): - nonlocal features - features = f - - # Request device info - await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) - - # Wait a bit for response - await asyncio.sleep(2) - - if features: - # Check what's supported - controls = DeviceCapabilityChecker.get_available_controls(features) - - # Power control - if controls.get("power_use"): - try: - await mqtt.control.set_power(device, True) - print("✓ Device powered ON") - except DeviceCapabilityError as e: - print(f"✗ Power control failed: {e}") - - # Recirculation control - if controls.get("recirculation_use"): - try: - await mqtt.control.set_recirculation_mode(device, 1) - print("✓ Recirculation enabled") - except DeviceCapabilityError as e: - print(f"✗ Recirculation failed: {e}") - - # Temperature control - if controls.get("dhw_temperature_setting_use"): - try: - await mqtt.control.set_dhw_temperature(device, 140.0) - print("✓ Temperature set to 140°F") - except DeviceCapabilityError as e: - print(f"✗ Temperature control failed: {e}") - - await mqtt.disconnect() - - asyncio.run(safe_device_control()) - -Example 2: Automatic Capability Checking with Decorator --------------------------------------------------------- - -.. code-block:: python - - # Control methods are automatically decorated with @requires_capability - # No additional code needed - just call them! - - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient - from nwp500 import DeviceCapabilityError - import asyncio - - async def simple_control(): - async with NavienAuthClient(email, password) as auth: - api = NavienAPIClient(auth) - device = await api.get_first_device() - - mqtt = NavienMqttClient(auth) - await mqtt.connect() - - # Request device info once - await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) - - # All control methods now have automatic capability checking - try: - await mqtt.control.set_power(device, True) - await mqtt.control.set_dhw_mode(device, 3) - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - print(f"Device doesn't support: {e}") - - await mqtt.disconnect() - - asyncio.run(simple_control()) - -Related Documentation -===================== - -* :doc:`mqtt_client` - MQTT client overview -* :doc:`models` - Data models (DeviceStatus, DeviceFeature, etc.) -* :doc:`exceptions` - Exception handling (DeviceCapabilityError, etc.) -* :doc:`../protocol/device_features` - Device features reference -* :doc:`../guides/scheduling` - Scheduling guide -* :doc:`../guides/energy_monitoring` - Energy monitoring guide -* :doc:`../guides/time_of_use` - Time-of-use guide diff --git a/docs/python_api/events.rst b/docs/python_api/events.rst deleted file mode 100644 index 31814e3..0000000 --- a/docs/python_api/events.rst +++ /dev/null @@ -1,358 +0,0 @@ -============ -Event System -============ - -The ``nwp500.events`` module provides an event-driven architecture for -reacting to device state changes, errors, and system events. - -Overview -======== - -The MQTT client uses an EventEmitter pattern that allows you to: - -* Subscribe to specific events with callback functions -* React to device state changes in real-time -* Handle connection events (interruption, resumption) -* Monitor errors and diagnostics -* Build reactive, event-driven applications - -All events are emitted asynchronously and callbacks are invoked with -relevant data. - -EventEmitter -============ - -Base class for event-driven components. - -.. py:class:: EventEmitter - - Provides event subscription and emission capabilities. - - **Methods:** - - .. py:method:: on(event, callback) - - Register a callback for an event. - - :param event: Event name - :type event: str - :param callback: Function to call when event fires - :type callback: Callable - - .. py:method:: off(event, callback=None) - - Unregister callback(s) for an event. - - :param event: Event name - :type event: str - :param callback: Specific callback to remove, or None for all - :type callback: Callable or None - - .. py:method:: emit(event, *args, **kwargs) - - Emit an event to all registered callbacks. - - :param event: Event name - :type event: str - :param args: Positional arguments for callbacks - :param kwargs: Keyword arguments for callbacks - -MQTT Client Events -================== - -The :doc:`mqtt_client` emits the following events: - -Connection Events ------------------ - -connection_interrupted -^^^^^^^^^^^^^^^^^^^^^^ - -Emitted when MQTT connection is lost. - -**Callback signature:** - -.. code-block:: python - - def on_interrupted(error): - """ - :param error: Error that caused interruption - :type error: Exception - """ - -**Example:** - -.. code-block:: python - - def handle_disconnect(error): - print(f"Connection lost: {error}") - # Save state, notify user, etc. - - mqtt.on('connection_interrupted', handle_disconnect) - -connection_resumed -^^^^^^^^^^^^^^^^^^ - -Emitted when MQTT connection is restored. - -**Callback signature:** - -.. code-block:: python - - def on_resumed(return_code, session_present): - """ - :param return_code: MQTT return code - :type return_code: int - :param session_present: Whether session was resumed - :type session_present: bool - """ - -**Example:** - -.. code-block:: python - - def handle_reconnect(return_code, session_present): - print("Connection restored") - # Re-request status, resume operations - await mqtt.control.request_device_status(device) - - mqtt.on('connection_resumed', handle_reconnect) - -Device Events -------------- - -status_received -^^^^^^^^^^^^^^^ - -Emitted when device status update is received. - -**Callback signature:** - -.. code-block:: python - - def on_status(status): - """ - :param status: Device status object - :type status: DeviceStatus - """ - -**Example:** - -.. code-block:: python - - def handle_status(status): - print(f"Temperature: {status.dhw_temperature}°F") - print(f"Power: {status.current_inst_power}W") - - mqtt.on('status_received', handle_status) - -feature_received -^^^^^^^^^^^^^^^^ - -Emitted when device feature/info update is received. - -**Callback signature:** - -.. code-block:: python - - def on_feature(feature): - """ - :param feature: Device feature object - :type feature: DeviceFeature - """ - -temperature_changed -^^^^^^^^^^^^^^^^^^^ - -Emitted when water temperature changes significantly. - -**Callback signature:** - -.. code-block:: python - - def on_temp_change(old_temp, new_temp): - """ - :param old_temp: Previous temperature - :type old_temp: float - :param new_temp: Current temperature - :type new_temp: float - """ - -mode_changed -^^^^^^^^^^^^ - -Emitted when operation mode changes. - -**Callback signature:** - -.. code-block:: python - - def on_mode_change(old_mode, new_mode): - """ - :param old_mode: Previous mode - :type old_mode: DhwOperationSetting - :param new_mode: Current mode - :type new_mode: DhwOperationSetting - """ - -error_detected -^^^^^^^^^^^^^^ - -Emitted when device reports an error code. - -**Callback signature:** - -.. code-block:: python - - def on_error(error_code, sub_error_code): - """ - :param error_code: Main error code - :type error_code: int - :param sub_error_code: Sub-error code - :type sub_error_code: int - """ - -Examples -======== - -Example 1: Basic Event Handling --------------------------------- - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienMqttClient - - async def main(): - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) - - # Register event handlers - mqtt.on('status_received', lambda s: print(f"Temp: {s.dhwTemperature}°F")) - mqtt.on('error_detected', lambda e, se: print(f"Error: {e}")) - - await mqtt.connect() - # Events will be emitted automatically - await asyncio.sleep(300) - -Example 2: Connection Monitoring ---------------------------------- - -.. code-block:: python - - async def monitor_connection(): - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) - - def on_disconnected(error): - print(f"Lost connection: {error}") - # Alert user, save state - - def on_reconnected(rc, session): - print("Connection restored!") - # Resume operations - - mqtt.on('connection_interrupted', on_disconnected) - mqtt.on('connection_resumed', on_reconnected) - - await mqtt.connect() - await asyncio.sleep(86400) # Monitor for 24h - -Example 3: Temperature Alerts ------------------------------- - -.. code-block:: python - - async def temperature_alerts(): - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) - - def check_temp(status): - if status.dhw_temperature < 110: - print("WARNING: Temperature below 110°F") - send_alert("Low water temperature") - - if status.dhw_temperature > 145: - print("WARNING: Temperature above 145°F") - send_alert("High water temperature") - - mqtt.on('status_received', check_temp) - - await mqtt.connect() - await mqtt.subscribe_device_status(device, lambda s: None) - await mqtt.start_periodic_requests(device, period_seconds=60) - - await asyncio.sleep(86400) - -Example 4: Multiple Event Handlers ------------------------------------ - -.. code-block:: python - - async def multi_handler(): - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) - - # Log all status updates - mqtt.on('status_received', lambda s: log_status(s)) - - # Track temperature - mqtt.on('temperature_changed', lambda old, new: - print(f"Temp: {old}°F → {new}°F")) - - # Monitor mode changes - mqtt.on('mode_changed', lambda old, new: - print(f"Mode: {old.name} → {new.name}")) - - # Alert on errors - mqtt.on('error_detected', lambda e, se: - send_alert(f"Error: {e}:{se}")) - - await mqtt.connect() - # All handlers will be called automatically - -Best Practices -============== - -1. **Register handlers before connecting:** - - .. code-block:: python - - # GOOD: Register first - mqtt.on('status_received', handler) - await mqtt.connect() - - # BAD: May miss early events - await mqtt.connect() - mqtt.on('status_received', handler) - -2. **Use lambda for simple handlers:** - - .. code-block:: python - - mqtt.on('status_received', lambda s: print(f"{s.dhwTemperature}°F")) - -3. **Use named functions for complex handlers:** - - .. code-block:: python - - def complex_handler(status): - # Complex logic - process_status(status) - update_database(status) - check_alerts(status) - - mqtt.on('status_received', complex_handler) - -4. **Clean up handlers when done:** - - .. code-block:: python - - mqtt.off('status_received', handler) # Remove specific - mqtt.off('status_received') # Remove all - -Related Documentation -===================== - -* :doc:`mqtt_client` - MQTT client with events -* :doc:`models` - Data models passed to event handlers -* :doc:`exceptions` - Exception handling diff --git a/docs/configuration.rst b/docs/reference/configuration.rst similarity index 96% rename from docs/configuration.rst rename to docs/reference/configuration.rst index c5228c3..d5f8c7e 100644 --- a/docs/configuration.rst +++ b/docs/reference/configuration.rst @@ -144,8 +144,8 @@ The MQTT client supports various configuration options through For detailed configuration guides, see: -* :doc:`guides/auto_recovery` - Connection recovery settings -* :doc:`guides/command_queue` - Offline command queuing +* :doc:`../how-to/auto-recovery` - Connection recovery settings +* :doc:`../how-to/queue-commands` - Offline command queuing Basic Example ------------- @@ -281,7 +281,7 @@ Example: Production Configuration Next Steps ========== -* :doc:`quickstart` - Build your first application +* :doc:`../tutorials/getting-started` - Build your first application * :doc:`python_api/auth_client` - Authentication details * :doc:`python_api/mqtt_client` - MQTT client configuration -* :doc:`guides/auto_recovery` - Automatic reconnection guide +* :doc:`../how-to/auto-recovery` - Automatic reconnection guide diff --git a/docs/enumerations.rst b/docs/reference/enumerations.rst similarity index 99% rename from docs/enumerations.rst rename to docs/reference/enumerations.rst index cd4afc2..88690a4 100644 --- a/docs/enumerations.rst +++ b/docs/reference/enumerations.rst @@ -305,5 +305,5 @@ Related Documentation For detailed protocol documentation, see: - :doc:`protocol/device_status` - Status field definitions -- :doc:`guides/time_of_use` - TOU scheduling and rate types +- :doc:`../how-to/optimize-tou` - TOU scheduling and rate types - :doc:`protocol/quick_reference` - Quick reference and control commands diff --git a/docs/reference/index.rst b/docs/reference/index.rst new file mode 100644 index 0000000..e4cb0b1 --- /dev/null +++ b/docs/reference/index.rst @@ -0,0 +1,43 @@ +========= +Reference +========= + +Technical descriptions of the API, models, and protocol. + +Python API +---------- + +.. toctree:: + :maxdepth: 1 + + python_api/auth_client + python_api/api_client + python_api/mqtt_client + python_api/models + python_api/events + python_api/exceptions + python_api/cli + api/modules + +Protocol Reference +------------------ + +.. toctree:: + :maxdepth: 1 + + protocol/rest_api + protocol/mqtt_protocol + protocol/device_status + protocol/data_conversions + protocol/device_features + protocol/error_codes + +General Reference +----------------- + +.. toctree:: + :maxdepth: 1 + + enumerations + installation + configuration diff --git a/docs/installation.rst b/docs/reference/installation.rst similarity index 88% rename from docs/installation.rst rename to docs/reference/installation.rst index 6e3a475..a0ba2f0 100644 --- a/docs/installation.rst +++ b/docs/reference/installation.rst @@ -5,7 +5,7 @@ Installation Requirements ============ -* Python 3.13 or higher +* Python 3.14 or higher * pip (Python package installer) * Navien Smart Control account @@ -18,7 +18,11 @@ The easiest way to install nwp500-python: pip install nwp500-python -This will install the library and all required dependencies. +For rich formatting and colors when using the CLI: + +.. code-block:: bash + + pip install nwp500-python[cli] Installing from Source ====================== @@ -50,8 +54,8 @@ Core Dependencies The library requires: -* ``aiohttp>=3.8.0`` - Async HTTP client for REST API -* ``awsiotsdk>=1.27.0`` - AWS IoT SDK for MQTT +* ``aiohttp>=3.13.5`` - Async HTTP client for REST API +* ``awsiotsdk>=1.29.0`` - AWS IoT SDK for MQTT * ``pydantic>=2.0.0`` - Data validation and models Optional Dependencies @@ -100,13 +104,13 @@ Troubleshooting ImportError: No module named 'nwp500' -------------------------------------- -Make sure you installed the package: +Check that you installed the package: .. code-block:: bash pip install nwp500-python -If using a virtual environment, ensure it's activated. +If using a virtual environment, activate it first. SSL/TLS Errors -------------- @@ -128,7 +132,7 @@ The MQTT client requires the AWS IoT SDK: .. code-block:: bash - pip install awsiotsdk>=1.27.0 + pip install awsiotsdk>=1.29.0 Upgrading ========= diff --git a/docs/protocol/data_conversions.rst b/docs/reference/protocol/data_conversions.rst similarity index 99% rename from docs/protocol/data_conversions.rst rename to docs/reference/protocol/data_conversions.rst index f53053d..5e4f631 100644 --- a/docs/protocol/data_conversions.rst +++ b/docs/reference/protocol/data_conversions.rst @@ -660,6 +660,6 @@ See Also * :doc:`device_status` - Complete status message structure and field definitions * :doc:`error_codes` - Error code reference for fault diagnosis -* :doc:`../guides/energy_monitoring` - Using energy data for optimization -* :doc:`../guides/time_of_use` - TOU scheduling and rate optimization -* :doc:`../guides/advanced_features_explained` - Weather-responsive heating, demand response, and tank stratification +* :doc:`../../how-to/track-energy` - Using energy data for optimization +* :doc:`../../how-to/optimize-tou` - TOU scheduling and rate optimization +* :doc:`../../explanation/advanced-features` - Weather-responsive heating, demand response, and tank stratification diff --git a/docs/protocol/device_features.rst b/docs/reference/protocol/device_features.rst similarity index 99% rename from docs/protocol/device_features.rst rename to docs/reference/protocol/device_features.rst index fc04155..2f3634b 100644 --- a/docs/protocol/device_features.rst +++ b/docs/reference/protocol/device_features.rst @@ -365,7 +365,7 @@ Usage Example print(f"Available: {', '.join(features)}") await mqtt_client.subscribe_device_feature(device, analyze_features) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) # Wait for response await asyncio.sleep(5) diff --git a/docs/protocol/device_status.rst b/docs/reference/protocol/device_status.rst similarity index 99% rename from docs/protocol/device_status.rst rename to docs/reference/protocol/device_status.rst index 2b9789f..b03d25a 100644 --- a/docs/protocol/device_status.rst +++ b/docs/reference/protocol/device_status.rst @@ -764,5 +764,5 @@ See Also -------- * :doc:`error_codes` - Complete error code reference with diagnostics -* :doc:`../guides/energy_monitoring` - Energy consumption tracking +* :doc:`../../how-to/track-energy` - Energy consumption tracking * :doc:`mqtt_protocol` - Status message format details diff --git a/docs/protocol/error_codes.rst b/docs/reference/protocol/error_codes.rst similarity index 100% rename from docs/protocol/error_codes.rst rename to docs/reference/protocol/error_codes.rst diff --git a/docs/protocol/mqtt_protocol.rst b/docs/reference/protocol/mqtt_protocol.rst similarity index 99% rename from docs/protocol/mqtt_protocol.rst rename to docs/reference/protocol/mqtt_protocol.rst index 683a2cd..b94fdd0 100644 --- a/docs/protocol/mqtt_protocol.rst +++ b/docs/reference/protocol/mqtt_protocol.rst @@ -918,7 +918,7 @@ this protocol. mqtt = NavienMqttClient(auth) await mqtt.connect() await mqtt.subscribe_device_status(device, callback) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) Related Documentation ===================== diff --git a/docs/protocol/quick_reference.rst b/docs/reference/protocol/quick_reference.rst similarity index 100% rename from docs/protocol/quick_reference.rst rename to docs/reference/protocol/quick_reference.rst diff --git a/docs/protocol/rest_api.rst b/docs/reference/protocol/rest_api.rst similarity index 100% rename from docs/protocol/rest_api.rst rename to docs/reference/protocol/rest_api.rst diff --git a/docs/python_api/api_client.rst b/docs/reference/python_api/api_client.rst similarity index 100% rename from docs/python_api/api_client.rst rename to docs/reference/python_api/api_client.rst diff --git a/docs/python_api/auth_client.rst b/docs/reference/python_api/auth_client.rst similarity index 98% rename from docs/python_api/auth_client.rst rename to docs/reference/python_api/auth_client.rst index 7233af2..546f916 100644 --- a/docs/python_api/auth_client.rst +++ b/docs/reference/python_api/auth_client.rst @@ -84,7 +84,7 @@ NavienAuthClient auth = NavienAuthClient() # With stored tokens (skip re-authentication) - stored = AuthTokens.from_dict(saved_data) + stored = AuthTokens.model_validate(saved_data) auth = NavienAuthClient( "email@example.com", "password", @@ -345,7 +345,7 @@ AuthTokens **Methods:** - .. py:method:: from_dict(data) + .. py:method:: model_validate(data) :classmethod: Create AuthTokens from dictionary (API response or saved data). @@ -373,7 +373,7 @@ AuthTokens token_data = tokens.to_dict() # Later, restore tokens - restored = AuthTokens.from_dict(token_data) + restored = AuthTokens.model_validate(token_data) AuthenticationResponse ---------------------- @@ -497,7 +497,7 @@ Example 5: Token Restoration (Skip Re-authentication) token_data = json.load(f) # Deserialize tokens - stored_tokens = AuthTokens.from_dict(token_data) + stored_tokens = AuthTokens.model_validate(token_data) # Initialize client with stored tokens # This skips initial authentication if tokens are still valid diff --git a/docs/python_api/cli.rst b/docs/reference/python_api/cli.rst similarity index 99% rename from docs/python_api/cli.rst rename to docs/reference/python_api/cli.rst index 9d3e853..9464aa8 100644 --- a/docs/python_api/cli.rst +++ b/docs/reference/python_api/cli.rst @@ -815,4 +815,4 @@ Related Documentation * :doc:`auth_client` - Python authentication API * :doc:`api_client` - Python REST API * :doc:`mqtt_client` - Python MQTT API -* :doc:`../guides/auto_recovery` - Connection recovery and resilience +* :doc:`../../how-to/auto-recovery` - Connection recovery and resilience diff --git a/docs/reference/python_api/events.rst b/docs/reference/python_api/events.rst new file mode 100644 index 0000000..ca2cabe --- /dev/null +++ b/docs/reference/python_api/events.rst @@ -0,0 +1,287 @@ +Event System +============ + +The MQTT client exposes two complementary callback patterns: + +* ``subscribe_*()`` methods parse device messages and call your callback with a + model object such as :class:`~nwp500.models.DeviceStatus` or + :class:`~nwp500.models.ReservationSchedule`. +* :meth:`nwp500.events.EventEmitter.on` listens for higher-level client events + from :class:`nwp500.mqtt_events.MqttClientEvents`. These callbacks always + receive **one typed event dataclass**. + +Overview +======== + +Use the event system when you want to react to connection changes, status +transitions, or derived state changes such as temperature deltas and error +conditions. + +Two Subscription Patterns +========================= + +Typed device subscriptions +-------------------------- + +These methods deliver parsed model objects directly to the callback. + +.. code-block:: python + + def on_status(status): + print(status.dhw_temperature) + print(status.current_inst_power) + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) + +Examples include: + +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_device_status` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_device_feature` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_energy_usage` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_reservation_response` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_weekly_reservation_response` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_recirculation_schedule_response` + +Client event subscriptions +-------------------------- + +Event emitter callbacks receive a single event object. + +.. code-block:: python + + from nwp500 import MqttClientEvents + + def on_status_event(event): + print(event.status.dhw_temperature) + + def on_resumed(event): + print(event.return_code) + print(event.session_present) + + mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_event) + mqtt.on(MqttClientEvents.CONNECTION_RESUMED, on_resumed) + +EventEmitter API +================ + +.. py:class:: EventEmitter + + Base class for event-driven components. + + .. py:method:: on(event, callback) + + Register a callback for an event name. + + .. py:method:: off(event, callback=None) + + Remove one callback or all callbacks for an event. + + .. py:method:: wait_for(event, timeout=None) + + Wait for the next event emission and return the positional event + arguments as a tuple. + + .. code-block:: python + + args = await mqtt.wait_for(MqttClientEvents.CONNECTION_RESUMED, timeout=30) + resumed = args[0] + print(resumed.session_present) + +MQTT Client Events +================== + +The :class:`nwp500.mqtt_events.MqttClientEvents` registry exposes all supported +client event names with IDE-friendly constants. + +.. code-block:: python + + from nwp500 import MqttClientEvents + + for event_name in MqttClientEvents.get_all_events(): + print(event_name) + +ConnectionInterruptedEvent +-------------------------- + +.. py:class:: nwp500.mqtt_events.ConnectionInterruptedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.CONNECTION_INTERRUPTED`. + + **Fields:** + + * ``error`` (:class:`Exception`) - The exception that interrupted the MQTT + connection. + + **Example:** + + .. code-block:: python + + def on_interrupted(event): + print(f"Connection lost: {event.error}") + + mqtt.on(MqttClientEvents.CONNECTION_INTERRUPTED, on_interrupted) + +ConnectionResumedEvent +---------------------- + +.. py:class:: nwp500.mqtt_events.ConnectionResumedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.CONNECTION_RESUMED`. + + **Fields:** + + * ``return_code`` (int) - MQTT return code from the resume attempt. + * ``session_present`` (bool) - Whether broker session state was preserved. + + **Example:** + + .. code-block:: python + + def on_resumed(event): + if not event.session_present: + print("Broker session was reset") + + mqtt.on(MqttClientEvents.CONNECTION_RESUMED, on_resumed) + +StatusReceivedEvent +------------------- + +.. py:class:: nwp500.mqtt_events.StatusReceivedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.STATUS_RECEIVED`. + + **Fields:** + + * ``status`` (:class:`~nwp500.models.DeviceStatus`) - Parsed device status. + +TemperatureChangedEvent +----------------------- + +.. py:class:: nwp500.mqtt_events.TemperatureChangedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.TEMPERATURE_CHANGED`. + + **Fields:** + + * ``old_temperature`` (float) - Previous DHW temperature in the current unit system. + * ``new_temperature`` (float) - New DHW temperature in the current unit system. + +ModeChangedEvent +---------------- + +.. py:class:: nwp500.mqtt_events.ModeChangedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.MODE_CHANGED`. + + **Fields:** + + * ``old_mode`` (:class:`~nwp500.CurrentOperationMode`) - Previous operating mode. + * ``new_mode`` (:class:`~nwp500.CurrentOperationMode`) - New operating mode. + +PowerChangedEvent +----------------- + +.. py:class:: nwp500.mqtt_events.PowerChangedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.POWER_CHANGED`. + + **Fields:** + + * ``old_power`` (float) - Previous instantaneous power draw in watts. + * ``new_power`` (float) - New instantaneous power draw in watts. + +HeatingStartedEvent +------------------- + +.. py:class:: nwp500.mqtt_events.HeatingStartedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.HEATING_STARTED`. + + **Fields:** + + * ``status`` (:class:`~nwp500.models.DeviceStatus`) - Status snapshot when + heating started. + +HeatingStoppedEvent +------------------- + +.. py:class:: nwp500.mqtt_events.HeatingStoppedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.HEATING_STOPPED`. + + **Fields:** + + * ``status`` (:class:`~nwp500.models.DeviceStatus`) - Status snapshot when + heating stopped. + +ErrorDetectedEvent +------------------ + +.. py:class:: nwp500.mqtt_events.ErrorDetectedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.ERROR_DETECTED`. + + **Fields:** + + * ``error_code`` (:class:`~nwp500.ErrorCode`) - Newly detected device error. + * ``status`` (:class:`~nwp500.models.DeviceStatus`) - Status snapshot that + contained the error. + +ErrorClearedEvent +----------------- + +.. py:class:: nwp500.mqtt_events.ErrorClearedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.ERROR_CLEARED`. + + **Fields:** + + * ``error_code`` (:class:`~nwp500.ErrorCode`) - Error code that cleared. + +FeatureReceivedEvent +-------------------- + +.. py:class:: nwp500.mqtt_events.FeatureReceivedEvent + + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.FEATURE_RECEIVED`. + + **Fields:** + + * ``feature`` (:class:`~nwp500.models.DeviceFeature`) - Parsed device feature payload. + +Usage Examples +============== + +React to typed event payloads +----------------------------- + +.. code-block:: python + + from nwp500 import MqttClientEvents + + def on_temperature_changed(event): + print(f"{event.old_temperature} -> {event.new_temperature}") + + def on_error(event): + print(f"Error: {event.error_code}") + print(f"Current mode: {event.status.operation_mode}") + + mqtt.on(MqttClientEvents.TEMPERATURE_CHANGED, on_temperature_changed) + mqtt.on(MqttClientEvents.ERROR_DETECTED, on_error) + +Wait for a connection event +--------------------------- + +.. code-block:: python + + args = await mqtt.wait_for(MqttClientEvents.CONNECTION_RESUMED, timeout=30) + resumed = args[0] + print(resumed.return_code) + +Related Documentation +===================== + +* :doc:`mqtt_client` - MQTT client API reference +* :doc:`models` - Models used by subscription callbacks +* :doc:`../../how-to/monitor-status` - Event-driven programming guide diff --git a/docs/python_api/exceptions.rst b/docs/reference/python_api/exceptions.rst similarity index 95% rename from docs/python_api/exceptions.rst rename to docs/reference/python_api/exceptions.rst index 725815d..5881848 100644 --- a/docs/python_api/exceptions.rst +++ b/docs/reference/python_api/exceptions.rst @@ -75,7 +75,7 @@ Nwp500Error try: mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except Nwp500Error as e: # Catches all library exceptions print(f"Library error: {e}") @@ -267,11 +267,11 @@ MqttNotConnectedError mqtt = NavienMqttClient(auth) try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except MqttNotConnectedError: # Not connected - establish connection first await mqtt.connect() - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) MqttPublishError ---------------- @@ -390,7 +390,7 @@ RangeValidationError from nwp500 import NavienMqttClient, RangeValidationError try: - await mqtt.control.set_dhw_temperature(device, 200.0) + await mqtt.set_dhw_temperature(device, 200.0) except RangeValidationError as e: print(f"Invalid {e.field}: {e.value}") print(f"Valid range: {e.min_value} to {e.max_value}") @@ -473,11 +473,11 @@ DeviceCapabilityError # Request device info first await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) try: # This raises DeviceCapabilityError if device doesn't support recirculation - await mqtt.control.set_recirculation_mode(device, 1) + await mqtt.set_recirculation_mode(device, 1) except DeviceCapabilityError as e: print(f"Feature not supported: {e.feature}") print(f"Error: {e}") @@ -500,7 +500,7 @@ DeviceCapabilityError # Check if device supports a feature if DeviceCapabilityChecker.supports("recirculation_use", device_features): - await mqtt.control.set_recirculation_mode(device, 1) + await mqtt.set_recirculation_mode(device, 1) else: print("Device doesn't support recirculation") @@ -539,7 +539,7 @@ Handle specific exception types for granular control: mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.control.set_dhw_temperature(device, 120.0) + await mqtt.set_dhw_temperature(device, 120.0) except InvalidCredentialsError: print("Invalid credentials - check email/password") @@ -622,18 +622,18 @@ Handle capability errors for device control commands: # Request device info first await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) # Option 1: Try control and catch capability error try: - await mqtt.control.set_recirculation_mode(device, 1) + await mqtt.set_recirculation_mode(device, 1) except DeviceCapabilityError as e: print(f"Device doesn't support: {e.feature}") # Fallback to alternative command # Option 2: Check capability before attempting if DeviceCapabilityChecker.supports("recirculation_use", device_features): - await mqtt.control.set_recirculation_mode(device, 1) + await mqtt.set_recirculation_mode(device, 1) else: print("Recirculation not supported") @@ -656,7 +656,7 @@ Use ``to_dict()`` for structured error logging: logger = logging.getLogger(__name__) try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except Nwp500Error as e: # Log structured error data logger.error("Operation failed", extra=e.to_dict()) @@ -674,7 +674,7 @@ Catch all library exceptions with ``Nwp500Error``: try: # Any library operation await mqtt.connect() - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except Nwp500Error as e: # All nwp500 exceptions inherit from Nwp500Error @@ -744,7 +744,7 @@ Best Practices .. code-block:: python try: - await mqtt.control.set_dhw_temperature(device, 200.0) + await mqtt.set_dhw_temperature(device, 200.0) except RangeValidationError as e: # Show helpful message print(f"Temperature must be between {e.min_value}°F and {e.max_value}°F") @@ -797,7 +797,7 @@ If upgrading from v4.x, update your exception handling: .. code-block:: python try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except RuntimeError as e: if "Not connected" in str(e): await mqtt.connect() @@ -809,10 +809,10 @@ If upgrading from v4.x, update your exception handling: from nwp500 import MqttNotConnectedError try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except MqttNotConnectedError: await mqtt.connect() - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) See the CHANGELOG.rst for complete migration guide with more examples. diff --git a/docs/python_api/models.rst b/docs/reference/python_api/models.rst similarity index 82% rename from docs/python_api/models.rst rename to docs/reference/python_api/models.rst index 8697d84..52c78e9 100644 --- a/docs/python_api/models.rst +++ b/docs/reference/python_api/models.rst @@ -36,7 +36,7 @@ See :doc:`../enumerations` for the complete enumeration reference including: from nwp500 import DhwOperationSetting, CurrentOperationMode, HeatSource, TemperatureType # Set operation mode (user preference) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) # Check current heat source if status.current_heat_use == HeatSource.HEATPUMP: @@ -387,6 +387,157 @@ Device capabilities, features, and firmware information. if feature.high_demand_use: print(" [OK] High Demand mode") +Scheduling Models +================= + +ReservationEntry +---------------- + +A single timed reservation entry used by :class:`ReservationSchedule`. + +.. py:class:: ReservationEntry + + **Raw Fields:** + + * ``enable`` (int) - Device boolean (``2`` enabled, ``1`` disabled) + * ``week`` (int) - Weekday bitfield + * ``hour`` (int) - Start hour (0-23) + * ``min`` (int) - Start minute (0-59) + * ``mode`` (int) - DHW operation mode ID + * ``param`` (int) - Temperature encoded in half-degrees Celsius + + **Computed Properties:** + + * ``enabled`` (bool) + * ``days`` (list[str]) + * ``time`` (str) + * ``temperature`` (float) + * ``unit`` (str) + * ``mode_name`` (str) + +ReservationSchedule +------------------- + +Full programmed reservation schedule used by ``request_reservations()`` and +``update_reservations()``. + +.. py:class:: ReservationSchedule + + **Fields:** + + * ``reservation_use`` (int) - Device boolean for global enable/disable state + * ``reservation`` (list[ReservationEntry]) - Reservation entries + + **Computed Properties / Methods:** + + * ``enabled`` (bool) + * :meth:`model_validate` - Parse a raw MQTT response payload + +WeeklyReservationEntry +---------------------- + +A single entry in the weekly reservation schedule used by +:meth:`nwp500.mqtt.client.NavienMqttClient.update_weekly_reservation`. + +.. py:class:: WeeklyReservationEntry + + **Raw Fields:** + + * ``enable`` (int) - Device boolean (``2`` enabled, ``1`` disabled) + * ``week`` (int) - Weekday bitfield + * ``hour`` (int) - Scheduled hour (0-23) + * ``min`` (int) - Scheduled minute (0-59) + * ``mode`` (int) - DHW operation mode ID + * ``param`` (int) - Temperature encoded in half-degrees Celsius + + **Computed Properties:** + + * ``enabled`` (bool) + * ``days`` (list[str]) + * ``time`` (str) + * ``temperature`` (float) + * ``unit`` (str) + * ``mode_name`` (str) + +WeeklyReservationSchedule +------------------------- + +Full weekly reservation schedule. + +.. py:class:: WeeklyReservationSchedule + + **Fields:** + + * ``reservation_use`` (int) - Device boolean for global enable/disable state + * ``reservation`` (list[WeeklyReservationEntry]) - Weekly schedule entries + + **Computed Properties / Methods:** + + * ``enabled`` (bool) + * :meth:`model_validate` - Parse a raw MQTT response payload + +RecirculationScheduleEntry +-------------------------- + +A single recirculation pump schedule entry. + +.. py:class:: RecirculationScheduleEntry + + **Fields:** + + * ``enable`` (int) - Device boolean (``2`` enabled, ``1`` disabled) + * ``week`` (int) - Weekday bitfield + * ``start_hour`` (int) - Start hour (0-23) + * ``start_min`` (int) - Start minute (0-59) + * ``end_hour`` (int) - End hour (0-23) + * ``end_min`` (int) - End minute (0-59) + * ``mode`` (int) - Recirculation mode ID + + **Computed Properties:** + + * ``enabled`` (bool) + * ``days`` (list[str]) + * ``start_time`` (str) + * ``end_time`` (str) + * ``mode_name`` (str) + +RecirculationSchedule +--------------------- + +Full recirculation schedule used by +:meth:`nwp500.mqtt.client.NavienMqttClient.configure_recirculation_schedule`. + +.. py:class:: RecirculationSchedule + + **Fields:** + + * ``schedule`` (list[RecirculationScheduleEntry]) - Scheduled recirculation windows + + **Methods:** + + * :meth:`model_validate` - Parse a raw MQTT response payload + +OtaCommitPayload +---------------- + +Payload model used by +:meth:`nwp500.mqtt.client.NavienMqttClient.commit_firmware_update`. + +.. py:class:: OtaCommitPayload + + **Fields:** + + * ``sw_code`` (int) - Firmware component code (for example controller, panel, + or WiFi module) + * ``sw_version`` (int) - Firmware version to commit + + **Example:** + + .. code-block:: python + + payload = OtaCommitPayload(swCode=1, swVersion=1234) + await mqtt.commit_firmware_update(device, payload) + Energy Models ============= @@ -574,10 +725,10 @@ Best Practices # ✓ Type-safe from nwp500 import DhwOperationSetting - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) # ✗ Magic numbers - await mqtt.control.set_dhw_mode(device, 3) + await mqtt.set_dhw_mode(device, 3) 2. **Check feature support:** @@ -586,7 +737,7 @@ Best Practices def on_feature(feature): if feature.energy_usage_use: # Device supports energy monitoring - await mqtt.control.request_energy_usage(device, year, months) + await mqtt.request_energy_usage(device, year, months) 3. **Monitor operation state:** diff --git a/docs/python_api/mqtt_client.rst b/docs/reference/python_api/mqtt_client.rst similarity index 59% rename from docs/python_api/mqtt_client.rst rename to docs/reference/python_api/mqtt_client.rst index b1069ec..7eb4ec4 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/reference/python_api/mqtt_client.rst @@ -2,29 +2,22 @@ MQTT Client ============ -The ``NavienMqttClient`` is the **primary interface** for real-time communication -with Navien devices. Use this for monitoring status and sending control commands. +``NavienMqttClient`` is the main interface for real-time communication with +Navien devices — status monitoring, device control, and event callbacks. .. important:: - **MQTT is the main way to interact with your Navien device.** Use the REST API - only for device discovery. MQTT provides real-time updates, lower latency, - bidirectional communication, and event-driven architecture. + Use the REST API only for device discovery. Everything else goes through MQTT. Overview ======== -The MQTT client provides: - -* **Real-Time Monitoring** - Subscribe to device status updates as they happen +* **Real-Time Monitoring** - Subscribe to device status updates * **Device Control** - Send commands (power, temperature, mode) * **Event System** - React to state changes with callbacks -* **Auto-Reconnection** - Automatic recovery from network issues with exponential backoff -* **Command Queueing** - Commands queued when offline, sent automatically on reconnect -* **Type-Safe** - Returns strongly-typed data models (DeviceStatus, DeviceFeature) -* **Periodic Requests** - Automatic periodic status/info requests -* **Energy Monitoring** - Query and subscribe to energy usage data - -All operations are fully asynchronous and non-blocking. +* **Auto-Reconnection** - Exponential backoff reconnection with command queueing +* **Type-Safe** - Returns typed models (DeviceStatus, DeviceFeature) +* **Periodic Requests** - Scheduled status polling +* **Energy Monitoring** - Query historical energy usage data Quick Start =========== @@ -56,7 +49,7 @@ Basic Monitoring print(f"Mode: {status.dhw_operation_setting.name}") await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Monitor for 60 seconds await asyncio.sleep(60) @@ -67,9 +60,12 @@ Basic Monitoring Device Control -------------- -Control operations require device capability information to be cached. Always request -device info before using control commands. See :doc:`device_control` for complete -control method reference, capability checking, and advanced features. +Control operations are now exposed directly on :class:`NavienMqttClient`; use +the direct ``mqtt.*`` methods for control operations. + +Control methods rely on cached device feature data for capability-aware +validation. Request device info first, or call +:meth:`nwp500.mqtt.client.NavienMqttClient.ensure_device_info_cached` before issuing commands. .. code-block:: python @@ -77,22 +73,18 @@ control method reference, capability checking, and advanced features. async with NavienAuthClient(email, password) as auth: api = NavienAPIClient(auth) device = await api.get_first_device() - + mqtt = NavienMqttClient(auth) await mqtt.connect() - - # Request device info first (populates capability cache) + await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) - - # Control operations (with automatic capability checking) - await mqtt.control.set_power(device, power_on=True) - await mqtt.control.set_dhw_mode(device, mode_id=3) # Energy Saver - await mqtt.control.set_dhw_temperature(device, 140.0) # Temperature in user's preferred unit - - await mqtt.disconnect() + await mqtt.request_device_info(device) - asyncio.run(control_device()) + await mqtt.set_power(device, power_on=True) + await mqtt.set_dhw_mode(device, mode_id=3) + await mqtt.set_dhw_temperature(device, 140.0) + + await mqtt.disconnect() API Reference ============= @@ -130,11 +122,11 @@ NavienMqttClient mqtt = NavienMqttClient(auth, config=config) # Register event handlers - def on_interrupted(error): - print(f"Connection lost: {error}") + def on_interrupted(event): + print(f"Connection lost: {event.error}") - def on_resumed(return_code, session_present): - print("Connection restored!") + def on_resumed(event): + print(f"Connection restored! session_present={event.session_present}") mqtt.on("connection_interrupted", on_interrupted) mqtt.on("connection_resumed", on_resumed) @@ -196,8 +188,8 @@ subscribe_device_status() Subscribe to device status updates with automatic parsing. - The callback receives DeviceStatus objects with 100+ fields including temperature, - power consumption, operation mode, and component states. + The callback receives :class:`~nwp500.models.DeviceStatus` objects containing + temperature, power, operation mode, component states, and more. :param device: Device object :type device: Device @@ -239,7 +231,7 @@ subscribe_device_status() print(f"ERROR: {status.error_code}") await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) request_device_status() ^^^^^^^^^^^^^^^^^^^^^^^ @@ -261,11 +253,11 @@ request_device_status() await mqtt.subscribe_device_status(device, on_status) # Then request - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Can request periodically while monitoring: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) await asyncio.sleep(30) # Every 30 seconds subscribe_device_feature() @@ -305,7 +297,7 @@ subscribe_device_feature() print("Reservations: Supported") await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) request_device_info() ^^^^^^^^^^^^^^^^^^^^^ @@ -324,7 +316,7 @@ request_device_info() .. code-block:: python await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) subscribe_device() ^^^^^^^^^^^^^^^^^^ @@ -366,313 +358,389 @@ subscribe_device() Control Methods --------------- +Capability Checking +^^^^^^^^^^^^^^^^^^^ + +Most control commands depend on device capabilities reported by +:class:`~nwp500.models.DeviceFeature`. Request device info first so the client +can validate support and ranges before sending commands. + +.. code-block:: python + + await mqtt.subscribe_device_feature(device, lambda feature: print(feature)) + await mqtt.request_device_info(device) + + # Alternative helper: request and wait until the cache is populated + await mqtt.ensure_device_info_cached(device) + +Common capability flags include ``power_use``, ``dhw_use``, +``dhw_temperature_setting_use``, ``program_reservation_use``, +``recirculation_use``, ``recirc_reservation_use``, ``freeze_protection_use``, +and ``smart_diagnostic_use``. + set_power() ^^^^^^^^^^^ .. py:method:: set_power(device, power_on) - Turn device on or off. + Turn device power on or off. + + **Capability Required:** ``power_use`` :param device: Device object :type device: Device - :param power_on: True to turn on, False to turn off + :param power_on: ``True`` to power on, ``False`` to power off :type power_on: bool :return: Publish packet ID :rtype: int - **Example:** - - .. code-block:: python - - # Turn on - await mqtt.control.set_power(device, power_on=True) - print("Device powered ON") - - # Turn off - await mqtt.control.set_power(device, power_on=False) - print("Device powered OFF") - set_dhw_mode() ^^^^^^^^^^^^^^ .. py:method:: set_dhw_mode(device, mode_id, vacation_days=None) - Set DHW (Domestic Hot Water) operation mode. + Set the DHW operating mode. - :param device: Device object - :type device: Device - :param mode_id: Mode ID (1-5) + **Capability Required:** ``dhw_use`` + + :param mode_id: One of the DHW operation mode IDs :type mode_id: int - :param vacation_days: Number of days for vacation mode (required if mode_id=5) + :param vacation_days: Required for vacation mode; valid range ``1``-``30`` :type vacation_days: int or None - :return: Publish packet ID - :rtype: int + :raises ParameterValidationError: If vacation mode is missing ``vacation_days`` + :raises RangeValidationError: If ``vacation_days`` is outside ``1``-``30`` - **Operation Modes:** +set_dhw_temperature() +^^^^^^^^^^^^^^^^^^^^^ - * 1 = Heat Pump Only - Most efficient, uses only heat pump - * 2 = Electric Only - Fast recovery, uses only electric heaters - * 3 = Energy Saver - Balanced, recommended for most users - * 4 = High Demand - Maximum heating capacity - * 5 = Vacation - Low power mode for extended absence +.. py:method:: set_dhw_temperature(device, temperature) - **Example:** + Set the target water temperature in the current unit system. - .. code-block:: python + **Capability Required:** ``dhw_temperature_setting_use`` - from nwp500 import DhwOperationSetting - - # Set to Heat Pump Only (most efficient) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.HEAT_PUMP.value) - - # Set to Energy Saver (balanced, recommended) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) - # or just: - await mqtt.control.set_dhw_mode(device, 3) - - # Set to High Demand (maximum heating) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.HIGH_DEMAND.value) - - # Set vacation mode for 7 days - await mqtt.control.set_dhw_mode( - device, - DhwOperationSetting.VACATION.value, - vacation_days=7 - ) + The valid range is checked against the device's reported + ``dhw_temperature_min`` and ``dhw_temperature_max`` values. -set_dhw_temperature() -^^^^^^^^^^^^^^^^^^^^^ +enable_anti_legionella() +^^^^^^^^^^^^^^^^^^^^^^^^ -.. py:method:: set_dhw_temperature(device, temperature) +.. py:method:: enable_anti_legionella(device, period_days) - Set target DHW temperature. + Enable the anti-Legionella cycle. - :param device: Device object - :type device: Device - :param temperature: Temperature in user's preferred unit (Celsius or Fahrenheit) - :type temperature: float - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If temperature is outside valid range + **Capability Required:** ``anti_legionella_setting_use`` - The temperature is automatically converted to the device's internal - format (half-degrees Celsius). The actual valid range depends on the - device's temperature preference and configuration. + :param period_days: Cycle period in days (``1``-``30``) + :type period_days: int - **Example:** +disable_anti_legionella() +^^^^^^^^^^^^^^^^^^^^^^^^^ - .. code-block:: python +.. py:method:: disable_anti_legionella(device) - # Set temperature (value interpreted in device's preferred unit) - await mqtt.control.set_dhw_temperature(device, 140.0) - - # Common temperatures (device-dependent units) - await mqtt.control.set_dhw_temperature(device, 120.0) # Standard - await mqtt.control.set_dhw_temperature(device, 130.0) # Medium - await mqtt.control.set_dhw_temperature(device, 140.0) # Hot - await mqtt.control.set_dhw_temperature(device, 150.0) # Maximum + Disable the anti-Legionella cycle. -enable_anti_legionella() -^^^^^^^^^^^^^^^^^^^^^^^^ +set_vacation_days() +^^^^^^^^^^^^^^^^^^^ -.. py:method:: enable_anti_legionella(device, period_days) +.. py:method:: set_vacation_days(device, days) - Enable anti-Legionella protection cycle. + Convenience wrapper for vacation mode. - :param device: Device object - :type device: Device - :param period_days: Cycle period in days (typically 7 or 14) - :type period_days: int - :return: Publish packet ID - :rtype: int + **Capability Required:** ``holiday_use`` + +update_reservations() +^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: update_reservations(device, reservations, *, enabled=True) + + Update the standard reservation program. + + :param reservations: Sequence of raw reservation entries using the protocol + fields ``enable``, ``week``, ``hour``, ``min``, ``mode``, and ``param`` + :type reservations: Sequence[dict[str, Any]] + :param enabled: Global reservation enable flag + :type enabled: bool **Example:** .. code-block:: python - # Enable weekly anti-Legionella cycle - await mqtt.control.enable_anti_legionella(device, period_days=7) - - # Enable bi-weekly cycle - await mqtt.control.enable_anti_legionella(device, period_days=14) + from nwp500 import build_reservation_entry -disable_anti_legionella() -^^^^^^^^^^^^^^^^^^^^^^^^^ + reservations = [ + build_reservation_entry( + enabled=True, + days=["MO", "TU", "WE", "TH", "FR"], + hour=6, + minute=0, + mode_id=4, + temperature=60.0, + ) + ] -.. py:method:: disable_anti_legionella(device) + await mqtt.update_reservations(device, reservations, enabled=True) - Disable anti-Legionella protection. +request_reservations() +^^^^^^^^^^^^^^^^^^^^^^ - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int +.. py:method:: request_reservations(device) - **Example:** + Request the current programmed reservations. - .. code-block:: python +subscribe_reservation_response() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - await mqtt.control.disable_anti_legionella(device) +.. py:method:: subscribe_reservation_response(device, callback) -Energy Monitoring Methods --------------------------- + Subscribe to parsed reservation read responses. -request_energy_usage() + :param callback: Called with :class:`~nwp500.models.ReservationSchedule` + :type callback: Callable[[ReservationSchedule], None] + +update_weekly_reservation() +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: update_weekly_reservation(device, schedule) + + Send a typed weekly reservation schedule. + + **Capability Required:** ``program_reservation_use`` + + :param schedule: Weekly reservation schedule payload + :type schedule: WeeklyReservationSchedule + +subscribe_weekly_reservation_response() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: subscribe_weekly_reservation_response(device, callback) + + Subscribe to parsed weekly reservation responses. + + :param callback: Called with :class:`~nwp500.models.WeeklyReservationSchedule` + :type callback: Callable[[WeeklyReservationSchedule], None] + +configure_reservation_water_program() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: configure_reservation_water_program(device) + + Enable the device's reservation water-program mode. + + **Capability Required:** ``program_reservation_use`` + +configure_recirculation_schedule() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: configure_recirculation_schedule(device, schedule) + + Configure the timed recirculation schedule. + + **Capability Required:** ``recirc_reservation_use`` + + :param schedule: Recirculation schedule payload + :type schedule: RecirculationSchedule + +subscribe_recirculation_schedule_response() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: subscribe_recirculation_schedule_response(device, callback) + + Subscribe to parsed recirculation schedule responses. + + :param callback: Called with :class:`~nwp500.models.RecirculationSchedule` + :type callback: Callable[[RecirculationSchedule], None] + +set_recirculation_mode() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: set_recirculation_mode(device, mode) + + Set the recirculation operating mode. + + **Capability Required:** ``recirculation_use`` + + :param mode: Mode ID in the range ``1``-``4`` + :type mode: int + +trigger_recirculation_hot_button() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: trigger_recirculation_hot_button(device) + + Trigger an immediate recirculation run. + + **Capability Required:** ``recirculation_use`` + +configure_tou_schedule() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: configure_tou_schedule(device, controller_serial_number, periods, *, enabled=True) + + Configure the Time-of-Use schedule. + + **Capability Required:** ``program_reservation_use`` + +request_tou_settings() ^^^^^^^^^^^^^^^^^^^^^^ -.. py:method:: request_energy_usage(device, year, months) +.. py:method:: request_tou_settings(device, controller_serial_number) - Request daily energy usage data for specified period. + Request the current TOU schedule. - :param device: Device object - :type device: Device - :param year: Year to query (e.g., 2024) - :type year: int - :param months: List of months to query (1-12) - :type months: list[int] - :return: Publish packet ID - :rtype: int +subscribe_tou_response() +^^^^^^^^^^^^^^^^^^^^^^^^ - **Example:** +.. py:method:: subscribe_tou_response(device, callback) - .. code-block:: python + Subscribe to parsed TOU schedule responses. - # Subscribe first - await mqtt.subscribe_energy_usage(device, on_energy) - - # Request current month - from datetime import datetime - now = datetime.now() - await mqtt.control.request_energy_usage(device, now.year, [now.month]) - - # Request multiple months - await mqtt.control.request_energy_usage(device, 2024, [8, 9, 10]) - - # Request full year - await mqtt.control.request_energy_usage(device, 2024, list(range(1, 13))) + The callback is invoked with a :class:`~nwp500.models.TOUReservationSchedule` + whenever the device responds to a :meth:`request_tou_settings` read or a + :meth:`configure_tou_schedule` write (both use the ``tou/rd`` response + topic). + + :param callback: Called with the parsed TOU schedule on each response. + :type callback: Callable[[TOUReservationSchedule], None] + +unsubscribe_tou_response() +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: unsubscribe_tou_response(device, callback) + + Unsubscribe a previously registered TOU response callback. + + :param callback: The same callable passed to :meth:`subscribe_tou_response`. + :type callback: Callable[[TOUReservationSchedule], None] + +set_tou_enabled() +^^^^^^^^^^^^^^^^^ + +.. py:method:: set_tou_enabled(device, enabled) + + Enable or disable TOU optimization. + + **Capability Required:** ``program_reservation_use`` + +request_energy_usage() +^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_energy_usage(device, year, months) + + Request daily energy usage data for one or more months. subscribe_energy_usage() ^^^^^^^^^^^^^^^^^^^^^^^^ .. py:method:: subscribe_energy_usage(device, callback) - Subscribe to energy usage query responses. + Subscribe to parsed energy usage responses. - :param device: Device object - :type device: Device - :param callback: Function receiving EnergyUsageResponse objects + :param callback: Called with :class:`~nwp500.models.EnergyUsageResponse` :type callback: Callable[[EnergyUsageResponse], None] - :return: Subscription packet ID - :rtype: int - **Example:** +check_firmware_update() +^^^^^^^^^^^^^^^^^^^^^^^ - .. code-block:: python +.. py:method:: check_firmware_update(device) - def on_energy(energy): - """Process energy usage data.""" - print(f"Total Usage: {energy.total.total_usage} Wh") - print(f"Heat Pump: {energy.total.heat_pump_percentage:.1f}%") - print(f"Electric: {energy.total.heat_element_percentage:.1f}%") - - print("\nDaily Breakdown:") - for monthly_data in energy.usage: - print(f" Month: {monthly_data.year}-{monthly_data.month}") - for day_data in monthly_data.data: - # Skip empty days (all zeros) - if day_data.total_usage > 0: - print(f" Day {monthly_data.data.index(day_data) + 1}:") - print(f" Total: {day_data.total_usage} Wh") - print(f" HP: {day_data.heat_pump_usage} Wh ({day_data.heat_pump_time}h)") - print(f" HE: {day_data.heat_element_usage} Wh ({day_data.heat_element_time}h)") - - await mqtt.subscribe_energy_usage(device, on_energy) - await mqtt.control.request_energy_usage(device, year=2024, months=[10]) + Trigger an OTA firmware availability check. The response arrives + asynchronously on the device's MQTT response topic. -Reservation Methods -------------------- +commit_firmware_update() +^^^^^^^^^^^^^^^^^^^^^^^^ -update_reservations() -^^^^^^^^^^^^^^^^^^^^^ +.. py:method:: commit_firmware_update(device, payload) -.. py:method:: update_reservations(device, enabled, reservations) + Commit a previously downloaded firmware update. - Update device reservation schedule. + :param payload: OTA commit payload identifying the component and version + :type payload: OtaCommitPayload - :param device: Device object - :type device: Device - :param enabled: Enable/disable reservation schedule - :type enabled: bool - :param reservations: List of reservation objects - :type reservations: list[dict] - :return: Publish packet ID - :rtype: int + .. warning:: - **Example:** + The device reboots when a firmware commit is applied. - .. code-block:: python +reconnect_wifi() +^^^^^^^^^^^^^^^^ - # Define reservations - reservations = [ - { - "startHour": 6, - "startMinute": 0, - "endHour": 22, - "endMinute": 0, - "weekDays": [1, 1, 1, 1, 1, 0, 0], # Mon-Fri - "temperature": 120 - }, - { - "startHour": 8, - "startMinute": 0, - "endHour": 20, - "endMinute": 0, - "weekDays": [0, 0, 0, 0, 0, 1, 1], # Sat-Sun - "temperature": 130 - } - ] - - # Update schedule - await mqtt.control.update_reservations(device, True, reservations) +.. py:method:: reconnect_wifi(device) -request_reservations() + Ask the device to reconnect to WiFi using its current configuration. + +reset_wifi() +^^^^^^^^^^^^ + +.. py:method:: reset_wifi(device) + + Clear the stored WiFi configuration. + + .. warning:: + + After ``reset_wifi()``, the device must be provisioned again. + +set_freeze_protection_temperature() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: set_freeze_protection_temperature(device, temperature) + + Set the freeze-protection threshold in the current unit system. + + Available on devices that expose ``freeze_protection_use``. + +run_smart_diagnostic() ^^^^^^^^^^^^^^^^^^^^^^ -.. py:method:: request_reservations(device) +.. py:method:: run_smart_diagnostic(device) - Request current reservation schedule. + Trigger the device's smart diagnostic routine. - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int + Available on devices that expose ``smart_diagnostic_use``. -Time-of-Use Methods -------------------- + The result appears in the next ``DeviceStatus.smart_diagnostic`` update. -set_tou_enabled() -^^^^^^^^^^^^^^^^^ +enable_intelligent_scheduling() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. py:method:: set_tou_enabled(device, enabled) +.. py:method:: enable_intelligent_scheduling(device) - Enable or disable Time-of-Use optimization. + Enable adaptive/intelligent scheduling mode. - :param device: Device object - :type device: Device - :param enabled: True to enable, False to disable - :type enabled: bool - :return: Publish packet ID - :rtype: int +disable_intelligent_scheduling() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - **Example:** +.. py:method:: disable_intelligent_scheduling(device) - .. code-block:: python + Disable adaptive/intelligent scheduling mode. - # Enable TOU - await mqtt.control.set_tou_enabled(device, True) - - # Disable TOU - await mqtt.control.set_tou_enabled(device, False) +enable_demand_response() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: enable_demand_response(device) + + Enable utility demand-response participation. + +disable_demand_response() +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: disable_demand_response(device) + + Disable utility demand-response participation. + +reset_air_filter() +^^^^^^^^^^^^^^^^^^ + +.. py:method:: reset_air_filter(device) + + Reset the air-filter maintenance timer. + +signal_app_connection() +^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: signal_app_connection(device) + + Publish an app-connection heartbeat event to the device. Periodic Request Methods ------------------------ @@ -758,7 +826,7 @@ signal_app_connection() .. code-block:: python await mqtt.connect() - await mqtt.control.signal_app_connection(device) + await mqtt.signal_app_connection(device) subscribe(), unsubscribe(), publish() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -782,7 +850,7 @@ is_connected .. code-block:: python if mqtt.is_connected: - await mqtt.control.set_power(device, True) + await mqtt.set_power(device, True) else: print("Not connected") @@ -870,7 +938,7 @@ Example 1: Complete Monitoring Application # Temperature changed if last_temp != status.dhw_temperature: print(f"[{now}] Temperature: {status.dhw_temperature}°F " - f"(Target: {status.dhw_temperatureSetting}°F)") + f"(Target: {status.dhw_temperature_setting}°F)") last_temp = status.dhw_temperature # Power changed @@ -890,7 +958,7 @@ Example 1: Complete Monitoring Application print(f"[{now}] Heating: {', '.join(components)}") await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Monitor indefinitely try: @@ -976,7 +1044,7 @@ Example 3: Multi-Device Monitoring for device in devices: callback = create_callback(device.device_info.device_name) await mqtt.subscribe_device_status(device, callback) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Monitor await asyncio.sleep(3600) @@ -987,80 +1055,69 @@ Example 3: Multi-Device Monitoring Best Practices ============== -1. **Always subscribe before requesting:** +Subscribe before requesting +---------------------------- - .. code-block:: python +The device responds on a topic you must already be listening to: - # CORRECT order - await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) - - # WRONG - response will be missed - await mqtt.control.request_device_status(device) - await mqtt.subscribe_device_status(device, on_status) +.. code-block:: python -2. **Use context managers:** + # correct + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) - .. code-block:: python + # wrong — response arrives before subscription + await mqtt.request_device_status(device) + await mqtt.subscribe_device_status(device, on_status) - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) - try: - await mqtt.connect() - # ... operations ... - finally: - await mqtt.disconnect() +Use context managers +--------------------- -3. **Handle connection events:** +.. code-block:: python - .. code-block:: python + async with NavienAuthClient(email, password) as auth: + mqtt = NavienMqttClient(auth) + try: + await mqtt.connect() + # ... operations ... + finally: + await mqtt.disconnect() - mqtt = NavienMqttClient(auth) - - def on_interrupted(error): - print(f"Connection lost: {error}") - # Save state, notify user, etc. - - def on_resumed(return_code, session_present): - print("Connection restored") - # Re-request status, etc. - - mqtt.on("connection_interrupted", on_interrupted) - mqtt.on("connection_resumed", on_resumed) +Handle connection events +------------------------ -4. **Use periodic requests for long-running monitoring:** +.. code-block:: python - .. code-block:: python + def on_interrupted(event): + print(f"Connection lost: {event.error}") - # Instead of manual loop - await mqtt.subscribe_device_status(device, on_status) - await mqtt.start_periodic_requests(device, period_seconds=300) - - # Monitor as long as needed - await asyncio.sleep(86400) # 24 hours - - await mqtt.stop_periodic_requests(device) + def on_resumed(event): + print(f"Connection restored (session_present={event.session_present})") -5. **Check connection status:** + mqtt.on("connection_interrupted", on_interrupted) + mqtt.on("connection_resumed", on_resumed) - .. code-block:: python +Periodic requests for long-running monitoring +---------------------------------------------- - if mqtt.is_connected: - await mqtt.control.set_power(device, True) - else: - print("Not connected - reconnecting...") - await mqtt.connect() +.. code-block:: python + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.start_periodic_requests(device, period_seconds=300) + await asyncio.sleep(86400) + await mqtt.stop_periodic_requests(device) Related Documentation ===================== * :doc:`auth_client` - Authentication client * :doc:`api_client` - REST API client -* :doc:`device_control` - Device control commands and capability checking * :doc:`models` - Data models (DeviceStatus, DeviceFeature, etc.) * :doc:`events` - Event system * :doc:`exceptions` - Exception handling * :doc:`../protocol/mqtt_protocol` - MQTT protocol details -* :doc:`../guides/energy_monitoring` - Energy monitoring guide -* :doc:`../guides/command_queue` - Command queueing guide -* :doc:`../guides/auto_recovery` - Auto-reconnection guide +* :doc:`../../how-to/track-energy` - Energy monitoring guide +* :doc:`../../how-to/schedule-operation` - Scheduling, recirculation, and intelligent modes +* :doc:`../../how-to/maintenance` - OTA, WiFi, freeze protection, and diagnostics +* :doc:`../../how-to/queue-commands` - Command queueing guide +* :doc:`../../how-to/auto-recovery` - Auto-reconnection guide diff --git a/docs/requirements.txt b/docs/requirements.txt index d95cbca..ad30930 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,3 +4,5 @@ sphinx>=3.2.1 # sphinx_rtd_theme sphinxcontrib-openapi>=0.8.0 +click>=8.3.0 +rich>=14.3.0 diff --git a/docs/quickstart.rst b/docs/tutorials/getting-started.rst similarity index 89% rename from docs/quickstart.rst rename to docs/tutorials/getting-started.rst index 2f16b73..b33a437 100644 --- a/docs/quickstart.rst +++ b/docs/tutorials/getting-started.rst @@ -2,13 +2,12 @@ Quickstart ========== -This guide will get you up and running with the nwp500-python library -in just a few minutes. +Install the library and start talking to your device. Prerequisites ============= -* Python 3.13 or higher +* Python 3.14 or higher * Navien Smart Control account (via Navilink mobile app) * At least one Navien NWP500 device registered to your account * Valid email and password for your Navien account @@ -126,7 +125,7 @@ Connect to MQTT for real-time device monitoring: # Subscribe and request status await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Monitor for 60 seconds print("Monitoring device...") @@ -163,18 +162,18 @@ Send control commands to change device settings: await mqtt.connect() # Turn on the device - await mqtt.control.set_power(device, power_on=True) + await mqtt.set_power(device, power_on=True) print("Device powered on") # Set to Energy Saver mode - await mqtt.control.set_dhw_mode( + await mqtt.set_dhw_mode( device, mode_id=DhwOperationSetting.ENERGY_SAVER.value ) print("Set to Energy Saver mode") # Set temperature to 120°F - await mqtt.control.set_dhw_temperature(device, 120.0) + await mqtt.set_dhw_temperature(device, 120.0) print("Temperature set to 120°F") await asyncio.sleep(2) @@ -254,11 +253,11 @@ Next Steps Now that you have the basics, explore these topics: -* :doc:`python_api/auth_client` - Deep dive into authentication -* :doc:`python_api/mqtt_client` - Complete MQTT client documentation -* :doc:`guides/energy_monitoring` - Track energy usage -* :doc:`guides/time_of_use` - Optimize for TOU pricing -* :doc:`guides/event_system` - Use the event-driven architecture +* :doc:`../reference/python_api/auth_client` - Deep dive into authentication +* :doc:`../reference/python_api/mqtt_client` - Complete MQTT client documentation +* :doc:`../how-to/track-energy` - Track energy usage +* :doc:`../how-to/optimize-tou` - Optimize for TOU pricing +* :doc:`../how-to/monitor-status` - Use the event-driven architecture Common Issues ============= @@ -276,7 +275,7 @@ Common Issues reach the Navien cloud platform. **Import Errors** - Make sure you installed the library: ``pip install nwp500-python`` + Check that the library is installed: ``pip install nwp500-python`` -For more help, see the :doc:`development/contributing` guide or file an +For more help, see the :doc:`../project/contributing` guide or file an issue on GitHub. diff --git a/examples/advanced/air_filter_reset.py b/examples/advanced/air_filter_reset.py index 535fef2..1a79dcc 100644 --- a/examples/advanced/air_filter_reset.py +++ b/examples/advanced/air_filter_reset.py @@ -54,7 +54,7 @@ def on_device_info(features): ) await mqtt_client.subscribe_device_feature(device, on_device_info) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.sleep(3) # Wait for device info # Reset air filter maintenance timer @@ -69,7 +69,7 @@ def on_filter_reset(status): filter_reset_complete = True await mqtt_client.subscribe_device_status(device, on_filter_reset) - await mqtt_client.control.reset_air_filter(device) + await mqtt_client.reset_air_filter(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds @@ -93,7 +93,7 @@ def on_updated_device_info(features): logger.info("Filter reset appears to have been successful!") await mqtt_client.subscribe_device_feature(device, on_updated_device_info) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.sleep(3) finally: diff --git a/examples/advanced/anti_legionella.py b/examples/advanced/anti_legionella.py index b872ed9..cd70a9c 100644 --- a/examples/advanced/anti_legionella.py +++ b/examples/advanced/anti_legionella.py @@ -116,7 +116,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.STATUS_REQUEST - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) try: await asyncio.wait_for(status_received.wait(), timeout=10) @@ -134,7 +134,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.ANTI_LEGIONELLA_ON - await mqtt_client.control.enable_anti_legionella(device, period_days=7) + await mqtt_client.enable_anti_legionella(device, period_days=7) try: await asyncio.wait_for(status_received.wait(), timeout=10) @@ -152,7 +152,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.ANTI_LEGIONELLA_OFF - await mqtt_client.control.disable_anti_legionella(device) + await mqtt_client.disable_anti_legionella(device) try: await asyncio.wait_for(status_received.wait(), timeout=10) @@ -169,7 +169,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.ANTI_LEGIONELLA_ON - await mqtt_client.control.enable_anti_legionella(device, period_days=14) + await mqtt_client.enable_anti_legionella(device, period_days=14) try: await asyncio.wait_for(status_received.wait(), timeout=10) diff --git a/examples/advanced/combined_callbacks.py b/examples/advanced/combined_callbacks.py index b4e8c9d..f19aa5b 100644 --- a/examples/advanced/combined_callbacks.py +++ b/examples/advanced/combined_callbacks.py @@ -133,13 +133,13 @@ def on_feature(feature: DeviceFeature): # Request both types of data print("Requesting device info and status...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.sleep(2) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print("[SUCCESS] Requests sent") print() diff --git a/examples/advanced/demand_response.py b/examples/advanced/demand_response.py index d2153d0..0a6915b 100644 --- a/examples/advanced/demand_response.py +++ b/examples/advanced/demand_response.py @@ -54,7 +54,7 @@ def on_current_status(status): ) await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Enable demand response @@ -69,7 +69,7 @@ def on_dr_enabled(status): dr_enabled = True await mqtt_client.subscribe_device_status(device, on_dr_enabled) - await mqtt_client.control.enable_demand_response(device) + await mqtt_client.enable_demand_response(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds @@ -98,7 +98,7 @@ def on_dr_disabled(status): dr_disabled = True await mqtt_client.subscribe_device_status(device, on_dr_disabled) - await mqtt_client.control.disable_demand_response(device) + await mqtt_client.disable_demand_response(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds diff --git a/examples/advanced/device_capabilities.py b/examples/advanced/device_capabilities.py index 1cd9a75..0b23949 100644 --- a/examples/advanced/device_capabilities.py +++ b/examples/advanced/device_capabilities.py @@ -227,10 +227,10 @@ def on_device_feature(feature: DeviceFeature): # Step 5: Request device info to get feature data print("Step 5: Requesting device information...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) print("[SUCCESS] Device info request sent") print() diff --git a/examples/advanced/device_status_debug.py b/examples/advanced/device_status_debug.py index eb59f45..b52a1a4 100644 --- a/examples/advanced/device_status_debug.py +++ b/examples/advanced/device_status_debug.py @@ -150,10 +150,10 @@ def on_device_status(status: DeviceStatus): # Step 5: Request device status print("Step 5: Requesting device status...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print("[SUCCESS] Status request sent") print() diff --git a/examples/advanced/energy_analytics.py b/examples/advanced/energy_analytics.py index f580023..d6aaa5e 100755 --- a/examples/advanced/energy_analytics.py +++ b/examples/advanced/energy_analytics.py @@ -140,7 +140,7 @@ def on_energy_usage(energy: EnergyUsageResponse): current_month = now.month print(f"\nRequesting energy usage for {current_year}-{current_month:02d}...") - await mqtt_client.control.request_energy_usage( + await mqtt_client.request_energy_usage( device, year=current_year, months=[current_month] ) print("[OK] Request sent") diff --git a/examples/advanced/firmware_payload_capture.py b/examples/advanced/firmware_payload_capture.py index 3a1c715..bb9f151 100644 --- a/examples/advanced/firmware_payload_capture.py +++ b/examples/advanced/firmware_payload_capture.py @@ -136,7 +136,7 @@ def on_feature(feature: DeviceFeature) -> None: device_info_event.set() await mqtt_client.subscribe_device_feature(device, on_feature) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.wait_for(device_info_event.wait(), timeout=30.0) if device_feature: @@ -147,12 +147,12 @@ def on_feature(feature: DeviceFeature) -> None: ) # --- Step 2: request device status --- - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # --- Step 3: request reservation (weekly) schedule --- print("\nRequesting weekly reservation schedule...") - await mqtt_client.control.request_reservations(device) + await mqtt_client.request_reservations(device) await asyncio.sleep(5) # --- Step 4: request TOU schedule (requires controller serial number) --- @@ -161,7 +161,7 @@ def on_feature(feature: DeviceFeature) -> None: if serial: print("Requesting TOU schedule...") try: - await mqtt_client.control.request_tou_settings(device, serial) + await mqtt_client.request_tou_settings(device, serial) await asyncio.sleep(5) except Exception as exc: print(f" TOU request failed: {exc}") diff --git a/examples/advanced/mqtt_diagnostics.py b/examples/advanced/mqtt_diagnostics.py index 81da334..b2d634e 100755 --- a/examples/advanced/mqtt_diagnostics.py +++ b/examples/advanced/mqtt_diagnostics.py @@ -128,9 +128,9 @@ async def monitor_connection_state(self, interval: float = 10.0) -> None: except Exception as e: _logger.error(f"Error monitoring state: {e}", exc_info=True) - async def on_connection_drop(self, error: Exception) -> None: + async def on_connection_drop(self, event) -> None: """Handle connection drop event.""" - _logger.warning(f"Connection dropped: {error}") + _logger.warning(f"Connection dropped: {event.error}") # Record with diagnostics active_subs = ( @@ -145,24 +145,22 @@ async def on_connection_drop(self, error: Exception) -> None: queued_cmds = self.mqtt_client.queued_commands_count if self.mqtt_client else 0 await self.diagnostics.record_connection_drop( - error=error, + error=event.error, active_subscriptions=active_subs, queued_commands=queued_cmds, ) - async def on_connection_resumed( - self, return_code: int, session_present: bool - ) -> None: + async def on_connection_resumed(self, event) -> None: """Handle connection resumed event.""" _logger.info( - f"Connection resumed: return_code={return_code}, " - f"session_present={session_present}" + f"Connection resumed: return_code={event.return_code}, " + f"session_present={event.session_present}" ) await self.diagnostics.record_connection_success( event_type="resumed", - session_present=session_present, - return_code=return_code, + session_present=event.session_present, + return_code=event.return_code, ) async def run_example( @@ -212,12 +210,12 @@ async def run_example( # Hook into connection events self.mqtt_client.on( "connection_interrupted", - lambda e: asyncio.create_task(self.on_connection_drop(e)), + lambda event: asyncio.create_task(self.on_connection_drop(event)), ) self.mqtt_client.on( "connection_resumed", - lambda rc, sp: asyncio.create_task( - self.on_connection_resumed(rc, sp) + lambda event: asyncio.create_task( + self.on_connection_resumed(event) ), ) diff --git a/examples/advanced/power_control.py b/examples/advanced/power_control.py index e041406..1ea23c9 100644 --- a/examples/advanced/power_control.py +++ b/examples/advanced/power_control.py @@ -52,7 +52,7 @@ def on_current_status(status): logger.info(f"Current DHW temperature: {status.dhw_temperature}{unit}") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Turn device off @@ -68,7 +68,7 @@ def on_power_off_response(status): power_off_complete = True await mqtt_client.subscribe_device_status(device, on_power_off_response) - await mqtt_client.control.set_power(device, power_on=False) + await mqtt_client.set_power(device, power_on=False) # Wait for confirmation for i in range(15): # Wait up to 15 seconds @@ -97,7 +97,7 @@ def on_power_on_response(status): power_on_complete = True await mqtt_client.subscribe_device_status(device, on_power_on_response) - await mqtt_client.control.set_power(device, power_on=True) + await mqtt_client.set_power(device, power_on=True) # Wait for confirmation for i in range(15): # Wait up to 15 seconds diff --git a/examples/advanced/recirculation_control.py b/examples/advanced/recirculation_control.py index f4cef62..e2f8a36 100644 --- a/examples/advanced/recirculation_control.py +++ b/examples/advanced/recirculation_control.py @@ -50,7 +50,7 @@ def on_current_status(status): logger.info(f"Current operation mode: {status.operation_mode.name}") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Set recirculation mode to "Always On" @@ -67,7 +67,7 @@ def on_mode_set(status): mode_set = True await mqtt_client.subscribe_device_status(device, on_mode_set) - await mqtt_client.control.set_recirculation_mode(device, 1) # 1 = Always On + await mqtt_client.set_recirculation_mode(device, 1) # 1 = Always On # Wait for confirmation for i in range(10): # Wait up to 10 seconds @@ -93,7 +93,7 @@ def on_hot_button(status): hot_button_triggered = True await mqtt_client.subscribe_device_status(device, on_hot_button) - await mqtt_client.control.trigger_recirculation_hot_button(device) + await mqtt_client.trigger_recirculation_hot_button(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds @@ -119,9 +119,7 @@ def on_button_only_set(status): button_only_set = True await mqtt_client.subscribe_device_status(device, on_button_only_set) - await mqtt_client.control.set_recirculation_mode( - device, 2 - ) # 2 = Button Only + await mqtt_client.set_recirculation_mode(device, 2) # 2 = Button Only # Wait for confirmation for i in range(10): # Wait up to 10 seconds diff --git a/examples/advanced/reconnection_demo.py b/examples/advanced/reconnection_demo.py index 0393317..c40b4f0 100644 --- a/examples/advanced/reconnection_demo.py +++ b/examples/advanced/reconnection_demo.py @@ -67,14 +67,14 @@ async def main(): ) # Register event handlers - def on_interrupted(error): - print(f"\n[WARNING] Connection interrupted: {error}") + def on_interrupted(event): + print(f"\n[WARNING] Connection interrupted: {event.error}") print(" Automatic reconnection will begin...") - def on_resumed(return_code, session_present): + def on_resumed(event): print("\n[SUCCESS] Connection resumed!") - print(f" Return code: {return_code}") - print(f" Session present: {session_present}") + print(f" Return code: {event.return_code}") + print(f" Session present: {event.session_present}") mqtt_client.on("connection_interrupted", on_interrupted) mqtt_client.on("connection_resumed", on_resumed) @@ -97,7 +97,7 @@ def on_status(status): print(f" Reconnecting: attempt {mqtt_client.reconnect_attempts}...") await mqtt_client.subscribe_device_status(device, on_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # Monitor connection status print("\n" + "=" * 70) @@ -126,7 +126,7 @@ def on_status(status): # Request status update if connected if mqtt_client.is_connected: - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print("\n" + "=" * 70) print(f"Monitoring complete. Received {status_count} status updates.") diff --git a/examples/advanced/reservation_schedule.py b/examples/advanced/reservation_schedule.py index f522d5e..57fa1cb 100644 --- a/examples/advanced/reservation_schedule.py +++ b/examples/advanced/reservation_schedule.py @@ -74,12 +74,12 @@ def on_reservation_update(topic: str, message: dict[str, Any]) -> None: await mqtt_client.subscribe(response_topic, on_reservation_update) print("Sending reservation program update...") - await mqtt_client.control.update_reservations( + await mqtt_client.update_reservations( device, [weekday_reservation], enabled=True ) print("Requesting current reservation program...") - await mqtt_client.control.request_reservations(device) + await mqtt_client.request_reservations(device) print("Waiting up to 15 seconds for reservation responses...") await asyncio.sleep(15) diff --git a/examples/advanced/token_restoration.py b/examples/advanced/token_restoration.py index 8c7ef2f..3c9871d 100644 --- a/examples/advanced/token_restoration.py +++ b/examples/advanced/token_restoration.py @@ -89,7 +89,7 @@ async def restore_tokens_example(): # Import after getting token_data to avoid circular import issues from nwp500.auth import AuthTokens - stored_tokens = AuthTokens.from_dict(token_data) + stored_tokens = AuthTokens.model_validate(token_data) logger.info(f"Stored tokens issued at: {stored_tokens.issued_at}") logger.info(f"Stored tokens expire at: {stored_tokens.expires_at}") diff --git a/examples/advanced/tou_openei.py b/examples/advanced/tou_openei.py index 4fa8530..5d12322 100755 --- a/examples/advanced/tou_openei.py +++ b/examples/advanced/tou_openei.py @@ -108,7 +108,7 @@ async def main() -> None: print("Enabling TOU mode…") mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.control.set_tou_enabled(device, enabled=True) + await mqtt.set_tou_enabled(device, enabled=True) await mqtt.disconnect() print("\nDone! TOU schedule configured and enabled.") diff --git a/examples/advanced/tou_schedule.py b/examples/advanced/tou_schedule.py index b1249dc..2d3579d 100644 --- a/examples/advanced/tou_schedule.py +++ b/examples/advanced/tou_schedule.py @@ -22,7 +22,7 @@ def capture_feature(feature) -> None: await mqtt_client.subscribe_device_feature(device, capture_feature) # Then request device info - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) # Wait for the response feature = await asyncio.wait_for(feature_future, timeout=15) @@ -112,7 +112,7 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None: await mqtt_client.subscribe(response_topic, on_tou_response) print("Uploading TOU schedule (enabling reservation)...") - await mqtt_client.control.configure_tou_schedule( + await mqtt_client.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=[off_peak, peak], @@ -120,17 +120,17 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None: ) print("Requesting current TOU settings for confirmation...") - await mqtt_client.control.request_tou_settings(device, controller_serial) + await mqtt_client.request_tou_settings(device, controller_serial) print("Waiting up to 15 seconds for TOU responses...") await asyncio.sleep(15) print("Toggling TOU off for quick test...") - await mqtt_client.control.set_tou_enabled(device, enabled=False) + await mqtt_client.set_tou_enabled(device, enabled=False) await asyncio.sleep(3) print("Re-enabling TOU...") - await mqtt_client.control.set_tou_enabled(device, enabled=True) + await mqtt_client.set_tou_enabled(device, enabled=True) await asyncio.sleep(3) await mqtt_client.disconnect() diff --git a/examples/advanced/water_reservation.py b/examples/advanced/water_reservation.py index ff03b26..e105682 100644 --- a/examples/advanced/water_reservation.py +++ b/examples/advanced/water_reservation.py @@ -54,7 +54,7 @@ def on_current_status(status): ) await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Enable water program reservation mode @@ -75,7 +75,7 @@ def on_water_program_configured(status): await mqtt_client.subscribe_device_status( device, on_water_program_configured ) - await mqtt_client.control.configure_reservation_water_program(device) + await mqtt_client.configure_reservation_water_program(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds diff --git a/examples/beginner/04_set_temperature.py b/examples/beginner/04_set_temperature.py index 993441a..1624ade 100644 --- a/examples/beginner/04_set_temperature.py +++ b/examples/beginner/04_set_temperature.py @@ -54,7 +54,7 @@ def on_current_status(status): logger.info(f"Current DHW temperature: {status.dhw_temperature}{unit}") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Set new target temperature to 140 (in user's preferred unit) @@ -82,7 +82,7 @@ def on_temp_change_response(status): await mqtt_client.subscribe_device_status(device, on_temp_change_response) # Send temperature change command using display temperature value - await mqtt_client.control.set_dhw_temperature(device, target_temperature) + await mqtt_client.set_dhw_temperature(device, target_temperature) # Wait for confirmation for i in range(15): # Wait up to 15 seconds diff --git a/examples/intermediate/advanced_auth_patterns.py b/examples/intermediate/advanced_auth_patterns.py index b55c21d..d5d6b4f 100644 --- a/examples/intermediate/advanced_auth_patterns.py +++ b/examples/intermediate/advanced_auth_patterns.py @@ -101,7 +101,7 @@ def on_status(status): await mqtt_client.subscribe_device_status(device, on_status) # Request initial status - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # Wait for a moment to receive updates await asyncio.sleep(3) diff --git a/examples/intermediate/command_queue.py b/examples/intermediate/command_queue.py index 0f7fb3c..badd3bc 100644 --- a/examples/intermediate/command_queue.py +++ b/examples/intermediate/command_queue.py @@ -80,11 +80,11 @@ async def command_queue_demo(): ) # Register event handlers - def on_interrupted(error): - print(f" [WARNING] Connection interrupted: {error}") + def on_interrupted(event): + print(f" [WARNING] Connection interrupted: {event.error}") print(f" [NOTE] Queued commands: {mqtt_client.queued_commands_count}") - def on_resumed(return_code, session_present): + def on_resumed(event): print(" [SUCCESS] Connection resumed!") print(f" [NOTE] Queued commands: {mqtt_client.queued_commands_count}") @@ -111,7 +111,7 @@ def on_message(topic, message): # Step 5: Test normal operation print("\n5. Testing normal operation (connected)...") print(" Sending status request...") - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print(" [SUCCESS] Command sent successfully") await asyncio.sleep(2) @@ -131,15 +131,15 @@ def on_message(topic, message): # These will be queued print(" Queuing status request...") - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print(f" Queue size: {mqtt_client.queued_commands_count}") print(" Queuing device info request...") - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) print(f" Queue size: {mqtt_client.queued_commands_count}") print(" Queuing temperature change...") - await mqtt_client.control.set_dhw_temperature(device, 130) + await mqtt_client.set_dhw_temperature(device, 130) print(f" Queue size: {mqtt_client.queued_commands_count}") print(f" [SUCCESS] Queued {mqtt_client.queued_commands_count} command(s)") @@ -164,7 +164,7 @@ def on_message(topic, message): # Try to exceed queue limit print(f" Sending {config.max_queued_commands + 5} commands...") for _i in range(config.max_queued_commands + 5): - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print( f" Queue size: {mqtt_client.queued_commands_count} (max: {config.max_queued_commands})" diff --git a/examples/intermediate/device_status_callback.py b/examples/intermediate/device_status_callback.py index 0c3b86b..3198267 100755 --- a/examples/intermediate/device_status_callback.py +++ b/examples/intermediate/device_status_callback.py @@ -209,10 +209,10 @@ def on_device_status(status: DeviceStatus): # Step 5: Request device status print("Step 5: Requesting device status...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print("[SUCCESS] Status request sent") print() diff --git a/examples/intermediate/error_handling.py b/examples/intermediate/error_handling.py index 4dfcd73..756da7a 100755 --- a/examples/intermediate/error_handling.py +++ b/examples/intermediate/error_handling.py @@ -108,7 +108,7 @@ async def example_mqtt_errors(): await mqtt.disconnect() try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except MqttNotConnectedError as e: print(f"[OK] Caught MqttNotConnectedError: {e}") print(" Can reconnect and retry the operation") @@ -157,7 +157,7 @@ async def example_validation_errors(): # Try to set invalid vacation days try: - await mqtt.control.set_dhw_mode(device, mode_id=5, vacation_days=50) + await mqtt.set_dhw_mode(device, mode_id=5, vacation_days=50) except RangeValidationError as e: print(f"[OK] Caught RangeValidationError: {e}") print(f" Field: {e.field}") diff --git a/examples/intermediate/event_driven_control.py b/examples/intermediate/event_driven_control.py index 280374c..92210a7 100644 --- a/examples/intermediate/event_driven_control.py +++ b/examples/intermediate/event_driven_control.py @@ -35,80 +35,77 @@ MqttClientEvents, CurrentOperationMode, ) -from nwp500.models import DeviceStatus # Example 1: Multiple listeners for the same event -def log_temperature(old_temp: float, new_temp: float): +def log_temperature(event): """Logger for temperature changes.""" - print(f"📊 [Logger] Temperature: {old_temp} → {new_temp}") + print(f"📊 [Logger] Temperature: {event.old_temperature} → {event.new_temperature}") -def alert_on_high_temp(old_temp: float, new_temp: float): +def alert_on_high_temp(event): """Alert handler for high temperatures.""" - if new_temp > 145: - print(f"[WARNING] [Alert] HIGH TEMPERATURE: {new_temp}!") + if event.new_temperature > 145: + print(f"[WARNING] [Alert] HIGH TEMPERATURE: {event.new_temperature}!") -async def save_temperature_to_db(old_temp: float, new_temp: float): +async def save_temperature_to_db(event): """Async database saver (simulated).""" # Simulate async database operation await asyncio.sleep(0.1) - print(f"💾 [Database] Saved temperature change: {new_temp}") + print(f"💾 [Database] Saved temperature change: {event.new_temperature}") # Example 2: Mode change handlers -def log_mode_change(old_mode: CurrentOperationMode, new_mode: CurrentOperationMode): +def log_mode_change(event): """Log operation mode changes.""" - print(f"🔄 [Mode] Changed from {old_mode.name} to {new_mode.name}") + print(f"🔄 [Mode] Changed from {event.old_mode.name} to {event.new_mode.name}") -def optimize_on_mode_change( - old_mode: CurrentOperationMode, new_mode: CurrentOperationMode -): +def optimize_on_mode_change(event): """Optimization handler.""" - if new_mode == CurrentOperationMode.HEAT_PUMP_MODE: + if event.new_mode == CurrentOperationMode.HEAT_PUMP_MODE: print("♻️ [Optimizer] Heat pump mode - maximum efficiency!") - elif new_mode == CurrentOperationMode.HYBRID_EFFICIENCY_MODE: + elif event.new_mode == CurrentOperationMode.HYBRID_EFFICIENCY_MODE: print("⚡ [Optimizer] Energy Saver mode - balanced performance!") - elif new_mode == CurrentOperationMode.HYBRID_BOOST_MODE: + elif event.new_mode == CurrentOperationMode.HYBRID_BOOST_MODE: print("⚡ [Optimizer] High Demand mode - fast recovery!") # Example 3: Power state handlers -def on_heating_started(status: DeviceStatus): +def on_heating_started(event): """Handler for when heating starts.""" - print(f"🔥 [Power] Heating STARTED - Power: {status.current_inst_power}W") + print(f"🔥 [Power] Heating STARTED - Power: {event.status.current_inst_power}W") -def on_heating_stopped(status: DeviceStatus): +def on_heating_stopped(event): """Handler for when heating stops.""" print("💤 [Power] Heating STOPPED") # Example 4: Error handlers -def on_error_detected(error_code: str, status: DeviceStatus): +def on_error_detected(event): """Handler for error detection.""" - print(f"[ERROR] [Error] ERROR DETECTED: {error_code}") - unit = status.get_field_unit("dhw_temperature") - print(f" Temperature: {status.dhw_temperature}{unit}") - print(f" Mode: {status.operation_mode}") + print(f"[ERROR] [Error] ERROR DETECTED: {event.error_code}") + unit = event.status.get_field_unit("dhw_temperature") + print(f" Temperature: {event.status.dhw_temperature}{unit}") + print(f" Mode: {event.status.operation_mode}") -def on_error_cleared(error_code: str): +def on_error_cleared(event): """Handler for error cleared.""" - print(f"[SUCCESS] [Error] ERROR CLEARED: {error_code}") + print(f"[SUCCESS] [Error] ERROR CLEARED: {event.error_code}") # Example 5: Connection state handlers -def on_connection_interrupted(error): +def on_connection_interrupted(event): """Handler for connection interruption.""" - print(f"🔌 [Connection] DISCONNECTED: {error}") + print(f"🔌 [Connection] DISCONNECTED: {event.error}") -def on_connection_resumed(return_code, session_present): +def on_connection_resumed(event): """Handler for connection resumption.""" - print(f"🔌 [Connection] RECONNECTED (code: {return_code})") + print(f"🔌 [Connection] RECONNECTED (code: {event.return_code})") async def main(): @@ -191,8 +188,8 @@ async def main(): # One-time listener example mqtt_client.once( MqttClientEvents.STATUS_RECEIVED, - lambda s: print( - f" 🎉 First status received: {s.dhw_temperature}{s.get_field_unit('dhw_temperature')}" + lambda event: print( + f" 🎉 First status received: {event.status.dhw_temperature}{event.status.get_field_unit('dhw_temperature')}" ), ) print(" [SUCCESS] Registered one-time status handler") @@ -230,7 +227,7 @@ async def main(): # Step 5: Request initial status print("7. Requesting initial status...") - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print(" [SUCCESS] Request sent") print() diff --git a/examples/intermediate/improved_auth.py b/examples/intermediate/improved_auth.py index f59e5ad..3b479ed 100644 --- a/examples/intermediate/improved_auth.py +++ b/examples/intermediate/improved_auth.py @@ -51,7 +51,7 @@ def on_status(status): print(f" Power: {status.current_inst_power}W") await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Keep alive for a few seconds to receive status print("\nMonitoring for 10 seconds...") diff --git a/examples/intermediate/mqtt_realtime_monitoring.py b/examples/intermediate/mqtt_realtime_monitoring.py index 21abe0f..592caeb 100755 --- a/examples/intermediate/mqtt_realtime_monitoring.py +++ b/examples/intermediate/mqtt_realtime_monitoring.py @@ -183,17 +183,17 @@ def on_device_feature(feature: DeviceFeature): # Signal app connection print("📤 Signaling app connection...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) # Request device info print("📤 Requesting device information...") - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.sleep(2) # Request device status print("📤 Requesting device status...") - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(2) # Wait for messages diff --git a/examples/intermediate/periodic_requests.py b/examples/intermediate/periodic_requests.py index cc807de..1730f3e 100755 --- a/examples/intermediate/periodic_requests.py +++ b/examples/intermediate/periodic_requests.py @@ -120,7 +120,7 @@ async def monitor_with_dots(seconds: int, interval: int = 5): # Send initial request immediately to get first response print("Sending initial status request...") - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) print("Monitoring for 60 seconds...") print("(First automatic request in ~20 seconds)") @@ -140,7 +140,7 @@ async def monitor_with_dots(seconds: int, interval: int = 5): # Send initial request immediately print("Sending initial device info request...") - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) print("Monitoring for 60 seconds...") print("(First automatic request in ~20 seconds)") @@ -169,9 +169,9 @@ async def monitor_with_dots(seconds: int, interval: int = 5): # Send initial requests for both types print("\nSending initial requests for both types...") - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) await asyncio.sleep(1) # Small delay between requests - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) print("\nMonitoring for 2 minutes...") print("(Status requests: ~20s, ~40s, ~60s, ~80s, ~100s, ~120s)") diff --git a/examples/intermediate/set_mode.py b/examples/intermediate/set_mode.py index ffae437..6534be8 100644 --- a/examples/intermediate/set_mode.py +++ b/examples/intermediate/set_mode.py @@ -50,7 +50,7 @@ def on_current_status(status): logger.info(f"Current mode: {status.operation_mode.name}") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Change to Energy Saver mode @@ -71,7 +71,7 @@ def on_mode_change_response(status): await mqtt_client.subscribe_device_status(device, on_mode_change_response) # Send mode change command (3 = Energy Saver, per MQTT protocol) - await mqtt_client.control.set_dhw_mode(device, 3) + await mqtt_client.set_dhw_mode(device, 3) # Wait for confirmation for i in range(15): # Wait up to 15 seconds diff --git a/examples/intermediate/vacation_mode.py b/examples/intermediate/vacation_mode.py index 2bae187..a0a93be 100644 --- a/examples/intermediate/vacation_mode.py +++ b/examples/intermediate/vacation_mode.py @@ -57,7 +57,7 @@ def on_current_status(status): ) await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Set vacation mode @@ -73,7 +73,7 @@ def on_vacation_set(status): vacation_set = True await mqtt_client.subscribe_device_status(device, on_vacation_set) - await mqtt_client.control.set_vacation_days(device, vacation_days) + await mqtt_client.set_vacation_days(device, vacation_days) # Wait for confirmation for i in range(10): # Wait up to 10 seconds diff --git a/examples/testing/periodic_device_info.py b/examples/testing/periodic_device_info.py index 1ace6b0..495cedd 100755 --- a/examples/testing/periodic_device_info.py +++ b/examples/testing/periodic_device_info.py @@ -97,7 +97,7 @@ def on_device_feature(feature: DeviceFeature): # Send initial request to get immediate response print(" Sending initial request...") - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) # Wait for a few updates with the default period print(" Waiting 15 seconds for response...") @@ -112,7 +112,7 @@ def on_device_feature(feature: DeviceFeature): # Send initial request for immediate feedback print(" Sending initial request...") - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) # Monitor for 2 minutes print("\n Monitoring for 2 minutes...") diff --git a/examples/testing/test_mqtt_messaging.py b/examples/testing/test_mqtt_messaging.py index 7bfa4d3..ec6da33 100644 --- a/examples/testing/test_mqtt_messaging.py +++ b/examples/testing/test_mqtt_messaging.py @@ -182,7 +182,7 @@ def mask_any(_): f"📤 [{datetime.now().strftime('%H:%M:%S')}] Signaling app connection..." ) try: - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) print(" [SUCCESS] Sent") except Exception as e: print(f" [ERROR] Error: {e}") @@ -193,7 +193,7 @@ def mask_any(_): f"📤 [{datetime.now().strftime('%H:%M:%S')}] Requesting device info..." ) try: - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) print(" [SUCCESS] Sent") except Exception as e: print(f" [ERROR] Error: {e}") @@ -204,7 +204,7 @@ def mask_any(_): f"📤 [{datetime.now().strftime('%H:%M:%S')}] Requesting device status..." ) try: - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print(" [SUCCESS] Sent") except Exception as e: print(f" [ERROR] Error: {e}") diff --git a/pyproject.toml b/pyproject.toml index 3f68238..5f056ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ version_scheme = "no-guess-dev" [tool.ruff] # Ruff configuration for code formatting and linting line-length = 80 -target-version = "py313" +target-version = "py314" # Exclude directories exclude = [ @@ -102,7 +102,7 @@ line-ending = "auto" strict = true # Python version target -python_version = "3.13" +python_version = "3.14" # Module discovery files = ["src/nwp500", "tests"] @@ -160,7 +160,7 @@ ignore_missing_imports = true [tool.pyright] # Pyright configuration for strict type checking -pythonVersion = "3.13" +pythonVersion = "3.14" typeCheckingMode = "strict" include = ["src/nwp500", "tests"] exclude = [".venv", "build", "dist", ".tox"] diff --git a/setup.cfg b/setup.cfg index b84aef3..8e8fe14 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,7 +32,7 @@ classifiers = Development Status :: 4 - Beta Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 Programming Language :: Python :: 3 :: Only @@ -44,7 +44,7 @@ package_dir = =src # Require a min/specific Python version (comma-separated conditions) -python_requires = >=3.13 +python_requires = >=3.14 # Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. # Version specifiers like >=2.2,<3.0 avoid problems due to API changes in @@ -52,7 +52,7 @@ python_requires = >=3.13 # For more information, check out https://semver.org/. install_requires = aiohttp>=3.13.5 - awsiotsdk>=1.28.2 + awsiotsdk>=1.29.0 pydantic>=2.0.0 diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 20aacc7..5c0d880 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -4,6 +4,8 @@ communication for NWP500 heat pump water heaters. """ +from __future__ import annotations + from importlib.metadata import ( PackageNotFoundError, version, @@ -111,10 +113,17 @@ MonthlyEnergyData, MqttCommand, MqttRequest, + OtaCommitPayload, + RecirculationSchedule, + RecirculationScheduleEntry, ReservationEntry, ReservationSchedule, TOUInfo, + TOUPeriod, + TOUReservationSchedule, TOUSchedule, + WeeklyReservationEntry, + WeeklyReservationSchedule, fahrenheit_to_half_celsius, preferred_to_half_celsius, reservation_param_to_preferred, @@ -168,8 +177,15 @@ "FirmwareInfo", "ReservationEntry", "ReservationSchedule", + "WeeklyReservationEntry", + "WeeklyReservationSchedule", + "RecirculationScheduleEntry", + "RecirculationSchedule", + "OtaCommitPayload", "TOUSchedule", "TOUInfo", + "TOUPeriod", + "TOUReservationSchedule", "MqttRequest", "MqttCommand", "EnergyUsageTotal", diff --git a/src/nwp500/_base.py b/src/nwp500/_base.py new file mode 100644 index 0000000..12d8878 --- /dev/null +++ b/src/nwp500/_base.py @@ -0,0 +1,78 @@ +"""Shared Pydantic base model for all Navien data models. + +Centralises the common configuration (camelCase aliases, extra="ignore", +enum serialization) so that both the authentication models and the device +protocol models share a single base class. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class NavienBaseModel(BaseModel): + """Base model for all Navien models. + + Provides: + - camelCase alias generation (``to_camel``) for JSON compatibility + - ``populate_by_name=True`` so Python snake_case names work too + - ``extra="ignore"`` to tolerate unknown protocol fields + - ``use_enum_values=False`` to keep enum objects during validation + - Custom ``model_dump`` that converts enums to their ``.name`` string + """ + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + def model_dump(self, **kwargs: Any) -> dict[str, Any]: + """Dump model to dict with enums serialised as their name strings.""" + if "mode" not in kwargs: + kwargs["mode"] = "python" + result = super().model_dump(**kwargs) + converted: dict[str, Any] = self._convert_enums_to_names(result) + return converted + + @staticmethod + def _convert_enums_to_names( + data: Any, visited: set[int] | None = None + ) -> Any: + """Recursively convert Enum values to their ``.name`` strings. + + Args: + data: The data structure to convert. + visited: Set of object IDs already visited (cycle guard). + """ + from enum import Enum + + if isinstance(data, Enum): + return data.name + if not isinstance(data, (dict, list, tuple)): + return data + + visited = visited or set() + if id(data) in visited: + return data + visited.add(id(data)) + + if isinstance(data, dict): + res: dict[Any, Any] | list[Any] | tuple[Any, ...] = { + k: NavienBaseModel._convert_enums_to_names(v, visited) + for k, v in data.items() + } + else: + res = type(data)( + [ + NavienBaseModel._convert_enums_to_names(i, visited) + for i in data + ] + ) + + visited.discard(id(data)) + return res diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index b88df7e..c9645c5 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging -from typing import Any, Literal, Self, cast +from typing import Any, Self, cast import aiohttp @@ -15,7 +15,7 @@ from .config import API_BASE_URL from .exceptions import APIError, AuthenticationError, TokenRefreshError from .models import ConvertedTOUPlan, Device, FirmwareInfo, TOUInfo -from .unit_system import set_unit_system +from .unit_system import UnitSystemType __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" @@ -50,7 +50,7 @@ def __init__( auth_client: NavienAuthClient, base_url: str = API_BASE_URL, session: aiohttp.ClientSession | None = None, - unit_system: Literal["metric", "us_customary"] | None = None, + unit_system: UnitSystemType = None, ): """ Initialize Navien API client. @@ -78,6 +78,7 @@ def __init__( self.base_url = base_url.rstrip("/") self._auth_client = auth_client + self._unit_system = unit_system self._session = session or auth_client.session if self._session is None: @@ -88,10 +89,6 @@ def __init__( self._owned_session = False self._owned_auth = False - # Set unit system preference if provided - if unit_system is not None: - set_unit_system(unit_system) - async def __aenter__(self) -> Self: """Enter async context manager.""" return self diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index 3a0bff8..b182c86 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -16,27 +16,25 @@ import json import logging from datetime import UTC, datetime, timedelta -from typing import Any, Literal, Self, cast +from typing import Any, Self, cast import aiohttp from pydantic import ( - BaseModel, - ConfigDict, Field, PrivateAttr, field_validator, model_validator, ) -from pydantic.alias_generators import to_camel from . import __version__ +from ._base import NavienBaseModel from .config import API_BASE_URL, REFRESH_ENDPOINT, SIGN_IN_ENDPOINT from .exceptions import ( AuthenticationError, InvalidCredentialsError, TokenRefreshError, ) -from .unit_system import set_unit_system +from .unit_system import UnitSystemType __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" @@ -45,14 +43,6 @@ _logger = logging.getLogger(__name__) -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.""" @@ -62,11 +52,6 @@ class UserInfo(NavienBaseModel): user_status: str = "" user_seq: int = 0 - @classmethod - def from_dict(cls, data: dict[str, Any]) -> UserInfo: - """Create UserInfo from API response dictionary (compatibility).""" - return cls.model_validate(data) - @property def full_name(self) -> str: """Return the user's full name.""" @@ -151,32 +136,6 @@ def model_post_init(self, __context: Any) -> None: else: self._aws_expires_at = None - @classmethod - def from_dict(cls, data: dict[str, Any]) -> AuthTokens: - """Create AuthTokens from API response dictionary or stored data. - - Args: - data: Dictionary containing token data. Can be from API response - (using camelCase keys) or from stored data (using snake_case - keys from to_dict()). - - Returns: - AuthTokens instance - """ - # 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: - 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 self.model_dump(mode="json") - @property def expires_at(self) -> datetime: """Get the cached expiration timestamp.""" @@ -222,6 +181,21 @@ def bearer_token(self) -> str: """Get the formatted Bearer token for Authorization header.""" return f"Bearer {self.access_token}" + def to_dict(self) -> dict[str, Any]: + """Convert tokens to a dictionary for serialization. + + This includes the calculated issued_at timestamp, which is needed + to maintain the correct expiration time when restoring tokens. + """ + data = self.model_dump() + # Ensure issued_at is serialized in a format that model_validate can + # parse + if isinstance(data.get("issued_at"), datetime): + data["issued_at"] = ( + data["issued_at"].strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z" + ) + return data + class AuthenticationResponse(NavienBaseModel): """Complete authentication response including user info and tokens.""" @@ -232,23 +206,21 @@ class AuthenticationResponse(NavienBaseModel): code: int = 200 message: str = Field(default="SUCCESS", alias="msg") + @model_validator(mode="before") @classmethod - def from_dict(cls, response_data: dict[str, Any]) -> AuthenticationResponse: - """Create AuthenticationResponse from API response.""" - # Map nested API response to flat model structure - # API response: { "code": ..., "msg": ..., "data": { ... } } - data = response_data.get("data", {}) - - # 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.model_validate(model_data) + def wrap_api_response(cls, data: Any) -> Any: + """Handle nested 'data' wrapper in API responses.""" + if isinstance(data, dict) and "data" in data: + # Lift fields from 'data' into the top level for validation + # while preserving top-level code/msg + response_data = data.get("data", {}) + if isinstance(response_data, dict): + merged = {**data, **response_data} + # Handle 'token' vs 'tokens' inconsistency in API + if "token" in response_data and "tokens" not in response_data: + merged["tokens"] = response_data["token"] + return merged + return data __all__ = [ @@ -300,7 +272,7 @@ class NavienAuthClient: ... await mqtt_client.connect() Restore session from stored tokens: - >>> stored_tokens = AuthTokens.from_dict(saved_data) + >>> stored_tokens = AuthTokens.model_validate(saved_data) >>> async with NavienAuthClient( ... user_id="user@example.com", ... password="password", @@ -318,7 +290,7 @@ def __init__( session: aiohttp.ClientSession | None = None, timeout: int = 30, stored_tokens: AuthTokens | None = None, - unit_system: Literal["metric", "us_customary"] | None = None, + unit_system: UnitSystemType = None, ): """ Initialize the authentication client. @@ -350,10 +322,7 @@ def __init__( # Store credentials for automatic authentication self._user_id = user_id self._password = password - - # Set unit system preference if provided - if unit_system is not None: - set_unit_system(unit_system) + self._unit_system: UnitSystemType = unit_system # Current authentication state self._auth_response: AuthenticationResponse | None = None @@ -473,7 +442,9 @@ async def sign_in( ) # Parse successful response - auth_response = AuthenticationResponse.from_dict(response_data) + auth_response = AuthenticationResponse.model_validate( + response_data + ) self._auth_response = auth_response self._user_email = user_id # Store the email for later use @@ -548,7 +519,7 @@ async def refresh_token( # Parse new tokens data = response_data.get("data", {}) - new_tokens = AuthTokens.from_dict(data) + new_tokens = AuthTokens.model_validate(data) # Preserve AWS credentials from old tokens if not in refresh # response @@ -825,4 +796,4 @@ async def refresh_access_token(refresh_token: str) -> AuthTokens: ) data = response_data.get("data", {}) - return AuthTokens.from_dict(data) + return AuthTokens.model_validate(data) diff --git a/src/nwp500/cli/__init__.py b/src/nwp500/cli/__init__.py index ca5d623..62ad37c 100644 --- a/src/nwp500/cli/__init__.py +++ b/src/nwp500/cli/__init__.py @@ -1,5 +1,7 @@ """CLI package for nwp500-python.""" +from __future__ import annotations + from .__main__ import run from .handlers import ( handle_device_info_request, diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 76a06ff..3d20691 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -1,5 +1,7 @@ """Navien Water Heater Control CLI - Main Entry Point.""" +from __future__ import annotations + import asyncio import functools import logging @@ -54,7 +56,7 @@ def _on_status(status: DeviceStatus) -> None: future.set_result(status) await mqtt.subscribe_device_status(device, _on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) try: status = await asyncio.wait_for(future, timeout=5.0) if status.temperature_type == TemperatureType.CELSIUS: diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py index 5e3f5b8..54febc2 100644 --- a/src/nwp500/cli/commands.py +++ b/src/nwp500/cli/commands.py @@ -1,5 +1,7 @@ """Command registry for NWP500 CLI.""" +from __future__ import annotations + from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index c10c49c..c31f65f 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -1,5 +1,7 @@ """Command handlers for CLI operations.""" +from __future__ import annotations + import asyncio import json import logging @@ -22,13 +24,14 @@ ValidationError, ) from nwp500.models import ReservationSchedule -from nwp500.mqtt.utils import redact_serial +from nwp500.mqtt.utils import get_response_data, redact_serial from nwp500.reservations import ( add_reservation, delete_reservation, fetch_reservations, update_reservation, ) +from nwp500.topic_builder import MqttTopicBuilder from nwp500.unit_system import get_unit_system from .output_formatters import ( @@ -115,7 +118,7 @@ async def get_controller_serial_number( feature: Any = await _wait_for_response( mqtt.subscribe_device_feature, device, - lambda: mqtt.control.request_device_info(device), + lambda: mqtt.request_device_info(device), timeout=timeout, action_name="controller serial", ) @@ -169,9 +172,7 @@ async def _handle_info_request( def raw_cb(topic: str, message: dict[str, Any]) -> None: if not future.done(): - res = message.get("response", {}).get( - data_key - ) or message.get(data_key) + res = get_response_data(message, data_key) if res: print_json(res) future.set_result(None) @@ -191,7 +192,7 @@ async def handle_status_request( mqtt, device, mqtt.subscribe_device_status, - mqtt.control.request_device_status, + mqtt.request_device_status, "status", "device status", raw, @@ -207,7 +208,7 @@ async def handle_device_info_request( mqtt, device, mqtt.subscribe_device_feature, - mqtt.control.request_device_info, + mqtt.request_device_info, "feature", "device information", raw, @@ -237,7 +238,7 @@ async def handle_set_mode_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_dhw_mode(device, mode_id), + lambda: mqtt.set_dhw_mode(device, mode_id), "setting mode", f"Mode changed to {mode_name}", ) @@ -251,7 +252,7 @@ async def handle_set_dhw_temp_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_dhw_temperature(device, temperature), + lambda: mqtt.set_dhw_temperature(device, temperature), "setting temperature", f"Temperature set to {temperature}{unit_suffix}", ) @@ -265,7 +266,7 @@ async def handle_power_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_power(device, power_on), + lambda: mqtt.set_power(device, power_on), f"turning {state}", f"Device turned {state}", ) @@ -322,12 +323,12 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: print_json(message) future.set_result(None) - device_type = device.device_info.device_type - response_topic = f"cmd/{device_type}/{mqtt.client_id}/res/rsv/rd" - await mqtt.subscribe(response_topic, raw_callback) - await mqtt.control.update_reservations( - device, reservations, enabled=enabled + device_type = str(device.device_info.device_type) + response_topic = MqttTopicBuilder.response_topic( + device_type, mqtt.client_id, "rsv/rd" ) + await mqtt.subscribe(response_topic, raw_callback) + await mqtt.update_reservations(device, reservations, enabled=enabled) try: await asyncio.wait_for(future, timeout=10) except TimeoutError: @@ -422,7 +423,7 @@ async def handle_enable_anti_legionella_request( ) -> None: """Enable Anti-Legionella disinfection cycle.""" try: - await mqtt.control.enable_anti_legionella(device, period_days) + await mqtt.enable_anti_legionella(device, period_days) print(f"✓ Anti-Legionella enabled (cycle every {period_days} day(s))") except (RangeValidationError, ValidationError) as e: _logger.error(f"Failed to enable Anti-Legionella: {e}") @@ -446,14 +447,14 @@ def _on_status(status: DeviceStatus) -> None: try: await mqtt.subscribe_device_status(device, _on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) status = await asyncio.wait_for(future, timeout=10) # Get current enabled state use = getattr(status, "anti_legionella_use", None) if use: - await mqtt.control.enable_anti_legionella(device, period_days) + await mqtt.enable_anti_legionella(device, period_days) print(f"Anti-Legionella period set to {period_days} day(s)") else: print( @@ -475,7 +476,7 @@ async def handle_disable_anti_legionella_request( ) -> None: """Disable Anti-Legionella disinfection cycle.""" try: - await mqtt.control.disable_anti_legionella(device) + await mqtt.disable_anti_legionella(device) print("✓ Anti-Legionella disabled") except DeviceError as e: _logger.error(f"Device error: {e}") @@ -495,7 +496,7 @@ def _on_status(status: DeviceStatus) -> None: future.set_result(status) await mqtt.subscribe_device_status(device, _on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) try: status = await asyncio.wait_for(future, timeout=10) period = getattr(status, "anti_legionella_period", None) @@ -628,7 +629,7 @@ async def handle_set_tou_enabled_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_tou_enabled(device, enabled), + lambda: mqtt.set_tou_enabled(device, enabled), f"{'enabling' if enabled else 'disabling'} TOU", f"TOU {'enabled' if enabled else 'disabled'}", ) @@ -853,7 +854,7 @@ async def handle_tou_apply_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_tou_enabled(device, True), + lambda: mqtt.set_tou_enabled(device, True), "enabling TOU", "TOU enabled", ) @@ -877,7 +878,7 @@ async def handle_get_energy_request( res: Any = await _wait_for_response( mqtt.subscribe_energy_usage, device, - lambda: mqtt.control.request_energy_usage(device, year, months), + lambda: mqtt.request_energy_usage(device, year, months), action_name="energy usage", timeout=15, ) @@ -901,7 +902,7 @@ async def handle_reset_air_filter_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.reset_air_filter(device), + lambda: mqtt.reset_air_filter(device), "resetting air filter", "Air filter timer reset", ) @@ -914,7 +915,7 @@ async def handle_set_vacation_days_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_vacation_days(device, days), + lambda: mqtt.set_vacation_days(device, days), "setting vacation days", f"Vacation days set to {days}", ) @@ -929,7 +930,7 @@ async def handle_set_recirculation_mode_request( status = await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_recirculation_mode(device, mode), + lambda: mqtt.set_recirculation_mode(device, mode), "setting recirculation mode", f"Recirculation mode set to {mode_name}", ) @@ -949,7 +950,7 @@ async def handle_trigger_recirculation_hot_button_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.trigger_recirculation_hot_button(device), + lambda: mqtt.trigger_recirculation_hot_button(device), "triggering hot button", "Hot button triggered", ) @@ -962,7 +963,7 @@ async def handle_enable_demand_response_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.enable_demand_response(device), + lambda: mqtt.enable_demand_response(device), "enabling DR", "Demand response enabled", ) @@ -975,7 +976,7 @@ async def handle_disable_demand_response_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.disable_demand_response(device), + lambda: mqtt.disable_demand_response(device), "disabling DR", "Demand response disabled", ) @@ -988,7 +989,7 @@ async def handle_configure_reservation_water_program_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.configure_reservation_water_program(device), + lambda: mqtt.configure_reservation_water_program(device), "configuring water program", "Water program configured", ) diff --git a/src/nwp500/cli/monitoring.py b/src/nwp500/cli/monitoring.py index 371a88f..51c9086 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -1,5 +1,7 @@ """Monitoring and periodic status polling.""" +from __future__ import annotations + import asyncio import logging @@ -41,9 +43,7 @@ def on_status_update(status: DeviceStatus) -> None: await mqtt.subscribe_device_status(device, on_status_update) await mqtt.start_periodic_requests(device, period_seconds=30) - await mqtt.control.request_device_status( - device - ) # Get an initial status right away + await mqtt.request_device_status(device) # Get an initial status right away # Keep the script running indefinitely await asyncio.Event().wait() diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index ee28df0..70c65c4 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -1,5 +1,7 @@ """Output formatting utilities for CLI (CSV, JSON).""" +from __future__ import annotations + import csv import json import logging diff --git a/src/nwp500/cli/rich_output.py b/src/nwp500/cli/rich_output.py index 426b420..c9099f3 100644 --- a/src/nwp500/cli/rich_output.py +++ b/src/nwp500/cli/rich_output.py @@ -1,5 +1,7 @@ """Rich-enhanced output formatting with graceful fallback.""" +from __future__ import annotations + import json import logging import os diff --git a/src/nwp500/cli/token_storage.py b/src/nwp500/cli/token_storage.py index 904e704..4b5b763 100644 --- a/src/nwp500/cli/token_storage.py +++ b/src/nwp500/cli/token_storage.py @@ -1,5 +1,7 @@ """Token storage and management for CLI authentication.""" +from __future__ import annotations + import json import logging from pathlib import Path @@ -47,8 +49,8 @@ def load_tokens() -> tuple[AuthTokens | None, str | None]: _logger.error("No email found in token file") return None, None - # Use the built-in from_dict() method for deserialization - tokens = AuthTokens.from_dict(data) + # Use the built-in model_validate() method for deserialization + tokens = AuthTokens.model_validate(data) _logger.info(f"Tokens loaded from {TOKEN_FILE} for user {email}") return tokens, email except (OSError, json.JSONDecodeError, KeyError, ValueError) as e: diff --git a/src/nwp500/command_decorators.py b/src/nwp500/command_decorators.py index 4395212..56ee072 100644 --- a/src/nwp500/command_decorators.py +++ b/src/nwp500/command_decorators.py @@ -4,6 +4,8 @@ before command execution, preventing unsupported commands from being sent. """ +from __future__ import annotations + import functools import inspect import logging diff --git a/src/nwp500/config.py b/src/nwp500/config.py index d9c092f..fa22ad7 100644 --- a/src/nwp500/config.py +++ b/src/nwp500/config.py @@ -1,7 +1,10 @@ """Configuration for the Navien API client.""" +from __future__ import annotations + API_BASE_URL = "https://nlus.naviensmartcontrol.com/api/v2.1" SIGN_IN_ENDPOINT = "/user/sign-in" REFRESH_ENDPOINT = "/auth/refresh" AWS_IOT_ENDPOINT = "a1t30mldyslmuq-ats.iot.us-east-1.amazonaws.com" AWS_REGION = "us-east-1" +MQTT_PROTOCOL_VERSION = 2 diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index e0e2c0d..d1778b2 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -9,19 +9,9 @@ from __future__ import annotations -import contextlib -import logging from collections.abc import Callable from typing import Any -from pydantic import ValidationInfo, ValidatorFunctionWrapHandler - -from .enums import TemperatureType, TempFormulaType -from .temperature import DeciCelsius, DeciCelsiusDelta, HalfCelsius, RawCelsius -from .unit_system import get_unit_system - -_logger = logging.getLogger(__name__) - __all__ = [ "device_bool_to_python", "device_bool_from_python", @@ -30,13 +20,6 @@ "mul_10", "enum_validator", "str_enum_validator", - "half_celsius_to_preferred", - "deci_celsius_to_preferred", - "raw_celsius_to_preferred", - "flow_rate_to_preferred", - "volume_to_preferred", - "div_10_celsius_to_preferred", - "div_10_celsius_delta_to_preferred", ] @@ -206,338 +189,3 @@ def validate(value: Any) -> Any: return enum_class(str(value)) return validate - - -def _get_temperature_preference(info: ValidationInfo) -> bool: - """Determine if Celsius is preferred based on unit system context. - - Checks for an explicit unit system override from context first, then falls - back to 'temperature_type' or 'temperatureType' in the validation data. - - Args: - info: Pydantic ValidationInfo context. - - Returns: - True if Celsius is preferred, False otherwise (defaults to Fahrenheit). - """ - # Check if unit system override is set in context - unit_system = get_unit_system() - if unit_system is not None: - is_celsius = unit_system == "metric" - unit_str = "Celsius" if is_celsius else "Fahrenheit" - _logger.debug( - f"Using explicit unit system override from context: {unit_system}, " - f"using {unit_str}" - ) - return is_celsius - - # Fall back to device's temperature_type setting - if not info.data: - _logger.debug("No validation data available, defaulting to Fahrenheit") - return False - - temp_type = info.data.get("temperature_type") - - if temp_type is None: - # Try looking for the alias if model is not populating by name - temp_type = info.data.get("temperatureType") - - if temp_type is None: - _logger.debug( - "temperature_type not found in validation data, " - "defaulting to Fahrenheit" - ) - return False - - # Handle both raw int values and Enum instances - match temp_type: - case TemperatureType.CELSIUS: - _logger.debug( - f"Detected temperature_type from Enum: {temp_type.name}, " - "using Celsius" - ) - return True - case TemperatureType.FAHRENHEIT: - _logger.debug( - f"Detected temperature_type from Enum: {temp_type.name}, " - "using Fahrenheit" - ) - return False - case int(): - try: - is_celsius = int(temp_type) == TemperatureType.CELSIUS.value - unit_str = "Celsius" if is_celsius else "Fahrenheit" - _logger.debug( - f"Detected temperature_type from int: {temp_type}, " - f"using {unit_str}" - ) - return is_celsius - except (ValueError, TypeError) as e: - msg = f"Could not parse temperature_type: {e}" - _logger.warning(f"{msg}, defaulting to Fahrenheit") - return False - case _: - _logger.warning( - "Could not parse temperature_type, defaulting to Fahrenheit" - ) - return False - - -def half_celsius_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert half-degrees Celsius to preferred unit (C or F). - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit). - - Args: - value: Raw device value in half-degrees Celsius format. - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Temperature in preferred unit. - """ - is_celsius = _get_temperature_preference(info) - if isinstance(value, (int, float)): - return HalfCelsius(value).to_preferred(is_celsius) - return float(value) - - -def deci_celsius_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert decicelsius to preferred unit (C or F). - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit). - - Args: - value: Raw device value in decicelsius format (0.1 °C per unit). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Temperature in preferred unit. - """ - is_celsius = _get_temperature_preference(info) - if isinstance(value, (int, float)): - return DeciCelsius(value).to_preferred(is_celsius) - return float(value) - - -def flow_rate_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert flow rate (LPM * 10) to preferred unit (LPM or GPM). - - Raw value from device is LPM * 10 (Metric native). - - If Metric (Celsius) mode: Return LPM (value / 10.0) - - If Imperial (Fahrenheit) mode: Convert to GPM (1 LPM ≈ 0.264172 GPM) - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit), which determines the flow rate unit. - - Args: - value: Raw device value (LPM * 10). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Flow rate in preferred unit (LPM or GPM). - """ - is_celsius = _get_temperature_preference(info) - lpm = div_10(value) - - if is_celsius: - return lpm - - # Convert LPM to GPM - return round(lpm * 0.264172, 2) - - -def volume_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert volume (Liters) to preferred unit (Liters or Gallons). - - Raw value from device is assumed to be in Liters (Metric native). - - If Metric (Celsius) mode: Return Liters - - If Imperial (Fahrenheit) mode: Convert to Gallons (1 L ≈ 0.264172 Gal) - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit), which determines the volume unit. - - Args: - value: Raw device value in Liters. - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Volume in preferred unit. - """ - is_celsius = _get_temperature_preference(info) - - # Handle incoming value - if isinstance(value, (int, float)): - liters = float(value) - else: - try: - liters = float(value) - except (ValueError, TypeError): - return 0.0 - - if is_celsius: - return liters - - # Convert Liters to Gallons - return round(liters * 0.264172, 2) - - -def raw_celsius_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert raw halves-of-Celsius to preferred unit (C or F). - - Raw device values are in halves of Celsius (0.5°C precision). - Used for outdoor/ambient temperature measurements. - - If Metric (Celsius) mode: Return Celsius (value / 2.0) - - If Imperial (Fahrenheit) mode: Convert to Fahrenheit using - formula-specific rounding based on temp_formula_type. - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit) and the temperature formula type. - - Args: - value: Raw device value (halves of Celsius). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference and formula type. - - Returns: - Temperature in preferred unit (Celsius or Fahrenheit). - """ - is_celsius = _get_temperature_preference(info) - - if isinstance(value, (int, float)): - raw_temp = RawCelsius(value) - else: - try: - raw_temp = RawCelsius(float(value)) - except (ValueError, TypeError): - return 0.0 - - if is_celsius: - return raw_temp.to_celsius() - - # For Fahrenheit, check if temp_formula_type is available - formula_type = TempFormulaType.STANDARD # Default to standard rounding - if info.data: - temp_formula = info.data.get("temp_formula_type") - if temp_formula is not None: - with contextlib.suppress(ValueError, TypeError): - # Convert to TempFormulaType enum - if isinstance(temp_formula, TempFormulaType): - formula_type = temp_formula - else: - formula_type = TempFormulaType(int(temp_formula)) - - return raw_temp.to_fahrenheit_with_formula(formula_type) - - -def div_10_celsius_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert decicelsius value (raw / 10) to preferred unit (C or F). - - Raw device values are in tenths of Celsius (0.1°C per unit). - - If Metric (Celsius) mode: Return Celsius (value / 10.0) - - If Imperial (Fahrenheit) mode: Convert to Fahrenheit - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit). - - Args: - value: Raw device value (tenths of Celsius). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Temperature in preferred unit (Celsius or Fahrenheit). - """ - is_celsius = _get_temperature_preference(info) - - if isinstance(value, (int, float)): - celsius = float(value) / 10.0 - else: - try: - celsius = float(value) / 10.0 - except (ValueError, TypeError): - return 0.0 - - if is_celsius: - return celsius - - # Convert Celsius to Fahrenheit - return round(celsius * 9 / 5 + 32, 1) - - -def div_10_celsius_delta_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert decicelsius delta value (raw / 10) to preferred unit (C or F). - - Raw device values are in tenths of Celsius (0.1°C per unit). - This represents a temperature DELTA (difference), not an absolute - temperature. - - Key difference from div_10_celsius_to_preferred: For deltas, we apply the - scale factor but NOT the +32 offset. - - - If Metric (Celsius) mode: Return Celsius delta (value / 10.0) - - If Imperial (Fahrenheit) mode: Convert to Fahrenheit delta (no +32) - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit). - - Args: - value: Raw device value (tenths of Celsius delta). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Temperature delta in preferred unit (Celsius or Fahrenheit). - """ - is_celsius = _get_temperature_preference(info) - - if isinstance(value, (int, float)): - return DeciCelsiusDelta(value).to_preferred(is_celsius) - return float(value) diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py index 06f48c8..d907ddf 100644 --- a/src/nwp500/device_capabilities.py +++ b/src/nwp500/device_capabilities.py @@ -6,6 +6,8 @@ individual checker functions. """ +from __future__ import annotations + from collections.abc import Callable from typing import TYPE_CHECKING @@ -48,7 +50,7 @@ class MqttDeviceCapabilityChecker: } @classmethod - def supports(cls, feature: str, device_features: "DeviceFeature") -> bool: + def supports(cls, feature: str, device_features: DeviceFeature) -> bool: """Check if device supports control of a specific feature. Args: @@ -71,7 +73,7 @@ def supports(cls, feature: str, device_features: "DeviceFeature") -> bool: @classmethod def assert_supported( - cls, feature: str, device_features: "DeviceFeature" + cls, feature: str, device_features: DeviceFeature ) -> None: """Assert that device supports control of a feature. @@ -103,7 +105,7 @@ def register_capability( @classmethod def get_available_controls( - cls, device_features: "DeviceFeature" + cls, device_features: DeviceFeature ) -> dict[str, bool]: """Get all controllable features available on a device. @@ -119,7 +121,7 @@ def get_available_controls( } -def _check_dhw_temperature_control(features: "DeviceFeature") -> bool: +def _check_dhw_temperature_control(features: DeviceFeature) -> bool: """Check if device supports DHW temperature control. Returns True if temperature control is enabled (not UNKNOWN or DISABLE). diff --git a/src/nwp500/device_info_cache.py b/src/nwp500/device_info_cache.py index 5228e5b..f131f2b 100644 --- a/src/nwp500/device_info_cache.py +++ b/src/nwp500/device_info_cache.py @@ -4,10 +4,12 @@ with automatic periodic updates to keep data synchronized with the device. """ +from __future__ import annotations + import asyncio import logging from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, ReadOnly, TypedDict if TYPE_CHECKING: from .models import DeviceFeature @@ -20,18 +22,18 @@ class CachedDeviceInfo(TypedDict): """Cached device information metadata.""" - mac: str - cached_at: str - expires_at: str | None - is_expired: bool + mac: ReadOnly[str] + cached_at: ReadOnly[str] + expires_at: ReadOnly[str | None] + is_expired: ReadOnly[bool] class CacheInfoResult(TypedDict): """Result of get_cache_info() call.""" - device_count: int - update_interval_minutes: float - devices: list[CachedDeviceInfo] + device_count: ReadOnly[int] + update_interval_minutes: ReadOnly[float] + devices: ReadOnly[list[CachedDeviceInfo]] class MqttDeviceInfoCache: @@ -57,7 +59,7 @@ def __init__(self, update_interval_minutes: int = 30) -> None: self._cache: dict[str, tuple[DeviceFeature, datetime]] = {} self._lock = asyncio.Lock() - async def get(self, device_mac: str) -> "DeviceFeature | None": + async def get(self, device_mac: str) -> DeviceFeature | None: """Get cached device features if available and not expired. Args: @@ -79,7 +81,7 @@ async def get(self, device_mac: str) -> "DeviceFeature | None": return features - async def set(self, device_mac: str, features: "DeviceFeature") -> None: + async def set(self, device_mac: str, features: DeviceFeature) -> None: """Cache device features with current timestamp. Args: @@ -128,7 +130,7 @@ def is_expired(self, timestamp: datetime) -> bool: age = datetime.now(UTC) - timestamp return age > self.update_interval - async def get_all_cached(self) -> dict[str, "DeviceFeature"]: + async def get_all_cached(self) -> dict[str, DeviceFeature]: """Get all currently cached device features. Returns: diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 063708c..5f783dd 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -6,6 +6,8 @@ These utilities are used by both the API client and MQTT client. """ +from __future__ import annotations + from collections.abc import Iterable from numbers import Real diff --git a/src/nwp500/enums.py b/src/nwp500/enums.py index ae72667..7899f07 100644 --- a/src/nwp500/enums.py +++ b/src/nwp500/enums.py @@ -7,6 +7,8 @@ See docs/protocol/quick_reference.rst for comprehensive protocol details. """ +from __future__ import annotations + from enum import IntEnum, StrEnum # ============================================================================ diff --git a/src/nwp500/events.py b/src/nwp500/events.py index fe2da2c..5101669 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -6,6 +6,8 @@ detection. """ +from __future__ import annotations + import asyncio import inspect import logging @@ -50,7 +52,7 @@ class EventEmitter: emitter.on('temperature_changed', update_ui) # Emit events - await emitter.emit('temperature_changed', old_temp, new_temp) + await emitter.emit('temperature_changed', temperature_event) # One-time listener emitter.once('device_ready', initialize) @@ -85,15 +87,18 @@ def on( from nwp500.unit_system import get_unit_system - def on_temp_change(old_temp: float, new_temp: float): + def on_temp_change(event): unit = "°C" if get_unit_system() == "metric" else "°F" - print(f"Temperature: {old_temp}{unit} → {new_temp}{unit}") + print( + f"Temperature: {event.old_temperature}{unit} → " + f"{event.new_temperature}{unit}" + ) emitter.on('temperature_changed', on_temp_change) # Async handler - async def save_to_db(temp: float): - await db.save(temp) + async def save_to_db(event): + await db.save(event.new_temperature) emitter.on('temperature_changed', save_to_db, priority=100) """ @@ -228,8 +233,8 @@ async def emit(self, event: str, *args: Any, **kwargs: Any) -> int: Example:: - # Emit with arguments - await emitter.emit('temperature_changed', 120, 130) + # Emit with an event object + await emitter.emit('temperature_changed', temperature_event) # Emit with keyword arguments await emitter.emit('status_updated', status=device_status) @@ -386,7 +391,9 @@ async def wait_for( await emitter.wait_for('device_ready', timeout=30) # Wait for specific condition - old_temp, new_temp = await emitter.wait_for('temperature_changed') + args, _ = await emitter.wait_for('temperature_changed') + temperature_event = args[0] + current_temp = temperature_event.new_temperature """ future: asyncio.Future[tuple[tuple[Any, ...], dict[str, Any]]] = ( asyncio.Future() diff --git a/src/nwp500/exceptions.py b/src/nwp500/exceptions.py index eacccf9..39e0ae6 100644 --- a/src/nwp500/exceptions.py +++ b/src/nwp500/exceptions.py @@ -36,14 +36,14 @@ # Old code (v4.x) try: - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) except RuntimeError as e: if "Not connected" in str(e): # handle connection error # New code (v5.0+) try: - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) except MqttNotConnectedError: # handle connection error except MqttError: @@ -65,6 +65,8 @@ # handle other validation errors """ +from __future__ import annotations + from typing import Any __author__ = "Emmanuel Levijarvi" @@ -273,7 +275,7 @@ class MqttNotConnectedError(MqttError): mqtt_client = NavienMqttClient(auth_client) # Must connect first await mqtt_client.connect() - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) """ pass diff --git a/src/nwp500/factory.py b/src/nwp500/factory.py index 378b2bd..4852626 100644 --- a/src/nwp500/factory.py +++ b/src/nwp500/factory.py @@ -18,6 +18,8 @@ ... devices = await api.list_devices() """ +from __future__ import annotations + import asyncio from .api_client import NavienAPIClient diff --git a/src/nwp500/models.py b/src/nwp500/models.py deleted file mode 100644 index 0dcb9e0..0000000 --- a/src/nwp500/models.py +++ /dev/null @@ -1,1453 +0,0 @@ -"""Data models for Navien NWP500 water heater communication. - -This module defines data classes for representing data structures -used in the Navien NWP500 water heater communication protocol. - -These models are based on the MQTT message formats and API responses. -""" - -from __future__ import annotations - -import logging -from typing import Annotated, Any, Self, cast - -from pydantic import ( - BaseModel, - BeforeValidator, - ConfigDict, - Field, - WrapValidator, - computed_field, - model_validator, -) -from pydantic.alias_generators import to_camel - -from .converters import ( - deci_celsius_to_preferred, - device_bool_to_python, - div_10, - div_10_celsius_delta_to_preferred, - div_10_celsius_to_preferred, - enum_validator, - flow_rate_to_preferred, - half_celsius_to_preferred, - mul_10, - raw_celsius_to_preferred, - tou_override_to_python, - volume_to_preferred, -) -from .enums import ( - DHW_OPERATION_SETTING_TEXT, - ConnectionStatus, - CurrentOperationMode, - DeviceType, - DHWControlTypeFlag, - DhwOperationSetting, - DREvent, - ErrorCode, - HeatSource, - RecirculationMode, - TemperatureType, - TempFormulaType, - UnitType, - VolumeCode, -) -from .field_factory import ( - signal_strength_field, - temperature_field, -) -from .temperature import ( - HalfCelsius, -) -from .unit_system import get_unit_system - -_logger = logging.getLogger(__name__) - - -# ============================================================================ -# Conversion Helpers & Validators -# ============================================================================ - -# Reusable Annotated types for conversions -DeviceBool = Annotated[bool, BeforeValidator(device_bool_to_python)] -CapabilityFlag = Annotated[bool, BeforeValidator(device_bool_to_python)] -Div10 = Annotated[float, BeforeValidator(div_10)] -TenWhToWh = Annotated[float, BeforeValidator(mul_10)] -HalfCelsiusToPreferred = Annotated[ - float, WrapValidator(half_celsius_to_preferred) -] -DeciCelsiusToPreferred = Annotated[ - float, WrapValidator(deci_celsius_to_preferred) -] -RawCelsiusToPreferred = Annotated[ - float, WrapValidator(raw_celsius_to_preferred) -] -Div10CelsiusToPreferred = Annotated[ - float, WrapValidator(div_10_celsius_to_preferred) -] -Div10CelsiusDeltaToPreferred = Annotated[ - float, WrapValidator(div_10_celsius_delta_to_preferred) -] -FlowRate = Annotated[float, WrapValidator(flow_rate_to_preferred)] -Volume = Annotated[float, WrapValidator(volume_to_preferred)] -TouStatus = Annotated[bool, BeforeValidator(bool)] -TouOverride = Annotated[bool, BeforeValidator(tou_override_to_python)] -VolumeCodeField = Annotated[ - VolumeCode, BeforeValidator(enum_validator(VolumeCode)) -] -ConnectionStatusField = Annotated[ - ConnectionStatus, BeforeValidator(enum_validator(ConnectionStatus)) -] - - -def fahrenheit_to_half_celsius(fahrenheit: float) -> int: - """Convert Fahrenheit to half-degrees Celsius (for device commands). - - Args: - fahrenheit: Temperature in Fahrenheit. - - Returns: - Raw device value in half-Celsius format. - - Example: - >>> fahrenheit_to_half_celsius(140.0) - 120 - """ - return int(HalfCelsius.from_fahrenheit(fahrenheit).raw_value) - - -def preferred_to_half_celsius(temperature: float) -> int: - """Convert temperature from preferred unit to half-degrees Celsius. - - Converts temperature from the user's preferred unit (Celsius or Fahrenheit, - based on global unit system context) to the half-Celsius format used by - the device for commands and reservations. - - Args: - temperature: Temperature in user's preferred unit - (Celsius or Fahrenheit). - - Returns: - Raw device value in half-Celsius format. - - Example: - >>> # With us_customary unit system - >>> preferred_to_half_celsius(140.0) # 140°F - 120 - >>> # With metric unit system - >>> preferred_to_half_celsius(60.0) # 60°C - 120 - """ - if get_unit_system() == "metric": - # User prefers Celsius, input is in Celsius - return int(HalfCelsius.from_celsius(temperature).raw_value) - else: - # User prefers Fahrenheit (or no preference), input is in Fahrenheit - return fahrenheit_to_half_celsius(temperature) - - -def reservation_param_to_preferred(param: int) -> float: - """Convert reservation param to user's preferred temperature unit. - - Device returns reservation temperatures as half-degrees Celsius (param). - This converts them to the user's preferred unit (Celsius or Fahrenheit) - based on the global unit system context. - - Args: - param: Raw device value in half-Celsius format. - - Returns: - Temperature in user's preferred unit (Celsius or Fahrenheit). - - Example: - >>> # With metric (Celsius) unit system - >>> reservation_param_to_preferred(120) - 60.0 - >>> # With us_customary (Fahrenheit) unit system - >>> reservation_param_to_preferred(120) - 140.0 - """ - half_celsius = HalfCelsius(param) - if get_unit_system() == "metric": - return round(half_celsius.to_celsius(), 1) - return round(half_celsius.to_fahrenheit(), 1) - - -class NavienBaseModel(BaseModel): - """Base model for all Navien models. - - Note: use_enum_values=False keeps enums as objects during validation. - Serialization to names happens in model_dump() method. - """ - - model_config = ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - extra="ignore", # Ignore unknown fields by default - use_enum_values=False, # Keep enums as objects during validation - ) - - def model_dump(self, **kwargs: Any) -> dict[str, Any]: - """Dump model to dict with enums as names by default.""" - # Default to 'name' mode for enums unless explicitly overridden - if "mode" not in kwargs: - kwargs["mode"] = "python" - result = super().model_dump(**kwargs) - # Convert enums to their names - converted: dict[str, Any] = self._convert_enums_to_names(result) - return converted - - @staticmethod - def _convert_enums_to_names( - data: Any, visited: set[int] | None = None - ) -> Any: - """Recursively convert Enum values to their names. - - Args: - data: The data structure to convert. - visited: Set of object IDs already visited to prevent infinite - recursion. None indicates uninitialized/first call. - """ - from enum import Enum - - if isinstance(data, Enum): - return data.name - if not isinstance(data, (dict, list, tuple)): - return data - - visited = visited or set() - if id(data) in visited: - return data - visited.add(id(data)) - - if isinstance(data, dict): - res: dict[Any, Any] | list[Any] | tuple[Any, ...] = { - k: NavienBaseModel._convert_enums_to_names(v, visited) - for k, v in data.items() - } - else: - # We know data is list or tuple here because of the earlier check - # `if not isinstance(data, (dict, list, tuple)): return data` - res = type(data)( - [ - NavienBaseModel._convert_enums_to_names(i, visited) - for i in data - ] - ) - - visited.discard(id(data)) - return res - - -class DeviceInfo(NavienBaseModel): - """Device information from API.""" - - home_seq: int = 0 - mac_address: str = "" - additional_value: str = "" - device_type: DeviceType | int = DeviceType.NPF700_WIFI - device_name: str = "Unknown" - connected: ConnectionStatusField = ConnectionStatus.DISCONNECTED - install_type: str | None = None - - -class Location(NavienBaseModel): - """Location information for a device.""" - - state: str | None = None - city: str | None = None - address: str | None = None - latitude: float | None = None - longitude: float | None = None - altitude: float | None = None - - -class Device(NavienBaseModel): - """Complete device information including location.""" - - device_info: DeviceInfo - location: Location - - def with_info(self, info: DeviceInfo) -> Self: - """Return a new Device instance with updated DeviceInfo.""" - return self.model_copy(update={"device_info": info}) - - -class FirmwareInfo(NavienBaseModel): - """Firmware information for a device.""" - - mac_address: str = "" - additional_value: str = "" - device_type: DeviceType | int = DeviceType.NPF700_WIFI - cur_sw_code: int = 0 - cur_version: int = 0 - downloaded_version: int | None = None - device_group: str | None = None - - -class TOUSchedule(NavienBaseModel): - """Time of Use schedule information.""" - - season: int = 0 - intervals: list[dict[str, Any]] = Field( - default_factory=list, alias="interval" - ) - - -class ConvertedTOUPlan(NavienBaseModel): - """A rate plan converted by the Navien backend from OpenEI format. - - Returned by POST /device/tou/convert. Contains the utility name, - plan name, and device-ready schedule with season/week bitfields - and scaled pricing. - """ - - utility: str = "" - name: str = "" - schedule: list[TOUSchedule] = Field(default_factory=list) - - -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 = 0 - schedule: list[TOUSchedule] = Field(default_factory=list) - - @model_validator(mode="before") - @classmethod - def _extract_nested_tou_info(cls, data: Any) -> Any: - # Handle nested structure where fields are in 'touInfo' - if isinstance(data, dict): - # Explicitly cast to dict[str, Any] for type safety - d = cast(dict[str, Any], data).copy() - if "touInfo" in d: - tou_data = d.pop("touInfo") - if isinstance(tou_data, dict): - d.update(tou_data) - return d - return data - - -class ReservationEntry(NavienBaseModel): - """A single scheduled reservation entry. - - Wraps the raw 6-byte protocol fields and provides computed properties - for display-ready values including unit-aware temperature conversion. - - The raw protocol fields are: - - enable: 2=enabled, 1=disabled (device boolean) - - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) - - hour: 0-23 - - min: 0-59 - - mode: DHW operation mode ID (1-6) - - param: temperature in half-degrees Celsius - """ - - enable: int = 2 - week: int = 0 - hour: int = 0 - min: int = 0 - mode: int = 1 - param: int = 0 - - @computed_field # type: ignore[prop-decorator] - @property - def enabled(self) -> bool: - """Whether this reservation is active (device bool: 2=on, 1=off).""" - return self.enable == 2 - - @computed_field # type: ignore[prop-decorator] - @property - def days(self) -> list[str]: - """Weekday names for this reservation.""" - from .encoding import decode_week_bitfield - - return decode_week_bitfield(self.week) - - @computed_field # type: ignore[prop-decorator] - @property - def time(self) -> str: - """Formatted time string (HH:MM).""" - return f"{self.hour:02d}:{self.min:02d}" - - @computed_field # type: ignore[prop-decorator] - @property - def temperature(self) -> float: - """Temperature in the user's preferred unit.""" - return reservation_param_to_preferred(self.param) - - @computed_field # type: ignore[prop-decorator] - @property - def unit(self) -> str: - """Temperature unit symbol.""" - return "°C" if get_unit_system() == "metric" else "°F" - - @computed_field # type: ignore[prop-decorator] - @property - def mode_name(self) -> str: - """Human-readable operation mode name.""" - try: - return DHW_OPERATION_SETTING_TEXT.get( - DhwOperationSetting(self.mode), f"Unknown ({self.mode})" - ) - except ValueError: - return f"Unknown ({self.mode})" - - -class ReservationSchedule(NavienBaseModel): - """Complete reservation schedule from the device. - - Can be constructed from raw MQTT response data. The ``reservation`` - field accepts either a hex string (from GET responses) or a list of - dicts/ReservationEntry objects. - """ - - reservation_use: int = Field(default=0, alias="reservationUse") - reservation: list[ReservationEntry] = Field(default_factory=list) - - model_config = ConfigDict( - alias_generator=None, - populate_by_name=True, - extra="ignore", - use_enum_values=False, - ) - - @model_validator(mode="before") - @classmethod - def _decode_hex_reservation(cls, data: Any) -> Any: - """Decode hex-encoded reservation string into entry list.""" - if isinstance(data, dict): - d = cast(dict[str, Any], data).copy() - raw = d.get("reservation", "") - if isinstance(raw, str): - if raw: - from .encoding import decode_reservation_hex - - d["reservation"] = decode_reservation_hex(raw) - else: - d["reservation"] = [] - return d - return data - - @computed_field # type: ignore[prop-decorator] - @property - def enabled(self) -> bool: - """Whether the reservation system is globally enabled. - - Device bool convention: 2=on, 1=off. - """ - return self.reservation_use == 2 - - -class DeviceStatus(NavienBaseModel): - """Represents the status of the Navien water heater device.""" - - # CRITICAL: temperature_type must be defined before any temperature - # fields that depend on it. Wrap validators need it in ValidationInfo.data. - # Reordering breaks unit conversions. See - # converters._get_temperature_preference() for details. - temperature_type: TemperatureType = Field( - default=TemperatureType.FAHRENHEIT, - description=( - "Type of temperature unit (1=Celsius, 2=Fahrenheit). " - "Controls all unit conversions." - ), - ) - - # Basic status fields - command: int = Field( - description="The command that triggered this status update" - ) - outside_temperature: RawCelsiusToPreferred = temperature_field( - "Outdoor/ambient temperature" - ) - special_function_status: int = Field( - description=( - "Status of special functions " - "(e.g., freeze protection, anti-seize operations)" - ) - ) - error_code: ErrorCode = Field( - default=ErrorCode.NO_ERROR, - description="Error code if any fault is detected", - ) - sub_error_code: int = Field( - description="Sub error code providing additional error details" - ) - smart_diagnostic: int = Field( - description=( - "Smart diagnostic status code for system health monitoring. " - "0 = no diagnostic conditions. " - "Non-zero = diagnostic condition detected. " - "Specific diagnostic codes are device firmware dependent." - ) - ) - fault_status1: int = Field(description="Fault status register 1") - fault_status2: int = Field(description="Fault status register 2") - wifi_rssi: int = signal_strength_field( - "WiFi signal strength in dBm. " - "Typical values: -30 (excellent) to -90 (poor)" - ) - dhw_charge_per: float = Field( - description=( - "DHW charge percentage - " - "estimated percentage of hot water capacity available" - ), - json_schema_extra={"unit_of_measurement": "%"}, - ) - dr_event_status: DREvent = Field( - default=DREvent.UNKNOWN, - description=( - "Demand Response (DR) event status from utility (CTA-2045). " - "0=UNKNOWN (No event), 1=RUN_NORMAL, 2=SHED (reduce power), " - "3=LOADUP (pre-heat), 4=LOADUP_ADV (advanced pre-heat), " - "5=CPE (customer peak event/grid emergency)" - ), - ) - vacation_day_setting: int = Field( - description="Vacation day setting", - json_schema_extra={"unit_of_measurement": "days"}, - ) - vacation_day_elapsed: int = Field( - description="Elapsed vacation days", - json_schema_extra={"unit_of_measurement": "days"}, - ) - anti_legionella_period: int = Field( - description=( - "Anti-legionella cycle interval. Range: 1-30 days, Default: 7 days" - ), - json_schema_extra={"unit_of_measurement": "days"}, - ) - program_reservation_type: int = Field( - description="Type of program reservation" - ) - temp_formula_type: TempFormulaType = Field( - description="Temperature formula type" - ) - current_statenum: int = Field(description="Current state number") - target_fan_rpm: int = Field( - description="Target fan RPM", - json_schema_extra={"unit_of_measurement": "RPM"}, - ) - current_fan_rpm: int = Field( - description="Current fan RPM", - json_schema_extra={"unit_of_measurement": "RPM"}, - ) - fan_pwm: int = Field(description="Fan PWM value") - mixing_rate: float = Field( - description=( - "Mixing valve rate percentage (0-100%). " - "Controls mixing of hot tank water with cold inlet water" - ), - json_schema_extra={"unit_of_measurement": "%"}, - ) - eev_step: int = Field( - description=( - "Electronic Expansion Valve (EEV) step position. " - "Valve opening rate expressed as step count" - ) - ) - air_filter_alarm_period: int = Field( - description=( - "Air filter maintenance cycle interval. " - "Range: Off or 1,000-10,000 hours, Default: 1,000 hours" - ), - json_schema_extra={"unit_of_measurement": "h"}, - ) - air_filter_alarm_elapsed: int = Field( - description=( - "Operating hours elapsed since last air filter maintenance reset. " - "Track this to schedule preventative replacement" - ), - json_schema_extra={"unit_of_measurement": "h"}, - ) - cumulated_op_time_eva_fan: int = Field( - description=( - "Cumulative operation time of the evaporator fan since installation" - ), - json_schema_extra={"unit_of_measurement": "h"}, - ) - cumulated_dhw_flow_rate: Volume = Field( - description=( - "Cumulative DHW flow - " - "total volume of hot water delivered since installation" - ), - json_schema_extra={ - "unit_of_measurement": "gal", - "device_class": "water", - }, - ) - tou_status: TouStatus = Field( - description=( - "Time of Use (TOU) scheduling enabled. " - "True = TOU is active/enabled, False = TOU is disabled" - ) - ) - dr_override_status: int = Field( - description=( - "Demand Response override status in hours. " - "0 = no override active. " - "Non-zero (1-72) = override active with specified remaining hours. " - "User can override DR commands for up to 72 hours." - ), - json_schema_extra={"unit_of_measurement": "hours"}, - ) - tou_override_status: TouOverride = Field( - description=( - "TOU override status. " - "True = user has overridden TOU to force immediate heating, " - "False = device follows TOU schedule normally" - ) - ) - total_energy_capacity: TenWhToWh = Field( - description="Total energy capacity of the tank in Watt-hours", - json_schema_extra={ - "unit_of_measurement": "Wh", - "device_class": "energy", - }, - ) - available_energy_capacity: TenWhToWh = Field( - description=( - "Available energy capacity - " - "remaining hot water energy available in Watt-hours" - ), - json_schema_extra={ - "unit_of_measurement": "Wh", - "device_class": "energy", - }, - ) - recirc_operation_mode: RecirculationMode = Field( - description="Recirculation operation mode" - ) - recirc_pump_operation_status: int = Field( - description="Recirculation pump operation status" - ) - recirc_hot_btn_ready: int = Field( - description="Recirculation HotButton ready status" - ) - recirc_operation_reason: int = Field( - description="Recirculation operation reason" - ) - recirc_error_status: int = Field(description="Recirculation error status") - current_inst_power: float = Field( - description=( - "Current instantaneous power consumption in Watts. " - "Does not include heating element power when active" - ), - json_schema_extra={ - "unit_of_measurement": "W", - "device_class": "power", - }, - ) - - # Boolean fields with device-specific encoding - did_reload: DeviceBool = Field( - description="Indicates if the device has recently reloaded or restarted" - ) - operation_busy: DeviceBool = Field( - description=( - "Indicates if the device is currently performing heating operations" - ) - ) - freeze_protection_use: DeviceBool = Field( - description=( - "Whether freeze protection is active. " - "Electric heater activates when tank water falls below threshold" - ) - ) - dhw_use: DeviceBool = Field( - description=( - "Domestic Hot Water (DHW) usage status - " - "indicates if hot water is currently being drawn from the tank" - ) - ) - dhw_use_sustained: DeviceBool = Field( - description=( - "Sustained DHW usage status - indicates prolonged hot water usage" - ) - ) - dhw_operation_busy: DeviceBool = Field( - default=False, - description=( - "DHW operation busy status - " - "indicates if the device is currently heating water to meet demand" - ), - ) - program_reservation_use: DeviceBool = Field( - description=( - "Whether a program reservation (scheduled operation) is in use" - ) - ) - eco_use: DeviceBool = Field( - description=( - "Whether ECO (Energy Cut Off) high-temp safety limit is triggered" - ) - ) - comp_use: DeviceBool = Field( - description=( - "Compressor usage status (True=On, False=Off). " - "The compressor is the main component of the heat pump" - ) - ) - eev_use: DeviceBool = Field( - description=( - "Electronic Expansion Valve (EEV) usage status. " - "The EEV controls refrigerant flow" - ) - ) - eva_fan_use: DeviceBool = Field( - description=( - "Evaporator fan usage status. " - "The fan pulls ambient air through the evaporator coil" - ) - ) - shut_off_valve_use: DeviceBool = Field( - description=( - "Shut-off valve usage status. " - "The valve controls refrigerant flow in the system" - ) - ) - con_ovr_sensor_use: DeviceBool = Field( - description="Condensate overflow sensor usage status" - ) - wtr_ovr_sensor_use: DeviceBool = Field( - description=( - "Water overflow/leak sensor usage status. " - "Triggers error E799 if leak detected" - ) - ) - anti_legionella_use: DeviceBool = Field( - description=( - "Whether anti-legionella function is enabled. " - "Device periodically heats tank to prevent Legionella bacteria" - ) - ) - anti_legionella_operation_busy: DeviceBool = Field( - description=( - "Whether the anti-legionella disinfection cycle " - "is currently running" - ) - ) - error_buzzer_use: DeviceBool = Field( - description="Whether the error buzzer is enabled" - ) - current_heat_use: HeatSource = Field( - description=( - "Currently active heat source. Indicates which heating " - "component(s) are actively running: 0=Unknown/not heating, " - "1=Heat Pump, 2=Electric Element, 3=Both simultaneously" - ) - ) - heat_upper_use: DeviceBool = Field( - description=( - "Upper electric heating element usage status. " - "Power: 3,755W @ 208V or 5,000W @ 240V" - ) - ) - heat_lower_use: DeviceBool = Field( - description=( - "Lower electric heating element usage status. " - "Power: 3,755W @ 208V or 5,000W @ 240V" - ) - ) - scald_use: DeviceBool = Field( - description=( - "Scald protection active status. " - "Warning when water reaches potentially hazardous levels" - ) - ) - air_filter_alarm_use: DeviceBool = Field( - description=( - "Air filter maintenance reminder enabled flag. " - "Triggers alerts based on operating hours. Default: On" - ) - ) - recirc_operation_busy: DeviceBool = Field( - description="Recirculation operation busy status" - ) - recirc_reservation_use: DeviceBool = Field( - description="Recirculation reservation usage status" - ) - - # Temperature fields - encoded in half-degrees Celsius - dhw_temperature: HalfCelsiusToPreferred = temperature_field( - "Current Domestic Hot Water (DHW) outlet temperature" - ) - dhw_temperature_setting: HalfCelsiusToPreferred = temperature_field( - "User-configured target DHW temperature" - ) - dhw_target_temperature_setting: HalfCelsiusToPreferred = temperature_field( - "Duplicate of dhw_temperature_setting for legacy API compatibility" - ) - freeze_protection_temperature: HalfCelsiusToPreferred = temperature_field( - "Freeze protection temperature setpoint. " - "Prevents tank from freezing in cold environments" - ) - dhw_temperature2: HalfCelsiusToPreferred = temperature_field( - "Second DHW temperature reading" - ) - hp_upper_on_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump upper on temperature setting" - ) - hp_upper_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump upper off temperature setting" - ) - hp_lower_on_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump lower on temperature setting" - ) - hp_lower_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump lower off temperature setting" - ) - he_upper_on_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heater element upper on temperature setting" - ) - he_upper_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heater element upper off temperature setting" - ) - he_lower_on_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heater element lower on temperature setting" - ) - he_lower_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heater element lower off temperature setting" - ) - heat_min_op_temperature: HalfCelsiusToPreferred = temperature_field( - "Minimum heat pump operation temperature. " - "Lowest tank setpoint allowed for heat pump operation" - ) - recirc_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Recirculation temperature setting" - ) - recirc_temperature: HalfCelsiusToPreferred = temperature_field( - "Recirculation temperature" - ) - recirc_faucet_temperature: HalfCelsiusToPreferred = temperature_field( - "Recirculation faucet temperature" - ) - - # Fields with scale division (raw / 10.0) - current_inlet_temperature: HalfCelsiusToPreferred = temperature_field( - "Cold water inlet temperature" - ) - current_dhw_flow_rate: FlowRate = Field( - description="Current DHW flow rate", - json_schema_extra={ - "unit_of_measurement": "GPM", - "device_class": "flow_rate", - }, - ) - hp_upper_on_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( - description="Heat pump upper on differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - hp_upper_off_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( - description="Heat pump upper off differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - hp_lower_on_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( - description="Heat pump lower on differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - hp_lower_off_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( - description="Heat pump lower off differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - he_upper_on_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( - description="Heater element upper on differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - he_upper_off_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( - description="Heater element upper off differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - he_lower_on_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( - alias="heLowerOnTDiffempSetting", - description="Heater element lower on differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) # Handle API typo: heLowerOnTDiffempSetting -> heLowerOnDiffTempSetting - he_lower_off_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( - description="Heater element lower off differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - recirc_dhw_flow_rate: FlowRate = Field( - description="Recirculation DHW flow rate (dynamic units: LPM/GPM)", - json_schema_extra={ - "device_class": "flow_rate", - }, - ) - - # Temperature fields with decicelsius to Fahrenheit conversion - tank_upper_temperature: DeciCelsiusToPreferred = temperature_field( - "Temperature of the upper part of the tank" - ) - tank_lower_temperature: DeciCelsiusToPreferred = temperature_field( - "Temperature of the lower part of the tank" - ) - discharge_temperature: DeciCelsiusToPreferred = temperature_field( - "Compressor discharge temperature - " - "temperature of refrigerant leaving the compressor" - ) - suction_temperature: DeciCelsiusToPreferred = temperature_field( - "Compressor suction temperature - " - "temperature of refrigerant entering the compressor" - ) - evaporator_temperature: DeciCelsiusToPreferred = temperature_field( - "Evaporator temperature - " - "temperature where heat is absorbed from ambient air" - ) - ambient_temperature: DeciCelsiusToPreferred = temperature_field( - "Ambient air temperature measured at the heat pump air intake" - ) - target_super_heat: DeciCelsiusToPreferred = temperature_field( - "Target superheat value - desired temperature difference " - "ensuring complete refrigerant vaporization" - ) - current_super_heat: DeciCelsiusToPreferred = temperature_field( - "Current superheat value - actual temperature difference " - "between suction and evaporator temperatures" - ) - - # Enum fields - operation_mode: CurrentOperationMode = Field( - default=CurrentOperationMode.STANDBY, - description="The current actual operational state of the device", - ) - dhw_operation_setting: DhwOperationSetting = Field( - default=DhwOperationSetting.ENERGY_SAVER, - description="User's configured DHW operation mode preference", - ) - freeze_protection_temp_min: HalfCelsiusToPreferred = temperature_field( - "Active freeze protection lower limit", - default=43.0, - ) - freeze_protection_temp_max: HalfCelsiusToPreferred = temperature_field( - "Active freeze protection upper limit", default=65.0 - ) - - def get_field_unit(self, field_name: str) -> str: - """Get the correct unit suffix based on temperature preference. - - Resolves dynamic units for temperature, flow rate, and volume fields - that change based on unit system context override or the device's - temperature_type setting (Celsius or Fahrenheit). - - Args: - field_name: Name of the field to get the unit for - - Returns: - Unit string (e.g., " °C", " LPM", " L") or empty if field not found - """ - model_fields = self.__class__.model_fields - if field_name not in model_fields: - return "" - - field_info = model_fields[field_name] - if not hasattr(field_info, "json_schema_extra"): - return "" - - extra = field_info.json_schema_extra - if not isinstance(extra, dict): - return "" - - # Check if unit system override is set in context - unit_system = get_unit_system() - if unit_system is not None: - is_celsius = unit_system == "metric" - else: - # Fall back to device's temperature_type setting - is_celsius = self.temperature_type == TemperatureType.CELSIUS - - device_class = extra.get("device_class") - - # Handle temperature units - if device_class == "temperature": - return " °C" if is_celsius else " °F" - - # Handle flow rate units - if device_class == "flow_rate": - return " LPM" if is_celsius else " GPM" - - # Handle volume units - if device_class == "water": - return " L" if is_celsius else " gal" - - # Fallback to static unit_of_measurement if present - if "unit_of_measurement" in extra: - unit_val = extra["unit_of_measurement"] - unit: str = str(unit_val) if unit_val is not None else "" - return f" {unit}" if unit else "" - - return "" - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DeviceStatus: - """Compatibility method for existing code.""" - return cls.model_validate(data) - - -class DeviceFeature(NavienBaseModel): - """Device capabilities, configuration, and firmware info.""" - - # IMPORTANT: temperature_type must be defined before any temperature fields - # so that it is available in the validation context (info.data). - temperature_type: TemperatureType = Field( - default=TemperatureType.FAHRENHEIT, - description=( - "Default temperature unit preference - " - "factory set to Fahrenheit for USA" - ), - ) - - country_code: int = Field( - description=( - "Country/region code where device is certified for operation. " - "Device-specific code defined by Navien. " - "Example: USA devices report code 3. Earlier project " - "documentation incorrectly listed code 1 for USA; field " - "observations of production devices confirm that code 3 is " - "the correct value." - ) - ) - model_type_code: UnitType | int = Field( - description=( - "Model type identifier: Maps to UnitType enum " - "(e.g., NPF=513 for heat pump water heater). " - "Identifies the device family and available capabilities" - ) - ) - control_type_code: int = Field( - description=( - "Control system type identifier: Specifies the version of the " - "digital control system (LCD display, WiFi, firmware variant). " - "Device-specific numeric code" - ) - ) - volume_code: VolumeCodeField = Field( - description=( - "Tank nominal capacity: 50 gallons (code 1), 65 gallons (code 2), " - "or 80 gallons (code 3)" - ), - json_schema_extra={"unit_of_measurement": "gal"}, - ) - controller_sw_version: int = Field( - description=( - "Main controller firmware version - " - "controls heat pump, heating elements, and system logic" - ) - ) - panel_sw_version: int = Field( - description=( - "Front panel display firmware version - " - "manages LCD display and user interface" - ) - ) - wifi_sw_version: int = Field( - description=( - "WiFi module firmware version - " - "handles app connectivity and cloud communication" - ) - ) - controller_sw_code: int = Field( - description=( - "Controller firmware variant/branch identifier " - "for support and compatibility" - ) - ) - panel_sw_code: int = Field( - description=( - "Panel firmware variant/branch identifier " - "for display features and UI capabilities" - ) - ) - wifi_sw_code: int = Field( - description=( - "WiFi firmware variant/branch identifier " - "for communication protocol version" - ) - ) - recirc_sw_version: int = Field( - description=( - "Recirculation module firmware version - " - "controls recirculation pump operation and temperature loop" - ) - ) - recirc_model_type_code: int = Field( - description=( - "Recirculation module model identifier: Specifies the type and " - "capabilities of the installed recirculation system. " - "Device-specific numeric code (0 if recirculation not installed)" - ) - ) - controller_serial_number: str = Field( - description=( - "Unique serial number of the main controller board " - "for warranty and service identification" - ) - ) - power_use: CapabilityFlag = Field( - default=False, - description=("Power control capability (2=supported, 1=not supported)"), - ) - holiday_use: CapabilityFlag = Field( - default=False, - description=( - "Vacation mode support (2=supported, 1=not supported) - " - "energy-saving mode for 0-99 days" - ), - ) - program_reservation_use: CapabilityFlag = Field( - default=False, - description=( - "Scheduled operation support (2=supported, 1=not supported) - " - "programmable heating schedules" - ), - ) - dhw_use: CapabilityFlag = Field( - default=False, - description=( - "Domestic hot water functionality (2=supported, 1=not supported) - " - "primary function of water heater" - ), - ) - dhw_temperature_setting_use: DHWControlTypeFlag = Field( - description=( - "DHW temperature control precision setting: " - "granularity of temperature adjustments available for DHW control" - ) - ) - smart_diagnostic_use: CapabilityFlag = Field( - default=False, - description=( - "Self-diagnostic capability (2=supported, 1=not supported) - " - "10-minute startup diagnostic, error code system" - ), - ) - wifi_rssi_use: CapabilityFlag = Field( - default=False, - description=( - "WiFi signal monitoring (2=supported, 1=not supported) - " - "reports signal strength in dBm" - ), - ) - temp_formula_type: TempFormulaType = Field( - default=TempFormulaType.ASYMMETRIC, - description=( - "Temperature calculation method identifier " - "for internal sensor calibration" - ), - ) - energy_usage_use: CapabilityFlag = Field( - default=False, - description=("Energy monitoring support (2=supp, 1=not) - tracks kWh"), - ) - freeze_protection_use: CapabilityFlag = Field( - default=False, - description=( - "Freeze protection capability (2=supported, 1=not supported) - " - "automatic heating when tank drops below threshold" - ), - ) - mixing_valve_use: CapabilityFlag = Field( - alias="mixingValveUse", - default=False, - description=("Thermostatic mixing valve support (2=supp, 1=not)"), - ) - dr_setting_use: CapabilityFlag = Field( - default=False, - description=( - "Demand Response support (2=supported, 1=not supported) - " - "CTA-2045 compliance for utility load management" - ), - ) - anti_legionella_setting_use: CapabilityFlag = Field( - default=False, - description=( - "Anti-Legionella function (2=supported, 1=not supported) - " - "periodic heating to 140°F (60°C) to prevent bacteria" - ), - ) - hpwh_use: CapabilityFlag = Field( - default=False, - description=( - "Heat Pump Water Heater mode (2=supported, 1=not supported) - " - "primary efficient heating using refrigeration cycle" - ), - ) - dhw_refill_use: CapabilityFlag = Field( - default=False, - description=( - "Tank refill detection (2=supported, 1=not supported) - " - "monitors for dry fire conditions during refill" - ), - ) - eco_use: CapabilityFlag = Field( - default=False, - description=( - "ECO safety switch capability (2=supported, 1=not supported) - " - "Energy Cut Off high-temperature limit protection" - ), - ) - electric_use: CapabilityFlag = Field( - default=False, - description=( - "Electric-only mode (2=supported, 1=not supported) - " - "heating element only for maximum recovery speed" - ), - ) - heatpump_use: CapabilityFlag = Field( - default=False, - description=( - "Heat pump only mode (2=supported, 1=not supported) - " - "most efficient operation using only refrigeration cycle" - ), - ) - energy_saver_use: CapabilityFlag = Field( - default=False, - description=( - "Energy Saver mode (2=supported, 1=not supported) - " - "hybrid efficiency mode balancing speed and efficiency (default)" - ), - ) - high_demand_use: CapabilityFlag = Field( - default=False, - description=( - "High Demand mode (2=supported, 1=not supported) - " - "hybrid boost mode prioritizing fast recovery" - ), - ) - recirculation_use: CapabilityFlag = Field( - default=False, - description=( - "Recirculation pump support (2=supported, 1=not supported) - " - "instant hot water delivery via dedicated loop" - ), - ) - recirc_reservation_use: CapabilityFlag = Field( - default=False, - description=( - "Recirculation schedule support (2=supported, 1=not supported) - " - "programmable recirculation on specified schedule" - ), - ) - title24_use: CapabilityFlag = Field( - default=False, - description=( - "Title 24 compliance (2=supported, 1=not supported) - " - "California energy code compliance for recirculation systems" - ), - ) - - # Temperature limit fields with half-degree Celsius scaling - dhw_temperature_min: HalfCelsiusToPreferred = temperature_field( - "Minimum DHW temperature setting - safety and efficiency lower limit" - ) - dhw_temperature_max: HalfCelsiusToPreferred = temperature_field( - "Maximum DHW temperature setting - scald protection upper limit" - ) - freeze_protection_temp_min: HalfCelsiusToPreferred = temperature_field( - "Minimum freeze protection threshold - " - "factory default activation temperature" - ) - freeze_protection_temp_max: HalfCelsiusToPreferred = temperature_field( - "Maximum freeze protection threshold - user-adjustable upper limit" - ) - recirc_temperature_min: HalfCelsiusToPreferred = temperature_field( - "Minimum recirculation temperature setting - " - "lower limit for recirculation loop temperature control" - ) - recirc_temperature_max: HalfCelsiusToPreferred = temperature_field( - "Maximum recirculation temperature setting - " - "upper limit for recirculation loop temperature control" - ) - - def get_field_unit(self, field_name: str) -> str: - """Get the correct unit suffix based on temperature preference. - - Resolves dynamic units for temperature, flow rate, and volume fields - that change based on unit system context override or the device's - temperature_type setting (Celsius or Fahrenheit). - - Args: - field_name: Name of the field to get the unit for - - Returns: - Unit string (e.g., " °C", " LPM", " L") or empty if field not found - """ - model_fields = self.__class__.model_fields - if field_name not in model_fields: - return "" - - field_info = model_fields[field_name] - if not hasattr(field_info, "json_schema_extra"): - return "" - - extra = field_info.json_schema_extra - if not isinstance(extra, dict): - return "" - - # Check if unit system override is set in context - unit_system = get_unit_system() - if unit_system is not None: - is_celsius = unit_system == "metric" - else: - # Fall back to device's temperature_type setting - is_celsius = self.temperature_type == TemperatureType.CELSIUS - - device_class = extra.get("device_class") - - # Handle temperature units - if device_class == "temperature": - return " °C" if is_celsius else " °F" - - # Handle flow rate units - if device_class == "flow_rate": - return " LPM" if is_celsius else " GPM" - - # Handle volume units - if device_class == "water": - return " L" if is_celsius else " gal" - - # Fallback to static unit_of_measurement if present - if "unit_of_measurement" in extra: - unit_val = extra["unit_of_measurement"] - unit: str = str(unit_val) if unit_val is not None else "" - return f" {unit}" if unit else "" - - return "" - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DeviceFeature: - """Compatibility method.""" - return cls.model_validate(data) - - -class MqttRequest(NavienBaseModel): - """MQTT command request payload.""" - - command: int - device_type: DeviceType | int - mac_address: str - additional_value: str = "..." - mode: str | None = None - param: list[int | float] = Field(default_factory=list) - param_str: str = "" - month: list[int] | None = None - year: int | None = None - - -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: MqttRequest | dict[str, Any] - protocol_version: int = 2 - - -class EnergyUsageBase(NavienBaseModel): - """Base energy usage fields common to daily and total responses.""" - - heat_pump_usage: int = Field(default=0, alias="hpUsage") - heat_element_usage: int = Field(default=0, alias="heUsage") - heat_pump_time: int = Field(default=0, alias="hpTime") - heat_element_time: int = Field(default=0, alias="heTime") - - @property - def total_usage(self) -> int: - return self.heat_pump_usage + self.heat_element_usage - - -class EnergyUsageTotal(EnergyUsageBase): - """Total energy usage data.""" - - @property - def heat_pump_percentage(self) -> float: - return ( - (self.heat_pump_usage / self.total_usage * 100.0) - if self.total_usage > 0 - else 0.0 - ) - - @property - def heat_element_percentage(self) -> float: - return ( - (self.heat_element_usage / self.total_usage * 100.0) - if self.total_usage > 0 - else 0.0 - ) - - @property - def total_time(self) -> int: - return self.heat_pump_time + self.heat_element_time - - -class EnergyUsageDay(EnergyUsageBase): - """Daily energy usage data.""" - - pass - - -class MonthlyEnergyData(NavienBaseModel): - """Monthly energy usage data grouping.""" - - year: int - month: int - data: list[EnergyUsageDay] - - -class EnergyUsageResponse(NavienBaseModel): - """Response for energy usage query.""" - - total: EnergyUsageTotal - usage: list[MonthlyEnergyData] - - def get_month_data(self, year: int, month: int) -> MonthlyEnergyData | None: - """Get energy usage data for a specific month. - - Args: - year: Year (e.g., 2025) - month: Month (1-12) - - Returns: - MonthlyEnergyData for that month, or None if not found - """ - for monthly_data in self.usage: - if monthly_data.year == year and monthly_data.month == month: - return monthly_data - return None - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> EnergyUsageResponse: - """Compatibility method.""" - return cls.model_validate(data) diff --git a/src/nwp500/models/__init__.py b/src/nwp500/models/__init__.py new file mode 100644 index 0000000..e23c9ca --- /dev/null +++ b/src/nwp500/models/__init__.py @@ -0,0 +1,96 @@ +"""Data models for Navien NWP500 water heater communication. + +This module defines data classes for representing data structures +used in the Navien NWP500 water heater communication protocol. + +These models are based on the MQTT message formats and API responses. +""" + +from __future__ import annotations + +from .._base import NavienBaseModel +from ._converters import ( + fahrenheit_to_half_celsius, + preferred_to_half_celsius, + reservation_param_to_preferred, +) +from .device import ( + ConnectionStatusField, + Device, + DeviceInfo, + FirmwareInfo, + Location, +) +from .energy import ( + EnergyUsageBase, + EnergyUsageDay, + EnergyUsageResponse, + EnergyUsageTotal, + MonthlyEnergyData, +) +from .feature import CapabilityFlag, DeviceFeature, VolumeCodeField +from .mqtt_models import MqttCommand, MqttRequest +from .schedule import ( + OtaCommitPayload, + RecirculationSchedule, + RecirculationScheduleEntry, + ReservationEntry, + ReservationSchedule, + WeeklyReservationEntry, + WeeklyReservationSchedule, +) +from .status import ( + DeviceBool, + DeviceStatus, + Div10, + TenWhToWh, + TouOverride, + TouStatus, +) +from .tou import ( + ConvertedTOUPlan, + TOUInfo, + TOUPeriod, + TOUReservationSchedule, + TOUSchedule, +) + +__all__ = [ + "NavienBaseModel", + "DeviceBool", + "CapabilityFlag", + "Div10", + "TenWhToWh", + "TouStatus", + "TouOverride", + "VolumeCodeField", + "ConnectionStatusField", + "fahrenheit_to_half_celsius", + "preferred_to_half_celsius", + "reservation_param_to_preferred", + "DeviceInfo", + "Location", + "Device", + "FirmwareInfo", + "TOUSchedule", + "ConvertedTOUPlan", + "TOUInfo", + "TOUPeriod", + "TOUReservationSchedule", + "ReservationEntry", + "ReservationSchedule", + "WeeklyReservationEntry", + "WeeklyReservationSchedule", + "RecirculationScheduleEntry", + "RecirculationSchedule", + "OtaCommitPayload", + "DeviceStatus", + "DeviceFeature", + "MqttRequest", + "MqttCommand", + "EnergyUsageBase", + "EnergyUsageTotal", + "EnergyUsageDay", + "MonthlyEnergyData", + "EnergyUsageResponse", +] diff --git a/src/nwp500/models/_converters.py b/src/nwp500/models/_converters.py new file mode 100644 index 0000000..b24b572 --- /dev/null +++ b/src/nwp500/models/_converters.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from ..temperature import HalfCelsius +from ..unit_system import get_unit_system + + +def fahrenheit_to_half_celsius(fahrenheit: float) -> int: + """Convert Fahrenheit to half-degrees Celsius (for device commands). + + Args: + fahrenheit: Temperature in Fahrenheit. + + Returns: + Raw device value in half-Celsius format. + + Example: + >>> fahrenheit_to_half_celsius(140.0) + 120 + """ + return int(HalfCelsius.from_fahrenheit(fahrenheit).raw_value) + + +def preferred_to_half_celsius(temperature: float) -> int: + """Convert temperature from preferred unit to half-degrees Celsius. + + Converts temperature from the user's preferred unit (Celsius or Fahrenheit, + based on global unit system context) to the half-Celsius format used by + the device for commands and reservations. + + Args: + temperature: Temperature in user's preferred unit + (Celsius or Fahrenheit). + + Returns: + Raw device value in half-Celsius format. + + Example: + >>> # With us_customary unit system + >>> preferred_to_half_celsius(140.0) # 140°F + 120 + >>> # With metric unit system + >>> preferred_to_half_celsius(60.0) # 60°C + 120 + """ + if get_unit_system() == "metric": + # User prefers Celsius, input is in Celsius + return int(HalfCelsius.from_celsius(temperature).raw_value) + else: + # User prefers Fahrenheit (or no preference), input is in Fahrenheit + return fahrenheit_to_half_celsius(temperature) + + +def reservation_param_to_preferred(param: int) -> float: + """Convert reservation param to user's preferred temperature unit. + + Device returns reservation temperatures as half-degrees Celsius (param). + This converts them to the user's preferred unit (Celsius or Fahrenheit) + based on the global unit system context. + + Args: + param: Raw device value in half-Celsius format. + + Returns: + Temperature in user's preferred unit (Celsius or Fahrenheit). + + Example: + >>> # With metric (Celsius) unit system + >>> reservation_param_to_preferred(120) + 60.0 + >>> # With us_customary (Fahrenheit) unit system + >>> reservation_param_to_preferred(120) + 140.0 + """ + half_celsius = HalfCelsius(param) + if get_unit_system() == "metric": + return round(half_celsius.to_celsius(), 1) + return round(half_celsius.to_fahrenheit(), 1) diff --git a/src/nwp500/models/device.py b/src/nwp500/models/device.py new file mode 100644 index 0000000..ae7a501 --- /dev/null +++ b/src/nwp500/models/device.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Annotated, Self + +from pydantic import BeforeValidator + +from .._base import NavienBaseModel +from ..converters import enum_validator +from ..enums import ConnectionStatus, DeviceType + +ConnectionStatusField = Annotated[ + ConnectionStatus, BeforeValidator(enum_validator(ConnectionStatus)) +] + + +class DeviceInfo(NavienBaseModel): + """Device information from API.""" + + home_seq: int = 0 + mac_address: str = "" + additional_value: str = "" + device_type: DeviceType | int = DeviceType.NPF700_WIFI + device_name: str = "Unknown" + connected: ConnectionStatusField = ConnectionStatus.DISCONNECTED + install_type: str | None = None + + +class Location(NavienBaseModel): + """Location information for a device.""" + + state: str | None = None + city: str | None = None + address: str | None = None + latitude: float | None = None + longitude: float | None = None + altitude: float | None = None + + +class Device(NavienBaseModel): + """Complete device information including location.""" + + device_info: DeviceInfo + location: Location + + def with_info(self, info: DeviceInfo) -> Self: + """Return a new Device instance with updated DeviceInfo.""" + return self.model_copy(update={"device_info": info}) + + +class FirmwareInfo(NavienBaseModel): + """Firmware information for a device.""" + + mac_address: str = "" + additional_value: str = "" + device_type: DeviceType | int = DeviceType.NPF700_WIFI + cur_sw_code: int = 0 + cur_version: int = 0 + downloaded_version: int | None = None + device_group: str | None = None diff --git a/src/nwp500/models/energy.py b/src/nwp500/models/energy.py new file mode 100644 index 0000000..d2410bf --- /dev/null +++ b/src/nwp500/models/energy.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pydantic import Field + +from .._base import NavienBaseModel + + +class EnergyUsageBase(NavienBaseModel): + """Base energy usage fields common to daily and total responses.""" + + heat_pump_usage: int = Field(default=0, alias="hpUsage") + heat_element_usage: int = Field(default=0, alias="heUsage") + heat_pump_time: int = Field(default=0, alias="hpTime") + heat_element_time: int = Field(default=0, alias="heTime") + + @property + def total_usage(self) -> int: + return self.heat_pump_usage + self.heat_element_usage + + +class EnergyUsageTotal(EnergyUsageBase): + """Total energy usage data.""" + + @property + def heat_pump_percentage(self) -> float: + return ( + (self.heat_pump_usage / self.total_usage * 100.0) + if self.total_usage > 0 + else 0.0 + ) + + @property + def heat_element_percentage(self) -> float: + return ( + (self.heat_element_usage / self.total_usage * 100.0) + if self.total_usage > 0 + else 0.0 + ) + + @property + def total_time(self) -> int: + return self.heat_pump_time + self.heat_element_time + + +class EnergyUsageDay(EnergyUsageBase): + """Daily energy usage data.""" + + pass + + +class MonthlyEnergyData(NavienBaseModel): + """Monthly energy usage data grouping.""" + + year: int + month: int + data: list[EnergyUsageDay] + + +class EnergyUsageResponse(NavienBaseModel): + """Response for energy usage query.""" + + total: EnergyUsageTotal + usage: list[MonthlyEnergyData] + + def get_month_data(self, year: int, month: int) -> MonthlyEnergyData | None: + """Get energy usage data for a specific month. + + Args: + year: Year (e.g., 2025) + month: Month (1-12) + + Returns: + MonthlyEnergyData for that month, or None if not found + """ + for monthly_data in self.usage: + if monthly_data.year == year and monthly_data.month == month: + return monthly_data + return None diff --git a/src/nwp500/models/feature.py b/src/nwp500/models/feature.py new file mode 100644 index 0000000..0dc5609 --- /dev/null +++ b/src/nwp500/models/feature.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +from typing import Annotated + +from pydantic import BeforeValidator, Field, computed_field + +from .._base import NavienBaseModel +from ..converters import device_bool_to_python, enum_validator +from ..enums import ( + DHWControlTypeFlag, + TemperatureType, + TempFormulaType, + UnitType, + VolumeCode, +) +from ..field_factory import temperature_field +from ..temperature import HalfCelsius +from ..unit_system import get_unit_system + +CapabilityFlag = Annotated[bool, BeforeValidator(device_bool_to_python)] +VolumeCodeField = Annotated[ + VolumeCode, BeforeValidator(enum_validator(VolumeCode)) +] + + +class DeviceFeature(NavienBaseModel): + """Device capabilities, configuration, and firmware info.""" + + # IMPORTANT: temperature_type must remain the first field so computed + # temperature properties can fall back to the device's native unit setting. + temperature_type: TemperatureType = Field( + default=TemperatureType.FAHRENHEIT, + description=( + "Default temperature unit preference - " + "factory set to Fahrenheit for USA" + ), + ) + + mac_address: str | None = Field( + default=None, + description="MAC address of the origin device", + ) + + country_code: int = Field( + description=( + "Country/region code where device is certified for operation. " + "Device-specific code defined by Navien. " + "Example: USA devices report code 3. Earlier project " + "documentation incorrectly listed code 1 for USA; field " + "observations of production devices confirm that code 3 is " + "the correct value." + ) + ) + model_type_code: UnitType | int = Field( + description=( + "Model type identifier: Maps to UnitType enum " + "(e.g., NPF=513 for heat pump water heater). " + "Identifies the device family and available capabilities" + ) + ) + control_type_code: int = Field( + description=( + "Control system type identifier: Specifies the version of the " + "digital control system (LCD display, WiFi, firmware variant). " + "Device-specific numeric code" + ) + ) + volume_code: VolumeCodeField = Field( + description=( + "Tank nominal capacity: 50 gallons (code 1), 65 gallons (code 2), " + "or 80 gallons (code 3)" + ), + json_schema_extra={"unit_of_measurement": "gal"}, + ) + controller_sw_version: int = Field( + description=( + "Main controller firmware version - " + "controls heat pump, heating elements, and system logic" + ) + ) + panel_sw_version: int = Field( + description=( + "Front panel display firmware version - " + "manages LCD display and user interface" + ) + ) + wifi_sw_version: int = Field( + description=( + "WiFi module firmware version - " + "handles app connectivity and cloud communication" + ) + ) + controller_sw_code: int = Field( + description=( + "Controller firmware variant/branch identifier " + "for support and compatibility" + ) + ) + panel_sw_code: int = Field( + description=( + "Panel firmware variant/branch identifier " + "for display features and UI capabilities" + ) + ) + wifi_sw_code: int = Field( + description=( + "WiFi firmware variant/branch identifier " + "for communication protocol version" + ) + ) + recirc_sw_version: int = Field( + description=( + "Recirculation module firmware version - " + "controls recirculation pump operation and temperature loop" + ) + ) + recirc_model_type_code: int = Field( + description=( + "Recirculation module model identifier: Specifies the type and " + "capabilities of the installed recirculation system. " + "Device-specific numeric code (0 if recirculation not installed)" + ) + ) + controller_serial_number: str = Field( + description=( + "Unique serial number of the main controller board " + "for warranty and service identification" + ) + ) + power_use: CapabilityFlag = Field( + default=False, + description=("Power control capability (2=supported, 1=not supported)"), + ) + holiday_use: CapabilityFlag = Field( + default=False, + description=( + "Vacation mode support (2=supported, 1=not supported) - " + "energy-saving mode for 0-99 days" + ), + ) + program_reservation_use: CapabilityFlag = Field( + default=False, + description=( + "Scheduled operation support (2=supported, 1=not supported) - " + "programmable heating schedules" + ), + ) + dhw_use: CapabilityFlag = Field( + default=False, + description=( + "Domestic hot water functionality (2=supported, 1=not supported) - " + "primary function of water heater" + ), + ) + dhw_temperature_setting_use: DHWControlTypeFlag = Field( + description=( + "DHW temperature control precision setting: " + "granularity of temperature adjustments available for DHW control" + ) + ) + smart_diagnostic_use: CapabilityFlag = Field( + default=False, + description=( + "Self-diagnostic capability (2=supported, 1=not supported) - " + "10-minute startup diagnostic, error code system" + ), + ) + wifi_rssi_use: CapabilityFlag = Field( + default=False, + description=( + "WiFi signal monitoring (2=supported, 1=not supported) - " + "reports signal strength in dBm" + ), + ) + temp_formula_type: TempFormulaType = Field( + default=TempFormulaType.ASYMMETRIC, + description=( + "Temperature calculation method identifier " + "for internal sensor calibration" + ), + ) + energy_usage_use: CapabilityFlag = Field( + default=False, + description=("Energy monitoring support (2=supp, 1=not) - tracks kWh"), + ) + freeze_protection_use: CapabilityFlag = Field( + default=False, + description=( + "Freeze protection capability (2=supported, 1=not supported) - " + "automatic heating when tank drops below threshold" + ), + ) + mixing_valve_use: CapabilityFlag = Field( + alias="mixingValveUse", + default=False, + description=("Thermostatic mixing valve support (2=supp, 1=not)"), + ) + dr_setting_use: CapabilityFlag = Field( + default=False, + description=( + "Demand Response support (2=supported, 1=not supported) - " + "CTA-2045 compliance for utility load management" + ), + ) + anti_legionella_setting_use: CapabilityFlag = Field( + default=False, + description=( + "Anti-Legionella function (2=supported, 1=not supported) - " + "periodic heating to 140°F (60°C) to prevent bacteria" + ), + ) + hpwh_use: CapabilityFlag = Field( + default=False, + description=( + "Heat Pump Water Heater mode (2=supported, 1=not supported) - " + "primary efficient heating using refrigeration cycle" + ), + ) + dhw_refill_use: CapabilityFlag = Field( + default=False, + description=( + "Tank refill detection (2=supported, 1=not supported) - " + "monitors for dry fire conditions during refill" + ), + ) + eco_use: CapabilityFlag = Field( + default=False, + description=( + "ECO safety switch capability (2=supported, 1=not supported) - " + "Energy Cut Off high-temperature limit protection" + ), + ) + electric_use: CapabilityFlag = Field( + default=False, + description=( + "Electric-only mode (2=supported, 1=not supported) - " + "heating element only for maximum recovery speed" + ), + ) + heatpump_use: CapabilityFlag = Field( + default=False, + description=( + "Heat pump only mode (2=supported, 1=not supported) - " + "most efficient operation using only refrigeration cycle" + ), + ) + energy_saver_use: CapabilityFlag = Field( + default=False, + description=( + "Energy Saver mode (2=supported, 1=not supported) - " + "hybrid efficiency mode balancing speed and efficiency (default)" + ), + ) + high_demand_use: CapabilityFlag = Field( + default=False, + description=( + "High Demand mode (2=supported, 1=not supported) - " + "hybrid boost mode prioritizing fast recovery" + ), + ) + recirculation_use: CapabilityFlag = Field( + default=False, + description=( + "Recirculation pump support (2=supported, 1=not supported) - " + "instant hot water delivery via dedicated loop" + ), + ) + recirc_reservation_use: CapabilityFlag = Field( + default=False, + description=( + "Recirculation schedule support (2=supported, 1=not supported) - " + "programmable recirculation on specified schedule" + ), + ) + title24_use: CapabilityFlag = Field( + default=False, + description=( + "Title 24 compliance (2=supported, 1=not supported) - " + "California energy code compliance for recirculation systems" + ), + ) + + # Raw temperature limit fields with half-degree Celsius scaling + dhw_temperature_min_raw: int = temperature_field( + "Minimum DHW temperature setting - safety and efficiency lower limit", + alias="dhwTemperatureMin", + ) + dhw_temperature_max_raw: int = temperature_field( + "Maximum DHW temperature setting - scald protection upper limit", + alias="dhwTemperatureMax", + ) + freeze_protection_temp_min_raw: int = temperature_field( + "Minimum freeze protection threshold - " + "factory default activation temperature", + alias="freezeProtectionTempMin", + ) + freeze_protection_temp_max_raw: int = temperature_field( + "Maximum freeze protection threshold - user-adjustable upper limit", + alias="freezeProtectionTempMax", + ) + recirc_temperature_min_raw: int = temperature_field( + "Minimum recirculation temperature setting - " + "lower limit for recirculation loop temperature control", + alias="recircTemperatureMin", + ) + recirc_temperature_max_raw: int = temperature_field( + "Maximum recirculation temperature setting - " + "upper limit for recirculation loop temperature control", + alias="recircTemperatureMax", + ) + + def _is_celsius(self) -> bool: + """Return True if metric/Celsius units should be used.""" + unit_system = get_unit_system() + if unit_system is not None: + return unit_system == "metric" + return self.temperature_type == TemperatureType.CELSIUS + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature_min(self) -> float: + return HalfCelsius(self.dhw_temperature_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature_max(self) -> float: + return HalfCelsius(self.dhw_temperature_max_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_min(self) -> float: + return HalfCelsius(self.freeze_protection_temp_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_max(self) -> float: + return HalfCelsius(self.freeze_protection_temp_max_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temperature_min(self) -> float: + return HalfCelsius(self.recirc_temperature_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temperature_max(self) -> float: + return HalfCelsius(self.recirc_temperature_max_raw).to_preferred( + self._is_celsius() + ) + + def get_field_unit(self, field_name: str) -> str: + """Get the correct unit suffix based on temperature preference. + + Resolves dynamic units for temperature, flow rate, and volume fields + that change based on unit system context override or the device's + temperature_type setting (Celsius or Fahrenheit). + + Args: + field_name: Name of the field to get the unit for + + Returns: + Unit string (e.g., " °C", " LPM", " L") or empty if field not found + """ + model_fields = self.__class__.model_fields + lookup_name = ( + field_name if field_name in model_fields else f"{field_name}_raw" + ) + if lookup_name not in model_fields: + return "" + + field_info = model_fields[lookup_name] + if not hasattr(field_info, "json_schema_extra"): + return "" + + extra = field_info.json_schema_extra + if not isinstance(extra, dict): + return "" + + is_celsius = self._is_celsius() + + device_class = extra.get("device_class") + + # Handle temperature units + if device_class == "temperature": + return " °C" if is_celsius else " °F" + + # Handle flow rate units + if device_class == "flow_rate": + return " LPM" if is_celsius else " GPM" + + # Handle volume units + if device_class == "water": + return " L" if is_celsius else " gal" + + # Fallback to static unit_of_measurement if present + if "unit_of_measurement" in extra: + unit_val = extra["unit_of_measurement"] + unit: str = str(unit_val) if unit_val is not None else "" + return f" {unit}" if unit else "" + + return "" diff --git a/src/nwp500/models/mqtt_models.py b/src/nwp500/models/mqtt_models.py new file mode 100644 index 0000000..151d38f --- /dev/null +++ b/src/nwp500/models/mqtt_models.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from .._base import NavienBaseModel +from ..enums import DeviceType + + +class MqttRequest(NavienBaseModel): + """MQTT command request payload.""" + + command: int + device_type: DeviceType | int + mac_address: str + additional_value: str = "..." + mode: str | None = None + param: list[int | float] = Field(default_factory=list) + param_str: str = "" + month: list[int] | None = None + year: int | None = None + + +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: MqttRequest | dict[str, Any] + protocol_version: int = 2 diff --git a/src/nwp500/models/schedule.py b/src/nwp500/models/schedule.py new file mode 100644 index 0000000..7666d7f --- /dev/null +++ b/src/nwp500/models/schedule.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +from typing import Any, cast + +from pydantic import ConfigDict, Field, computed_field, model_validator + +from .._base import NavienBaseModel +from ..enums import ( + DHW_OPERATION_SETTING_TEXT, + DhwOperationSetting, + RecirculationMode, +) +from ..unit_system import get_unit_system +from ._converters import reservation_param_to_preferred + + +class ReservationEntry(NavienBaseModel): + """A single scheduled reservation entry. + + Wraps the raw 6-byte protocol fields and provides computed properties + for display-ready values including unit-aware temperature conversion. + + The raw protocol fields are: + - enable: 2=enabled, 1=disabled (device boolean) + - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) + - hour: 0-23 + - min: 0-59 + - mode: DHW operation mode ID (1-6) + - param: temperature in half-degrees Celsius + """ + + enable: int = 2 + week: int = 0 + hour: int = 0 + min: int = 0 + mode: int = 1 + param: int = 0 + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether this reservation is active (device bool: 2=on, 1=off).""" + return self.enable == 2 + + @computed_field # type: ignore[prop-decorator] + @property + def days(self) -> list[str]: + """Weekday names for this reservation.""" + from ..encoding import decode_week_bitfield + + return decode_week_bitfield(self.week) + + @computed_field # type: ignore[prop-decorator] + @property + def time(self) -> str: + """Formatted time string (HH:MM).""" + return f"{self.hour:02d}:{self.min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def temperature(self) -> float: + """Temperature in the user's preferred unit.""" + return reservation_param_to_preferred(self.param) + + @computed_field # type: ignore[prop-decorator] + @property + def unit(self) -> str: + """Temperature unit symbol.""" + return "°C" if get_unit_system() == "metric" else "°F" + + @computed_field # type: ignore[prop-decorator] + @property + def mode_name(self) -> str: + """Human-readable operation mode name.""" + try: + return DHW_OPERATION_SETTING_TEXT.get( + DhwOperationSetting(self.mode), f"Unknown ({self.mode})" + ) + except ValueError: + return f"Unknown ({self.mode})" + + +class ReservationSchedule(NavienBaseModel): + """Complete reservation schedule from the device. + + Can be constructed from raw MQTT response data. The ``reservation`` + field accepts either a hex string (from GET responses) or a list of + dicts/ReservationEntry objects. + """ + + reservation_use: int = Field(default=0, alias="reservationUse") + reservation: list[ReservationEntry] = Field(default_factory=list) + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @model_validator(mode="before") + @classmethod + def _decode_hex_reservation(cls, data: Any) -> Any: + """Decode hex-encoded reservation string into entry list.""" + if isinstance(data, dict): + d = cast(dict[str, Any], data).copy() + raw = d.get("reservation", "") + if isinstance(raw, str): + if raw: + from ..encoding import decode_reservation_hex + + d["reservation"] = decode_reservation_hex(raw) + else: + d["reservation"] = [] + return d + return data + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether the reservation system is globally enabled. + + Device bool convention: 2=on, 1=off. + """ + return self.reservation_use == 2 + + +class WeeklyReservationEntry(NavienBaseModel): + """A single entry in a weekly temperature reservation schedule. + + Similar to :class:`ReservationEntry` but used with the RESERVATION_WEEKLY + command (33554438), which configures a separate weekly temperature schedule + independent of the timed reservation system. + + The raw protocol fields mirror the standard reservation format: + - enable: 2=enabled, 1=disabled (device boolean) + - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) + - hour: 0-23 + - min: 0-59 + - mode: DHW operation mode ID (1-6) + - param: temperature in half-degrees Celsius + """ + + enable: int = 2 + week: int = 0 + hour: int = 0 + min: int = 0 + mode: int = 1 + param: int = 0 + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether this entry is active (device bool: 2=on, 1=off).""" + return self.enable == 2 + + @computed_field # type: ignore[prop-decorator] + @property + def days(self) -> list[str]: + """Weekday names for this entry.""" + from ..encoding import decode_week_bitfield + + return decode_week_bitfield(self.week) + + @computed_field # type: ignore[prop-decorator] + @property + def time(self) -> str: + """Formatted time string (HH:MM).""" + return f"{self.hour:02d}:{self.min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def temperature(self) -> float: + """Temperature in the user's preferred unit.""" + return reservation_param_to_preferred(self.param) + + @computed_field # type: ignore[prop-decorator] + @property + def unit(self) -> str: + """Temperature unit symbol.""" + return "°C" if get_unit_system() == "metric" else "°F" + + @computed_field # type: ignore[prop-decorator] + @property + def mode_name(self) -> str: + """Human-readable operation mode name.""" + try: + return DHW_OPERATION_SETTING_TEXT.get( + DhwOperationSetting(self.mode), f"Unknown ({self.mode})" + ) + except ValueError: + return f"Unknown ({self.mode})" + + +class WeeklyReservationSchedule(NavienBaseModel): + """Complete weekly reservation schedule (RESERVATION_WEEKLY command). + + Used with command code 33554438 to configure a temperature schedule + that repeats weekly. Accepts the same hex-encoded format as the + standard reservation schedule. + """ + + reservation_use: int = Field(default=0, alias="reservationUse") + reservation: list[WeeklyReservationEntry] = Field(default_factory=list) + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @model_validator(mode="before") + @classmethod + def _decode_hex_reservation(cls, data: Any) -> Any: + """Decode hex-encoded reservation string into entry list.""" + if isinstance(data, dict): + d = cast(dict[str, Any], data).copy() + raw = d.get("reservation", "") + if isinstance(raw, str): + if raw: + from ..encoding import decode_reservation_hex + + d["reservation"] = decode_reservation_hex(raw) + else: + d["reservation"] = [] + return d + return data + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether the weekly reservation system is globally enabled. + + Device bool convention: 2=on, 1=off. + """ + return self.reservation_use == 2 + + +class RecirculationScheduleEntry(NavienBaseModel): + """A single entry in a recirculation pump schedule. + + Used with the RECIR_RESERVATION command (33554444) to set timed + recirculation cycles. Each entry defines a time window and pump mode. + + Fields: + - enable: 2=enabled, 1=disabled (device boolean) + - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) + - start_hour: 0-23 + - start_min: 0-59 + - end_hour: 0-23 + - end_min: 0-59 + - mode: recirculation mode + (1=Constant, 2=Timer, 3=Temperature, 4=Sensor) + """ + + enable: int = 2 + week: int = 0 + start_hour: int = Field(default=0, alias="startHour") + start_min: int = Field(default=0, alias="startMin") + end_hour: int = Field(default=0, alias="endHour") + end_min: int = Field(default=0, alias="endMin") + mode: int = 1 + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether this entry is active (device bool: 2=on, 1=off).""" + return self.enable == 2 + + @computed_field # type: ignore[prop-decorator] + @property + def days(self) -> list[str]: + """Weekday names for this entry.""" + from ..encoding import decode_week_bitfield + + return decode_week_bitfield(self.week) + + @computed_field # type: ignore[prop-decorator] + @property + def start_time(self) -> str: + """Formatted start time string (HH:MM).""" + return f"{self.start_hour:02d}:{self.start_min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def end_time(self) -> str: + """Formatted end time string (HH:MM).""" + return f"{self.end_hour:02d}:{self.end_min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def mode_name(self) -> str: + """Human-readable recirculation mode name.""" + try: + return RecirculationMode(self.mode).name.replace("_", " ").title() + except ValueError: + return f"Unknown ({self.mode})" + + +class RecirculationSchedule(NavienBaseModel): + """Complete recirculation pump schedule (RECIR_RESERVATION command). + + Used with command code 33554444 to configure timed recirculation + pump operation windows. + """ + + schedule: list[RecirculationScheduleEntry] = Field(default_factory=list) + + +class OtaCommitPayload(NavienBaseModel): + """Payload for committing a firmware component update. + + Used with the OTA_COMMIT command (33554442). This command uses a + special ``commitOta`` structure instead of the standard mode/param + format. + + Args: + sw_code: Software component code identifying which firmware to commit. + 1 = Controller, 2 = Panel, 4 = WiFi/communication module. + sw_version: Version number to commit (as reported by the OTA check). + """ + + sw_code: int = Field(alias="swCode") + sw_version: int = Field(alias="swVersion") + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + ) diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py new file mode 100644 index 0000000..4315c2b --- /dev/null +++ b/src/nwp500/models/status.py @@ -0,0 +1,927 @@ +from __future__ import annotations + +from typing import Annotated + +from pydantic import BeforeValidator, Field, computed_field + +from .._base import NavienBaseModel +from ..converters import ( + device_bool_to_python, + div_10, + mul_10, + tou_override_to_python, +) +from ..enums import ( + CurrentOperationMode, + DhwOperationSetting, + DREvent, + ErrorCode, + HeatSource, + RecirculationMode, + TemperatureType, + TempFormulaType, +) +from ..field_factory import signal_strength_field, temperature_field +from ..temperature import ( + DeciCelsius, + DeciCelsiusDelta, + HalfCelsius, + RawCelsius, +) +from ..unit_system import get_unit_system + +DeviceBool = Annotated[bool, BeforeValidator(device_bool_to_python)] +Div10 = Annotated[float, BeforeValidator(div_10)] +TenWhToWh = Annotated[float, BeforeValidator(mul_10)] +TouStatus = Annotated[bool, BeforeValidator(bool)] +TouOverride = Annotated[bool, BeforeValidator(tou_override_to_python)] + + +class DeviceStatus(NavienBaseModel): + """Represents the status of the Navien water heater device.""" + + # CRITICAL: temperature_type must remain the first field so computed + # temperature properties can fall back to the device's native unit setting. + temperature_type: TemperatureType = Field( + default=TemperatureType.FAHRENHEIT, + description=( + "Type of temperature unit (1=Celsius, 2=Fahrenheit). " + "Controls all unit conversions." + ), + ) + + mac_address: str | None = Field( + default=None, + description="MAC address of the origin device", + ) + + # Basic status fields + command: int = Field( + description="The command that triggered this status update" + ) + special_function_status: int = Field( + description=( + "Status of special functions " + "(e.g., freeze protection, anti-seize operations)" + ) + ) + error_code: ErrorCode = Field( + default=ErrorCode.NO_ERROR, + description="Error code if any fault is detected", + ) + sub_error_code: int = Field( + description="Sub error code providing additional error details" + ) + smart_diagnostic: int = Field( + description=( + "Smart diagnostic status code for system health monitoring. " + "0 = no diagnostic conditions. " + "Non-zero = diagnostic condition detected. " + "Specific diagnostic codes are device firmware dependent." + ) + ) + fault_status1: int = Field(description="Fault status register 1") + fault_status2: int = Field(description="Fault status register 2") + wifi_rssi: int = signal_strength_field( + "WiFi signal strength in dBm. " + "Typical values: -30 (excellent) to -90 (poor)" + ) + dhw_charge_per: float = Field( + description=( + "DHW charge percentage - " + "estimated percentage of hot water capacity available" + ), + json_schema_extra={"unit_of_measurement": "%"}, + ) + dr_event_status: DREvent = Field( + default=DREvent.UNKNOWN, + description=( + "Demand Response (DR) event status from utility (CTA-2045). " + "0=UNKNOWN (No event), 1=RUN_NORMAL, 2=SHED (reduce power), " + "3=LOADUP (pre-heat), 4=LOADUP_ADV (advanced pre-heat), " + "5=CPE (customer peak event/grid emergency)" + ), + ) + vacation_day_setting: int = Field( + description="Vacation day setting", + json_schema_extra={"unit_of_measurement": "days"}, + ) + vacation_day_elapsed: int = Field( + description="Elapsed vacation days", + json_schema_extra={"unit_of_measurement": "days"}, + ) + anti_legionella_period: int = Field( + description=( + "Anti-legionella cycle interval. Range: 1-30 days, Default: 7 days" + ), + json_schema_extra={"unit_of_measurement": "days"}, + ) + program_reservation_type: int = Field( + description="Type of program reservation" + ) + temp_formula_type: TempFormulaType = Field( + description="Temperature formula type" + ) + outside_temperature_raw: int = temperature_field( + "Outdoor/ambient temperature", alias="outsideTemperature" + ) + current_statenum: int = Field(description="Current state number") + target_fan_rpm: int = Field( + description="Target fan RPM", + json_schema_extra={"unit_of_measurement": "RPM"}, + ) + current_fan_rpm: int = Field( + description="Current fan RPM", + json_schema_extra={"unit_of_measurement": "RPM"}, + ) + fan_pwm: int = Field(description="Fan PWM value") + mixing_rate: float = Field( + description=( + "Mixing valve rate percentage (0-100%). " + "Controls mixing of hot tank water with cold inlet water" + ), + json_schema_extra={"unit_of_measurement": "%"}, + ) + eev_step: int = Field( + description=( + "Electronic Expansion Valve (EEV) step position. " + "Valve opening rate expressed as step count" + ) + ) + air_filter_alarm_period: int = Field( + description=( + "Air filter maintenance cycle interval. " + "Range: Off or 1,000-10,000 hours, Default: 1,000 hours" + ), + json_schema_extra={"unit_of_measurement": "h"}, + ) + air_filter_alarm_elapsed: int = Field( + description=( + "Operating hours elapsed since last air filter maintenance reset. " + "Track this to schedule preventative replacement" + ), + json_schema_extra={"unit_of_measurement": "h"}, + ) + cumulated_op_time_eva_fan: int = Field( + description=( + "Cumulative operation time of the evaporator fan since installation" + ), + json_schema_extra={"unit_of_measurement": "h"}, + ) + cumulated_dhw_flow_rate_raw: int = Field( + alias="cumulatedDhwFlowRate", + description=( + "Cumulative DHW flow - " + "total volume of hot water delivered since installation" + ), + json_schema_extra={ + "unit_of_measurement": "gal", + "device_class": "water", + }, + ) + tou_status: TouStatus = Field( + description=( + "Time of Use (TOU) scheduling enabled. " + "True = TOU is active/enabled, False = TOU is disabled" + ) + ) + dr_override_status: int = Field( + description=( + "Demand Response override status in hours. " + "0 = no override active. " + "Non-zero (1-72) = override active with specified remaining hours. " + "User can override DR commands for up to 72 hours." + ), + json_schema_extra={"unit_of_measurement": "hours"}, + ) + tou_override_status: TouOverride = Field( + description=( + "TOU override status. " + "True = user has overridden TOU to force immediate heating, " + "False = device follows TOU schedule normally" + ) + ) + total_energy_capacity: TenWhToWh = Field( + description="Total energy capacity of the tank in Watt-hours", + json_schema_extra={ + "unit_of_measurement": "Wh", + "device_class": "energy", + }, + ) + available_energy_capacity: TenWhToWh = Field( + description=( + "Available energy capacity - " + "remaining hot water energy available in Watt-hours" + ), + json_schema_extra={ + "unit_of_measurement": "Wh", + "device_class": "energy", + }, + ) + recirc_operation_mode: RecirculationMode = Field( + description="Recirculation operation mode" + ) + recirc_pump_operation_status: int = Field( + description="Recirculation pump operation status" + ) + recirc_hot_btn_ready: int = Field( + description="Recirculation HotButton ready status" + ) + recirc_operation_reason: int = Field( + description="Recirculation operation reason" + ) + recirc_error_status: int = Field(description="Recirculation error status") + current_inst_power: float = Field( + description=( + "Current instantaneous power consumption in Watts. " + "Does not include heating element power when active" + ), + json_schema_extra={ + "unit_of_measurement": "W", + "device_class": "power", + }, + ) + + # Boolean fields with device-specific encoding + did_reload: DeviceBool = Field( + description="Indicates if the device has recently reloaded or restarted" + ) + operation_busy: DeviceBool = Field( + description=( + "Indicates if the device is currently performing heating operations" + ) + ) + freeze_protection_use: DeviceBool = Field( + description=( + "Whether freeze protection is active. " + "Electric heater activates when tank water falls below threshold" + ) + ) + dhw_use: DeviceBool = Field( + description=( + "Domestic Hot Water (DHW) usage status - " + "indicates if hot water is currently being drawn from the tank" + ) + ) + dhw_use_sustained: DeviceBool = Field( + description=( + "Sustained DHW usage status - indicates prolonged hot water usage" + ) + ) + dhw_operation_busy: DeviceBool = Field( + default=False, + description=( + "DHW operation busy status - " + "indicates if the device is currently heating water to meet demand" + ), + ) + program_reservation_use: DeviceBool = Field( + description=( + "Whether a program reservation (scheduled operation) is in use" + ) + ) + eco_use: DeviceBool = Field( + description=( + "Whether ECO (Energy Cut Off) high-temp safety limit is triggered" + ) + ) + comp_use: DeviceBool = Field( + description=( + "Compressor usage status (True=On, False=Off). " + "The compressor is the main component of the heat pump" + ) + ) + eev_use: DeviceBool = Field( + description=( + "Electronic Expansion Valve (EEV) usage status. " + "The EEV controls refrigerant flow" + ) + ) + eva_fan_use: DeviceBool = Field( + description=( + "Evaporator fan usage status. " + "The fan pulls ambient air through the evaporator coil" + ) + ) + shut_off_valve_use: DeviceBool = Field( + description=( + "Shut-off valve usage status. " + "The valve controls refrigerant flow in the system" + ) + ) + con_ovr_sensor_use: DeviceBool = Field( + description="Condensate overflow sensor usage status" + ) + wtr_ovr_sensor_use: DeviceBool = Field( + description=( + "Water overflow/leak sensor usage status. " + "Triggers error E799 if leak detected" + ) + ) + anti_legionella_use: DeviceBool = Field( + description=( + "Whether anti-legionella function is enabled. " + "Device periodically heats tank to prevent Legionella bacteria" + ) + ) + anti_legionella_operation_busy: DeviceBool = Field( + description=( + "Whether the anti-legionella disinfection cycle " + "is currently running" + ) + ) + error_buzzer_use: DeviceBool = Field( + description="Whether the error buzzer is enabled" + ) + current_heat_use: HeatSource = Field( + description=( + "Currently active heat source. Indicates which heating " + "component(s) are actively running: 0=Unknown/not heating, " + "1=Heat Pump, 2=Electric Element, 3=Both simultaneously" + ) + ) + heat_upper_use: DeviceBool = Field( + description=( + "Upper electric heating element usage status. " + "Power: 3,755W @ 208V or 5,000W @ 240V" + ) + ) + heat_lower_use: DeviceBool = Field( + description=( + "Lower electric heating element usage status. " + "Power: 3,755W @ 208V or 5,000W @ 240V" + ) + ) + scald_use: DeviceBool = Field( + description=( + "Scald protection active status. " + "Warning when water reaches potentially hazardous levels" + ) + ) + air_filter_alarm_use: DeviceBool = Field( + description=( + "Air filter maintenance reminder enabled flag. " + "Triggers alerts based on operating hours. Default: On" + ) + ) + recirc_operation_busy: DeviceBool = Field( + description="Recirculation operation busy status" + ) + recirc_reservation_use: DeviceBool = Field( + description="Recirculation reservation usage status" + ) + + # Raw temperature, flow, and volume fields + dhw_temperature_raw: int = temperature_field( + "Current Domestic Hot Water (DHW) outlet temperature", + alias="dhwTemperature", + ) + dhw_temperature_setting_raw: int = temperature_field( + "User-configured target DHW temperature", + alias="dhwTemperatureSetting", + ) + dhw_target_temperature_setting_raw: int = temperature_field( + "Duplicate of dhw_temperature_setting for legacy API compatibility", + alias="dhwTargetTemperatureSetting", + ) + freeze_protection_temperature_raw: int = temperature_field( + "Freeze protection temperature setpoint. " + "Prevents tank from freezing in cold environments", + alias="freezeProtectionTemperature", + ) + dhw_temperature2_raw: int = temperature_field( + "Second DHW temperature reading", + alias="dhwTemperature2", + ) + hp_upper_on_temp_setting_raw: int = temperature_field( + "Heat pump upper on temperature setting", + alias="hpUpperOnTempSetting", + ) + hp_upper_off_temp_setting_raw: int = temperature_field( + "Heat pump upper off temperature setting", + alias="hpUpperOffTempSetting", + ) + hp_lower_on_temp_setting_raw: int = temperature_field( + "Heat pump lower on temperature setting", + alias="hpLowerOnTempSetting", + ) + hp_lower_off_temp_setting_raw: int = temperature_field( + "Heat pump lower off temperature setting", + alias="hpLowerOffTempSetting", + ) + he_upper_on_temp_setting_raw: int = temperature_field( + "Heater element upper on temperature setting", + alias="heUpperOnTempSetting", + ) + he_upper_off_temp_setting_raw: int = temperature_field( + "Heater element upper off temperature setting", + alias="heUpperOffTempSetting", + ) + he_lower_on_temp_setting_raw: int = temperature_field( + "Heater element lower on temperature setting", + alias="heLowerOnTempSetting", + ) + he_lower_off_temp_setting_raw: int = temperature_field( + "Heater element lower off temperature setting", + alias="heLowerOffTempSetting", + ) + heat_min_op_temperature_raw: int = temperature_field( + "Minimum heat pump operation temperature. " + "Lowest tank setpoint allowed for heat pump operation", + alias="heatMinOpTemperature", + ) + recirc_temp_setting_raw: int = temperature_field( + "Recirculation temperature setting", + alias="recircTempSetting", + ) + recirc_temperature_raw: int = temperature_field( + "Recirculation temperature", + alias="recircTemperature", + ) + recirc_faucet_temperature_raw: int = temperature_field( + "Recirculation faucet temperature", + alias="recircFaucetTemperature", + ) + current_inlet_temperature_raw: int = temperature_field( + "Cold water inlet temperature", + alias="currentInletTemperature", + ) + current_dhw_flow_rate_raw: int = Field( + alias="currentDhwFlowRate", + description="Current DHW flow rate", + json_schema_extra={ + "unit_of_measurement": "GPM", + "device_class": "flow_rate", + }, + ) + hp_upper_on_diff_temp_setting_raw: int = Field( + alias="hpUpperOnDiffTempSetting", + description="Heat pump upper on differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + hp_upper_off_diff_temp_setting_raw: int = Field( + alias="hpUpperOffDiffTempSetting", + description="Heat pump upper off differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + hp_lower_on_diff_temp_setting_raw: int = Field( + alias="hpLowerOnDiffTempSetting", + description="Heat pump lower on differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + hp_lower_off_diff_temp_setting_raw: int = Field( + alias="hpLowerOffDiffTempSetting", + description="Heat pump lower off differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + he_upper_on_diff_temp_setting_raw: int = Field( + alias="heUpperOnDiffTempSetting", + description="Heater element upper on differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + he_upper_off_diff_temp_setting_raw: int = Field( + alias="heUpperOffDiffTempSetting", + description="Heater element upper off differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + he_lower_on_diff_temp_setting_raw: int = Field( + alias="heLowerOnTDiffempSetting", + description="Heater element lower on differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) # Handle API typo: heLowerOnTDiffempSetting -> heLowerOnDiffTempSetting + he_lower_off_diff_temp_setting_raw: int = Field( + alias="heLowerOffDiffTempSetting", + description="Heater element lower off differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + recirc_dhw_flow_rate_raw: int = Field( + alias="recircDhwFlowRate", + description="Recirculation DHW flow rate (dynamic units: LPM/GPM)", + json_schema_extra={ + "device_class": "flow_rate", + }, + ) + tank_upper_temperature_raw: int = temperature_field( + "Temperature of the upper part of the tank", + alias="tankUpperTemperature", + ) + tank_lower_temperature_raw: int = temperature_field( + "Temperature of the lower part of the tank", + alias="tankLowerTemperature", + ) + discharge_temperature_raw: int = temperature_field( + "Compressor discharge temperature - " + "temperature of refrigerant leaving the compressor", + alias="dischargeTemperature", + ) + suction_temperature_raw: int = temperature_field( + "Compressor suction temperature - " + "temperature of refrigerant entering the compressor", + alias="suctionTemperature", + ) + evaporator_temperature_raw: int = temperature_field( + "Evaporator temperature - " + "temperature where heat is absorbed from ambient air", + alias="evaporatorTemperature", + ) + ambient_temperature_raw: int = temperature_field( + "Ambient air temperature measured at the heat pump air intake", + alias="ambientTemperature", + ) + target_super_heat_raw: int = temperature_field( + "Target superheat value - desired temperature difference " + "ensuring complete refrigerant vaporization", + alias="targetSuperHeat", + ) + current_super_heat_raw: int = temperature_field( + "Current superheat value - actual temperature difference " + "between suction and evaporator temperatures", + alias="currentSuperHeat", + ) + + # Enum fields + operation_mode: CurrentOperationMode = Field( + default=CurrentOperationMode.STANDBY, + description="The current actual operational state of the device", + ) + dhw_operation_setting: DhwOperationSetting = Field( + default=DhwOperationSetting.ENERGY_SAVER, + description="User's configured DHW operation mode preference", + ) + freeze_protection_temp_min_raw: int = temperature_field( + "Active freeze protection lower limit", + alias="freezeProtectionTempMin", + default=43, + ) + freeze_protection_temp_max_raw: int = temperature_field( + "Active freeze protection upper limit", + alias="freezeProtectionTempMax", + default=65, + ) + + def _is_celsius(self) -> bool: + """Return True if metric/Celsius units should be used.""" + unit_system = get_unit_system() + if unit_system is not None: + return unit_system == "metric" + return self.temperature_type == TemperatureType.CELSIUS + + @computed_field # type: ignore[prop-decorator] + @property + def outside_temperature(self) -> float: + raw = RawCelsius(self.outside_temperature_raw) + if self._is_celsius(): + return raw.to_celsius() + return raw.to_fahrenheit_with_formula(self.temp_formula_type) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature(self) -> float: + return HalfCelsius(self.dhw_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature_setting(self) -> float: + return HalfCelsius(self.dhw_temperature_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_target_temperature_setting(self) -> float: + return HalfCelsius( + self.dhw_target_temperature_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temperature(self) -> float: + return HalfCelsius(self.freeze_protection_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature2(self) -> float: + return HalfCelsius(self.dhw_temperature2_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_on_temp_setting(self) -> float: + return HalfCelsius(self.hp_upper_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_off_temp_setting(self) -> float: + return HalfCelsius(self.hp_upper_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_on_temp_setting(self) -> float: + return HalfCelsius(self.hp_lower_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_off_temp_setting(self) -> float: + return HalfCelsius(self.hp_lower_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_on_temp_setting(self) -> float: + return HalfCelsius(self.he_upper_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_off_temp_setting(self) -> float: + return HalfCelsius(self.he_upper_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_on_temp_setting(self) -> float: + return HalfCelsius(self.he_lower_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_off_temp_setting(self) -> float: + return HalfCelsius(self.he_lower_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def heat_min_op_temperature(self) -> float: + return HalfCelsius(self.heat_min_op_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temp_setting(self) -> float: + return HalfCelsius(self.recirc_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temperature(self) -> float: + return HalfCelsius(self.recirc_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_faucet_temperature(self) -> float: + return HalfCelsius(self.recirc_faucet_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def current_inlet_temperature(self) -> float: + return HalfCelsius(self.current_inlet_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def current_dhw_flow_rate(self) -> float: + lpm = self.current_dhw_flow_rate_raw / 10.0 + if self._is_celsius(): + return lpm + return round(lpm * 0.264172, 2) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_upper_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_upper_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_lower_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_lower_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_upper_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_upper_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_lower_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_lower_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_dhw_flow_rate(self) -> float: + lpm = self.recirc_dhw_flow_rate_raw / 10.0 + if self._is_celsius(): + return lpm + return round(lpm * 0.264172, 2) + + @computed_field # type: ignore[prop-decorator] + @property + def tank_upper_temperature(self) -> float: + return DeciCelsius(self.tank_upper_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def tank_lower_temperature(self) -> float: + return DeciCelsius(self.tank_lower_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def discharge_temperature(self) -> float: + return DeciCelsius(self.discharge_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def suction_temperature(self) -> float: + return DeciCelsius(self.suction_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def evaporator_temperature(self) -> float: + return DeciCelsius(self.evaporator_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def ambient_temperature(self) -> float: + return DeciCelsius(self.ambient_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def target_super_heat(self) -> float: + return DeciCelsius(self.target_super_heat_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def current_super_heat(self) -> float: + return DeciCelsius(self.current_super_heat_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def cumulated_dhw_flow_rate(self) -> float: + liters = float(self.cumulated_dhw_flow_rate_raw) + if self._is_celsius(): + return liters + return round(liters * 0.264172, 2) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_min(self) -> float: + return HalfCelsius(self.freeze_protection_temp_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_max(self) -> float: + return HalfCelsius(self.freeze_protection_temp_max_raw).to_preferred( + self._is_celsius() + ) + + def get_field_unit(self, field_name: str) -> str: + """Get the correct unit suffix based on temperature preference. + + Resolves dynamic units for temperature, flow rate, and volume fields + that change based on unit system context override or the device's + temperature_type setting (Celsius or Fahrenheit). + + Args: + field_name: Name of the field to get the unit for + + Returns: + Unit string (e.g., " °C", " LPM", " L") or empty if field not found + """ + model_fields = self.__class__.model_fields + lookup_name = ( + field_name if field_name in model_fields else f"{field_name}_raw" + ) + if lookup_name not in model_fields: + return "" + + field_info = model_fields[lookup_name] + if not hasattr(field_info, "json_schema_extra"): + return "" + + extra = field_info.json_schema_extra + if not isinstance(extra, dict): + return "" + + is_celsius = self._is_celsius() + + device_class = extra.get("device_class") + + # Handle temperature units + if device_class == "temperature": + return " °C" if is_celsius else " °F" + + # Handle flow rate units + if device_class == "flow_rate": + return " LPM" if is_celsius else " GPM" + + # Handle volume units + if device_class == "water": + return " L" if is_celsius else " gal" + + # Fallback to static unit_of_measurement if present + if "unit_of_measurement" in extra: + unit_val = extra["unit_of_measurement"] + unit: str = str(unit_val) if unit_val is not None else "" + return f" {unit}" if unit else "" + + return "" diff --git a/src/nwp500/models/tou.py b/src/nwp500/models/tou.py new file mode 100644 index 0000000..5f85885 --- /dev/null +++ b/src/nwp500/models/tou.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from typing import Any, cast + +from pydantic import ConfigDict, Field, computed_field, model_validator + +from .._base import NavienBaseModel + + +class TOUSchedule(NavienBaseModel): + """Time of Use schedule information.""" + + season: int = 0 + intervals: list[dict[str, Any]] = Field( + default_factory=list, alias="interval" + ) + + +class ConvertedTOUPlan(NavienBaseModel): + """A rate plan converted by the Navien backend from OpenEI format. + + Returned by POST /device/tou/convert. Contains the utility name, + plan name, and device-ready schedule with season/week bitfields + and scaled pricing. + """ + + utility: str = "" + name: str = "" + schedule: list[TOUSchedule] = Field(default_factory=list) + + +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 = 0 + schedule: list[TOUSchedule] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def _extract_nested_tou_info(cls, data: Any) -> Any: + # Handle nested structure where fields are in 'touInfo' + if isinstance(data, dict): + # Explicitly cast to dict[str, Any] for type safety + d = cast(dict[str, Any], data).copy() + if "touInfo" in d: + tou_data = d.pop("touInfo") + if isinstance(tou_data, dict): + d.update(tou_data) + return d + return data + + +class TOUPeriod(NavienBaseModel): + """A single TOU pricing period from an MQTT ``tou/rd`` response. + + Each period defines a time window, active season/week bitfields, + and the pricing range for that window. + + Fields use camelCase aliases to match the raw MQTT payload: + - season: bitfield of active months (bit N-1 set for month N) + - week: bitfield of active weekdays (Sun=bit7, …, Sat=bit1) + - startHour / startMinute: start of the time window (0-23 / 0-59) + - endHour / endMinute: end of the time window (0-23 / 0-59) + - priceMin / priceMax: encoded integer prices (divide by + 10^decimalPoint) + - decimalPoint: number of decimal places for price values + """ + + season: int = 0 + week: int = 0 + start_hour: int = Field(default=0, alias="startHour") + start_min: int = Field(default=0, alias="startMinute") + end_hour: int = Field(default=0, alias="endHour") + end_min: int = Field(default=0, alias="endMinute") + price_min: int = Field(default=0, alias="priceMin") + price_max: int = Field(default=0, alias="priceMax") + decimal_point: int = Field(default=5, alias="decimalPoint") + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @computed_field # type: ignore[prop-decorator] + @property + def start_time(self) -> str: + """Formatted start time (HH:MM).""" + return f"{self.start_hour:02d}:{self.start_min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def end_time(self) -> str: + """Formatted end time (HH:MM).""" + return f"{self.end_hour:02d}:{self.end_min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def decoded_price_min(self) -> float: + """Minimum price decoded to a float (price_min / 10^decimal_point).""" + divisor: float = 10.0**self.decimal_point + return float(self.price_min) / divisor + + @computed_field # type: ignore[prop-decorator] + @property + def decoded_price_max(self) -> float: + """Maximum price decoded to a float (price_max / 10^decimal_point).""" + divisor: float = 10.0**self.decimal_point + return float(self.price_max) / divisor + + +class TOUReservationSchedule(NavienBaseModel): + """TOU schedule as returned by the MQTT ``tou/rd`` response topic. + + This model matches the raw MQTT payload for both + :meth:`~nwp500.NavienMqttClient.request_tou_settings` read responses + and :meth:`~nwp500.NavienMqttClient.configure_tou_schedule` write + confirmations — both use ``CommandCode.TOU_RESERVATION`` and the + ``tou/rd`` response topic. + + The payload structure is:: + + { + "reservationUse": 2, # 0=disabled, 2=enabled + "reservation": [ # list of TOU period dicts + { + "season": 4095, "week": 254, + "startHour": 0, "startMinute": 0, + "endHour": 23, "endMinute": 59, + "priceMin": 10, "priceMax": 25, + "decimalPoint": 2 + }, + ... + ] + } + """ + + reservation_use: int = Field(default=0, alias="reservationUse") + reservation: list[TOUPeriod] = Field(default_factory=list) + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether TOU scheduling is globally enabled. + + Protocol convention: 0=disabled, 2=enabled. + """ + return self.reservation_use == 2 diff --git a/src/nwp500/mqtt/__init__.py b/src/nwp500/mqtt/__init__.py index 2a0d205..2a82fd3 100644 --- a/src/nwp500/mqtt/__init__.py +++ b/src/nwp500/mqtt/__init__.py @@ -11,6 +11,8 @@ - MqttMetrics, ConnectionDropEvent, ConnectionEvent: Diagnostic types """ +from __future__ import annotations + from .client import NavienMqttClient from .diagnostics import ( ConnectionDropEvent, diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 256885e..3a3a6f1 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -15,7 +15,8 @@ import json import logging import uuid -from collections.abc import Callable +import warnings +from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any, cast from awscrt import mqtt @@ -31,7 +32,11 @@ MqttPublishError, TokenRefreshError, ) -from ..unit_system import UnitSystemType, set_unit_system +from ..mqtt_events import ( + ConnectionInterruptedEvent, + ConnectionResumedEvent, +) +from ..unit_system import UnitSystemType from .command_queue import MqttCommandQueue from .connection import MqttConnection from .control import MqttDeviceController @@ -50,6 +55,11 @@ DeviceFeature, DeviceStatus, EnergyUsageResponse, + OtaCommitPayload, + RecirculationSchedule, + ReservationSchedule, + TOUReservationSchedule, + WeeklyReservationSchedule, ) __author__ = "Emmanuel Levijarvi" @@ -102,7 +112,8 @@ class NavienMqttClient(EventEmitter): ... ... # Type-safe event listeners with IDE autocomplete ... mqtt_client.on( - ... MqttClientEvents.TEMPERATURE_CHANGED, log_temperature + ... MqttClientEvents.TEMPERATURE_CHANGED, + ... lambda event: log_temperature(event.new_temperature), ... ) ... mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, update_ui) ... mqtt_client.on( @@ -180,10 +191,6 @@ def __init__( # Initialize EventEmitter super().__init__() - # Set unit system preference if provided - if unit_system is not None: - set_unit_system(unit_system) - self._auth_client = auth_client self._unit_system: UnitSystemType = unit_system self.config = config or MqttConnectionConfig() @@ -198,11 +205,18 @@ def __init__( # Command queue (independent, can be created immediately) self._command_queue = MqttCommandQueue(config=self.config) + # Device controller (independent of connection status, + # uses client.publish for queuing) + self._device_controller = MqttDeviceController( + client_id=self.config.client_id or "", + session_id=self._session_id, + publish_func=self.publish, + ) + # Components that depend on connection (initialized in connect()) 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 @@ -255,7 +269,12 @@ def _on_connection_interrupted_internal( self._connected = False # Emit event - self._schedule_coroutine(self.emit("connection_interrupted", error)) + self._schedule_coroutine( + self.emit( + "connection_interrupted", + ConnectionInterruptedEvent(error=error), + ) + ) # Delegate to reconnection handler if available if self._reconnection_handler and self.config.auto_reconnect: @@ -284,9 +303,20 @@ def _on_connection_interrupted_internal( ) def _on_connection_resumed_internal( - self, return_code: Any, session_present: Any + self, + connection: mqtt.Connection, + return_code: Any, + session_present: Any, + **kwargs: Any, ) -> None: - """Internal handler for connection resumption.""" + """Internal handler for connection resumption. + + Args: + connection: MQTT connection that was resumed + return_code: MQTT return code from the resumed connection + session_present: Whether the previous session was present + **kwargs: Forward-compatibility kwargs from AWS SDK + """ _logger.info( f"Connection resumed: return_code={return_code}, " f"session_present={session_present}" @@ -295,7 +325,13 @@ def _on_connection_resumed_internal( # Emit event self._schedule_coroutine( - self.emit("connection_resumed", return_code, session_present) + self.emit( + "connection_resumed", + ConnectionResumedEvent( + return_code=return_code, + session_present=session_present, + ), + ) ) # Delegate to reconnection handler to reset state @@ -566,16 +602,10 @@ async def connect(self) -> bool: event_emitter=self, schedule_coroutine=self._schedule_coroutine, device_info_cache=device_info_cache, - unit_system=self._unit_system, ) - # Initialize device controller with cache - self._device_controller = MqttDeviceController( - client_id=client_id, - session_id=self._session_id, - publish_func=self._connection_manager.publish, - device_info_cache=device_info_cache, - ) + # Update device controller cache + self._device_controller.device_info_cache = device_info_cache # Set the auto-request callback on the controller # Wrap ensure_device_info_cached to match callback signature @@ -678,8 +708,12 @@ def _create_credentials_provider(self) -> Any: # Get current tokens from auth client auth_tokens = self._auth_client.current_tokens - if not auth_tokens: - raise MqttCredentialsError("No tokens available from auth client") + if ( + not auth_tokens + or not auth_tokens.access_key_id + or not auth_tokens.secret_key + ): + raise MqttCredentialsError("AWS credentials not available") return AwsCredentialsProvider.new_static( access_key_id=auth_tokens.access_key_id, @@ -887,6 +921,16 @@ async def subscribe_device_status( "subscribe_device_status", device, callback ) + async def unsubscribe_device_status( + self, device: Device, callback: Callable[[DeviceStatus], None] + ) -> None: + """Unsubscribe a specific device status callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_device_status( + device, callback + ) + async def subscribe_device_feature( self, device: Device, callback: Callable[[DeviceFeature], None] ) -> int: @@ -915,6 +959,310 @@ async def subscribe_energy_usage( "subscribe_energy_usage", device, callback ) + async def unsubscribe_energy_usage( + self, + device: Device, + callback: Callable[[EnergyUsageResponse], None], + ) -> None: + """Unsubscribe a specific energy usage callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_energy_usage( + device, callback + ) + + async def subscribe_reservation_response( + self, + device: Device, + callback: Callable[[ReservationSchedule], None], + ) -> int: + """Subscribe to reservation read responses with automatic parsing.""" + return await self._delegate_subscription( + "subscribe_reservation_response", device, callback + ) + + async def unsubscribe_reservation_response( + self, + device: Device, + callback: Callable[[ReservationSchedule], None], + ) -> None: + """Unsubscribe a specific reservation response callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_reservation_response( + device, callback + ) + + async def subscribe_weekly_reservation_response( + self, + device: Device, + callback: Callable[[WeeklyReservationSchedule], None], + ) -> int: + """Subscribe to weekly reservation read responses.""" + return await self._delegate_subscription( + "subscribe_weekly_reservation_response", device, callback + ) + + async def unsubscribe_weekly_reservation_response( + self, + device: Device, + callback: Callable[[WeeklyReservationSchedule], None], + ) -> None: + """Unsubscribe a specific weekly reservation callback.""" + if not self._connected or not self._subscription_manager: + return + manager = self._subscription_manager + await manager.unsubscribe_weekly_reservation_response(device, callback) + + async def subscribe_recirculation_schedule_response( + self, + device: Device, + callback: Callable[[RecirculationSchedule], None], + ) -> int: + """Subscribe to recirculation schedule read responses.""" + return await self._delegate_subscription( + "subscribe_recirculation_schedule_response", device, callback + ) + + async def unsubscribe_recirculation_schedule_response( + self, + device: Device, + callback: Callable[[RecirculationSchedule], None], + ) -> None: + """Unsubscribe a specific recirculation schedule callback.""" + if not self._connected or not self._subscription_manager: + return + manager = self._subscription_manager + await manager.unsubscribe_recirculation_schedule_response( + device, callback + ) + + async def subscribe_tou_response( + self, + device: Device, + callback: Callable[[TOUReservationSchedule], None], + ) -> int: + """Subscribe to Time-of-Use schedule read responses with automatic + parsing. + + Subscribes to the ``tou/rd`` response topic for the given device. + The callback receives a fully-parsed + :class:`~nwp500.models.TOUReservationSchedule` whenever the device + responds to a TOU read or configure request (triggered by + :meth:`request_tou_settings` or :meth:`configure_tou_schedule`). + + Args: + device: Device whose TOU responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + return await self._delegate_subscription( + "subscribe_tou_response", device, callback + ) + + async def unsubscribe_tou_response( + self, + device: Device, + callback: Callable[[TOUReservationSchedule], None], + ) -> None: + """Unsubscribe a specific TOU response callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_tou_response( + device, callback + ) + + # ------------------------------------------------------------------------- + # Device control proxies (delegate to self.control) + # ------------------------------------------------------------------------- + + async def request_device_status(self, device: Device) -> int: + """Request general device status.""" + return await self._device_controller.request_device_status(device) + + async def request_device_info(self, device: Device) -> int: + """Request device information (features, firmware, etc.).""" + return await self._device_controller.request_device_info(device) + + async def set_power(self, device: Device, power_on: bool) -> int: + """Turn device on or off.""" + return await self._device_controller.set_power(device, power_on) + + async def set_dhw_mode( + self, device: Device, mode_id: int, vacation_days: int | None = None + ) -> int: + """Set DHW operation mode.""" + return await self._device_controller.set_dhw_mode( + device, mode_id, vacation_days + ) + + async def enable_anti_legionella( + self, device: Device, period_days: int + ) -> int: + """Enable Anti-Legionella disinfection.""" + return await self._device_controller.enable_anti_legionella( + device, period_days + ) + + async def disable_anti_legionella(self, device: Device) -> int: + """Disable the Anti-Legionella disinfection cycle.""" + return await self._device_controller.disable_anti_legionella(device) + + async def set_dhw_temperature( + self, device: Device, temperature: float + ) -> int: + """Set DHW target temperature in the user's preferred unit.""" + return await self._device_controller.set_dhw_temperature( + device, temperature + ) + + async def update_reservations( + self, + device: Device, + reservations: Sequence[dict[str, Any]], + *, + enabled: bool = True, + ) -> int: + """Update programmed reservations.""" + return await self._device_controller.update_reservations( + device, reservations, enabled=enabled + ) + + async def request_reservations(self, device: Device) -> int: + """Request the current reservation program from the device.""" + return await self._device_controller.request_reservations(device) + + async def configure_tou_schedule( + self, + device: Device, + controller_serial_number: str, + periods: Sequence[dict[str, Any]], + *, + enabled: bool = True, + ) -> int: + """Configure the Time-of-Use rate schedule.""" + return await self._device_controller.configure_tou_schedule( + device, controller_serial_number, periods, enabled=enabled + ) + + async def request_tou_settings( + self, device: Device, controller_serial_number: str + ) -> int: + """Request the current TOU settings from the device.""" + return await self._device_controller.request_tou_settings( + device, controller_serial_number + ) + + async def set_tou_enabled(self, device: Device, enabled: bool) -> int: + """Enable or disable Time-of-Use optimization.""" + return await self._device_controller.set_tou_enabled(device, enabled) + + async def request_energy_usage( + self, device: Device, year: int, months: list[int] + ) -> int: + """Request daily energy usage data for specified month(s).""" + return await self._device_controller.request_energy_usage( + device, year, months + ) + + async def signal_app_connection(self, device: Device) -> int: + """Signal that the app has connected.""" + return await self._device_controller.signal_app_connection(device) + + async def enable_demand_response(self, device: Device) -> int: + """Enable utility demand response participation.""" + return await self._device_controller.enable_demand_response(device) + + async def disable_demand_response(self, device: Device) -> int: + """Disable utility demand response participation.""" + return await self._device_controller.disable_demand_response(device) + + async def reset_air_filter(self, device: Device) -> int: + """Reset air filter maintenance timer.""" + return await self._device_controller.reset_air_filter(device) + + async def set_vacation_days(self, device: Device, days: int) -> int: + """Set vacation/away mode duration (1-30 days).""" + return await self._device_controller.set_vacation_days(device, days) + + async def update_weekly_reservation( + self, device: Device, schedule: WeeklyReservationSchedule + ) -> int: + """Configure the weekly temperature reservation schedule.""" + return await self._device_controller.update_weekly_reservation( + device, schedule + ) + + async def configure_reservation_water_program(self, device: Device) -> int: + """Enable/configure water program reservation mode.""" + return await self._control.configure_reservation_water_program(device) + + async def configure_recirculation_schedule( + self, device: Device, schedule: RecirculationSchedule + ) -> int: + """Configure the recirculation pump timed schedule.""" + return await self._device_controller.configure_recirculation_schedule( + device, schedule + ) + + async def set_recirculation_mode(self, device: Device, mode: int) -> int: + """Set recirculation pump operation mode (1-4).""" + return await self._device_controller.set_recirculation_mode( + device, mode + ) + + async def trigger_recirculation_hot_button(self, device: Device) -> int: + """Manually trigger the recirculation pump hot button.""" + return await self._device_controller.trigger_recirculation_hot_button( + device + ) + + async def check_firmware_update(self, device: Device) -> int: + """Check for available over-the-air firmware updates.""" + return await self._device_controller.check_firmware_update(device) + + async def commit_firmware_update( + self, device: Device, payload: OtaCommitPayload + ) -> int: + """Commit a previously downloaded firmware update.""" + return await self._device_controller.commit_firmware_update( + device, payload + ) + + async def reconnect_wifi(self, device: Device) -> int: + """Trigger a WiFi reconnection on the device.""" + return await self._device_controller.reconnect_wifi(device) + + async def reset_wifi(self, device: Device) -> int: + """Reset WiFi settings to factory defaults.""" + return await self._device_controller.reset_wifi(device) + + async def set_freeze_protection_temperature( + self, device: Device, temperature: float + ) -> int: + """Set the freeze protection activation temperature.""" + return await self._device_controller.set_freeze_protection_temperature( + device, temperature + ) + + async def run_smart_diagnostic(self, device: Device) -> int: + """Trigger the smart diagnostic routine on the device.""" + return await self._device_controller.run_smart_diagnostic(device) + + async def enable_intelligent_scheduling(self, device: Device) -> int: + """Enable intelligent/adaptive heating mode.""" + return await self._device_controller.enable_intelligent_scheduling( + device + ) + + async def disable_intelligent_scheduling(self, device: Device) -> int: + """Disable intelligent/adaptive heating mode.""" + return await self._device_controller.disable_intelligent_scheduling( + device + ) + async def ensure_device_info_cached( self, device: Device, timeout: float = 30.0 ) -> bool: @@ -959,7 +1307,7 @@ def on_feature(feature: DeviceFeature) -> None: await self.subscribe_device_feature(device, on_feature) try: _logger.info(f"Requesting device info from {redacted_mac}") - await self.control.request_device_info(device) + await self._device_controller.request_device_info(device) _logger.info(f"Waiting for device feature (timeout={timeout}s)") feature = await asyncio.wait_for(future, timeout=timeout) # Cache the feature immediately @@ -976,22 +1324,24 @@ def on_feature(feature: DeviceFeature) -> None: await self.unsubscribe_device_feature(device, on_feature) @property - def control(self) -> MqttDeviceController: + def _control(self) -> MqttDeviceController: """ - Get the device controller for sending commands. - - The control property enforces that the client must be connected before - accessing any control methods. This is by design to ensure device - commands are only sent when MQTT connection is established and active. - Commands like request_device_info that populate the cache are not - accessible through this property and must be called separately if - needed before connection is fully established. + Get the internal device controller for sending commands. - Raises: - MqttNotConnectedError: If client is not connected + Note: + This property is now internal. Use the delegated methods on + NavienMqttClient directly for device control. """ - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") + return self._device_controller + + @property + @warnings.deprecated( + "The .control attribute is deprecated and will be removed in v9.0.0. " + "Use the delegated methods on NavienMqttClient directly (e.g., " + "client.set_power() instead of client.control.set_power())." + ) + def control(self) -> MqttDeviceController: + """Deprecated access to device controller.""" return self._device_controller async def start_periodic_requests( diff --git a/src/nwp500/mqtt/connection.py b/src/nwp500/mqtt/connection.py index 1a6452f..4493ddb 100644 --- a/src/nwp500/mqtt/connection.py +++ b/src/nwp500/mqtt/connection.py @@ -6,6 +6,8 @@ including credential management and connection state tracking. """ +from __future__ import annotations + import asyncio import json import logging @@ -45,12 +47,14 @@ class MqttConnection: def __init__( self, - config: "MqttConnectionConfig", - auth_client: "NavienAuthClient", + config: MqttConnectionConfig, + auth_client: NavienAuthClient, on_connection_interrupted: ( Callable[[mqtt.Connection, AwsCrtError], None] | None ) = None, - on_connection_resumed: Callable[[Any, Any | None], None] | None = None, + on_connection_resumed: ( + Callable[[mqtt.Connection, Any, Any | None], None] | None + ) = None, ): """ Initialize connection manager. @@ -189,8 +193,12 @@ def _create_credentials_provider(self) -> Any: # Get current tokens from auth client auth_tokens = self._auth_client.current_tokens - if not auth_tokens: - raise MqttCredentialsError("No tokens available from auth client") + if ( + not auth_tokens + or not auth_tokens.access_key_id + or not auth_tokens.secret_key + ): + raise MqttCredentialsError("AWS credentials not available") return AwsCredentialsProvider.new_static( access_key_id=auth_tokens.access_key_id, @@ -269,7 +277,7 @@ async def subscribe( topic=topic, qos=qos, callback=callback ) subscribe_future = cast(asyncio.Future[Any], subscribe_future_raw) - packet_id = cast(int, packet_id_raw) + packet_id = packet_id_raw try: await asyncio.shield(asyncio.wrap_future(subscribe_future)) @@ -311,7 +319,7 @@ async def unsubscribe(self, topic: str) -> int: topic=topic ) unsubscribe_future = cast(asyncio.Future[Any], unsubscribe_future_raw) - packet_id = cast(int, packet_id_raw) + packet_id = int(packet_id_raw) try: await asyncio.shield(asyncio.wrap_future(unsubscribe_future)) @@ -366,7 +374,7 @@ async def publish( topic=topic, payload=payload_bytes, qos=qos ) publish_future = cast(asyncio.Future[Any], publish_future_raw) - packet_id = cast(int, packet_id_raw) + packet_id = int(packet_id_raw) # Shield the operation to prevent cancellation from propagating to # the underlying concurrent.futures.Future. This avoids diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 1167c29..bab96c3 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -17,12 +17,15 @@ - Recirculation pump control and scheduling """ +from __future__ import annotations + import logging from collections.abc import Awaitable, Callable, Sequence from datetime import UTC, datetime from typing import Any from ..command_decorators import requires_capability +from ..config import MQTT_PROTOCOL_VERSION from ..device_capabilities import MqttDeviceCapabilityChecker from ..device_info_cache import MqttDeviceInfoCache from ..enums import CommandCode, DhwOperationSetting @@ -34,6 +37,9 @@ from ..models import ( Device, DeviceFeature, + OtaCommitPayload, + RecirculationSchedule, + WeeklyReservationSchedule, preferred_to_half_celsius, ) from ..topic_builder import MqttTopicBuilder @@ -96,10 +102,15 @@ def set_ensure_device_info_callback( self._ensure_device_info_callback = callback @property - def device_info_cache(self) -> "MqttDeviceInfoCache": + def device_info_cache(self) -> MqttDeviceInfoCache: """Get the device info cache.""" return self._device_info_cache + @device_info_cache.setter + def device_info_cache(self, cache: MqttDeviceInfoCache) -> None: + """Set the device info cache.""" + self._device_info_cache = cache + async def _ensure_device_info_cached( self, device: Device, timeout: float = 5.0 ) -> None: @@ -216,18 +227,17 @@ def _build_command( **kwargs, } - # Use navilink- prefix for device ID in topics (from reference - # implementation) - device_topic = f"navilink-{device_id}" - + device_type_str = str(device_type) return { "clientID": self._client_id, "sessionID": self._session_id, - "protocolVersion": 2, + "protocolVersion": MQTT_PROTOCOL_VERSION, "request": request, - "requestTopic": f"cmd/{device_type}/{device_topic}", - "responseTopic": ( - f"cmd/{device_type}/{device_topic}/{self._client_id}/res" + "requestTopic": MqttTopicBuilder.command_topic( + device_type_str, device_id + ), + "responseTopic": MqttTopicBuilder.response_ack_topic( + device_type_str, device_id, self._client_id ), } @@ -649,12 +659,31 @@ async def reset_air_filter(self, device: Device) -> int: @requires_capability("holiday_use") async def set_vacation_days(self, device: Device, days: int) -> int: """Set vacation/away mode duration (1-30 days).""" - self._validate_range("days", days, 1, 30) - return await self._mode_command( - device, - CommandCode.DHW_MODE, - "dhw-mode", - [DhwOperationSetting.VACATION.value, days], + return await self.set_dhw_mode( + device, DhwOperationSetting.VACATION.value, vacation_days=days + ) + + @requires_capability("program_reservation_use") + async def update_weekly_reservation( + self, device: Device, schedule: WeeklyReservationSchedule + ) -> int: + """Configure the weekly temperature reservation schedule. + + Sends the complete weekly schedule to the device using command + code RESERVATION_WEEKLY (33554438). + + Args: + device: Device to configure + schedule: Weekly reservation schedule with entries for each + time slot + + Returns: + Publish packet ID + """ + return await self._send_command( + device=device, + command_code=CommandCode.RESERVATION_WEEKLY, + reservation=schedule.model_dump(by_alias=True), ) @requires_capability("program_reservation_use") @@ -668,16 +697,22 @@ async def configure_reservation_water_program(self, device: Device) -> int: async def configure_recirculation_schedule( self, device: Device, - schedule: dict[str, Any], + schedule: RecirculationSchedule, ) -> int: - """ - Configure recirculation pump schedule. - ... + """Configure the recirculation pump timed schedule. + + Args: + device: Device to configure + schedule: Recirculation schedule with one or more time window + entries + + Returns: + Publish packet ID """ return await self._send_command( device=device, command_code=CommandCode.RECIR_RESERVATION, - schedule=schedule, + schedule=schedule.model_dump(by_alias=True), ) @requires_capability("recirculation_use") @@ -694,3 +729,149 @@ async def trigger_recirculation_hot_button(self, device: Device) -> int: return await self._mode_command( device, CommandCode.RECIR_HOT_BTN, "recirc-hotbtn", [1] ) + + async def check_firmware_update(self, device: Device) -> int: + """Check for available over-the-air firmware updates. + + Sends the OTA_CHECK command (33554443) to query whether a firmware + update is available. The device responds on the control ack topic. + + Args: + device: Device to check for updates + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.OTA_CHECK, "ota-check" + ) + + async def commit_firmware_update( + self, device: Device, payload: OtaCommitPayload + ) -> int: + """Commit a previously downloaded firmware update. + + Sends the OTA_COMMIT command (33554442) with a special + ``commitOta`` structure (not the standard mode/param format). + + Args: + device: Device to update + payload: OTA commit payload specifying which firmware component + and version to commit. + + Returns: + Publish packet ID + """ + return await self._send_command( + device=device, + command_code=CommandCode.OTA_COMMIT, + commitOta=payload.model_dump(by_alias=True), + ) + + async def reconnect_wifi(self, device: Device) -> int: + """Trigger a WiFi reconnection on the device. + + Sends the WIFI_RECONNECT command (33554446). Useful when the + device has lost its WiFi connection and needs to re-associate. + + Args: + device: Device to reconnect + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.WIFI_RECONNECT, "wifi-reconnect" + ) + + async def reset_wifi(self, device: Device) -> int: + """Reset WiFi settings to factory defaults. + + Sends the WIFI_RESET command (33554447). This will clear stored + WiFi credentials and require re-provisioning the device. + + .. warning:: + This operation clears all stored WiFi credentials. The device + will need to be re-provisioned to reconnect to the network. + + Args: + device: Device to reset + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.WIFI_RESET, "wifi-reset" + ) + + async def set_freeze_protection_temperature( + self, device: Device, temperature: float + ) -> int: + """Set the freeze protection activation temperature. + + Sends the FREZ_TEMP command (33554451). The device activates + freeze protection heating when the ambient temperature drops + below this threshold. + + Args: + device: Device to configure + temperature: Activation temperature in the user's preferred unit + (°C if unit system is metric, °F otherwise). + Valid range: 35–45°F (1.7–7.2°C). + + Returns: + Publish packet ID + """ + raw = preferred_to_half_celsius(temperature) + return await self._mode_command( + device, CommandCode.FREZ_TEMP, "frez-temp", [raw] + ) + + async def run_smart_diagnostic(self, device: Device) -> int: + """Trigger the smart diagnostic routine on the device. + + Sends the SMART_DIAGNOSTIC command (33554455). The diagnostic + result is reflected in the ``smart_diagnostic`` field of the next + :class:`~nwp500.models.DeviceStatus` update. + + Args: + device: Device to diagnose + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.SMART_DIAGNOSTIC, "smart-diagnostic" + ) + + async def enable_intelligent_scheduling(self, device: Device) -> int: + """Enable intelligent/adaptive heating mode. + + Sends the RESERVATION_INTELLIGENT_ON command (33554468). In this + mode the device learns usage patterns and pre-heats water + proactively to reduce energy consumption. + + Args: + device: Device to configure + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.RESERVATION_INTELLIGENT_ON, "intelligent-on" + ) + + async def disable_intelligent_scheduling(self, device: Device) -> int: + """Disable intelligent/adaptive heating mode. + + Sends the RESERVATION_INTELLIGENT_OFF command (33554467). + + Args: + device: Device to configure + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.RESERVATION_INTELLIGENT_OFF, "intelligent-off" + ) diff --git a/src/nwp500/mqtt/state_tracker.py b/src/nwp500/mqtt/state_tracker.py new file mode 100644 index 0000000..ba91e4a --- /dev/null +++ b/src/nwp500/mqtt/state_tracker.py @@ -0,0 +1,162 @@ +"""Per-device state change detection for Navien MQTT clients. + +Compares successive :class:`DeviceStatus` snapshots for each device and emits +granular events when individual fields change (temperature, mode, power, +errors). +""" + +from __future__ import annotations + +import logging + +from ..events import EventEmitter +from ..models import DeviceStatus +from ..mqtt_events import ( + ErrorClearedEvent, + ErrorDetectedEvent, + HeatingStartedEvent, + HeatingStoppedEvent, + ModeChangedEvent, + PowerChangedEvent, + TemperatureChangedEvent, +) +from ..unit_system import get_unit_system + +_logger = logging.getLogger(__name__) + + +class DeviceStateTracker: + """Tracks previous device states and emits change events. + + Each device (identified by MAC address) gets its own slot in + ``_previous_status``. On every new status update, this class compares + it against the stored snapshot and emits events for changed fields, + then stores the new snapshot. + """ + + def __init__(self, event_emitter: EventEmitter) -> None: + self._event_emitter = event_emitter + self._previous_status: dict[str, DeviceStatus] = {} + + def clear(self) -> None: + """Drop all stored snapshots (call on disconnect).""" + self._previous_status.clear() + + async def process(self, device_mac: str, status: DeviceStatus) -> None: + """Compare *status* with the previous snapshot for *device_mac*. + + Emits the following events when values change: + + - ``temperature_changed(TemperatureChangedEvent(...))`` + - ``mode_changed(ModeChangedEvent(...))`` + - ``power_changed(PowerChangedEvent(...))`` + - ``heating_started(HeatingStartedEvent(...))`` + - ``heating_stopped(HeatingStoppedEvent(...))`` + - ``error_detected(ErrorDetectedEvent(...))`` + - ``error_cleared(ErrorClearedEvent(...))`` + + Args: + device_mac: MAC address used as the per-device key. + status: Freshly received :class:`DeviceStatus`. + """ + if device_mac not in self._previous_status: + self._previous_status[device_mac] = status + return + + prev = self._previous_status[device_mac] + + try: + # Temperature change (compare raw values) + if status.dhw_temperature_raw != prev.dhw_temperature_raw: + await self._event_emitter.emit( + "temperature_changed", + TemperatureChangedEvent( + device_mac=device_mac, + old_temperature=prev.dhw_temperature, + new_temperature=status.dhw_temperature, + ), + ) + unit_suffix = "°C" if get_unit_system() == "metric" else "°F" + _logger.debug( + "Temperature changed: %s%s → %s%s", + prev.dhw_temperature, + unit_suffix, + status.dhw_temperature, + unit_suffix, + ) + + # Operation mode change (compare raw values) + if status.operation_mode != prev.operation_mode: + await self._event_emitter.emit( + "mode_changed", + ModeChangedEvent( + device_mac=device_mac, + old_mode=prev.operation_mode, + new_mode=status.operation_mode, + ), + ) + _logger.debug( + "Mode changed: %s → %s", + prev.operation_mode, + status.operation_mode, + ) + + # Power consumption change (compare raw values) + if status.current_inst_power != prev.current_inst_power: + await self._event_emitter.emit( + "power_changed", + PowerChangedEvent( + device_mac=device_mac, + old_power=prev.current_inst_power, + new_power=status.current_inst_power, + ), + ) + _logger.debug( + "Power changed: %sW → %sW", + prev.current_inst_power, + status.current_inst_power, + ) + + # Heating started / stopped (compare raw values) + 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", + HeatingStartedEvent(device_mac=device_mac, status=status), + ) + _logger.debug("Heating started") + + if not curr_heating and prev_heating: + await self._event_emitter.emit( + "heating_stopped", + HeatingStoppedEvent(device_mac=device_mac, status=status), + ) + _logger.debug("Heating stopped") + + # Error detection / clearance + if status.error_code and not prev.error_code: + await self._event_emitter.emit( + "error_detected", + ErrorDetectedEvent( + device_mac=device_mac, + error_code=status.error_code, + status=status, + ), + ) + _logger.info("Error detected: %s", status.error_code) + + if not status.error_code and prev.error_code: + await self._event_emitter.emit( + "error_cleared", + ErrorClearedEvent( + device_mac=device_mac, error_code=prev.error_code + ), + ) + _logger.info("Error cleared: %s", prev.error_code) + + except (TypeError, AttributeError, RuntimeError) as e: + _logger.error("Error detecting state changes: %s", e, exc_info=True) + finally: + self._previous_status[device_mac] = status diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index 577bc26..52cf1d8 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -23,10 +23,20 @@ from ..events import EventEmitter from ..exceptions import MqttNotConnectedError -from ..models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse +from ..models import ( + Device, + DeviceFeature, + DeviceStatus, + EnergyUsageResponse, + RecirculationSchedule, + ReservationSchedule, + TOUReservationSchedule, + WeeklyReservationSchedule, +) +from ..mqtt_events import FeatureReceivedEvent, StatusReceivedEvent from ..topic_builder import MqttTopicBuilder -from ..unit_system import UnitSystemType, get_unit_system, set_unit_system -from .utils import redact_topic, topic_matches_pattern +from .state_tracker import DeviceStateTracker +from .utils import get_response_data, redact_topic, topic_matches_pattern if TYPE_CHECKING: from ..device_info_cache import MqttDeviceInfoCache @@ -55,7 +65,6 @@ def __init__( event_emitter: EventEmitter, schedule_coroutine: Callable[[Any], None], device_info_cache: MqttDeviceInfoCache | None = None, - unit_system: UnitSystemType = None, ): """ Initialize subscription manager. @@ -67,15 +76,12 @@ def __init__( schedule_coroutine: Function to schedule async tasks device_info_cache: Optional MqttDeviceInfoCache for caching device features - unit_system: Preferred unit system ("metric", "us_customary", - or None) """ self._connection = connection self._client_id = client_id self._event_emitter = event_emitter self._schedule_coroutine = schedule_coroutine self._device_info_cache = device_info_cache - self._unit_system: UnitSystemType = unit_system # Track subscriptions and handlers self._subscriptions: dict[str, mqtt.QoS] = {} @@ -83,8 +89,8 @@ def __init__( str, list[Callable[[str, dict[str, Any]], None]] ] = {} - # Track previous state for change detection - self._previous_status: DeviceStatus | None = None + # Per-device state change detection + self._state_tracker = DeviceStateTracker(event_emitter) @property def subscriptions(self) -> dict[str, mqtt.QoS]: @@ -168,6 +174,20 @@ async def subscribe( if not self._connection: raise MqttNotConnectedError("Not connected to MQTT broker") + # Track handler first + if topic not in self._message_handlers: + self._message_handlers[topic] = [] + if callback not in self._message_handlers[topic]: + self._message_handlers[topic].append(callback) + + # Check if already subscribed to this topic at the broker level + if topic in self._subscriptions: + # Already subscribed. If requested QoS is higher than current, + # we should upgrade, but standard practice is to just return. + # Most brokers handle multiple overlapping subscriptions. + # Return a synthetic packet ID (0) as we didn't send a request. + return 0 + _logger.info(f"Subscribing to topic: {redact_topic(topic)}") try: @@ -196,30 +216,41 @@ async def subscribe( f"{subscribe_result['qos']}" ) - # Store subscription and handler + # Store subscription self._subscriptions[topic] = qos - if topic not in self._message_handlers: - self._message_handlers[topic] = [] - if callback not in self._message_handlers[topic]: - self._message_handlers[topic].append(callback) return int(packet_id) except (AwsCrtError, RuntimeError) as e: + # Clean up handler on failure if this was the first one + if (h := self._message_handlers.get(topic)) and callback in h: + h.remove(callback) _logger.error( f"Failed to subscribe to '{redact_topic(topic)}': {e}" ) raise - async def unsubscribe(self, topic: str) -> int: + async def unsubscribe( + self, + topic: str, + callback: Callable[[str, dict[str, Any]], None] | None = None, + ) -> int: """ Unsubscribe from an MQTT topic. + If a callback is provided, only that specific handler is removed. + The underlying MQTT unsubscribe from the broker is only performed + if no handlers remain for the topic. + + If no callback is provided, all handlers are removed and the broker + is unsubscribed immediately. + Args: topic: MQTT topic to unsubscribe from + callback: Optional specific handler to remove Returns: - Unsubscribe packet ID + Unsubscribe packet ID (or 0 if no broker call was made) Raises: RuntimeError: If not connected to MQTT broker @@ -228,12 +259,19 @@ async def unsubscribe(self, topic: str) -> int: if not self._connection: raise MqttNotConnectedError("Not connected to MQTT broker") - # Redact topic for logging to avoid leaking sensitive information - # (device IDs). We perform this check early to ensure we don't log raw - # topics. - # Note: CodeQL flags log calls using the topic variable (even redacted) - # as a security risk ("Clear-text logging of sensitive information"). - # To pass CI, we must use a generic message here. + if topic not in self._message_handlers: + return 0 + + if callback is not None: + # Remove specific handler + if callback in self._message_handlers[topic]: + self._message_handlers[topic].remove(callback) + + # If handlers still exist, don't unsubscribe from broker yet + if self._message_handlers[topic]: + return 0 + + # No callback provided or no handlers left: unsubscribe from broker _logger.info("Unsubscribing from topic (redacted)") try: @@ -370,44 +408,62 @@ async def subscribe_device_status( self, device: Device, callback: Callable[[DeviceStatus], None] ) -> int: """Subscribe to device status messages with automatic parsing.""" + device_mac = device.device_info.mac_address def post_parse(status: DeviceStatus) -> None: self._schedule_coroutine( - self._event_emitter.emit("status_received", status) + self._event_emitter.emit( + "status_received", + StatusReceivedEvent(device_mac=device_mac, status=status), + ) + ) + self._schedule_coroutine( + self._state_tracker.process(device_mac, status) ) - self._schedule_coroutine(self._detect_state_changes(status)) handler = self._make_handler( - DeviceStatus, callback, "status", post_parse + DeviceStatus, callback, "status", post_parse, device_mac=device_mac ) return await self.subscribe_device(device=device, callback=handler) + async def unsubscribe_device_status( + self, device: Device, callback: Callable[[DeviceStatus], None] + ) -> None: + """Unsubscribe a specific device status callback.""" + device_id = device.device_info.mac_address + device_type = str(device.device_info.device_type) + topic = MqttTopicBuilder.command_topic(device_type, device_id, "#") + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + def _make_handler( self, model: Any, callback: Callable[[Any], None], key: str | None = None, post_parse: Callable[[Any], None] | None = None, + device_mac: str | None = None, ) -> Callable[[str, dict[str, Any]], None]: """Generic factory for MQTT message handlers.""" def handler(topic: str, message: dict[str, Any]) -> None: try: - # Set unit system context before parsing if configured - # This ensures validators use the correct unit system even - # when called from AWS CRT threads - if self._unit_system is not None: - set_unit_system(self._unit_system) - - res = message.get("response", {}) - # Try nested response field, then fallback to top-level - data = (res.get(key) if key else res) or ( - message.get(key) if key else None - ) + data = get_response_data(message, key) if not data: return - parsed = model.from_dict(data) + parsed = model.model_validate(data) + if device_mac and hasattr(parsed, "mac_address"): + parsed.mac_address = device_mac + if post_parse: post_parse(parsed) callback(parsed) @@ -425,108 +481,32 @@ def handler(topic: str, message: dict[str, Any]) -> None: cast(Any, handler)._original_callback = callback return handler - async def _detect_state_changes(self, status: DeviceStatus) -> None: - """ - Detect state changes and emit granular events. - - This method compares the current status with the previous status - and emits events for any detected changes. - - Args: - status: Current device status - """ - if self._previous_status is None: - # First status received, just store it - self._previous_status = status - return - - prev = self._previous_status - - try: - # Temperature change - if status.dhw_temperature != prev.dhw_temperature: - await self._event_emitter.emit( - "temperature_changed", - prev.dhw_temperature, - status.dhw_temperature, - ) - unit_suffix = "°C" if get_unit_system() == "metric" else "°F" - _logger.debug( - f"Temperature changed: {prev.dhw_temperature}" - f"{unit_suffix} → {status.dhw_temperature}{unit_suffix}" - ) - - # Operation mode change - if status.operation_mode != prev.operation_mode: - await self._event_emitter.emit( - "mode_changed", - prev.operation_mode, - status.operation_mode, - ) - _logger.debug( - f"Mode changed: {prev.operation_mode} → " - f"{status.operation_mode}" - ) - - # Power consumption change - if status.current_inst_power != prev.current_inst_power: - await self._event_emitter.emit( - "power_changed", - prev.current_inst_power, - status.current_inst_power, - ) - _logger.debug( - f"Power changed: {prev.current_inst_power}W → " - f"{status.current_inst_power}W" - ) - - # Heating started/stopped - 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) - _logger.debug("Heating started") - - if not curr_heating and prev_heating: - await self._event_emitter.emit("heating_stopped", status) - _logger.debug("Heating stopped") - - # Error detection - if status.error_code and not prev.error_code: - await self._event_emitter.emit( - "error_detected", status.error_code, status - ) - _logger.info(f"Error detected: {status.error_code}") - - 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) - finally: - # Always update previous status - self._previous_status = status - async def subscribe_device_feature( self, device: Device, callback: Callable[[DeviceFeature], None] ) -> int: """Subscribe to device feature/info messages with automatic parsing.""" + device_mac = device.device_info.mac_address def post_parse(feature: DeviceFeature) -> None: if self._device_info_cache: self._schedule_coroutine( - self._device_info_cache.set( - device.device_info.mac_address, feature - ) + self._device_info_cache.set(device_mac, feature) ) self._schedule_coroutine( - self._event_emitter.emit("feature_received", feature) + self._event_emitter.emit( + "feature_received", + FeatureReceivedEvent( + device_mac=device_mac, feature=feature + ), + ) ) handler = self._make_handler( - DeviceFeature, callback, "feature", post_parse + DeviceFeature, + callback, + "feature", + post_parse, + device_mac=device_mac, ) return await self.subscribe_device(device=device, callback=handler) @@ -541,19 +521,15 @@ async def unsubscribe_device_feature( if topic not in self._message_handlers: return - # Find and remove the specific handler - handlers = self._message_handlers[topic] - handlers_to_remove = [] - for h in handlers: + # Find the specific internal handler that wraps this callback + target_handler = None + for h in self._message_handlers[topic]: if getattr(h, "_original_callback", None) == callback: - handlers_to_remove.append(h) + target_handler = h + break - for h in handlers_to_remove: - handlers.remove(h) - - # If no handlers left, unsubscribe from MQTT - if not handlers: - await self.unsubscribe(topic) + if target_handler: + await self.unsubscribe(topic, target_handler) async def subscribe_energy_usage( self, @@ -569,8 +545,228 @@ async def subscribe_energy_usage( ) return await self.subscribe(topic, handler) + async def unsubscribe_energy_usage( + self, + device: Device, + callback: Callable[[EnergyUsageResponse], None], + ) -> None: + """Unsubscribe a specific energy usage callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "energy-usage-daily-query/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + + async def subscribe_reservation_response( + self, + device: Device, + callback: Callable[[ReservationSchedule], None], + ) -> int: + """Subscribe to reservation read responses with automatic parsing. + + Subscribes to the ``rsv/rd`` response topic for the given device. + The callback receives a fully-parsed + :class:`~nwp500.models.ReservationSchedule` whenever the device + responds to a reservation read request. + + Args: + device: Device whose reservation responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + handler = self._make_handler(ReservationSchedule, callback) + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "rsv/rd", + ) + return await self.subscribe(topic, handler) + + async def unsubscribe_reservation_response( + self, + device: Device, + callback: Callable[[ReservationSchedule], None], + ) -> None: + """Unsubscribe a specific reservation response callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "rsv/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + + async def subscribe_weekly_reservation_response( + self, + device: Device, + callback: Callable[[WeeklyReservationSchedule], None], + ) -> int: + """Subscribe to weekly reservation read responses. + + Subscribes to the ``rsv-weekly/rd`` response topic for the given + device. The callback receives a + :class:`~nwp500.models.WeeklyReservationSchedule` + whenever the device responds to a weekly reservation read request. + + Args: + device: Device whose weekly reservation responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + handler = self._make_handler(WeeklyReservationSchedule, callback) + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "rsv-weekly/rd", + ) + return await self.subscribe(topic, handler) + + async def unsubscribe_weekly_reservation_response( + self, + device: Device, + callback: Callable[[WeeklyReservationSchedule], None], + ) -> None: + """Unsubscribe a specific weekly reservation callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "rsv-weekly/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + + async def subscribe_recirculation_schedule_response( + self, + device: Device, + callback: Callable[[RecirculationSchedule], None], + ) -> int: + """Subscribe to recirculation schedule read responses. + + Subscribes to the ``recirc-rsv/rd`` response topic for the given device. + The callback receives a :class:`~nwp500.models.RecirculationSchedule` + whenever the device responds to a recirculation schedule read request. + + Args: + device: Device whose recirculation schedule responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + handler = self._make_handler(RecirculationSchedule, callback) + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "recirc-rsv/rd", + ) + return await self.subscribe(topic, handler) + + async def unsubscribe_recirculation_schedule_response( + self, + device: Device, + callback: Callable[[RecirculationSchedule], None], + ) -> None: + """Unsubscribe a specific recirculation schedule callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "recirc-rsv/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + + async def subscribe_tou_response( + self, + device: Device, + callback: Callable[[TOUReservationSchedule], None], + ) -> int: + """Subscribe to Time-of-Use schedule read responses with automatic + parsing. + + Subscribes to the ``tou/rd`` response topic for the given device. + The callback receives a fully-parsed + :class:`~nwp500.models.TOUReservationSchedule` whenever the device + responds to a TOU read or configure request (triggered by + :meth:`~nwp500.NavienMqttClient.request_tou_settings` or + :meth:`~nwp500.NavienMqttClient.configure_tou_schedule`). + + Args: + device: Device whose TOU responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + handler = self._make_handler(TOUReservationSchedule, callback) + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "tou/rd", + ) + return await self.subscribe(topic, handler) + + async def unsubscribe_tou_response( + self, + device: Device, + callback: Callable[[TOUReservationSchedule], None], + ) -> None: + """Unsubscribe a specific TOU response callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "tou/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + def clear_subscriptions(self) -> None: """Clear all subscription tracking (called on disconnect).""" self._subscriptions.clear() self._message_handlers.clear() - self._previous_status = None + self._state_tracker.clear() diff --git a/src/nwp500/mqtt/utils.py b/src/nwp500/mqtt/utils.py index d9a8ec6..9d677bf 100644 --- a/src/nwp500/mqtt/utils.py +++ b/src/nwp500/mqtt/utils.py @@ -269,6 +269,57 @@ class PeriodicRequestType(Enum): DEVICE_STATUS = "device_status" +_ALT_KEYS: dict[str, str] = { + "status": "st", + "feature": "did", +} + +_SENTINEL = object() + + +def get_response_data(message: dict[str, Any], key: str | None) -> Any: + """Extract data from an MQTT message, supporting key variants. + + Checks both the nested ``response`` dict and the top-level message, + using both the primary key and its alternate short-form name (e.g. + ``"status"`` / ``"st"``, ``"feature"`` / ``"did"``). Lookup order + preserves a strict *nested-first* precedence: + + 1. ``response[key]`` + 2. ``response[alt_key]`` + 3. ``message[key]`` + 4. ``message[alt_key]`` + + Key presence is checked explicitly (not by truthiness), so falsy + values like ``0``, ``False``, or ``{}`` are returned correctly and + do not fall through to a lower-precedence candidate. + + Args: + message: Raw MQTT message dict. + key: Primary key to look up. When ``None``, the nested + ``response`` dict is returned directly. + + Returns: + The value of the first *present* key in priority order, + or ``None`` if no candidate key is found. + """ + res: dict[str, Any] = message.get("response", {}) + if key is None: + return res + alt_key = _ALT_KEYS.get(key) + for source, k in ( + (res, key), + (res, alt_key), + (message, key), + (message, alt_key), + ): + if k is not None: + value = source.get(k, _SENTINEL) + if value is not _SENTINEL: + return value + return None + + def topic_matches_pattern(topic: str, pattern: str) -> bool: """ Check if a topic matches a subscription pattern with wildcards. diff --git a/src/nwp500/mqtt_events.py b/src/nwp500/mqtt_events.py index 6276d47..22aeac6 100644 --- a/src/nwp500/mqtt_events.py +++ b/src/nwp500/mqtt_events.py @@ -14,9 +14,12 @@ from nwp500.unit_system import get_unit_system # Type-safe event listening with autocomplete - def on_temperature_changed(old_temp, new_temp): + def on_temperature_changed(event): unit = "°C" if get_unit_system() == "metric" else "°F" - print(f"Temp: {old_temp}{unit} → {new_temp}{unit}") + print( + f"Temp: {event.old_temperature}{unit} → " + f"{event.new_temperature}{unit}" + ) mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, on_temperature_changed) @@ -25,6 +28,8 @@ def on_temperature_changed(old_temp, new_temp): print(event_name) """ +from __future__ import annotations + from dataclasses import dataclass from typing import TYPE_CHECKING, cast @@ -62,10 +67,12 @@ class StatusReceivedEvent: """Emitted when a device status message is received. Attributes: + device_mac: MAC address of the origin device status: The current device status snapshot """ - status: "DeviceStatus" + device_mac: str + status: DeviceStatus @dataclass(frozen=True) @@ -73,12 +80,14 @@ class TemperatureChangedEvent: """Emitted when the DHW temperature changes. Attributes: + device_mac: MAC address of the origin device old_temperature: Previous DHW temperature in user's preferred unit (Celsius or Fahrenheit based on unit system context) new_temperature: New DHW temperature in user's preferred unit (Celsius or Fahrenheit based on unit system context) """ + device_mac: str old_temperature: float new_temperature: float @@ -88,12 +97,14 @@ class ModeChangedEvent: """Emitted when the device operation mode changes. Attributes: + device_mac: MAC address of the origin device old_mode: Previous operation mode new_mode: New operation mode """ - old_mode: "CurrentOperationMode" - new_mode: "CurrentOperationMode" + device_mac: str + old_mode: CurrentOperationMode + new_mode: CurrentOperationMode @dataclass(frozen=True) @@ -101,10 +112,12 @@ class PowerChangedEvent: """Emitted when instantaneous power consumption changes. Attributes: + device_mac: MAC address of the origin device old_power: Previous power consumption in watts new_power: New power consumption in watts """ + device_mac: str old_power: float new_power: float @@ -114,10 +127,12 @@ class HeatingStartedEvent: """Emitted when device transitions from idle to heating. Attributes: + device_mac: MAC address of the origin device status: Device status when heating started """ - status: "DeviceStatus" + device_mac: str + status: DeviceStatus @dataclass(frozen=True) @@ -125,10 +140,12 @@ class HeatingStoppedEvent: """Emitted when device transitions from heating to idle. Attributes: + device_mac: MAC address of the origin device status: Device status when heating stopped """ - status: "DeviceStatus" + device_mac: str + status: DeviceStatus @dataclass(frozen=True) @@ -136,12 +153,14 @@ class ErrorDetectedEvent: """Emitted when a device error is first detected. Attributes: + device_mac: MAC address of the origin device error_code: The error code that occurred status: Device status when error was detected """ - error_code: "ErrorCode" - status: "DeviceStatus" + device_mac: str + error_code: ErrorCode + status: DeviceStatus @dataclass(frozen=True) @@ -149,10 +168,12 @@ class ErrorClearedEvent: """Emitted when a device error is resolved. Attributes: + device_mac: MAC address of the origin device error_code: The error code that was cleared """ - error_code: "ErrorCode" + device_mac: str + error_code: ErrorCode @dataclass(frozen=True) @@ -160,10 +181,12 @@ class FeatureReceivedEvent: """Emitted when device feature information is received. Attributes: + device_mac: MAC address of the origin device feature: The device feature information """ - feature: "DeviceFeature" + device_mac: str + feature: DeviceFeature class MqttClientEvents: @@ -177,11 +200,14 @@ class MqttClientEvents: mqtt_client.on( MqttClientEvents.TEMPERATURE_CHANGED, - lambda old_temp, new_temp: update_display(new_temp) + lambda event: update_display(event.new_temperature) ) # Wait for a specific event - await mqtt_client.wait_for(MqttClientEvents.CONNECTION_RESUMED) + args, _ = await mqtt_client.wait_for( + MqttClientEvents.CONNECTION_RESUMED + ) + connection_event = args[0] # List all available events events = ', '.join(MqttClientEvents.get_all_events()) @@ -196,7 +222,7 @@ class MqttClientEvents: """Emitted: MQTT connection interrupted with error. Args: - error (Exception): The error that caused the interruption + event (ConnectionInterruptedEvent): Event object with the error field. See: :class:`ConnectionInterruptedEvent` """ @@ -205,8 +231,8 @@ class MqttClientEvents: """Emitted: MQTT connection resumed after interruption. Args: - return_code (int): MQTT return code (0 = success) - session_present (bool): Whether session state was preserved + event (ConnectionResumedEvent): Event object with return_code and + session_present fields. See: :class:`ConnectionResumedEvent` """ @@ -216,7 +242,7 @@ class MqttClientEvents: """Emitted: Device status message received. Args: - status (DeviceStatus): Current device status snapshot + event (StatusReceivedEvent): Event object with the status field. See: :class:`StatusReceivedEvent` """ @@ -225,10 +251,8 @@ class MqttClientEvents: """Emitted: DHW temperature changed. Args: - old_temperature (float): Previous DHW temperature in user's - preferred unit - new_temperature (float): New DHW temperature in user's preferred - unit + event (TemperatureChangedEvent): Event object with old_temperature + and new_temperature fields. See: :class:`TemperatureChangedEvent` """ @@ -237,8 +261,8 @@ class MqttClientEvents: """Emitted: Device operation mode changed. Args: - old_mode (CurrentOperationMode): Previous mode - new_mode (CurrentOperationMode): New mode + event (ModeChangedEvent): Event object with old_mode and new_mode + fields. See: :class:`ModeChangedEvent` """ @@ -247,8 +271,8 @@ class MqttClientEvents: """Emitted: Instantaneous power consumption changed. Args: - old_power (float): Previous power consumption (W) - new_power (float): New power consumption (W) + event (PowerChangedEvent): Event object with old_power and new_power + fields. See: :class:`PowerChangedEvent` """ @@ -258,7 +282,7 @@ class MqttClientEvents: """Emitted: Device started heating. Args: - status (DeviceStatus): Device status when heating started + event (HeatingStartedEvent): Event object with the status field. See: :class:`HeatingStartedEvent` """ @@ -267,7 +291,7 @@ class MqttClientEvents: """Emitted: Device stopped heating. Args: - status (DeviceStatus): Device status when heating stopped + event (HeatingStoppedEvent): Event object with the status field. See: :class:`HeatingStoppedEvent` """ @@ -277,8 +301,8 @@ class MqttClientEvents: """Emitted: Device error detected. Args: - error_code (ErrorCode): The error code - status (DeviceStatus): Status when error was detected + event (ErrorDetectedEvent): Event object with error_code and status + fields. See: :class:`ErrorDetectedEvent` """ @@ -287,7 +311,7 @@ class MqttClientEvents: """Emitted: Device error cleared. Args: - error_code (ErrorCode): The error code that was cleared + event (ErrorClearedEvent): Event object with the error_code field. See: :class:`ErrorClearedEvent` """ @@ -297,7 +321,7 @@ class MqttClientEvents: """Emitted: Device feature information received. Args: - feature (DeviceFeature): Device feature information + event (FeatureReceivedEvent): Event object with the feature field. See: :class:`FeatureReceivedEvent` """ diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py index b7711a4..5dd6172 100644 --- a/src/nwp500/reservations.py +++ b/src/nwp500/reservations.py @@ -14,11 +14,10 @@ import asyncio import logging from collections.abc import Sequence -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from .encoding import build_reservation_entry, encode_week_bitfield from .models import ReservationSchedule -from .unit_system import get_unit_system, set_unit_system if TYPE_CHECKING: from .models import Device @@ -58,44 +57,25 @@ async def fetch_reservations( future: asyncio.Future[ReservationSchedule] = ( asyncio.get_running_loop().create_future() ) - caller_unit_system = get_unit_system() - - def raw_callback(topic: str, message: dict[str, Any]) -> None: - if ( - future.done() - or "response" not in message - or "/res/rsv/" not in topic - ): - return - response = message.get("response", {}) - # Ensure it's actually a reservation response (not some other /res/ msg) - if "reservationUse" not in response and "reservation" not in response: - return - previous = get_unit_system() - try: - if caller_unit_system: - set_unit_system(caller_unit_system) - schedule = ReservationSchedule(**response) - finally: - if previous is not None: - set_unit_system(previous) - future.set_result(schedule) - - device_type = str(device.device_info.device_type) - response_topic = f"cmd/{device_type}/{mqtt.client_id}/res/rsv/rd" - await mqtt.subscribe(response_topic, raw_callback) - await mqtt.control.request_reservations(device) + + def on_schedule(schedule: ReservationSchedule) -> None: + if not future.done(): + future.set_result(schedule) + + await mqtt.subscribe_reservation_response(device, on_schedule) + await mqtt.request_reservations(device) try: return await asyncio.wait_for(future, timeout=timeout) except TimeoutError: return None finally: try: - await mqtt.unsubscribe(response_topic) + await mqtt.unsubscribe_reservation_response(device, on_schedule) except Exception: _logger.warning( - "Failed to unsubscribe reservations response handler for %s", - response_topic, + "Failed to unsubscribe reservations response handler for " + "device %s", + device.device_info.mac_address, exc_info=True, ) @@ -161,9 +141,7 @@ async def add_reservation( ] current_reservations.append(reservation_entry) - await mqtt.control.update_reservations( - device, current_reservations, enabled=True - ) + await mqtt.update_reservations(device, current_reservations, enabled=True) async def delete_reservation( @@ -206,7 +184,7 @@ async def delete_reservation( still_enabled = schedule.enabled and len(current_reservations) > 0 - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, current_reservations, enabled=still_enabled ) @@ -300,7 +278,7 @@ async def update_reservation( ] current_reservations[index - 1] = new_entry - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, current_reservations, enabled=schedule.enabled ) diff --git a/src/nwp500/topic_builder.py b/src/nwp500/topic_builder.py index e5dbd3a..41d9259 100644 --- a/src/nwp500/topic_builder.py +++ b/src/nwp500/topic_builder.py @@ -1,40 +1,70 @@ """ MQTT topic building utilities for Navien devices. + +All MQTT topic construction goes through this class so that the topic schema +is defined in exactly one place. + +Topic schema: + Device command (ctrl/query): cmd/{device_type}/navilink-{mac}/{suffix} + Device subscribe (wildcard): cmd/{device_type}/navilink-{mac}/# + Response (control ack): cmd/{device_type}/navilink-{mac}/{client_id}/res + Response (query result): cmd/{device_type}/{client_id}/res/{suffix} + Event: evt/{device_type}/navilink-{mac}/{suffix} """ +from __future__ import annotations + class MqttTopicBuilder: """Helper to construct standard MQTT topics for Navien devices.""" @staticmethod def device_topic(mac_address: str) -> str: - """Get the base device topic from MAC address.""" + """Get the navilink device path segment from MAC address.""" return f"navilink-{mac_address}" @staticmethod def command_topic( device_type: str, mac_address: str, suffix: str = "ctrl" ) -> str: - """ - Build a command topic. - Format: cmd/{device_type}/navilink-{mac}/{suffix} + """Build a device command topic. + + Format: ``cmd/{device_type}/navilink-{mac}/{suffix}`` """ dt = MqttTopicBuilder.device_topic(mac_address) return f"cmd/{device_type}/{dt}/{suffix}" @staticmethod - def response_topic(device_type: str, client_id: str, suffix: str) -> str: + def response_ack_topic( + device_type: str, mac_address: str, client_id: str + ) -> str: + """Build the default response topic for control commands. + + The device sends its acknowledgement to this topic; the client + subscribes via the ``command_topic(..., "#")`` wildcard. + + Format: ``cmd/{device_type}/navilink-{mac}/{client_id}/res`` """ - Build a response topic. - Format: cmd/{device_type}/{client_id}/res/{suffix} + dt = MqttTopicBuilder.device_topic(mac_address) + return f"cmd/{device_type}/{dt}/{client_id}/res" + + @staticmethod + def response_topic(device_type: str, client_id: str, suffix: str) -> str: + """Build a client-specific response topic for query commands. + + Used when the device should reply directly to a client-keyed topic + rather than the device topic (e.g. reservation reads, TOU reads, + energy queries). + + Format: ``cmd/{device_type}/{client_id}/res/{suffix}`` """ return f"cmd/{device_type}/{client_id}/res/{suffix}" @staticmethod def event_topic(device_type: str, mac_address: str, suffix: str) -> str: - """ - Build an event topic. - Format: evt/{device_type}/navilink-{mac}/{suffix} + """Build a device event topic. + + Format: ``evt/{device_type}/navilink-{mac}/{suffix}`` """ dt = MqttTopicBuilder.device_topic(mac_address) return f"evt/{device_type}/{dt}/{suffix}" diff --git a/src/nwp500/utils.py b/src/nwp500/utils.py index 5f1be09..964db2e 100644 --- a/src/nwp500/utils.py +++ b/src/nwp500/utils.py @@ -5,6 +5,8 @@ including performance monitoring decorators and helper functions. """ +from __future__ import annotations + import functools import inspect import logging diff --git a/tests/test_auth.py b/tests/test_auth.py index bf4b58c..7ad9a8f 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -64,8 +64,8 @@ def test_user_info_full_name_with_empty_names(): assert user_info.full_name == "Doe" -def test_user_info_from_dict(): - """Test UserInfo.from_dict class method.""" +def test_user_info_model_validate_validate(): + """Test UserInfo.model_validate class method.""" data = { "userType": "premium", "userFirstName": "Jane", @@ -74,7 +74,7 @@ def test_user_info_from_dict(): "userSeq": 456, } - user_info = UserInfo.from_dict(data) + user_info = UserInfo.model_validate(data) assert user_info.user_type == "premium" assert user_info.user_first_name == "Jane" @@ -83,11 +83,11 @@ def test_user_info_from_dict(): assert user_info.user_seq == 456 -def test_user_info_from_dict_with_missing_fields(): - """Test UserInfo.from_dict with missing fields.""" +def test_user_info_model_validate_validate_with_missing_fields(): + """Test UserInfo.model_validate with missing fields.""" data = {} - user_info = UserInfo.from_dict(data) + user_info = UserInfo.model_validate(data) assert user_info.user_type == "" assert user_info.user_first_name == "" @@ -250,8 +250,8 @@ def test_auth_tokens_bearer_token(): assert tokens.bearer_token == "Bearer my_access_token" -def test_auth_tokens_from_dict(): - """Test AuthTokens.from_dict class method.""" +def test_auth_tokens_model_validate_validate(): + """Test AuthTokens.model_validate class method.""" data = { "idToken": "test_id", "accessToken": "test_access", @@ -263,7 +263,7 @@ def test_auth_tokens_from_dict(): "authorizationExpiresIn": 1800, } - tokens = AuthTokens.from_dict(data) + tokens = AuthTokens.model_validate(data) assert tokens.id_token == "test_id" assert tokens.access_token == "test_access" @@ -275,11 +275,11 @@ def test_auth_tokens_from_dict(): assert tokens.authorization_expires_in == 1800 -def test_auth_tokens_from_dict_minimal(): - """Test AuthTokens.from_dict with minimal data.""" +def test_auth_tokens_model_validate_validate_minimal(): + """Test AuthTokens.model_validate with minimal data.""" data = {} - tokens = AuthTokens.from_dict(data) + tokens = AuthTokens.model_validate(data) assert tokens.id_token == "" assert tokens.access_token == "" @@ -292,8 +292,8 @@ def test_auth_tokens_from_dict_minimal(): # Test AuthenticationResponse dataclass -def test_authentication_response_from_dict(): - """Test AuthenticationResponse.from_dict class method.""" +def test_authentication_response_model_validate_validate(): + """Test AuthenticationResponse.model_validate class method.""" data = { "code": 200, "msg": "SUCCESS", @@ -315,7 +315,7 @@ def test_authentication_response_from_dict(): }, } - response = AuthenticationResponse.from_dict(data) + response = AuthenticationResponse.model_validate(data) assert response.code == 200 assert response.message == "SUCCESS" @@ -869,8 +869,8 @@ def test_auth_tokens_to_dict(): assert result["issued_at"] == expected_issued_at -def test_auth_tokens_from_dict_with_issued_at(): - """Test AuthTokens.from_dict with issued_at timestamp.""" +def test_auth_tokens_model_validate_validate_with_issued_at(): + """Test AuthTokens.model_validate with issued_at timestamp.""" issued_at = datetime.now(UTC) - timedelta(seconds=1800) data = { "id_token": "test_id", @@ -884,7 +884,7 @@ def test_auth_tokens_from_dict_with_issued_at(): "issued_at": issued_at.isoformat(), } - tokens = AuthTokens.from_dict(data) + tokens = AuthTokens.model_validate(data) assert tokens.id_token == "test_id" assert tokens.access_token == "test_access" @@ -915,7 +915,7 @@ def test_auth_tokens_serialization_roundtrip(): # Serialize and deserialize serialized = original.to_dict() - restored = AuthTokens.from_dict(serialized) + restored = AuthTokens.model_validate(serialized) # Verify all fields match assert restored.id_token == original.id_token @@ -937,8 +937,8 @@ def test_auth_tokens_serialization_roundtrip(): assert restored.is_expired == original.is_expired -def test_auth_tokens_from_dict_with_empty_strings(): - """Test AuthTokens.from_dict handles empty strings in camelCase.""" +def test_auth_tokens_model_validate_validate_with_empty_strings(): + """Test AuthTokens.model_validate handles empty strings in camelCase.""" # Simulate API response with empty optional fields (camelCase) # Should fall back to snake_case alternatives data = { @@ -955,7 +955,7 @@ def test_auth_tokens_from_dict_with_empty_strings(): "secret_key": "fallback_secret", } - tokens = AuthTokens.from_dict(data) + tokens = AuthTokens.model_validate(data) assert tokens.id_token == "test_id" assert tokens.access_token == "fallback_access" # Should use snake_case diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index feb1f46..e3887c2 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -7,6 +7,7 @@ try: from nwp500.cli.handlers import ( get_controller_serial_number, + handle_device_info_request, handle_set_dhw_temp_request, handle_set_mode_request, handle_status_request, @@ -28,13 +29,14 @@ def mock_device(): def mock_mqtt(): mqtt = MagicMock() # Control attribute contains device control methods - mqtt.control = MagicMock() - mqtt.control.request_device_info = AsyncMock() - mqtt.control.request_device_status = AsyncMock() - mqtt.control.set_dhw_mode = AsyncMock() - mqtt.control.set_dhw_temperature = AsyncMock() + + mqtt.request_device_info = AsyncMock() + mqtt.request_device_status = AsyncMock() + mqtt.set_dhw_mode = AsyncMock() + mqtt.set_dhw_temperature = AsyncMock() # Async methods on mqtt itself + mqtt.subscribe_device = AsyncMock() mqtt.subscribe_device_feature = AsyncMock() mqtt.subscribe_device_status = AsyncMock() return mqtt @@ -59,7 +61,7 @@ async def side_effect_subscribe(device, callback): ) assert serial == "TEST_SERIAL_123" - mock_mqtt.control.request_device_info.assert_called_once_with(mock_device) + mock_mqtt.request_device_info.assert_called_once_with(mock_device) @pytest.mark.asyncio @@ -74,7 +76,7 @@ async def test_get_controller_serial_number_timeout(mock_mqtt, mock_device): ) assert serial is None - mock_mqtt.control.request_device_info.assert_called_once_with(mock_device) + mock_mqtt.request_device_info.assert_called_once_with(mock_device) @pytest.mark.asyncio @@ -91,7 +93,7 @@ async def side_effect_subscribe(device, callback): await handle_status_request(mock_mqtt, mock_device) - mock_mqtt.control.request_device_status.assert_called_once_with(mock_device) + mock_mqtt.request_device_status.assert_called_once_with(mock_device) captured = capsys.readouterr() # Check for human-readable format output assert "DEVICE STATUS" in captured.out @@ -118,7 +120,7 @@ async def side_effect_subscribe(device, callback): await handle_set_mode_request(mock_mqtt, mock_device, "heat-pump") # 1 = Heat Pump - mock_mqtt.control.set_dhw_mode.assert_called_once_with(mock_device, 1) + mock_mqtt.set_dhw_mode.assert_called_once_with(mock_device, 1) @pytest.mark.asyncio @@ -126,7 +128,7 @@ async def test_handle_set_mode_request_invalid_mode(mock_mqtt, mock_device): """Test setting an invalid mode.""" await handle_set_mode_request(mock_mqtt, mock_device, "invalid-mode") - mock_mqtt.control.set_dhw_mode.assert_not_called() + mock_mqtt.set_dhw_mode.assert_not_called() @pytest.mark.asyncio @@ -144,6 +146,60 @@ async def side_effect_subscribe(device, callback): await handle_set_dhw_temp_request(mock_mqtt, mock_device, 120.0) - mock_mqtt.control.set_dhw_temperature.assert_called_once_with( - mock_device, 120.0 - ) + mock_mqtt.set_dhw_temperature.assert_called_once_with(mock_device, 120.0) + + +@pytest.mark.asyncio +async def test_handle_status_request_raw_with_st_key( + mock_mqtt, mock_device, capsys +): + """Raw status request handles the 'st' alt key from Navien devices.""" + status_data = {"operationMode": 1, "hotWaterTemperature": 500} + + async def subscribe_and_invoke(device, callback): + callback("cmd/52/device/st", {"response": {"st": status_data}}) + + mock_mqtt.subscribe_device = AsyncMock(side_effect=subscribe_and_invoke) + + await handle_status_request(mock_mqtt, mock_device, raw=True) + + captured = capsys.readouterr() + assert "operationMode" in captured.out + assert "hotWaterTemperature" in captured.out + + +@pytest.mark.asyncio +async def test_handle_device_info_request_raw_with_did_key( + mock_mqtt, mock_device, capsys +): + """Raw device info request handles the 'did' alt key from Navien devices.""" + feature_data = {"serialNumber": "ABC123", "modelName": "NWP500"} + + async def subscribe_and_invoke(device, callback): + callback("cmd/52/device/st/did", {"response": {"did": feature_data}}) + + mock_mqtt.subscribe_device = AsyncMock(side_effect=subscribe_and_invoke) + + await handle_device_info_request(mock_mqtt, mock_device, raw=True) + + captured = capsys.readouterr() + assert "serialNumber" in captured.out + assert "modelName" in captured.out + + +@pytest.mark.asyncio +async def test_handle_status_request_raw_with_standard_key( + mock_mqtt, mock_device, capsys +): + """Raw status request handles the standard 'status' key.""" + status_data = {"operationMode": 2, "hotWaterTemperature": 600} + + async def subscribe_and_invoke(device, callback): + callback("cmd/52/device/st", {"response": {"status": status_data}}) + + mock_mqtt.subscribe_device = AsyncMock(side_effect=subscribe_and_invoke) + + await handle_status_request(mock_mqtt, mock_device, raw=True) + + captured = capsys.readouterr() + assert "operationMode" in captured.out diff --git a/tests/test_multi_device.py b/tests/test_multi_device.py new file mode 100644 index 0000000..f48101c --- /dev/null +++ b/tests/test_multi_device.py @@ -0,0 +1,243 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nwp500.enums import CurrentOperationMode +from nwp500.events import EventEmitter +from nwp500.models import DeviceFeature, DeviceStatus +from nwp500.mqtt.state_tracker import DeviceStateTracker +from nwp500.mqtt.subscriptions import MqttSubscriptionManager +from nwp500.mqtt_events import ( + StatusReceivedEvent, + TemperatureChangedEvent, +) + + +def test_models_have_mac_address(): + """Test that DeviceStatus and DeviceFeature have mac_address field.""" + # Use model_construct to avoid providing all required fields + status = DeviceStatus.model_construct( + command=0, mac_address="00:11:22:33:44:55" + ) + assert status.mac_address == "00:11:22:33:44:55" + + feature = DeviceFeature.model_construct( + controller_serial_number="ABC123", mac_address="00:11:22:33:44:55" + ) + assert feature.mac_address == "00:11:22:33:44:55" + + +def test_events_have_device_mac(): + """Test that events carry device_mac.""" + status = DeviceStatus.model_construct(command=0) + event = StatusReceivedEvent(device_mac="00:11:22:33:44:55", status=status) + assert event.device_mac == "00:11:22:33:44:55" + + temp_event = TemperatureChangedEvent( + device_mac="00:11:22:33:44:55", + old_temperature=120.0, + new_temperature=122.0, + ) + assert temp_event.device_mac == "00:11:22:33:44:55" + + +@pytest.mark.asyncio +async def test_state_tracker_emits_with_mac(): + """Test that DeviceStateTracker includes mac_address in events.""" + emitter = MagicMock(spec=EventEmitter) + emitter.emit = AsyncMock(return_value=1) + tracker = DeviceStateTracker(emitter) + + mac1 = "00:11:22:33:44:55" + mac2 = "AA:BB:CC:DD:EE:FF" + + # We need to provide enough fields for computed properties if they are used + # DeviceStatus uses dhwTemperature computed property + # which uses dhw_temperature_raw + status1_v1 = DeviceStatus.model_construct( + dhw_temperature_raw=100, + operation_mode=CurrentOperationMode.STANDBY, + current_inst_power=0.0, + error_code=0, + ) + status1_v2 = DeviceStatus.model_construct( + dhw_temperature_raw=104, + operation_mode=CurrentOperationMode.STANDBY, + current_inst_power=0.0, + error_code=0, + ) + + # First update sets initial state + await tracker.process(mac1, status1_v1) + assert emitter.emit.call_count == 0 + + # Second update triggers event + await tracker.process(mac1, status1_v2) + assert emitter.emit.call_count == 1 + + args, kwargs = emitter.emit.call_args + assert args[0] == "temperature_changed" + event = args[1] + assert isinstance(event, TemperatureChangedEvent) + assert event.device_mac == mac1 + + # Update for different device + status2_v1 = DeviceStatus.model_construct( + dhw_temperature_raw=110, + operation_mode=CurrentOperationMode.STANDBY, + current_inst_power=0.0, + error_code=0, + ) + status2_v2 = DeviceStatus.model_construct( + dhw_temperature_raw=114, + operation_mode=CurrentOperationMode.STANDBY, + current_inst_power=0.0, + error_code=0, + ) + + await tracker.process(mac2, status2_v1) + await tracker.process(mac2, status2_v2) + + # Should have emitted another event for mac2 + assert emitter.emit.call_count == 2 + args, kwargs = emitter.emit.call_args + event = args[1] + assert event.device_mac == mac2 + + +def test_make_handler_injects_mac(): + """Test that MqttSubscriptionManager._make_handler injects mac_address.""" + # Mock dependencies for MqttSubscriptionManager + connection = MagicMock() + event_emitter = MagicMock() + schedule_coroutine = MagicMock() + + manager = MqttSubscriptionManager( + connection=connection, + client_id="test_client", + event_emitter=event_emitter, + schedule_coroutine=schedule_coroutine, + ) + + mac = "00:11:22:33:44:55" + callback_called = [] + + def my_callback(parsed): + callback_called.append(parsed) + + handler = manager._make_handler( + model=DeviceStatus, callback=my_callback, key="status", device_mac=mac + ) + + # Simulate receiving a message + message = { + "status": { + "command": 0, + "specialFunctionStatus": 0, + "errorCode": 0, + "subErrorCode": 0, + "smartDiagnostic": 0, + "faultStatus1": 0, + "faultStatus2": 0, + "wifiRssi": 0, + "dhwChargePer": 0.0, + "drEventStatus": 0, + "vacationDaySetting": 0, + "vacationDayElapsed": 0, + "antiLegionellaPeriod": 0, + "programReservationType": 0, + "tempFormulaType": 0, + "currentStatenum": 0, + "targetFanRpm": 0, + "currentFanRpm": 0, + "fanPwm": 0, + "mixingRate": 0.0, + "eevStep": 0, + "airFilterAlarmPeriod": 0, + "airFilterAlarmElapsed": 0, + "cumulatedOpTimeEvaFan": 0, + "cumulatedDhwFlowRate": 0.0, + "touStatus": 0, + "drOverrideStatus": 0, + "touOverrideStatus": 0, + "totalEnergyCapacity": 0.0, + "availableEnergyCapacity": 0.0, + "recircOperationMode": 0, + "recircPumpOperationStatus": 0, + "recircHotBtnReady": 0, + "recircOperationReason": 0, + "recircErrorStatus": 0, + "currentInstPower": 0.0, + "didReload": 0, + "operationBusy": 0, + "freezeProtectionUse": 0, + "dhwUse": 0, + "dhwUseSustained": 0, + "programReservationUse": 0, + "ecoUse": 0, + "compUse": 0, + "eevUse": 0, + "evaFanUse": 0, + "shutOffValveUse": 0, + "conOvrSensorUse": 0, + "wtrOvrSensorUse": 0, + "antiLegionellaUse": 0, + "antiLegionellaOperationBusy": 0, + "errorBuzzerUse": 0, + "currentHeatUse": 0, + "heatUpperUse": 0, + "heatLowerUse": 0, + "scaldUse": 0, + "airFilterAlarmUse": 0, + "recircOperationBusy": 0, + "recircReservationUse": 0, + "dhwTemperature": 100, + "dhwTemperatureSetting": 100, + "dhwTargetTemperatureSetting": 100, + "freezeProtectionTemperature": 100, + "dhwTemperature2": 100, + "hpUpperOnTempSetting": 100, + "hpUpperOffTempSetting": 100, + "hpLowerOnTempSetting": 100, + "hpLowerOffTempSetting": 100, + "heUpperOnTempSetting": 100, + "heUpperOffTempSetting": 100, + "heLowerOnTempSetting": 100, + "heLowerOffTempSetting": 100, + "heatMinOpTemperature": 100, + "recircTempSetting": 100, + "recircTemperature": 100, + "recircFaucetTemperature": 100, + "currentInletTemperature": 100, + "currentDhwFlowRate": 100, + "hpUpperOnDiffTempSetting": 100, + "hpUpperOffDiffTempSetting": 100, + "hpLowerOnDiffTempSetting": 100, + "hpLowerOffDiffTempSetting": 100, + "heUpperOnDiffTempSetting": 100, + "heUpperOffDiffTempSetting": 100, + "heLowerOnTDiffempSetting": 100, + "heLowerOffDiffTempSetting": 100, + "recircDhwFlowRate": 100, + "tankUpperTemperature": 100, + "tankLowerTemperature": 100, + "dischargeTemperature": 100, + "suctionTemperature": 100, + "evaporatorTemperature": 100, + "ambientTemperature": 100, + "targetSuperHeat": 100, + "currentSuperHeat": 100, + "operationMode": 0, + "dhwOperationSetting": 3, + "temperatureType": 2, + "freezeProtectionTempMin": 43.0, + "freezeProtectionTempMax": 65.0, + } + } + + handler("test/topic", message) + + assert len(callback_called) == 1 + parsed = callback_called[0] + assert isinstance(parsed, DeviceStatus) + assert parsed.mac_address == mac diff --git a/tests/test_reservations.py b/tests/test_reservations.py index 2bf3a25..de15501 100644 --- a/tests/test_reservations.py +++ b/tests/test_reservations.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest @@ -32,9 +32,11 @@ def mock_mqtt(mock_device: MagicMock) -> MagicMock: mqtt = MagicMock() mqtt.client_id = "test-client" mqtt.subscribe = AsyncMock() + mqtt.subscribe_reservation_response = AsyncMock() + mqtt.unsubscribe_reservation_response = AsyncMock() mqtt.unsubscribe = AsyncMock() - mqtt.control.request_reservations = AsyncMock() - mqtt.control.update_reservations = AsyncMock() + mqtt.request_reservations = AsyncMock() + mqtt.update_reservations = AsyncMock() return mqtt @@ -79,35 +81,26 @@ async def test_fetch_reservations_success( schedule = _make_schedule([_entry()]) captured_callback: list[Any] = [] - async def fake_subscribe(topic: str, cb: Any) -> int: + async def fake_subscribe_reservation(device: Any, cb: Any) -> int: captured_callback.append(cb) return 1 - mock_mqtt.subscribe.side_effect = fake_subscribe + mock_mqtt.subscribe_reservation_response.side_effect = ( + fake_subscribe_reservation + ) async def fake_request(device: Any) -> None: # Simulate the device response arriving after subscribe - topic = "cmd/NWP500/test-client/res/rsv/rd" - msg = { - "response": { - "reservationUse": 2, - "reservation": "023e061e0478", - } - } for cb in captured_callback: - cb(topic, msg) + cb(schedule) - mock_mqtt.control.request_reservations.side_effect = fake_request + mock_mqtt.request_reservations.side_effect = fake_request - with patch( - "nwp500.reservations.ReservationSchedule", - return_value=schedule, - ): - result = await fetch_reservations(mock_mqtt, mock_device) + result = await fetch_reservations(mock_mqtt, mock_device) assert result is schedule - mock_mqtt.unsubscribe.assert_called_once_with( - "cmd/NWP500/test-client/res/rsv/rd" + mock_mqtt.unsubscribe_reservation_response.assert_called_once_with( + mock_device, ANY ) @@ -116,39 +109,44 @@ async def test_fetch_reservations_timeout( mock_mqtt: MagicMock, mock_device: MagicMock ) -> None: """fetch_reservations returns None on timeout and still unsubscribes.""" - mock_mqtt.subscribe = AsyncMock() - mock_mqtt.control.request_reservations = AsyncMock() # never fires callback + mock_mqtt.subscribe_reservation_response = AsyncMock() + mock_mqtt.request_reservations = AsyncMock() # never fires callback result = await fetch_reservations(mock_mqtt, mock_device, timeout=0.01) assert result is None - mock_mqtt.unsubscribe.assert_called_once_with( - "cmd/NWP500/test-client/res/rsv/rd" + mock_mqtt.unsubscribe_reservation_response.assert_called_once_with( + mock_device, ANY ) @pytest.mark.asyncio -async def test_fetch_reservations_ignores_wrong_topic( +async def test_fetch_reservations_ignores_multiple_responses( mock_mqtt: MagicMock, mock_device: MagicMock ) -> None: - """fetch_reservations ignores messages on non-reservation topics.""" + """fetch_reservations resolves on first response, ignores later ones.""" + schedule = _make_schedule([_entry()]) + second_schedule = _make_schedule([_entry(hour=9)]) captured_callback: list[Any] = [] - async def fake_subscribe(topic: str, cb: Any) -> int: + async def fake_subscribe_reservation(device: Any, cb: Any) -> int: captured_callback.append(cb) return 1 - mock_mqtt.subscribe.side_effect = fake_subscribe + mock_mqtt.subscribe_reservation_response.side_effect = ( + fake_subscribe_reservation + ) async def fake_request(device: Any) -> None: - # Wrong topic — should be ignored + # Fire callback twice — only the first should resolve the future for cb in captured_callback: - cb("cmd/NWP500/test-client/res/other/rd", {"response": {"foo": 1}}) + cb(schedule) + cb(second_schedule) - mock_mqtt.control.request_reservations.side_effect = fake_request + mock_mqtt.request_reservations.side_effect = fake_request - result = await fetch_reservations(mock_mqtt, mock_device, timeout=0.01) - assert result is None + result = await fetch_reservations(mock_mqtt, mock_device) + assert result is schedule # --------------------------------------------------------------------------- @@ -176,8 +174,8 @@ async def test_add_reservation_success( temperature=120.0, ) - mock_mqtt.control.update_reservations.assert_called_once() - _, reservations = mock_mqtt.control.update_reservations.call_args.args + mock_mqtt.update_reservations.assert_called_once() + _, reservations = mock_mqtt.update_reservations.call_args.args assert len(reservations) == 2 @@ -267,8 +265,8 @@ async def test_delete_reservation_success( with patch("nwp500.reservations.fetch_reservations", return_value=schedule): await delete_reservation(mock_mqtt, mock_device, index=1) - mock_mqtt.control.update_reservations.assert_called_once() - _, reservations = mock_mqtt.control.update_reservations.call_args.args + mock_mqtt.update_reservations.assert_called_once() + _, reservations = mock_mqtt.update_reservations.call_args.args assert len(reservations) == 1 assert reservations[0]["hour"] == 8 @@ -283,7 +281,7 @@ async def test_delete_reservation_disables_when_empty( with patch("nwp500.reservations.fetch_reservations", return_value=schedule): await delete_reservation(mock_mqtt, mock_device, index=1) - enabled = mock_mqtt.control.update_reservations.call_args.kwargs["enabled"] + enabled = mock_mqtt.update_reservations.call_args.kwargs["enabled"] assert enabled is False @@ -322,8 +320,8 @@ async def test_update_reservation_temperature( with patch("nwp500.reservations.fetch_reservations", return_value=schedule): await update_reservation(mock_mqtt, mock_device, 1, temperature=150.0) - mock_mqtt.control.update_reservations.assert_called_once() - _, reservations = mock_mqtt.control.update_reservations.call_args.args + mock_mqtt.update_reservations.assert_called_once() + _, reservations = mock_mqtt.update_reservations.call_args.args # param must differ from the original 120 (150°F = 65.6°C → param=131) assert reservations[0]["param"] != 120 @@ -338,7 +336,7 @@ async def test_update_reservation_preserves_fields( with patch("nwp500.reservations.fetch_reservations", return_value=schedule): await update_reservation(mock_mqtt, mock_device, 1, hour=8) - _, reservations = mock_mqtt.control.update_reservations.call_args.args + _, reservations = mock_mqtt.update_reservations.call_args.args assert reservations[0]["hour"] == 8 assert reservations[0]["param"] == 120