diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3065a6..6344262 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,10 @@ jobs: - name: Run tox tests run: tox -e default + - name: Install project testing extras (ensure pytest-asyncio present) + run: | + python -m pip install --upgrade pip + python -m pip install '.[testing]' build: name: Build Distribution diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 5ef1674..f43cb3a 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -120,7 +120,15 @@ Clone the repository #. You should run:: - pip install -U pip setuptools -e . + pip install -U pip setuptools -e . + +.. note:: + + To run the full test suite (including async tests annotated with + ``@pytest.mark.asyncio``), install the testing extras which include + ``pytest-asyncio``:: + + pip install -e '.[testing]' to be able to import the package under development in the Python REPL. diff --git a/README.rst b/README.rst index fdbbf57..f672d88 100644 --- a/README.rst +++ b/README.rst @@ -13,6 +13,9 @@ Features * **Device Monitoring**: Access real-time status information including temperatures, power consumption, and tank charge level * **Temperature Control**: Set target water temperature (100-140°F) * **Operation Mode Control**: Switch between Heat Pump, Energy Saver, High Demand, Electric, and Vacation modes +* **Reservation Management**: Schedule automatic temperature and mode changes +* **Time of Use (TOU)**: Configure energy pricing schedules for demand response +* **Anti-Legionella Protection**: Monitor periodic disinfection cycles (140°F heating) * **Comprehensive Status Data**: Access to 70+ device status fields including compressor status, heater status, flow rates, and more * **MQTT Protocol Support**: Low-level MQTT communication with Navien devices * **Non-Blocking Async Operations**: Fully compatible with async event loops (Home Assistant safe) @@ -189,19 +192,23 @@ The library supports low-level MQTT communication with Navien devices: **Control Topics** * ``cmd/{deviceType}/{deviceId}/ctrl`` - Send control commands + * ``cmd/{deviceType}/{deviceId}/ctrl/rsv/rd`` - Manage reservations + * ``cmd/{deviceType}/{deviceId}/ctrl/tou/rd`` - Time of Use settings * ``cmd/{deviceType}/{deviceId}/st`` - Request status updates **Control Commands** * Power control (on/off) - * DHW mode changes + * DHW mode changes (including vacation mode) * Temperature settings - * Reservation management + * Reservation management (scheduled mode/temperature changes) + * Time of Use (TOU) pricing schedules **Status Requests** * Device information * General device status * Energy usage queries * Reservation information + * TOU settings See the full `MQTT Protocol Documentation`_ for detailed message formats. diff --git a/docs/MQTT_CLIENT.rst b/docs/MQTT_CLIENT.rst index 3935f94..0809fec 100644 --- a/docs/MQTT_CLIENT.rst +++ b/docs/MQTT_CLIENT.rst @@ -82,9 +82,12 @@ Usage Examples # Turn device on/off await mqtt_client.set_power(device, power_on=True) - # Set DHW mode (1=Heat Pump Only, 2=Electric Only, 3=Energy Saver, 4=High Demand) + # Set DHW mode (1=Heat Pump Only, 2=Electric Only, 3=Energy Saver, 4=High Demand, 5=Vacation) await mqtt_client.set_dhw_mode(device, mode_id=3) + # Set vacation mode with duration + await mqtt_client.set_dhw_mode(device, mode_id=5, vacation_days=7) + # Set target temperature await mqtt_client.set_dhw_temperature(device, temperature=120) @@ -1158,6 +1161,265 @@ applications await mqtt_client.start_periodic_device_info_requests(device) await mqtt_client.start_periodic_device_status_requests(device) +Advanced Features +----------------- + +Vacation Mode +~~~~~~~~~~~~~ + +Set the device to vacation mode to save energy during extended absences: + +.. code:: python + + # Set vacation mode for 7 days + await mqtt_client.set_dhw_mode(device, mode_id=5, vacation_days=7) + + # Check vacation status in device status + def on_status(topic: str, message: dict): + status = message.get('response', {}).get('status', {}) + if status.get('dhwOperationSetting') == 5: + days_set = status.get('vacationDaySetting', 0) + days_elapsed = status.get('vacationDayElapsed', 0) + days_remaining = days_set - days_elapsed + print(f"Vacation mode: {days_remaining} days remaining") + + await mqtt_client.subscribe_device(device, on_status) + await mqtt_client.request_device_status(device) + +Reservation Management +~~~~~~~~~~~~~~~~~~~~~~ + +Manage programmed temperature and mode changes: + +.. code:: python + + # Create a reservation for weekday mornings + reservation = { + "enable": 1, # 1=enabled, 2=disabled + "week": 124, # Weekdays (Mon-Fri) + "hour": 6, + "min": 30, + "mode": 4, # High Demand mode + "param": 120 # Target temperature (140°F display = 120°F message) + } + + # Send reservation update + await mqtt_client.publish( + topic=f"cmd/52/{device.device_info.mac_address}/ctrl/rsv/rd", + payload={ + "clientID": mqtt_client.client_id, + "protocolVersion": 2, + "request": { + "command": 16777226, + "deviceType": 52, + "macAddress": device.device_info.mac_address, + "reservationUse": 1, # Enable reservations + "reservation": [reservation] + }, + "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl/rsv/rd", + "responseTopic": "...", + "sessionID": str(int(time.time() * 1000)) + } + ) + +**Week Bitfield Values:** + +* ``127`` - All days (Sunday through Saturday) +* ``62`` - Weekdays (Monday through Friday) +* ``65`` - Weekend (Saturday and Sunday) +* ``31`` - Sunday through Thursday + +Time of Use (TOU) Pricing +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Configure energy pricing schedules for demand response: + +.. code:: python + + # Define TOU periods + tou_periods = [ + { + "season": 31, # All seasons + "week": 124, # Weekdays + "startHour": 0, + "startMinute": 0, + "endHour": 14, + "endMinute": 59, + "priceMin": 34831, # $0.34831 per kWh + "priceMax": 34831, + "decimalPoint": 5 # Divide by 100000 + }, + { + "season": 31, + "week": 124, + "startHour": 15, + "startMinute": 0, + "endHour": 20, + "endMinute": 59, + "priceMin": 45000, # $0.45 per kWh (peak pricing) + "priceMax": 45000, + "decimalPoint": 5 + } + ] + + # Send TOU settings + await mqtt_client.publish( + topic=f"cmd/52/{device.device_info.mac_address}/ctrl/tou/rd", + payload={ + "clientID": mqtt_client.client_id, + "protocolVersion": 2, + "request": { + "command": 33554439, + "deviceType": 52, + "macAddress": device.device_info.mac_address, + "controllerSerialNumber": device.controller_serial_number, + "reservationUse": 2, # Enable TOU + "reservation": tou_periods + }, + "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl/tou/rd", + "responseTopic": "...", + "sessionID": str(int(time.time() * 1000)) + } + ) + +**Note:** TOU settings help the device optimize operation based on energy prices, potentially reducing costs during peak pricing periods. + +Anti-Legionella Monitoring +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Monitor the Anti-Legionella protection cycle that prevents bacterial growth: + +.. code:: python + + # Check Anti-Legionella status + def on_status(topic: str, message: dict): + status = message.get('response', {}).get('status', {}) + + # Check if feature is enabled + anti_legionella_enabled = status.get('antiLegionellaUse') == 2 + + # Get cycle period in days + period_days = status.get('antiLegionellaPeriod', 0) + + # Check if currently running + is_running = status.get('antiLegionellaOperationBusy') == 2 + + print(f"Anti-Legionella: {'Enabled' if anti_legionella_enabled else 'Disabled'}") + print(f"Cycle Period: Every {period_days} days") + print(f"Status: {'Running' if is_running else 'Idle'}") + + if is_running: + print("Device is heating to 140°F for bacterial disinfection") + + await mqtt_client.subscribe_device(device, on_status) + await mqtt_client.request_device_status(device) + +**Controlling Anti-Legionella:** + +.. code:: python + + import time + + # Enable Anti-Legionella with 7-day cycle + await mqtt_client.publish( + topic=f"cmd/52/{device.device_info.mac_address}/ctrl", + payload={ + "clientID": mqtt_client.client_id, + "protocolVersion": 2, + "request": { + "command": 33554472, + "deviceType": 52, + "macAddress": device.device_info.mac_address, + "mode": "anti-leg-on", + "param": [7], # 7-day cycle period + "paramStr": "" + }, + "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl", + "responseTopic": "...", + "sessionID": str(int(time.time() * 1000)) + } + ) + + # Disable Anti-Legionella (not recommended - health risk) + await mqtt_client.publish( + topic=f"cmd/52/{device.device_info.mac_address}/ctrl", + payload={ + "clientID": mqtt_client.client_id, + "protocolVersion": 2, + "request": { + "command": 33554473, + "deviceType": 52, + "macAddress": device.device_info.mac_address, + "mode": "anti-leg-off", + "param": [], + "paramStr": "" + }, + "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl", + "responseTopic": "...", + "sessionID": str(int(time.time() * 1000)) + } + ) + +**Important Safety Notes:** + +* Anti-Legionella heats water to 140°F (60°C) to kill Legionella bacteria +* Requires a mixing valve to prevent scalding at taps +* Cycle period is typically 7 days but can be configured for 1-30 days +* During the cycle, the device will heat the entire tank to the disinfection temperature +* This is a health safety feature recommended for all water heaters +* **WARNING**: Disabling Anti-Legionella increases health risks - consult local codes + +TOU Quick Enable/Disable +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Toggle TOU functionality without modifying the schedule: + +.. code:: python + + import time + + # Enable TOU + await mqtt_client.publish( + topic=f"cmd/52/{device.device_info.mac_address}/ctrl", + payload={ + "clientID": mqtt_client.client_id, + "protocolVersion": 2, + "request": { + "command": 33554476, + "deviceType": 52, + "macAddress": device.device_info.mac_address, + "mode": "tou-on", + "param": [], + "paramStr": "" + }, + "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl", + "responseTopic": "...", + "sessionID": str(int(time.time() * 1000)) + } + ) + + # Disable TOU + await mqtt_client.publish( + topic=f"cmd/52/{device.device_info.mac_address}/ctrl", + payload={ + "clientID": mqtt_client.client_id, + "protocolVersion": 2, + "request": { + "command": 33554475, + "deviceType": 52, + "macAddress": device.device_info.mac_address, + "mode": "tou-off", + "param": [], + "paramStr": "" + }, + "requestTopic": f"cmd/52/{device.device_info.mac_address}/ctrl", + "responseTopic": "...", + "sessionID": str(int(time.time() * 1000)) + } + ) + +**Note:** The TOU schedule remains stored when disabled and will resume when re-enabled. + Troubleshooting --------------- diff --git a/docs/MQTT_MESSAGES.rst b/docs/MQTT_MESSAGES.rst index ea1b7a4..5a83ff2 100644 --- a/docs/MQTT_MESSAGES.rst +++ b/docs/MQTT_MESSAGES.rst @@ -52,6 +52,10 @@ Control messages are sent to the ``cmd/{deviceType}/{deviceId}/ctrl`` topic. The * Power control: 33554433 (power-off) or 33554434 (power-on) * DHW mode control: 33554437 * DHW temperature control: 33554464 +* Reservation management: 16777226 +* TOU (Time of Use) settings: 33554439 +* Anti-Legionella control: 33554471 (disable) or 33554472 (enable) +* TOU enable/disable: 33554475 (disable) or 33554476 (enable) Power Control ^^^^^^^^^^^^^ @@ -80,7 +84,7 @@ DHW Mode * ``mode``: "dhw-mode" * Changes the Domestic Hot Water (DHW) mode. - * ``param``\ : ``[]`` + * ``param``\ : ``[]`` or ``[, ]`` for vacation mode * ``paramStr``\ : ``""`` .. list-table:: @@ -101,6 +105,9 @@ DHW Mode * - 4 - High Demand - Maximum heating mode using all available components as needed for fastest recovery with higher capacity. + * - 5 + - Vacation Mode + - Suspends heating to save energy during extended absences. Requires vacation days parameter (e.g., ``[5, 4]`` for 4-day vacation). .. note:: Additional modes may appear in status responses: @@ -108,6 +115,15 @@ DHW Mode * Mode 0: Standby (device in idle state) * Mode 6: Power Off (device is powered off) +**Vacation Mode Parameters:** + +When setting vacation mode (mode 5), provide two parameters: + +* ``param[0]``: Mode ID (5) +* ``param[1]``: Number of vacation days (1-30) + +Example: ``"param": [5, 7]`` sets vacation mode for 7 days. + Set DHW Temperature ^^^^^^^^^^^^^^^^^^^ @@ -126,6 +142,297 @@ Set DHW Temperature Valid message range: ~95-131°F (displays as ~115-151°F, max 150°F) +Anti-Legionella Control +^^^^^^^^^^^^^^^^^^^^^^^^ + +* **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl`` +* **Command Codes**: + + * ``33554471`` - Disable Anti-Legionella + * ``33554472`` - Enable Anti-Legionella (with cycle period) + +* ``mode``: "anti-leg-on" (for enable) or "anti-leg-off" (for disable) + + * Enables or configures Anti-Legionella protection + * ``param``\ : ``[]`` for enable (1-30 days), ``[]`` for disable + * ``paramStr``\ : ``""`` + +**Enable Anti-Legionella Example:** + +.. code-block:: json + + { + "clientID": "...", + "protocolVersion": 2, + "request": { + "additionalValue": "...", + "command": 33554472, + "deviceType": 52, + "macAddress": "...", + "mode": "anti-leg-on", + "param": [7], + "paramStr": "" + }, + "requestTopic": "cmd/52/navilink-04786332fca0/ctrl", + "responseTopic": "...", + "sessionID": "..." + } + +**Observed Response After Enable:** + +After sending the enable command, the device status shows: + +* ``antiLegionellaUse`` changes from 1 (disabled) to 2 (enabled) +* ``antiLegionellaPeriod`` is set to the specified period value + +**Disable Anti-Legionella Example:** + +.. code-block:: json + + { + "clientID": "...", + "protocolVersion": 2, + "request": { + "additionalValue": "...", + "command": 33554471, + "deviceType": 52, + "macAddress": "...", + "mode": "anti-leg-off", + "param": [], + "paramStr": "" + }, + "requestTopic": "cmd/52/navilink-04786332fca0/ctrl", + "responseTopic": "...", + "sessionID": "..." + } + +**Observed Response After Disable:** + +After sending the disable command, the device status shows: + +* ``antiLegionellaUse`` changes from 2 (enabled) to 1 (disabled) +* ``antiLegionellaPeriod`` retains its previous value + +.. warning:: + Disabling Anti-Legionella protection may increase health risks. Legionella bacteria can grow + in water heaters maintained at temperatures below 140°F (60°C). Consult local health codes + before disabling this safety feature. + +**Period Parameter:** + +* Valid range: 1-30 days +* Typical value: 7 days (weekly disinfection) +* Longer periods may increase bacterial growth risk +* Shorter periods use more energy but provide better protection + +Reservation Management +^^^^^^^^^^^^^^^^^^^^^^ + +* **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl/rsv/rd`` +* **Command Code**: ``16777226`` +* ``mode``: Not used for reservations + + * Manages programmed reservations for temperature changes + * ``reservationUse``\ : ``1`` (enable) or ``2`` (disable) + * ``reservation``\ : Array of reservation objects + +**Reservation Object Fields:** + +* ``enable``\ : ``1`` (enabled) or ``2`` (disabled) +* ``week``\ : Bitfield for days of week (e.g., ``124`` = weekdays, ``3`` = weekend) +* ``hour``\ : Hour (0-23) +* ``min``\ : Minute (0-59) +* ``mode``\ : Operation mode to set (1-5) +* ``param``\ : Temperature or other parameter (temperature is 20°F less than display value) + +**Example Payload:** + +.. code-block:: json + + { + "clientID": "...", + "protocolVersion": 2, + "request": { + "additionalValue": "...", + "command": 16777226, + "deviceType": 52, + "macAddress": "...", + "reservationUse": 1, + "reservation": [ + { + "enable": 2, + "week": 24, + "hour": 12, + "min": 10, + "mode": 1, + "param": 98 + } + ] + }, + "requestTopic": "cmd/52/navilink-04786332fca0/ctrl/rsv/rd", + "responseTopic": "...", + "sessionID": "..." + } + +**Week Bitfield Values:** + +The ``week`` field uses a bitfield where each bit represents a day: + +* Bit 0 (1): Sunday +* Bit 1 (2): Monday +* Bit 2 (4): Tuesday +* Bit 3 (8): Wednesday +* Bit 4 (16): Thursday +* Bit 5 (32): Friday +* Bit 6 (64): Saturday + +Common combinations: + +* ``127`` (all days): Sunday through Saturday +* ``62`` (weekdays): Monday through Friday (2+4+8+16+32=62) +* ``65`` (weekend): Saturday and Sunday (64+1=65) + +Common combinations: + +* ``127`` (all days): Sunday through Saturday +* ``62`` (weekdays): Monday through Friday +* ``65`` (weekend): Saturday and Sunday +* ``24`` (mid-week): Wednesday and Thursday (8+16 = 24) + +TOU (Time of Use) Settings +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl/tou/rd`` +* **Command Code**: ``33554439`` +* Manages Time of Use energy pricing schedules + + * ``reservationUse``\ : ``1`` (enable) or ``2`` (disable) + * ``reservation``\ : Array of TOU period objects + * ``controllerSerialNumber``\ : Device controller serial number + +**TOU Period Object Fields:** + +* ``season``\ : Season identifier (bitfield, e.g., ``31`` for specific months) +* ``week``\ : Days of week bitfield (same as reservation management) +* ``startHour``\ : Start hour (0-23) +* ``startMinute``\ : Start minute (0-59) +* ``endHour``\ : End hour (0-23) +* ``endMinute``\ : End minute (0-59) +* ``priceMin``\ : Minimum price (integer, scaled by decimal point) +* ``priceMax``\ : Maximum price (integer, scaled by decimal point) +* ``decimalPoint``\ : Decimal places for price (e.g., ``5`` means divide by 100000) + +**Example Payload:** + +.. code-block:: json + + { + "clientID": "...", + "protocolVersion": 2, + "request": { + "additionalValue": "...", + "command": 33554439, + "deviceType": 52, + "macAddress": "...", + "controllerSerialNumber": "56496061BT22230408", + "reservationUse": 2, + "reservation": [ + { + "season": 31, + "week": 124, + "startHour": 0, + "startMinute": 0, + "endHour": 14, + "endMinute": 59, + "priceMin": 34831, + "priceMax": 34831, + "decimalPoint": 5 + }, + { + "season": 31, + "week": 124, + "startHour": 15, + "startMinute": 0, + "endHour": 15, + "endMinute": 59, + "priceMin": 36217, + "priceMax": 36217, + "decimalPoint": 5 + } + ] + }, + "requestTopic": "cmd/52/navilink-04786332fca0/ctrl/tou/rd", + "responseTopic": "...", + "sessionID": "..." + } + +**Price Calculation:** + +The actual price is calculated as: ``price_value / (10 ^ decimalPoint)`` + +For example, with ``priceMin: 34831`` and ``decimalPoint: 5``: ``34831 / 100000 = 0.34831`` + +TOU Enable/Disable Control +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl`` +* **Command Codes**: + + * ``33554475`` - Disable TOU + * ``33554476`` - Enable TOU + +* ``mode``: "tou-off" or "tou-on" + + * Quick enable/disable of TOU functionality + * ``param``\ : ``[]`` + * ``paramStr``\ : ``""`` + +**Enable TOU Example:** + +.. code-block:: json + + { + "clientID": "...", + "protocolVersion": 2, + "request": { + "additionalValue": "...", + "command": 33554476, + "deviceType": 52, + "macAddress": "...", + "mode": "tou-on", + "param": [], + "paramStr": "" + }, + "requestTopic": "cmd/52/navilink-04786332fca0/ctrl", + "responseTopic": "...", + "sessionID": "..." + } + +**Disable TOU Example:** + +.. code-block:: json + + { + "clientID": "...", + "protocolVersion": 2, + "request": { + "additionalValue": "...", + "command": 33554475, + "deviceType": 52, + "macAddress": "...", + "mode": "tou-off", + "param": [], + "paramStr": "" + }, + "requestTopic": "cmd/52/navilink-04786332fca0/ctrl", + "responseTopic": "...", + "sessionID": "..." + } + +.. note:: + These commands provide quick enable/disable without modifying the TOU schedule. + The schedule configured via command 33554439 remains stored and can be re-enabled. + Response Messages (\ ``/res``\ ) -------------------------------- @@ -140,6 +447,51 @@ Device Status Messages The device status is sent in the ``status`` object of the response messages. For a complete description of all fields found in the ``status`` object, see :doc:`DEVICE_STATUS_FIELDS`. +**Status Command Field:** + +The ``status`` object includes a ``command`` field that indicates the type of status data: + +* ``67108883`` (0x04000013) - Standard status snapshot +* ``67108892`` (0x0400001C) - Extended status snapshot + +These command codes are informational and indicate which status fields are populated in the response. + +**Vacation Mode Status Fields:** + +When the device is in vacation mode (``dhwOperationSetting: 5``), the status includes: + +* ``vacationDaySetting``\ : Total vacation days configured +* ``vacationDayElapsed``\ : Days elapsed since vacation mode started +* ``dhwOperationSetting``\ : Set to ``5`` when in vacation mode +* ``operationMode``\ : Current operational state (typically ``0`` for standby during vacation) + +**Reservation Status Fields:** + +* ``programReservationType``\ : Type of reservation program (0 = none, 1 = active) +* ``reservationUse``\ : Whether reservations are enabled (1 = enabled, 2 = disabled) + +**Anti-Legionella Status Fields:** + +The device includes Anti-Legionella protection that periodically heats water to 140°F (60°C) to prevent bacterial growth: + +* ``antiLegionellaUse``\ : Anti-Legionella enable flag + + * **1** = disabled + * **2** = enabled + +* ``antiLegionellaPeriod``\ : Days between Anti-Legionella cycles (typically 7 days, range 1-30) +* ``antiLegionellaOperationBusy``\ : Currently performing Anti-Legionella cycle + + * **1** = OFF (not currently running) + * **2** = ON (currently heating to disinfection temperature) + +.. note:: + Anti-Legionella is a safety feature that heats the water tank to 140°F at programmed intervals + to kill Legionella bacteria. This requires a mixing valve to prevent scalding at taps. + The feature can be configured for 1-30 day intervals. When the + enable command (33554472) is sent with a period parameter, ``antiLegionellaUse`` changes + from 1 (disabled) to 2 (enabled), and ``antiLegionellaPeriod`` is updated to the specified value. + Status Request Messages ----------------------- @@ -289,6 +641,61 @@ Request Software Download Information "sessionID": "..." } +Request Reservation Information +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl/rsv/rd`` +* **Description**: Request current reservation settings. +* **Command Code**: ``16777226`` +* **Payload**: + +.. code-block:: json + + { + "clientID": "...", + "protocolVersion": 2, + "request": { + "additionalValue": "...", + "command": 16777226, + "deviceType": 52, + "macAddress": "..." + }, + "requestTopic": "cmd/52/navilink-{macAddress}/ctrl/rsv/rd", + "responseTopic": "...", + "sessionID": "..." + } + +* **Response Topic**: ``cmd/{deviceType}/{...}/res/rsv/rd`` +* **Response Fields**: Contains ``reservationUse`` and ``reservation`` array with current settings + +Request TOU Information +^^^^^^^^^^^^^^^^^^^^^^^ + +* **Topic**: ``cmd/{deviceType}/{deviceId}/ctrl/tou/rd`` +* **Description**: Request current Time of Use pricing settings. +* **Command Code**: ``33554439`` +* **Payload**: + +.. code-block:: json + + { + "clientID": "...", + "protocolVersion": 2, + "request": { + "additionalValue": "...", + "command": 33554439, + "deviceType": 52, + "macAddress": "...", + "controllerSerialNumber": "..." + }, + "requestTopic": "cmd/52/navilink-{macAddress}/ctrl/tou/rd", + "responseTopic": "...", + "sessionID": "..." + } + +* **Response Topic**: ``cmd/{deviceType}/{...}/res/tou/rd`` +* **Response Fields**: Contains ``reservationUse`` and ``reservation`` array with current TOU schedule + End Connection ^^^^^^^^^^^^^^ @@ -376,3 +783,137 @@ Response messages are published to topics matching the pattern ``cmd/{deviceType ... } } + +Command Code Reference +---------------------- + +Complete reference of all MQTT command codes: + +**Power Control** + +.. list-table:: + :header-rows: 1 + :widths: 15 40 45 + + * - Code + - Purpose + - Mode/Notes + * - 33554433 + - Power Off + - mode: "power-off" + * - 33554434 + - Power On + - mode: "power-on" + +**DHW Control** + +.. list-table:: + :header-rows: 1 + :widths: 15 40 45 + + * - Code + - Purpose + - Mode/Notes + * - 33554437 + - DHW Mode Change + - mode: "dhw-mode", param: [mode_id] or [5, days] for vacation + * - 33554464 + - DHW Temperature + - mode: "dhw-temperature", param: [temp] (20°F offset) + +**Anti-Legionella Control** + +.. list-table:: + :header-rows: 1 + :widths: 15 40 45 + + * - Code + - Purpose + - Mode/Notes + * - 33554471 + - Disable Anti-Legionella + - mode: "anti-leg-off", param: [] + * - 33554472 + - Enable Anti-Legionella + - mode: "anti-leg-on", param: [period_days] (1-30) + +**TOU Control** + +.. list-table:: + :header-rows: 1 + :widths: 15 40 45 + + * - Code + - Purpose + - Mode/Notes + * - 33554439 + - Configure TOU Schedule + - Topic: /ctrl/tou/rd, full schedule configuration + * - 33554475 + - Disable TOU + - mode: "tou-off", quick toggle without changing schedule + * - 33554476 + - Enable TOU + - mode: "tou-on", quick toggle without changing schedule + +**Reservation Management** + +.. list-table:: + :header-rows: 1 + :widths: 15 40 45 + + * - Code + - Purpose + - Mode/Notes + * - 16777226 + - Manage Reservations + - Topic: /ctrl/rsv/rd, schedule temperature/mode changes + +**Status Requests** + +.. list-table:: + :header-rows: 1 + :widths: 15 40 45 + + * - Code + - Purpose + - Mode/Notes + * - 16777217 + - Device Information + - Topic: /st/did, returns feature data + * - 16777219 + - Device Status + - Topic: /st, returns current status + * - 16777225 + - Energy Usage Query + - Topic: /st/energy-usage-daily-query/rd + * - 16777227 + - Software Download Info + - Topic: /st/dl-sw-info + * - 16777218 + - End Connection + - Topic: /st/end + +**Status Response Indicators** + +.. list-table:: + :header-rows: 1 + :widths: 15 40 45 + + * - Code + - Purpose + - Mode/Notes + * - 67108883 + - Standard Status Type + - Appears in response status.command field + * - 67108892 + - Extended Status Type + - Appears in response status.command field + +**Command Code Format** + +Command codes follow a pattern based on their category: + +* ``0x01......`` (16777216+) - Request/Query commands +* ``0x02......`` (33554432+) - Control commands +* ``0x04......`` (67108864+) - Status response type indicators diff --git a/docs/openapi.yaml b/docs/openapi.yaml index bc3dd5e..dd1fa49 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,5 +1,5 @@ -openapi: 3.0.0 +openapi: 3.1.0 info: title: Navien Smart Control API description: | @@ -157,6 +157,7 @@ paths: example: "user@example.com" responses: '200': + description: Push token updated successfully content: application/json: schema: @@ -381,34 +382,49 @@ paths: example: 0 /device/tou: get: - summary: Time of Use - description: Retrieves Time of Use (TOU) information for a device. + summary: Get Time of Use Settings + description: | + Retrieves Time of Use (TOU) pricing information for a device. This endpoint returns + both utility rate information and device-specific TOU schedules for demand response optimization. + + The response includes pricing schedules with time periods, days of week, and seasonal variations. + Prices are represented as integers scaled by the decimalPoint field (divide by 10^decimalPoint). parameters: - name: additionalValue in: query required: true schema: type: string + example: "5322" + description: Additional device identifier - name: controllerId in: query required: true schema: type: string + example: "56496061BT22230408" + description: Controller serial number - name: macAddress in: query required: true schema: type: string + example: "04786332fca0" + description: Device MAC address - name: userId in: query required: true schema: type: string + example: "user@example.com" + description: User email address - name: userType in: query required: true schema: type: string + example: "O" + description: User type (O = Owner) responses: '200': description: Time of Use information @@ -452,35 +468,45 @@ paths: season: type: integer example: 3615 + description: Season identifier bitfield for when this schedule applies interval: type: array + description: Array of time periods with pricing information items: type: object properties: priceMin: type: integer example: 36261 + description: Minimum price (divide by 10^decimalPoint for actual price) endHour: type: integer example: 15 + description: End hour for this pricing period (0-23) priceMax: type: integer example: 46378 + description: Maximum price (divide by 10^decimalPoint for actual price) week: type: integer - example: 124 + example: 62 + description: Days of week bitfield (62=weekdays [Mon–Fri], 65=weekend [Sat+Sun], 127=all days; Sunday=1, Monday=2, ..., Saturday=64) decimalPoint: type: integer example: 5 + description: Decimal places for price calculation (e.g., 5 means divide by 100000) startHour: type: integer example: 0 + description: Start hour for this pricing period (0-23) startMinute: type: integer example: 0 + description: Start minute (0-59) endMinute: type: integer example: 59 + description: End minute (0-59) utility: type: string example: "Pacific Gas & Electric Co" @@ -512,6 +538,7 @@ paths: type: string responses: '200': + description: Push token updated successfully content: application/json: schema: diff --git a/examples/anti_legionella_example.py b/examples/anti_legionella_example.py new file mode 100644 index 0000000..a52c08b --- /dev/null +++ b/examples/anti_legionella_example.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Example: Toggle Anti-Legionella protection via MQTT. + +This example demonstrates: +1. Getting the initial Anti-Legionella status +2. Enabling Anti-Legionella with a specific period +3. Displaying the status after enabling +4. Disabling Anti-Legionella +5. Displaying the status after disabling + +Note: Disabling Anti-Legionella may increase health risks from Legionella bacteria. +""" + +import asyncio +import os +import sys +from typing import Any + +from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient + + +def display_anti_legionella_status(status: dict[str, Any], label: str = "") -> None: + """Display Anti-Legionella status in a formatted way.""" + period = status.get("antiLegionellaPeriod") + use_value = status.get("antiLegionellaUse") + enabled = use_value == 2 + busy = status.get("antiLegionellaOperationBusy") == 2 + + if period is not None and use_value is not None: + prefix = f"{label}: " if label else "" + status_str = "ENABLED" if enabled else "DISABLED" + running_str = " (running now)" if busy else "" + + print(f"{prefix}Anti-Legionella is {status_str}") + print(f" - Period: every {period} day(s)") + print( + f" - Status: {'Running disinfection cycle' if busy else 'Not running'}{running_str}" + ) + print( + f" - Raw values: antiLegionellaUse={use_value}, antiLegionellaPeriod={period}, busy={busy}" + ) + + +async def main() -> None: + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + + if not email or not password: + print("Error: Set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables") + sys.exit(1) + + async with NavienAuthClient(email, password) as auth_client: + api_client = NavienAPIClient(auth_client=auth_client) + device = await api_client.get_first_device() + if not device: + print("No devices found for this account") + return + + print(f"Connected to device: {device.device_info.device_name}") + print(f"Device MAC: {device.device_info.mac_address}") + print() + + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + + # Track the latest status + latest_status = {} + status_received = asyncio.Event() + + def on_status(topic: str, message: dict[str, Any]) -> None: + nonlocal latest_status + # Debug: print what we received + print(f"[DEBUG] Received message on topic: {topic}") + status = message.get("response", {}).get("status", {}) + if status.get("antiLegionellaPeriod") is not None: + latest_status = status + status_received.set() + print("[DEBUG] Anti-Legionella status captured") + else: + print("[DEBUG] Message doesn't contain antiLegionellaPeriod") + + # Listen for status updates + device_type = device.device_info.device_type + device_id = device.device_info.mac_address + device_topic = f"navilink-{device_id}" + response_topic = f"cmd/{device_type}/{device_topic}/#" + print(f"[DEBUG] Subscribing to: {response_topic}") + await mqtt_client.subscribe(response_topic, on_status) + print("[DEBUG] Subscription successful") + await asyncio.sleep(1) # Give subscription time to settle + + # Step 1: Get initial status + print("=" * 70) + print("STEP 1: Getting initial Anti-Legionella status...") + print("=" * 70) + status_received.clear() + await mqtt_client.request_device_status(device) + + try: + await asyncio.wait_for(status_received.wait(), timeout=10) + display_anti_legionella_status(latest_status, "INITIAL STATE") + except asyncio.TimeoutError: + print("Timeout waiting for status response") + return + + print() + await asyncio.sleep(2) + + # Step 2: Enable Anti-Legionella + print("=" * 70) + print("STEP 2: Enabling Anti-Legionella cycle every 7 days...") + print("=" * 70) + status_received.clear() + await mqtt_client.enable_anti_legionella(device, period_days=7) + + try: + await asyncio.wait_for(status_received.wait(), timeout=10) + display_anti_legionella_status(latest_status, "AFTER ENABLE") + except asyncio.TimeoutError: + print("Timeout waiting for status response after enable") + + print() + await asyncio.sleep(2) + + # Step 3: Disable Anti-Legionella + print("=" * 70) + print("STEP 3: Disabling Anti-Legionella cycle...") + print("WARNING: This reduces protection against Legionella bacteria!") + print("=" * 70) + status_received.clear() + await mqtt_client.disable_anti_legionella(device) + + try: + await asyncio.wait_for(status_received.wait(), timeout=10) + display_anti_legionella_status(latest_status, "AFTER DISABLE") + except asyncio.TimeoutError: + print("Timeout waiting for status response after disable") + + print() + await asyncio.sleep(2) + + # Step 4: Re-enable with different period + print("=" * 70) + print("STEP 4: Re-enabling Anti-Legionella with 14-day cycle...") + print("=" * 70) + status_received.clear() + await mqtt_client.enable_anti_legionella(device, period_days=14) + + try: + await asyncio.wait_for(status_received.wait(), timeout=10) + display_anti_legionella_status(latest_status, "AFTER RE-ENABLE") + except asyncio.TimeoutError: + print("Timeout waiting for status response after re-enable") + + print() + await asyncio.sleep(1) + + await mqtt_client.disconnect() + print("=" * 70) + print("Done. Anti-Legionella protection is now enabled with 14-day cycle.") + print("=" * 70) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nCancelled by user") diff --git a/examples/reservation_schedule_example.py b/examples/reservation_schedule_example.py new file mode 100644 index 0000000..aa09de2 --- /dev/null +++ b/examples/reservation_schedule_example.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Example: Configure reservation program using documented MQTT payloads.""" + +import asyncio +import os +import sys +from typing import Any + +from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient + + +async def main() -> None: + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + + if not email or not password: + print("Error: Set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables") + sys.exit(1) + + async with NavienAuthClient(email, password) as auth_client: + api_client = NavienAPIClient(auth_client=auth_client) + device = await api_client.get_first_device() + if not device: + print("No devices found for this account") + return + + # Build a weekday morning reservation for High Demand mode at 140°F display (120°F message) + weekday_reservation = NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + hour=6, + minute=30, + mode_id=4, # High Demand + param=120, # Remember: message value is 20°F lower than display value + ) + + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + + # Listen for reservation responses so we can print the updated schedule + response_topic = f"cmd/{device.device_info.device_type}/{mqtt_client.config.client_id}/res/rsv/rd" + + def on_reservation_update(topic: str, message: dict[str, Any]) -> None: + response = message.get("response", {}) + reservations = response.get("reservation", []) + print("\nReceived reservation response:") + print( + f" reservationUse: {response.get('reservationUse')} (1=enabled, 2=disabled)" + ) + print(f" entries: {len(reservations)}") + for idx, entry in enumerate(reservations, start=1): + week_days = NavienAPIClient.decode_week_bitfield(entry.get("week", 0)) + display_temp = entry.get("param", 0) + 20 + print( + " - #{idx}: {time:02d}:{minute:02d} mode={mode} display_temp={temp}F days={days}".format( + idx=idx, + time=entry.get("hour", 0), + minute=entry.get("min", 0), + mode=entry.get("mode"), + temp=display_temp, + days=", ".join(week_days) or "", + ) + ) + + await mqtt_client.subscribe(response_topic, on_reservation_update) + + print("Sending reservation program update...") + await mqtt_client.update_reservations( + device, [weekday_reservation], enabled=True + ) + + print("Requesting current reservation program...") + await mqtt_client.request_reservations(device) + + print("Waiting up to 15 seconds for reservation responses...") + await asyncio.sleep(15) + + await mqtt_client.disconnect() + print("Done.") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nCancelled by user") diff --git a/examples/tou_schedule_example.py b/examples/tou_schedule_example.py new file mode 100644 index 0000000..3a4d2d6 --- /dev/null +++ b/examples/tou_schedule_example.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Example: Configure Time-of-Use (TOU) pricing schedule over MQTT.""" + +import asyncio +import os +import sys +from typing import Any + +from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient + + +async def _wait_for_controller_serial(mqtt_client: NavienMqttClient, device) -> str: + loop = asyncio.get_running_loop() + feature_future: asyncio.Future = loop.create_future() + + def capture_feature(feature) -> None: + if not feature_future.done(): + feature_future.set_result(feature) + + mqtt_client.once("feature_received", capture_feature) + await mqtt_client.request_device_info(device) + feature = await asyncio.wait_for(feature_future, timeout=15) + return feature.controllerSerialNumber + + +async def main() -> None: + email = os.getenv("NAVIEN_EMAIL") + password = os.getenv("NAVIEN_PASSWORD") + + if not email or not password: + print("Error: Set NAVIEN_EMAIL and NAVIEN_PASSWORD environment variables") + sys.exit(1) + + async with NavienAuthClient(email, password) as auth_client: + api_client = NavienAPIClient(auth_client=auth_client) + device = await api_client.get_first_device() + if not device: + print("No devices found for this account") + return + + mqtt_client = NavienMqttClient(auth_client) + await mqtt_client.connect() + + print("Requesting controller serial number via device info...") + try: + controller_serial = await _wait_for_controller_serial(mqtt_client, device) + except asyncio.TimeoutError: + print("Timed out waiting for device features") + await mqtt_client.disconnect() + return + + print("Controller serial number acquired.") + + # Build two TOU periods as documented in MQTT_MESSAGES.rst + off_peak = NavienAPIClient.build_tou_period( + season_months=range(1, 13), + week_days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + start_hour=0, + start_minute=0, + end_hour=14, + end_minute=59, + price_min=0.34831, + price_max=0.34831, + decimal_point=5, + ) + peak = NavienAPIClient.build_tou_period( + season_months=range(1, 13), + week_days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + start_hour=15, + start_minute=0, + end_hour=20, + end_minute=59, + price_min=0.45000, + price_max=0.45000, + decimal_point=5, + ) + + response_topic = f"cmd/{device.device_info.device_type}/{mqtt_client.config.client_id}/res/tou/rd" + + def on_tou_response(topic: str, message: dict[str, Any]) -> None: + response = message.get("response", {}) + reservation = response.get("reservation", []) + print("\nTOU response received:") + print(f" reservationUse: {response.get('reservationUse')}") + for idx, entry in enumerate(reservation, start=1): + week_days = NavienAPIClient.decode_week_bitfield(entry.get("week", 0)) + price_min_value = NavienAPIClient.decode_price( + entry.get("priceMin", 0), entry.get("decimalPoint", 0) + ) + price_max_value = NavienAPIClient.decode_price( + entry.get("priceMax", 0), entry.get("decimalPoint", 0) + ) + print( + " - #{idx} {start_h:02d}:{start_m:02d}-{end_h:02d}:{end_m:02d} price={min_price:.5f}-{max_price:.5f} days={days}".format( + idx=idx, + start_h=entry.get("startHour", 0), + start_m=entry.get("startMinute", 0), + end_h=entry.get("endHour", 0), + end_m=entry.get("endMinute", 0), + min_price=price_min_value, + max_price=price_max_value, + days=", ".join(week_days) or "", + ) + ) + + await mqtt_client.subscribe(response_topic, on_tou_response) + + print("Uploading TOU schedule (enabling reservation)...") + await mqtt_client.configure_tou_schedule( + device=device, + controller_serial_number=controller_serial, + periods=[off_peak, peak], + enabled=True, + ) + + print("Requesting current TOU settings for confirmation...") + 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.set_tou_enabled(device, enabled=False) + await asyncio.sleep(3) + + print("Re-enabling TOU...") + await mqtt_client.set_tou_enabled(device, enabled=True) + await asyncio.sleep(3) + + await mqtt_client.disconnect() + print("Done.") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nCancelled by user") diff --git a/setup.cfg b/setup.cfg index 513f16d..5a0722a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,20 +5,20 @@ [metadata] name = nwp500-python -description = Add a short description here! +description = A library for controlling Navien NWP500 Water Heaters via NaviLink author = Emmanuel Levijarvi author_email = emansl@gmail.com license = MIT license_files = LICENSE.txt long_description = file: README.rst long_description_content_type = text/x-rst; charset=UTF-8 -url = https://github.com/pyscaffold/pyscaffold/ +url = https://github.com/eman/nwp500-python # Add here related links, for example: project_urls = - Documentation = https://pyscaffold.org/ -# Source = https://github.com/pyscaffold/pyscaffold/ + Documentation = https://nwp500-python.readthedocs.io/en/latest/ + Source = https://github.com/eman/nwp500-python # Changelog = https://pyscaffold.org/en/latest/changelog.html -# Tracker = https://github.com/pyscaffold/pyscaffold/issues + Tracker = https://github.com/eman/nwp500-python/issues # Conda-Forge = https://anaconda.org/conda-forge/pyscaffold # Download = https://pypi.org/project/PyScaffold/#files # Twitter = https://twitter.com/PyScaffold diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index 5df327c..015585b 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -6,7 +6,9 @@ """ import logging -from typing import Any, Optional +from collections.abc import Iterable +from numbers import Real +from typing import Any, Optional, Union import aiohttp @@ -56,6 +58,18 @@ class NavienAPIClient: ... devices = await api_client.list_devices() """ + _WEEKDAY_ORDER = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ] + _WEEKDAY_NAME_TO_BIT = {name.lower(): 1 << idx for idx, name in enumerate(_WEEKDAY_ORDER)} + _MONTH_TO_BIT = {month: 1 << (month - 1) for month in range(1, 13)} + def __init__( self, auth_client: NavienAuthClient, @@ -371,3 +385,155 @@ def is_authenticated(self) -> bool: def user_email(self) -> Optional[str]: """Get current user email.""" return self._auth_client.user_email + + # Helper utilities ------------------------------------------------- + + @staticmethod + def encode_week_bitfield(days: Iterable[Union[str, int]]) -> int: + """Convert a collection of day names or indices into the reservation bitfield.""" + bitfield = 0 + for value in days: + if isinstance(value, str): + key = value.strip().lower() + if key not in NavienAPIClient._WEEKDAY_NAME_TO_BIT: + raise ValueError(f"Unknown weekday: {value}") + bitfield |= NavienAPIClient._WEEKDAY_NAME_TO_BIT[key] + elif isinstance(value, int): + if 0 <= value <= 6: + bitfield |= 1 << value + elif 1 <= value <= 7: + bitfield |= 1 << (value - 1) + else: + raise ValueError("Day index must be between 0-6 or 1-7") + else: + raise TypeError("Weekday values must be strings or integers") + return bitfield + + @staticmethod + def decode_week_bitfield(bitfield: int) -> list[str]: + """Decode a reservation bitfield back into a list of weekday names.""" + days: list[str] = [] + for idx, name in enumerate(NavienAPIClient._WEEKDAY_ORDER): + if bitfield & (1 << idx): + days.append(name) + return days + + @staticmethod + def encode_season_bitfield(months: Iterable[int]) -> int: + """Encode a collection of month numbers (1-12) into a TOU season bitfield.""" + bitfield = 0 + for month in months: + if month not in NavienAPIClient._MONTH_TO_BIT: + raise ValueError("Month values must be in the range 1-12") + bitfield |= NavienAPIClient._MONTH_TO_BIT[month] + return bitfield + + @staticmethod + def decode_season_bitfield(bitfield: int) -> list[int]: + """Decode a TOU season bitfield into the corresponding month numbers.""" + months: list[int] = [] + for month, mask in NavienAPIClient._MONTH_TO_BIT.items(): + if bitfield & mask: + months.append(month) + return months + + @staticmethod + def encode_price(value: Real, decimal_point: int) -> int: + """Encode a price into the integer representation expected by the device.""" + if decimal_point < 0: + raise ValueError("decimal_point must be >= 0") + scale = 10**decimal_point + return int(round(float(value) * scale)) + + @staticmethod + def decode_price(value: int, decimal_point: int) -> float: + """Decode an integer price value using the provided decimal point.""" + if decimal_point < 0: + raise ValueError("decimal_point must be >= 0") + scale = 10**decimal_point + return value / scale if scale else float(value) + + @staticmethod + def build_reservation_entry( + *, + enabled: Union[bool, int], + days: Iterable[Union[str, int]], + hour: int, + minute: int, + mode_id: int, + param: int, + ) -> dict[str, int]: + """Build a reservation payload entry matching the documented MQTT format.""" + if not 0 <= hour <= 23: + raise ValueError("hour must be between 0 and 23") + if not 0 <= minute <= 59: + raise ValueError("minute must be between 0 and 59") + if mode_id < 0: + raise ValueError("mode_id must be non-negative") + + if isinstance(enabled, bool): + enable_flag = 1 if enabled else 2 + elif enabled in (1, 2): + enable_flag = int(enabled) + else: + raise ValueError("enabled must be True/False or 1/2") + + week_bitfield = NavienAPIClient.encode_week_bitfield(days) + + return { + "enable": enable_flag, + "week": week_bitfield, + "hour": hour, + "min": minute, + "mode": mode_id, + "param": param, + } + + @staticmethod + def build_tou_period( + *, + season_months: Iterable[int], + week_days: Iterable[Union[str, int]], + start_hour: int, + start_minute: int, + end_hour: int, + end_minute: int, + price_min: Union[int, Real], + price_max: Union[int, Real], + decimal_point: int, + ) -> dict[str, int]: + """Build a TOU period entry consistent with MQTT command requirements.""" + for label, value, upper in ( + ("start_hour", start_hour, 23), + ("end_hour", end_hour, 23), + ): + if not 0 <= value <= upper: + raise ValueError(f"{label} must be between 0 and {upper}") + for label, value in (("start_minute", start_minute), ("end_minute", end_minute)): + if not 0 <= value <= 59: + raise ValueError(f"{label} must be between 0 and 59") + + week_bitfield = NavienAPIClient.encode_week_bitfield(week_days) + season_bitfield = NavienAPIClient.encode_season_bitfield(season_months) + + if isinstance(price_min, Real) and not isinstance(price_min, int): + encoded_min = NavienAPIClient.encode_price(price_min, decimal_point) + else: + encoded_min = int(price_min) + + if isinstance(price_max, Real) and not isinstance(price_max, int): + encoded_max = NavienAPIClient.encode_price(price_max, decimal_point) + else: + encoded_max = int(price_max) + + return { + "season": season_bitfield, + "week": week_bitfield, + "startHour": start_hour, + "startMinute": start_minute, + "endHour": end_hour, + "endMinute": end_minute, + "priceMin": encoded_min, + "priceMax": encoded_max, + "decimalPoint": decimal_point, + } diff --git a/src/nwp500/constants.py b/src/nwp500/constants.py index 9cfdf29..82914c6 100644 --- a/src/nwp500/constants.py +++ b/src/nwp500/constants.py @@ -10,6 +10,20 @@ CMD_DHW_MODE = 33554437 CMD_DHW_TEMPERATURE = 33554464 CMD_ENERGY_USAGE_QUERY = 16777225 +CMD_RESERVATION_MANAGEMENT = 16777226 +CMD_TOU_SETTINGS = 33554439 +CMD_ANTI_LEGIONELLA_DISABLE = 33554471 +CMD_ANTI_LEGIONELLA_ENABLE = 33554472 +CMD_TOU_DISABLE = 33554475 +CMD_TOU_ENABLE = 33554476 + +# Note for maintainers: +# These command codes and the expected payload fields are defined in +# `docs/MQTT_MESSAGES.rst` under the "Control Messages" section and +# the subsections for Power Control, DHW Mode, Anti-Legionella, +# Reservation Management and TOU Settings. When updating constants or +# payload builders, verify against that document to avoid protocol +# mismatches. # Known Firmware Versions and Field Changes # Track firmware versions where new fields were introduced to help with debugging diff --git a/src/nwp500/events.py b/src/nwp500/events.py index 1ce5e2e..f7a4812 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -7,6 +7,7 @@ """ import asyncio +import inspect import logging from collections import defaultdict from dataclasses import dataclass @@ -203,11 +204,11 @@ async def emit(self, event: str, *args, **kwargs) -> int: for listener in listeners: try: - # Call handler (supports both sync and async) - if asyncio.iscoroutinefunction(listener.callback): - await listener.callback(*args, **kwargs) - else: - listener.callback(*args, **kwargs) + # Call handler and await if it returned an awaitable. + result = listener.callback(*args, **kwargs) + + if inspect.isawaitable(result): + await result called_count += 1 diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt_client.py index 0ea4393..8a7927d 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt_client.py @@ -15,6 +15,7 @@ import logging import uuid from collections import deque +from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime from enum import Enum @@ -26,13 +27,19 @@ from .auth import NavienAuthClient from .config import AWS_IOT_ENDPOINT, AWS_REGION from .constants import ( + CMD_ANTI_LEGIONELLA_DISABLE, + CMD_ANTI_LEGIONELLA_ENABLE, CMD_DEVICE_INFO_REQUEST, CMD_DHW_MODE, CMD_DHW_TEMPERATURE, CMD_ENERGY_USAGE_QUERY, CMD_POWER_OFF, CMD_POWER_ON, + CMD_RESERVATION_MANAGEMENT, CMD_STATUS_REQUEST, + CMD_TOU_DISABLE, + CMD_TOU_ENABLE, + CMD_TOU_SETTINGS, ) from .events import EventEmitter from .models import ( @@ -40,6 +47,7 @@ DeviceFeature, DeviceStatus, EnergyUsageResponse, + OperationMode, ) __author__ = "Emmanuel Levijarvi" @@ -49,6 +57,67 @@ _logger = logging.getLogger(__name__) +def _redact(obj, keys_to_redact=None): + """Return a redacted copy of obj with sensitive keys masked. + + This is a lightweight sanitizer for log messages to avoid emitting + secrets such as access keys, session tokens, passwords, emails, + clientIDs and sessionIDs. + """ + if keys_to_redact is None: + keys_to_redact = { + "access_key_id", + "secret_access_key", + "secret_key", + "session_token", + "sessionToken", + "sessionID", + "clientID", + "clientId", + "client_id", + "password", + "pushToken", + "push_token", + "token", + "auth", + "macAddress", + "mac_address", + "email", + } + + # Primitive types: return as-is + if obj is None or isinstance(obj, (bool, int, float)): + return obj + if isinstance(obj, str): + # avoid printing long secret-like strings fully + if len(obj) > 256: + return obj[:64] + "......" + obj[-64:] + return obj + + # dicts: redact sensitive keys recursively + if isinstance(obj, dict): + redacted = {} + for k, v in obj.items(): + if str(k) in keys_to_redact: + redacted[k] = "" + else: + redacted[k] = _redact(v, keys_to_redact) + return redacted + + # lists / tuples: redact elements + if isinstance(obj, (list, tuple)): + return type(obj)(_redact(v, keys_to_redact) for v in obj) + + # fallback: represent object as string but avoid huge dumps + try: + s = str(obj) + if len(s) > 512: + return s[:256] + "......" + return s + except Exception: + return "" + + @dataclass class MqttConnectionConfig: """Configuration for MQTT connection.""" @@ -557,7 +626,7 @@ def _on_message_received(self, topic: str, payload: bytes, **kwargs): try: # Parse JSON payload message = json.loads(payload.decode("utf-8")) - _logger.debug(f"Received message on topic '{topic}': {message}") + _logger.debug("Received message on topic: %s", topic) # Call registered handlers that match this topic # Need to match against subscription patterns with wildcards @@ -724,7 +793,6 @@ async def publish( raise RuntimeError("Not connected to MQTT broker") _logger.debug(f"Publishing to topic: {topic}") - _logger.debug(f"Payload: {payload}") try: # Serialize to JSON @@ -1148,21 +1216,26 @@ async def set_power(self, device: Device, power_on: bool) -> int: return await self.publish(topic, command) - async def set_dhw_mode(self, device: Device, mode_id: int) -> int: + async def set_dhw_mode( + self, + device: Device, + mode_id: int, + vacation_days: Optional[int] = None, + ) -> int: """ Set DHW (Domestic Hot Water) operation mode. Args: device: Device object - mode_id: Mode ID (1=Heat Pump Only, 2=Electric Only, 3=Energy Saver, 4=High Demand) - device_type: Device type (52 for NWP500) - additional_value: Additional value from device info + mode_id: Mode ID (1=Heat Pump Only, 2=Electric Only, 3=Energy Saver, + 4=High Demand, 5=Vacation) + vacation_days: Number of vacation days (required when mode_id == 5) Returns: Publish packet ID Note: - Valid selectable mode IDs are 1, 2, 3, and 4. + Valid selectable mode IDs are 1, 2, 3, 4, and 5 (vacation). Additional modes may appear in status responses: - 0: Standby (device in idle state) - 6: Power Off (device is powered off) @@ -1172,7 +1245,19 @@ async def set_dhw_mode(self, device: Device, mode_id: int) -> int: - 2: Electric Only (least efficient, fastest recovery) - 3: Energy Saver (balanced, good default) - 4: High Demand (maximum heating capacity) + - 5: Vacation Mode (requires vacation_days parameter) """ + if mode_id == OperationMode.VACATION.value: + if vacation_days is None: + raise ValueError("Vacation mode requires vacation_days (1-30)") + if not 1 <= vacation_days <= 30: + raise ValueError("vacation_days must be between 1 and 30") + param = [mode_id, vacation_days] + else: + if vacation_days is not None: + raise ValueError("vacation_days is only valid for vacation mode (mode 5)") + param = [mode_id] + device_id = device.device_info.mac_address device_type = device.device_info.device_type additional_value = device.device_info.additional_value @@ -1185,7 +1270,83 @@ async def set_dhw_mode(self, device: Device, mode_id: int) -> int: command=CMD_DHW_MODE, # DHW mode control command (different from power commands) additional_value=additional_value, mode="dhw-mode", - param=[mode_id], + param=param, + paramStr="", + ) + command["requestTopic"] = topic + + return await self.publish(topic, command) + + async def enable_anti_legionella(self, device: Device, period_days: int) -> int: + """Enable Anti-Legionella disinfection with a 1-30 day cycle. + + This command has been confirmed through HAR analysis of the official Navien app. + When sent, the device responds with antiLegionellaUse=2 (enabled) and + antiLegionellaPeriod set to the specified value. + + See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" for the authoritative + command code (33554472) and expected payload format: + {"mode": "anti-leg-on", "param": [], "paramStr": ""} + + Args: + device: The device to control + period_days: Days between disinfection cycles (1-30) + + Returns: + The message ID of the published command + + Raises: + ValueError: If period_days is not in the valid range [1, 30] + """ + if not 1 <= period_days <= 30: + raise ValueError("period_days must be between 1 and 30") + + device_id = device.device_info.mac_address + device_type = device.device_info.device_type + additional_value = device.device_info.additional_value + device_topic = f"navilink-{device_id}" + topic = f"cmd/{device_type}/{device_topic}/ctrl" + + command = self._build_command( + device_type=device_type, + device_id=device_id, + command=CMD_ANTI_LEGIONELLA_ENABLE, + additional_value=additional_value, + mode="anti-leg-on", + param=[period_days], + paramStr="", + ) + command["requestTopic"] = topic + + return await self.publish(topic, command) + + async def disable_anti_legionella(self, device: Device) -> int: + """Disable the Anti-Legionella disinfection cycle. + + This command has been confirmed through HAR analysis of the official Navien app. + When sent, the device responds with antiLegionellaUse=1 (disabled) while + antiLegionellaPeriod retains its previous value. + + The correct command code is 33554471 (not 33554473 as previously assumed). + + See docs/MQTT_MESSAGES.rst "Anti-Legionella Control" section for details. + + Returns: + The message ID of the published command + """ + device_id = device.device_info.mac_address + device_type = device.device_info.device_type + additional_value = device.device_info.additional_value + device_topic = f"navilink-{device_id}" + topic = f"cmd/{device_type}/{device_topic}/ctrl" + + command = self._build_command( + device_type=device_type, + device_id=device_id, + command=CMD_ANTI_LEGIONELLA_DISABLE, + additional_value=additional_value, + mode="anti-leg-off", + param=[], paramStr="", ) command["requestTopic"] = topic @@ -1234,6 +1395,19 @@ async def set_dhw_temperature(self, device: Device, temperature: int) -> int: return await self.publish(topic, command) + command = self._build_command( + device_type=device_type, + device_id=device_id, + command=CMD_DHW_TEMPERATURE, # DHW temperature control command + additional_value=additional_value, + mode="dhw-temperature", + param=[temperature], + paramStr="", + ) + command["requestTopic"] = topic + + return await self.publish(topic, command) + async def set_dhw_temperature_display(self, device: Device, display_temperature: int) -> int: """ Set DHW target temperature using the DISPLAY value (what you see on device/app). @@ -1256,6 +1430,150 @@ async def set_dhw_temperature_display(self, device: Device, display_temperature: message_temperature = display_temperature - 20 return await self.set_dhw_temperature(device, message_temperature) + async def update_reservations( + self, + device: Device, + reservations: Sequence[dict[str, Any]], + *, + enabled: bool = True, + ) -> int: + """Update programmed reservations for temperature/mode changes.""" + # See docs/MQTT_MESSAGES.rst "Reservation Management" for the + # command code (16777226) and the reservation object fields + # (enable, week, hour, min, mode, param). + device_id = device.device_info.mac_address + device_type = device.device_info.device_type + additional_value = device.device_info.additional_value + device_topic = f"navilink-{device_id}" + topic = f"cmd/{device_type}/{device_topic}/ctrl/rsv/rd" + + reservation_use = 1 if enabled else 2 + reservation_payload = [dict(entry) for entry in reservations] + + command = self._build_command( + device_type=device_type, + device_id=device_id, + command=CMD_RESERVATION_MANAGEMENT, + additional_value=additional_value, + reservationUse=reservation_use, + reservation=reservation_payload, + ) + command["requestTopic"] = topic + command["responseTopic"] = f"cmd/{device_type}/{self.config.client_id}/res/rsv/rd" + + return await self.publish(topic, command) + + async def request_reservations(self, device: Device) -> int: + """Request the current reservation program from the device.""" + device_id = device.device_info.mac_address + device_type = device.device_info.device_type + additional_value = device.device_info.additional_value + device_topic = f"navilink-{device_id}" + topic = f"cmd/{device_type}/{device_topic}/ctrl/rsv/rd" + + command = self._build_command( + device_type=device_type, + device_id=device_id, + command=CMD_RESERVATION_MANAGEMENT, + additional_value=additional_value, + ) + command["requestTopic"] = topic + command["responseTopic"] = f"cmd/{device_type}/{self.config.client_id}/res/rsv/rd" + + return await self.publish(topic, command) + + async def configure_tou_schedule( + self, + device: Device, + controller_serial_number: str, + periods: Sequence[dict[str, Any]], + *, + enabled: bool = True, + ) -> int: + """Configure Time-of-Use pricing schedule via MQTT.""" + # See docs/MQTT_MESSAGES.rst "TOU (Time of Use) Settings" for + # the command code (33554439) and TOU period fields + # (season, week, startHour, startMinute, endHour, endMinute, + # priceMin, priceMax, decimalPoint). + if not controller_serial_number: + raise ValueError("controller_serial_number is required") + if not periods: + raise ValueError("At least one TOU period must be provided") + + device_id = device.device_info.mac_address + device_type = device.device_info.device_type + additional_value = device.device_info.additional_value + device_topic = f"navilink-{device_id}" + topic = f"cmd/{device_type}/{device_topic}/ctrl/tou/rd" + + reservation_use = 1 if enabled else 2 + reservation_payload = [dict(period) for period in periods] + + command = self._build_command( + device_type=device_type, + device_id=device_id, + command=CMD_TOU_SETTINGS, + additional_value=additional_value, + controllerSerialNumber=controller_serial_number, + reservationUse=reservation_use, + reservation=reservation_payload, + ) + command["requestTopic"] = topic + command["responseTopic"] = f"cmd/{device_type}/{self.config.client_id}/res/tou/rd" + + return await self.publish(topic, command) + + async def request_tou_settings( + self, + device: Device, + controller_serial_number: str, + ) -> int: + """Request current Time-of-Use schedule from the device.""" + if not controller_serial_number: + raise ValueError("controller_serial_number is required") + + device_id = device.device_info.mac_address + device_type = device.device_info.device_type + additional_value = device.device_info.additional_value + device_topic = f"navilink-{device_id}" + topic = f"cmd/{device_type}/{device_topic}/ctrl/tou/rd" + + command = self._build_command( + device_type=device_type, + device_id=device_id, + command=CMD_TOU_SETTINGS, + additional_value=additional_value, + controllerSerialNumber=controller_serial_number, + ) + command["requestTopic"] = topic + command["responseTopic"] = f"cmd/{device_type}/{self.config.client_id}/res/tou/rd" + + return await self.publish(topic, command) + + async def set_tou_enabled(self, device: Device, enabled: bool) -> int: + """Quickly toggle Time-of-Use functionality without modifying the schedule.""" + device_id = device.device_info.mac_address + device_type = device.device_info.device_type + additional_value = device.device_info.additional_value + device_topic = f"navilink-{device_id}" + topic = f"cmd/{device_type}/{device_topic}/ctrl" + + command_code = CMD_TOU_ENABLE if enabled else CMD_TOU_DISABLE + mode = "tou-on" if enabled else "tou-off" + + command = self._build_command( + device_type=device_type, + device_id=device_id, + command=command_code, + additional_value=additional_value, + mode=mode, + param=[], + paramStr="", + ) + command["requestTopic"] = topic + + return await self.publish(topic, command) + async def request_energy_usage(self, device: Device, year: int, months: list[int]) -> int: """ Request daily energy usage data for specified month(s). diff --git a/tests/test_api_helpers.py b/tests/test_api_helpers.py new file mode 100644 index 0000000..5f95c57 --- /dev/null +++ b/tests/test_api_helpers.py @@ -0,0 +1,105 @@ +"""Tests for NavienAPIClient helper utilities.""" + +import math + +import pytest # type: ignore[import] + +from nwp500.api_client import NavienAPIClient + + +def test_encode_decode_week_bitfield(): + days = ["Monday", "Wednesday", "Friday"] + bitfield = NavienAPIClient.encode_week_bitfield(days) + assert bitfield == (2 | 8 | 32) + decoded = NavienAPIClient.decode_week_bitfield(bitfield) + assert decoded == ["Monday", "Wednesday", "Friday"] + + # Support integer indices (0=Sunday) and 1-based (1=Monday) + assert NavienAPIClient.encode_week_bitfield([0, 6]) == (1 | 64) + assert NavienAPIClient.encode_week_bitfield([1, 7]) == (2 | 64) + + with pytest.raises(ValueError): + NavienAPIClient.encode_week_bitfield(["Funday"]) # type: ignore[arg-type] + + +def test_encode_decode_season_bitfield(): + months = [1, 6, 12] + bitfield = NavienAPIClient.encode_season_bitfield(months) + assert bitfield == (1 | 32 | 2048) + decoded = NavienAPIClient.decode_season_bitfield(bitfield) + assert decoded == months + + with pytest.raises(ValueError): + NavienAPIClient.encode_season_bitfield([0]) + + +def test_price_encoding_round_trip(): + encoded = NavienAPIClient.encode_price(0.34831, 5) + assert encoded == 34831 + decoded = NavienAPIClient.decode_price(encoded, 5) + assert math.isclose(decoded, 0.34831, rel_tol=1e-9) + + with pytest.raises(ValueError): + NavienAPIClient.encode_price(1.23, -1) + + +def test_build_reservation_entry(): + reservation = NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Monday", "Tuesday"], + hour=6, + minute=30, + mode_id=4, + param=120, + ) + + assert reservation["enable"] == 1 + assert reservation["week"] == (2 | 4) + assert reservation["hour"] == 6 + assert reservation["min"] == 30 + assert reservation["mode"] == 4 + assert reservation["param"] == 120 + + with pytest.raises(ValueError): + NavienAPIClient.build_reservation_entry( + enabled=True, + days=["Monday"], + hour=24, + minute=0, + mode_id=1, + param=100, + ) + + +def test_build_tou_period(): + period = NavienAPIClient.build_tou_period( + season_months=range(1, 13), + week_days=["Monday", "Friday"], + start_hour=0, + start_minute=0, + end_hour=14, + end_minute=59, + price_min=0.34831, + price_max=0.36217, + decimal_point=5, + ) + + assert period["season"] == (2**12 - 1) + assert period["week"] == (2 | 32) + assert period["startHour"] == 0 + assert period["endHour"] == 14 + assert period["priceMin"] == 34831 + assert period["priceMax"] == 36217 + + with pytest.raises(ValueError): + NavienAPIClient.build_tou_period( + season_months=[1], + week_days=["Sunday"], + start_hour=25, + start_minute=0, + end_hour=1, + end_minute=0, + price_min=0.1, + price_max=0.2, + decimal_point=5, + )