From 8fc5fa57facddb024a6a859c7253e46dfeba3a24 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 3 Dec 2025 10:34:13 -0800 Subject: [PATCH] feat: add bidirectional temperature conversion with Fahrenheit API BREAKING CHANGES: - build_reservation_entry() now accepts temperature_f (Fahrenheit) instead of param - set_dhw_temperature() now accepts temperature_f: float instead of int - Removed set_dhw_temperature_display() which used incorrect conversion Added: - fahrenheit_to_half_celsius() utility function for Fahrenheit to device format Fixed: - Temperature encoding bug in set_dhw_temperature() that used incorrect 'subtract 20' conversion instead of proper half-degrees Celsius encoding --- CHANGELOG.rst | 63 ++++++++++++ docs/guides/command_queue.rst | 2 +- docs/guides/reservations.rst | 122 ++++++++++++++--------- docs/index.rst | 2 +- docs/python_api/constants.rst | 7 +- docs/python_api/exceptions.rst | 10 +- docs/python_api/mqtt_client.rst | 53 +++------- docs/quickstart.rst | 2 +- examples/reservation_schedule_example.py | 15 +-- src/nwp500/__init__.py | 3 + src/nwp500/cli/__main__.py | 4 +- src/nwp500/cli/commands.py | 13 ++- src/nwp500/encoding.py | 22 +++- src/nwp500/models.py | 24 +++++ src/nwp500/mqtt_client.py | 50 ++-------- src/nwp500/mqtt_device_control.py | 61 ++++-------- tests/test_api_helpers.py | 28 +++++- tests/test_models.py | 12 ++- 18 files changed, 287 insertions(+), 206 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1f97182..86a3712 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,69 @@ Changelog ========= +Version 6.1.0 (2025-12-03) +========================== + +**BREAKING CHANGES**: Temperature API simplified with Fahrenheit input + +This release fixes incorrect temperature conversions and provides a cleaner API +where users pass temperatures in Fahrenheit directly, with automatic conversion +to the device's internal format. + +Changed +------- + +- **``build_reservation_entry()``**: Now accepts ``temperature_f`` (Fahrenheit) + instead of raw ``param`` value. The conversion to half-degrees Celsius is + handled automatically. + + .. code-block:: python + + # OLD (removed) + build_reservation_entry(..., param=120) + + # NEW + build_reservation_entry(..., temperature_f=140.0) + +- **``set_dhw_temperature()``**: Now accepts ``temperature_f: float`` (Fahrenheit) + instead of raw integer. Valid range: 95-150°F. + + .. code-block:: python + + # OLD (removed) + await mqtt.set_dhw_temperature(device, 120) + + # NEW + await mqtt.set_dhw_temperature(device, 140.0) + +Removed +------- + +- **``set_dhw_temperature_display()``**: Removed. This method used an incorrect + conversion formula (subtracting 20 instead of proper half-degrees Celsius + encoding). Use ``set_dhw_temperature()`` with Fahrenheit directly. + +Added +----- + +- **``fahrenheit_to_half_celsius()``**: New utility function for converting + Fahrenheit to the device's half-degrees Celsius format. Exported from the + main package for advanced use cases. + + .. code-block:: python + + from nwp500 import fahrenheit_to_half_celsius + + param = fahrenheit_to_half_celsius(140.0) # Returns 120 + +Fixed +----- + +- **Temperature Encoding Bug**: Fixed ``set_dhw_temperature()`` which was using + an incorrect "subtract 20" conversion instead of proper half-degrees Celsius + encoding. This caused temperatures to be set incorrectly for values other + than 140°F (where both formulas happened to give the same result). + Version 6.0.8 (2025-12-02) ========================== diff --git a/docs/guides/command_queue.rst b/docs/guides/command_queue.rst index c0ec12e..21e2389 100644 --- a/docs/guides/command_queue.rst +++ b/docs/guides/command_queue.rst @@ -258,7 +258,7 @@ Reliable Device Control .. code-block:: python # Even during network issues, commands are preserved - await mqtt_client.set_dhw_temperature_display(device, 140) + 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 diff --git a/docs/guides/reservations.rst b/docs/guides/reservations.rst index aa2a9bc..1f18aab 100644 --- a/docs/guides/reservations.rst +++ b/docs/guides/reservations.rst @@ -51,7 +51,7 @@ Here's a simple example that sets up a weekday morning reservation: hour=6, minute=30, mode_id=4, # High Demand - param=120 # 140°F (half-degrees Celsius: 60°C × 2) + temperature_f=140.0 # Temperature in Fahrenheit ) # Send to device @@ -140,14 +140,18 @@ Field Descriptions * 95°F display → ``param = 70`` (35°C × 2) * 120°F display → ``param = 98`` (48.9°C × 2) - * 130°F display → ``param = 110`` (54.4°C × 2) + * 130°F display → ``param = 109`` (54.4°C × 2) * 140°F display → ``param = 120`` (60°C × 2) - * 150°F display → ``param = 132`` (65.6°C × 2) + * 150°F display → ``param = 131`` (65.6°C × 2) For non-temperature modes (Vacation, Power Off), the param value is typically ignored but should be set to a valid temperature value (e.g., ``98`` for 120°F) for consistency. + **Note:** When using ``build_reservation_entry()``, you don't need to + calculate the param value manually - just pass ``temperature_f`` in + Fahrenheit and the conversion is handled automatically. + Helper Functions ================ @@ -156,44 +160,59 @@ The library provides helper functions to make building reservations easier. Building Reservation Entries ----------------------------- -Use ``build_reservation_entry()`` to create properly formatted entries: +Use ``build_reservation_entry()`` to create properly formatted entries. +The function accepts temperature in Fahrenheit and handles the conversion +to the device's internal format automatically: .. code-block:: python - from nwp500 import NavienAPIClient + from nwp500 import build_reservation_entry # Weekday morning - High Demand mode at 140°F - entry = NavienAPIClient.build_reservation_entry( + entry = build_reservation_entry( enabled=True, days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], hour=6, minute=30, mode_id=4, # High Demand - param=120 # 140°F (half-degrees Celsius: 60°C × 2) + temperature_f=140.0 # Temperature in Fahrenheit ) # Returns: {'enable': 1, 'week': 62, 'hour': 6, 'min': 30, # 'mode': 4, 'param': 120} # Weekend - Energy Saver mode at 120°F - entry2 = NavienAPIClient.build_reservation_entry( + entry2 = build_reservation_entry( enabled=True, days=["Saturday", "Sunday"], hour=8, minute=0, mode_id=3, # Energy Saver - param=98 # ~120°F (half-degrees Celsius: 48.9°C × 2) + temperature_f=120.0 ) # You can also use day indices (0=Sunday, 6=Saturday) - entry3 = NavienAPIClient.build_reservation_entry( + entry3 = build_reservation_entry( enabled=True, days=[1, 2, 3, 4, 5], # Monday-Friday hour=18, minute=0, mode_id=1, # Heat Pump Only - param=110 # ~130°F (half-degrees Celsius: 54.4°C × 2) + temperature_f=130.0 ) +Temperature Conversion Utility +------------------------------- + +For advanced use cases, you can use ``fahrenheit_to_half_celsius()`` directly: + +.. code-block:: python + + from nwp500 import fahrenheit_to_half_celsius + + param = fahrenheit_to_half_celsius(140.0) # Returns 120 + param = fahrenheit_to_half_celsius(120.0) # Returns 98 + param = fahrenheit_to_half_celsius(95.0) # Returns 70 + Encoding Week Bitfields ------------------------ @@ -251,33 +270,33 @@ Send a new reservation schedule to the device: # Build multiple reservation entries reservations = [ # Weekday morning: High Demand at 140°F - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], hour=6, minute=30, mode_id=4, - param=120 + temperature_f=140.0 ), # Weekday evening: Energy Saver at 130°F - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], hour=18, minute=0, mode_id=3, - param=110 + temperature_f=130.0 ), # Weekend: Heat Pump Only at 120°F - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=["Saturday", "Sunday"], hour=8, minute=0, mode_id=1, - param=100 + temperature_f=120.0 ), ] @@ -414,22 +433,22 @@ Different settings for work days and weekends: reservations = [ # Weekday morning: early start, high demand - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=[1, 2, 3, 4, 5], # Mon-Fri hour=5, minute=30, mode_id=4, # High Demand - param=120 # 140°F + temperature_f=140.0 ), # Weekend morning: later start, energy saver - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=[0, 6], # Sun, Sat hour=8, minute=0, mode_id=3, # Energy Saver - param=110 # 130°F + temperature_f=130.0 ), ] @@ -442,40 +461,40 @@ Minimize energy use during peak hours: reservations = [ # Morning prep: 6:00 AM - High Demand for showers - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=[1, 2, 3, 4, 5], hour=6, minute=0, mode_id=4, - param=120 + temperature_f=140.0 ), # Day: 9:00 AM - Switch to Energy Saver - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=[1, 2, 3, 4, 5], hour=9, minute=0, mode_id=3, - param=100 + temperature_f=120.0 ), # Evening: 5:00 PM - Heat Pump Only (before peak pricing) - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=[1, 2, 3, 4, 5], hour=17, minute=0, mode_id=1, - param=110 + temperature_f=130.0 ), # Night: 10:00 PM - Back to Energy Saver - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=[1, 2, 3, 4, 5], hour=22, minute=0, mode_id=3, - param=100 + temperature_f=120.0 ), ] @@ -487,23 +506,23 @@ Automatically enable vacation mode during a trip: .. code-block:: python # Enable vacation mode at start of trip - start_vacation = NavienAPIClient.build_reservation_entry( + start_vacation = build_reservation_entry( enabled=True, days=["Friday"], # Leaving Friday evening hour=20, minute=0, mode_id=5, # Vacation Mode - param=100 # Temperature doesn't matter for vacation mode + temperature_f=120.0 # Temperature doesn't matter for vacation mode ) # Return to normal operation when you get back - end_vacation = NavienAPIClient.build_reservation_entry( + end_vacation = build_reservation_entry( enabled=True, days=["Sunday"], # Returning Sunday afternoon hour=14, minute=0, mode_id=3, # Energy Saver - param=110 # 130°F + temperature_f=130.0 ) reservations = [start_vacation, end_vacation] @@ -511,16 +530,21 @@ Automatically enable vacation mode during a trip: Important Notes =============== -Temperature Encoding --------------------- +Temperature Conversion +----------------------- + +When using ``build_reservation_entry()``, pass temperatures in Fahrenheit +using the ``temperature_f`` parameter. The function automatically converts +to the device's internal format (half-degrees Celsius). + +The valid temperature range is 95°F to 150°F. -The ``param`` field uses **half-degrees Celsius** encoding: +For reading reservation responses from the device, the ``param`` field +contains the raw half-degrees Celsius value. Convert to Fahrenheit with: -* Formula: ``fahrenheit = (param / 2.0) * 9/5 + 32`` -* If you want the display to show 140°F, use ``param=120`` (which is 60°C × 2) -* If you see ``param=98`` in a response, it means ~120°F display -* This encoding applies to all temperature-based modes (Heat Pump, Electric, - Energy Saver, High Demand) +.. code-block:: python + + fahrenheit = (param / 2.0) * 9/5 + 32 Device Limits ------------- @@ -559,7 +583,9 @@ Full working example with error handling and response monitoring: from nwp500 import ( NavienAPIClient, NavienAuthClient, - NavienMqttClient + NavienMqttClient, + build_reservation_entry, + decode_week_bitfield, ) @@ -586,33 +612,33 @@ Full working example with error handling and response monitoring: # Build comprehensive schedule reservations = [ # Weekday morning - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], hour=6, minute=30, mode_id=4, # High Demand - param=120 # 140°F (half-degrees Celsius: 60°C × 2) + temperature_f=140.0 ), # Weekday day - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], hour=9, minute=0, mode_id=3, # Energy Saver - param=98 # ~120°F (half-degrees Celsius: 48.9°C × 2) + temperature_f=120.0 ), # Weekend morning - NavienAPIClient.build_reservation_entry( + build_reservation_entry( enabled=True, days=["Saturday", "Sunday"], hour=8, minute=0, mode_id=3, # Energy Saver - param=110 # ~130°F (half-degrees Celsius: 54.4°C × 2) + temperature_f=130.0 ), ] diff --git a/docs/index.rst b/docs/index.rst index 8524436..bf50a3d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -75,7 +75,7 @@ Basic Example # Control device await mqtt.set_power(device, power_on=True) - await mqtt.set_dhw_temperature(device, temperature=120) + await mqtt.set_dhw_temperature(device, 120.0) await asyncio.sleep(30) await mqtt.disconnect() diff --git a/docs/python_api/constants.rst b/docs/python_api/constants.rst index 400ec1e..1126b80 100644 --- a/docs/python_api/constants.rst +++ b/docs/python_api/constants.rst @@ -157,11 +157,8 @@ DHW Control Commands .. code-block:: python - # For 140°F display, send 120°F message - await mqtt.set_dhw_temperature(device, 120) - - # Or use convenience method - await mqtt.set_dhw_temperature_display(device, 140) + # Set temperature to 140°F + await mqtt.set_dhw_temperature(device, 140.0) Anti-Legionella Commands ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/python_api/exceptions.rst b/docs/python_api/exceptions.rst index ebdebb7..2bdabcc 100644 --- a/docs/python_api/exceptions.rst +++ b/docs/python_api/exceptions.rst @@ -389,12 +389,12 @@ RangeValidationError from nwp500 import NavienMqttClient, RangeValidationError try: - await mqtt.set_dhw_temperature(device, temperature=200) + 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}") - # Output: Invalid temperature: 200 - # Valid range: 100 to 140 + # Output: Invalid temperature_f: 200.0 + # Valid range: 95 to 150 Device Exceptions ================= @@ -464,7 +464,7 @@ Handle specific exception types for granular control: mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.set_dhw_temperature(device, temperature=120) + await mqtt.set_dhw_temperature(device, 120.0) except InvalidCredentialsError: print("Invalid credentials - check email/password") @@ -632,7 +632,7 @@ Best Practices .. code-block:: python try: - await mqtt.set_dhw_temperature(device, temperature=200) + 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") diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index 6a50186..0b1a14a 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -79,7 +79,7 @@ Device Control # Control operations await mqtt.set_power(device, power_on=True) await mqtt.set_dhw_mode(device, mode_id=3) # Energy Saver - await mqtt.set_dhw_temperature_display(device, 140) + await mqtt.set_dhw_temperature(device, 140.0) await mqtt.disconnect() @@ -434,58 +434,33 @@ set_dhw_mode() set_dhw_temperature() ^^^^^^^^^^^^^^^^^^^^^ -.. py:method:: set_dhw_temperature(device, temperature) +.. py:method:: set_dhw_temperature(device, temperature_f) - Set target DHW temperature using MESSAGE value (20°F less than display). + Set target DHW temperature. :param device: Device object :type device: Device - :param temperature: Temperature in °F (message value, NOT display value) - :type temperature: int + :param temperature_f: Temperature in Fahrenheit (95-150°F) + :type temperature_f: float :return: Publish packet ID :rtype: int + :raises RangeValidationError: If temperature is outside 95-150°F range - .. important:: - The message value is 20°F LESS than the display value. - For a target display temperature of 140°F, send 120°F. - Use ``set_dhw_temperature_display()`` to use display values directly. + The temperature is automatically converted to the device's internal + format (half-degrees Celsius). **Example:** .. code-block:: python - # For 140°F display, send 120°F message value - await mqtt.set_dhw_temperature(device, temperature=120) - -set_dhw_temperature_display() -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: set_dhw_temperature_display(device, display_temperature) - - Set target DHW temperature using DISPLAY value (convenience method). - - Automatically converts display value to message value by subtracting 20°F. - - :param device: Device object - :type device: Device - :param display_temperature: Temperature as shown on display/app (°F) - :type display_temperature: int - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - # Set display temperature to 140°F - # (automatically sends 120°F message value) - await mqtt.set_dhw_temperature_display(device, 140) + # Set temperature to 140°F + await mqtt.set_dhw_temperature(device, 140.0) # Common temperatures - await mqtt.set_dhw_temperature_display(device, 120) # Standard - await mqtt.set_dhw_temperature_display(device, 130) # Medium - await mqtt.set_dhw_temperature_display(device, 140) # Hot - await mqtt.set_dhw_temperature_display(device, 150) # Maximum + await mqtt.set_dhw_temperature(device, 120.0) # Standard + await mqtt.set_dhw_temperature(device, 130.0) # Medium + await mqtt.set_dhw_temperature(device, 140.0) # Hot + await mqtt.set_dhw_temperature(device, 150.0) # Maximum enable_anti_legionella() ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/quickstart.rst b/docs/quickstart.rst index f3cbbe4..a60fb84 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -174,7 +174,7 @@ Send control commands to change device settings: print("Set to Energy Saver mode") # Set temperature to 120°F - await mqtt.set_dhw_temperature(device, temperature=120) + await mqtt.set_dhw_temperature(device, 120.0) print("Temperature set to 120°F") await asyncio.sleep(2) diff --git a/examples/reservation_schedule_example.py b/examples/reservation_schedule_example.py index 636c9f2..6385f10 100644 --- a/examples/reservation_schedule_example.py +++ b/examples/reservation_schedule_example.py @@ -7,8 +7,7 @@ from typing import Any from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient -from nwp500.encoding import decode_week_bitfield -from nwp500.encoding import build_reservation_entry +from nwp500.encoding import build_reservation_entry, decode_week_bitfield async def main() -> None: @@ -26,14 +25,14 @@ async def main() -> None: print("No devices found for this account") return - # Build a weekday morning reservation for High Demand mode at 140°F display (120°F message) + # Build a weekday morning reservation for High Demand mode at 140°F weekday_reservation = 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 + temperature_f=140.0, # Temperature in Fahrenheit ) mqtt_client = NavienMqttClient(auth_client) @@ -52,14 +51,16 @@ def on_reservation_update(topic: str, message: dict[str, Any]) -> None: print(f" entries: {len(reservations)}") for idx, entry in enumerate(reservations, start=1): week_days = decode_week_bitfield(entry.get("week", 0)) - display_temp = entry.get("param", 0) + 20 + # Convert half-degrees Celsius param back to Fahrenheit for display + param = entry.get("param", 0) + temp_f = (param / 2.0) * 9 / 5 + 32 print( - " - #{idx}: {time:02d}:{minute:02d} mode={mode} display_temp={temp}F days={days}".format( + " - #{idx}: {time:02d}:{minute:02d} mode={mode} temp={temp:.1f}°F days={days}".format( idx=idx, time=entry.get("hour", 0), minute=entry.get("min", 0), mode=entry.get("mode"), - temp=display_temp, + temp=temp_f, days=", ".join(week_days) or "", ) ) diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 9e3e37e..ef02da9 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -83,6 +83,7 @@ TemperatureUnit, TOUInfo, TOUSchedule, + fahrenheit_to_half_celsius, ) from nwp500.mqtt_client import NavienMqttClient from nwp500.mqtt_utils import MqttConnectionConfig, PeriodicRequestType @@ -110,6 +111,8 @@ "EnergyUsageDay", "MonthlyEnergyData", "EnergyUsageResponse", + # Conversion utilities + "fahrenheit_to_half_celsius", # Authentication "NavienAuthClient", "AuthenticationResponse", diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 07f2fb1..376c47f 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -313,10 +313,10 @@ def parse_args(args: list[str]) -> argparse.Namespace: ) group.add_argument( "--set-dhw-temp", - type=int, + type=float, metavar="TEMP", help="Set DHW (Domestic Hot Water) target temperature in Fahrenheit " - "(115-150°F) and display response.", + "(95-150°F) and display response.", ) group.add_argument( "--power-on", diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py index c29f547..3b23b98 100644 --- a/src/nwp500/cli/commands.py +++ b/src/nwp500/cli/commands.py @@ -258,7 +258,7 @@ def on_status_response(status: DeviceStatus) -> None: async def handle_set_dhw_temp_request( - mqtt: NavienMqttClient, device: Device, temperature: int + mqtt: NavienMqttClient, device: Device, temperature: float ) -> None: """ Set DHW target temperature and display the response. @@ -266,14 +266,13 @@ async def handle_set_dhw_temp_request( Args: mqtt: MQTT client instance device: Device to control - temperature: Target temperature in Fahrenheit (display value) + temperature: Target temperature in Fahrenheit (95-150°F) """ # Validate temperature range - # Based on MQTT client documentation: display range approximately 115-150°F - if temperature < 115 or temperature > 150: + if temperature < 95 or temperature > 150: _logger.error( f"Temperature {temperature}°F is out of range. " - f"Valid range: 115-150°F" + f"Valid range: 95-150°F" ) return @@ -293,8 +292,8 @@ def on_status_response(status: DeviceStatus) -> None: try: _logger.info(f"Setting DHW target temperature to {temperature}°F...") - # Send the temperature change command using display temperature - await mqtt.set_dhw_temperature_display(device, temperature) + # Send the temperature change command + await mqtt.set_dhw_temperature(device, temperature) # Wait for status response (temperature change confirmation) try: diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 53ea260..e447fe9 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -328,7 +328,7 @@ def build_reservation_entry( hour: int, minute: int, mode_id: int, - param: int, + temperature_f: float, ) -> dict[str, int]: """ Build a reservation payload entry matching the documented MQTT format. @@ -339,13 +339,15 @@ def build_reservation_entry( hour: Hour (0-23) minute: Minute (0-59) mode_id: DHW operation mode ID (1-6, see DhwOperationSetting) - param: Additional parameter value + temperature_f: Target temperature in Fahrenheit (95-150°F). + Automatically converted to half-degrees Celsius for the device. Returns: Dictionary with reservation entry fields Raises: - RangeValidationError: If hour, minute, or mode_id is out of range + RangeValidationError: If hour, minute, mode_id, or temperature is out + of range ParameterValidationError: If enabled type is invalid Examples: @@ -355,10 +357,13 @@ def build_reservation_entry( ... hour=6, ... minute=30, ... mode_id=3, - ... param=120 + ... temperature_f=140.0 ... ) {'enable': 1, 'week': 42, 'hour': 6, 'min': 30, 'mode': 3, 'param': 120} """ + # Import here to avoid circular import + from .models import fahrenheit_to_half_celsius + if not 0 <= hour <= 23: raise RangeValidationError( "hour must be between 0 and 23", @@ -383,6 +388,14 @@ def build_reservation_entry( min_value=1, max_value=6, ) + if not 95 <= temperature_f <= 150: + raise RangeValidationError( + "temperature_f must be between 95 and 150°F", + field="temperature_f", + value=temperature_f, + min_value=95, + max_value=150, + ) if isinstance(enabled, bool): enable_flag = 1 if enabled else 2 @@ -396,6 +409,7 @@ def build_reservation_entry( ) week_bitfield = encode_week_bitfield(days) + param = fahrenheit_to_half_celsius(temperature_f) return { "enable": enable_flag, diff --git a/src/nwp500/models.py b/src/nwp500/models.py index e1e1b33..5a5ef7d 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -41,6 +41,30 @@ def _half_celsius_to_fahrenheit(v: Any) -> float: return float(v) +def fahrenheit_to_half_celsius(fahrenheit: float) -> int: + """Convert Fahrenheit to half-degrees Celsius (for device commands). + + This is the inverse of the HalfCelsiusToF conversion used for reading. + Use this when sending temperature values to the device (e.g., reservations). + + Args: + fahrenheit: Temperature in Fahrenheit (e.g., 140.0) + + Returns: + Integer value in half-degrees Celsius for device param field + + Examples: + >>> fahrenheit_to_half_celsius(140.0) + 120 + >>> fahrenheit_to_half_celsius(120.0) + 98 + >>> fahrenheit_to_half_celsius(95.0) + 70 + """ + celsius = (fahrenheit - 32) * 5 / 9 + return round(celsius * 2) + + def _deci_celsius_to_fahrenheit(v: Any) -> float: """Convert decicelsius (tenths of Celsius) to Fahrenheit.""" if isinstance(v, (int, float)): diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt_client.py index 1143925..eacd6ba 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt_client.py @@ -978,65 +978,33 @@ async def disable_anti_legionella(self, device: Device) -> int: return await self._device_controller.disable_anti_legionella(device) async def set_dhw_temperature( - self, device: Device, temperature: int + self, device: Device, temperature_f: float ) -> int: """ Set DHW target temperature. - IMPORTANT: The temperature value sent in the message is 20 degrees LOWER - than what displays on the device/app. For example: - - Send 121°F → Device displays 141°F - - Send 131°F → Device displays 151°F (capped at 150°F max) - - Valid range: approximately 95-131°F (message value) - Display range: approximately 115-151°F (display value, max 150°F) - Args: device: Device object - temperature: Target temperature in Fahrenheit (message - value, NOT display value) + temperature_f: Target temperature in Fahrenheit (95-150°F). + Automatically converted to the device's internal format. Returns: Publish packet ID + Raises: + MqttNotConnectedError: If not connected to broker + RangeValidationError: If temperature is outside 95-150°F range + Example: - # To set display temperature to 140°F, send 120°F - await client.set_dhw_temperature(device, 120) + await client.set_dhw_temperature(device, 140.0) """ if not self._connected or not self._device_controller: raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.set_dhw_temperature( - device, temperature + device, temperature_f ) - 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). - - This is a convenience method that automatically converts - display temperature - to the message value by subtracting 20 degrees. - - Args: - device: Device object - display_temperature: Target temperature as shown on - display/app (Fahrenheit) - - Returns: - Publish packet ID - - Example: - # To set display temperature to 140°F - await client.set_dhw_temperature_display(device, 140) - # This sends 120°F in the message - """ - message_temperature = display_temperature - 20 - return await self.set_dhw_temperature(device, message_temperature) - async def update_reservations( self, device: Device, diff --git a/src/nwp500/mqtt_device_control.py b/src/nwp500/mqtt_device_control.py index 2e643c9..8dfc433 100644 --- a/src/nwp500/mqtt_device_control.py +++ b/src/nwp500/mqtt_device_control.py @@ -20,7 +20,7 @@ from .constants import CommandCode from .exceptions import ParameterValidationError, RangeValidationError -from .models import Device, DhwOperationSetting +from .models import Device, DhwOperationSetting, fahrenheit_to_half_celsius __author__ = "Emmanuel Levijarvi" @@ -350,31 +350,36 @@ async def disable_anti_legionella(self, device: Device) -> int: return await self._publish(topic, command) async def set_dhw_temperature( - self, device: Device, temperature: int + self, device: Device, temperature_f: float ) -> int: """ Set DHW target temperature. - IMPORTANT: The temperature value sent in the message is 20 degrees LOWER - than what displays on the device/app. For example: - - Send 121°F → Device displays 141°F - - Send 131°F → Device displays 151°F (capped at 150°F max) - - Valid range: approximately 95-131°F (message value) - Display range: approximately 115-151°F (display value, max 150°F) - Args: device: Device object - temperature: Target temperature in Fahrenheit (message value, NOT - display value) + temperature_f: Target temperature in Fahrenheit (95-150°F). + Automatically converted to the device's internal format. Returns: Publish packet ID + Raises: + RangeValidationError: If temperature is outside 95-150°F range + Example: - # To set display temperature to 140°F, send 120°F - await controller.set_dhw_temperature(device, 120) + await controller.set_dhw_temperature(device, 140.0) """ + if not 95 <= temperature_f <= 150: + raise RangeValidationError( + "temperature_f must be between 95 and 150°F", + field="temperature_f", + value=temperature_f, + min_value=95, + max_value=150, + ) + + param = fahrenheit_to_half_celsius(temperature_f) + device_id = device.device_info.mac_address device_type = device.device_info.device_type additional_value = device.device_info.additional_value @@ -387,39 +392,13 @@ async def set_dhw_temperature( command=CommandCode.DHW_TEMPERATURE, additional_value=additional_value, mode="dhw-temperature", - param=[temperature], + param=[param], 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. - - Uses what you see on device/app. - - This is a convenience method that automatically converts display - temperature to the message value by subtracting 20 degrees. - - Args: - device: Device object - display_temperature: Target temperature as shown on display/app - (Fahrenheit) - - Returns: - Publish packet ID - - Example: - # To set display temperature to 140°F - await controller.set_dhw_temperature_display(device, 140) - # This sends 120°F in the message - """ - message_temperature = display_temperature - 20 - return await self.set_dhw_temperature(device, message_temperature) - async def update_reservations( self, device: Device, diff --git a/tests/test_api_helpers.py b/tests/test_api_helpers.py index 85eed07..93514f0 100644 --- a/tests/test_api_helpers.py +++ b/tests/test_api_helpers.py @@ -68,7 +68,7 @@ def test_build_reservation_entry(): hour=6, minute=30, mode_id=4, - param=120, + temperature_f=140.0, ) assert reservation["enable"] == 1 @@ -76,7 +76,18 @@ def test_build_reservation_entry(): assert reservation["hour"] == 6 assert reservation["min"] == 30 assert reservation["mode"] == 4 - assert reservation["param"] == 120 + assert reservation["param"] == 120 # 140°F = 60°C = 120 half-degrees + + # Test 120°F conversion + reservation2 = build_reservation_entry( + enabled=True, + days=["Monday"], + hour=8, + minute=0, + mode_id=3, + temperature_f=120.0, + ) + assert reservation2["param"] == 98 # 120°F ≈ 48.9°C ≈ 98 half-degrees with pytest.raises(RangeValidationError): build_reservation_entry( @@ -85,7 +96,18 @@ def test_build_reservation_entry(): hour=24, minute=0, mode_id=1, - param=100, + temperature_f=120.0, + ) + + # Test temperature out of range + with pytest.raises(RangeValidationError): + build_reservation_entry( + enabled=True, + days=["Monday"], + hour=6, + minute=0, + mode_id=1, + temperature_f=200.0, # Too high ) diff --git a/tests/test_models.py b/tests/test_models.py index f5c1e02..95521c6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,6 @@ import pytest -from nwp500.models import DeviceStatus +from nwp500.models import DeviceStatus, fahrenheit_to_half_celsius @pytest.fixture @@ -130,3 +130,13 @@ def test_device_status_div10(default_status_data): default_status_data["currentInletTemperature"] = 500 status = DeviceStatus.model_validate(default_status_data) assert status.current_inlet_temperature == 50.0 + + +def test_fahrenheit_to_half_celsius(): + """Test fahrenheit_to_half_celsius conversion for device commands.""" + # Standard temperature conversions + assert fahrenheit_to_half_celsius(140.0) == 120 # 60°C × 2 + assert fahrenheit_to_half_celsius(120.0) == 98 # ~48.9°C × 2 + assert fahrenheit_to_half_celsius(95.0) == 70 # 35°C × 2 + assert fahrenheit_to_half_celsius(150.0) == 131 # ~65.6°C × 2 + assert fahrenheit_to_half_celsius(130.0) == 109 # ~54.4°C × 2