Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,95 @@
Changelog
=========

Version 7.4.0 (UNRELEASED)
==========================

Breaking Changes
----------------
- **Temperature Setpoint Limits**: Replaced hardcoded temperature limits with device-provided values

- ``set_dhw_temperature()`` now validates against device-specific ``dhw_temperature_min`` and ``dhw_temperature_max`` instead of hardcoded 95-150°F bounds
- ``build_reservation_entry()`` changed parameter name from ``temperature_f`` to ``temperature`` (unit-agnostic)
- Added optional ``temperature_min`` and ``temperature_max`` parameters to ``build_reservation_entry()`` for device-specific limit overrides
- Temperature parameters now accept values in the user's preferred unit (Celsius or Fahrenheit) based on global unit system context
- Fixes Home Assistant and other integrations that prefer Celsius unit display

**Migration guide:**

.. code-block:: python

# OLD (hardcoded 95-150°F)
await mqtt.control.set_dhw_temperature(device, temperature_f=140.0)
entry = build_reservation_entry(
enabled=True,
days=["Monday"],
hour=6,
minute=0,
mode_id=3,
temperature_f=140.0,
)

# NEW (device-provided limits, unit-aware)
# Temperature value automatically uses user's preferred unit
await mqtt.control.set_dhw_temperature(device, 140.0)

# Device features provide min/max in user's preferred unit
features = await device_info_cache.get(device.device_info.mac_address)
entry = build_reservation_entry(
enabled=True,
days=["Monday"],
hour=6,
minute=0,
mode_id=3,
temperature=140.0,
temperature_min=features.dhw_temperature_min,
temperature_max=features.dhw_temperature_max,
)

Added
-----
- **Reservation Temperature Conversion**: New ``reservation_param_to_preferred()`` utility function for unit-aware reservation display

- Converts device reservation parameters (half-degree Celsius) to user's preferred unit
- Respects global unit system context (metric/us_customary)
- Enables proper thermostat/reservation scheduling display in Home Assistant and other integrations
- Example usage:

.. code-block:: python

from nwp500 import reservation_param_to_preferred

# Display reservation temperature in user's preferred unit
param = 120 # Device raw value in half-Celsius
temp = reservation_param_to_preferred(param)
# Returns: 60.0 (Celsius) or 140.0 (Fahrenheit) based on unit context

- **Unit-Aware Temperature Conversion**: New ``preferred_to_half_celsius()`` utility function

- Converts temperature from user's preferred unit to half-degree Celsius for device commands
- Respects global unit system context (metric/us_customary)
- Replaces misleading ``fahrenheit_to_half_celsius()`` in unit-agnostic code paths
- Used internally by ``set_dhw_temperature()`` and ``build_reservation_entry()``

Changed
-------
- **Unit System Agnostic Display**: All logging and user-facing messages now respect global unit system context

- Temperature change logs dynamically show °C or °F based on user preference
- CLI monitoring and temperature setting messages use correct unit suffix
- Event listener documentation updated with unit-aware examples
- Reservation schedule examples now use ``reservation_param_to_preferred()`` for proper unit handling

Fixed
-----
- **Critical: Temperature Unit Bug in Set Operations**: Fixed incorrect temperature conversion when setting DHW temperature and reservations

- ``set_dhw_temperature()`` was calling ``fahrenheit_to_half_celsius()`` with unit-agnostic temperature parameter
- ``build_reservation_entry()`` had the same issue
- **Impact**: If user preferred Celsius, temperature would be interpreted as Fahrenheit, causing wrong setpoints
- **Fix**: Use new ``preferred_to_half_celsius()`` that respects unit system context
- This was a critical data correctness bug that would cause incorrect device behavior for Celsius users

Version 7.3.2 (2026-01-25)
==========================

Expand Down
2 changes: 1 addition & 1 deletion docs/protocol/mqtt_protocol.rst
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ DHW Temperature
Temperature values are encoded in **half-degrees Celsius**.
Use formula: ``fahrenheit = (param / 2.0) * 9/5 + 32``
For 140°F, send ``param=120`` (which is 60°C × 2).
Valid range: 95-150°F (70-150 raw value).
Valid range: Device-specific (see device features for ``dhw_temperature_min`` and ``dhw_temperature_max``).

Anti-Legionella
---------------
Expand Down
5 changes: 4 additions & 1 deletion examples/advanced/combined_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,11 @@ def on_feature(feature: DeviceFeature):
print(f"\n📋 Feature Info #{counts['feature']}")
print(f" Serial: {feature.controller_serial_number}")
print(f" FW Version: {feature.controller_sw_version}")
unit_suffix = (
"°C" if feature.temperature_type.name == "CELSIUS" else "°F"
)
print(
f" Temp Range: {feature.dhw_temperature_min}-{feature.dhw_temperature_max}°F"
f" Temp Range: {feature.dhw_temperature_min}-{feature.dhw_temperature_max}{unit_suffix}"
)
print(
f" Heat Pump: {'Yes' if feature.heatpump_use == OnOffFlag.ON else 'No'}"
Expand Down
7 changes: 5 additions & 2 deletions examples/advanced/device_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,16 @@ def on_device_feature(feature: DeviceFeature):
)

print("\nConfiguration:")
unit_suffix = (
"°C" if feature.temperature_type.name == "CELSIUS" else "°F"
)
print(f" Temperature Unit: {feature.temperature_type.name}")
print(f" Temp Formula Type: {feature.temp_formula_type}")
print(
f" DHW Temp Range: {feature.dhw_temperature_min}°F - {feature.dhw_temperature_max}°F"
f" DHW Temp Range: {feature.dhw_temperature_min}{unit_suffix} - {feature.dhw_temperature_max}{unit_suffix}"
)
print(
f" Freeze Prot Range: {feature.freeze_protection_temp_min}°F - {feature.freeze_protection_temp_max}°F"
f" Freeze Prot Range: {feature.freeze_protection_temp_min}{unit_suffix} - {feature.freeze_protection_temp_max}{unit_suffix}"
)

print("\nFeature Support:")
Expand Down
16 changes: 11 additions & 5 deletions examples/advanced/reservation_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ async def main() -> None:
hour=6,
minute=30,
mode_id=4, # High Demand
temperature_f=140.0, # Temperature in Fahrenheit
temperature=140.0, # Temperature in user's preferred unit
)

mqtt_client = NavienMqttClient(auth_client)
Expand All @@ -42,25 +42,31 @@ async def main() -> None:
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:
from nwp500 import reservation_param_to_preferred
from nwp500.unit_system import get_unit_system

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)}")
unit_suffix = "°C" if get_unit_system() == "metric" else "°F"
for idx, entry in enumerate(reservations, start=1):
week_days = decode_week_bitfield(entry.get("week", 0))
# Convert half-degrees Celsius param back to Fahrenheit for display
# Convert half-degrees Celsius param to user's preferred unit
param = entry.get("param", 0)
temp_f = (param / 2.0) * 9 / 5 + 32
temp = reservation_param_to_preferred(param)
print(
" - #{idx}: {time:02d}:{minute:02d} mode={mode} temp={temp:.1f}°F days={days}".format(
" - #{idx}: {time:02d}:{minute:02d} mode={mode} "
"temp={temp:.1f}{unit} days={days}".format(
idx=idx,
time=entry.get("hour", 0),
minute=entry.get("min", 0),
mode=entry.get("mode"),
temp=temp_f,
temp=temp,
unit=unit_suffix,
days=", ".join(week_days) or "<none>",
)
)
Expand Down
4 changes: 4 additions & 0 deletions src/nwp500/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@
TOUInfo,
TOUSchedule,
fahrenheit_to_half_celsius,
preferred_to_half_celsius,
reservation_param_to_preferred,
)
from nwp500.mqtt import (
ConnectionDropEvent,
Expand Down Expand Up @@ -179,6 +181,8 @@
"VolumeCode",
# Conversion utilities
"fahrenheit_to_half_celsius",
"preferred_to_half_celsius",
"reservation_param_to_preferred",
# Authentication
"NavienAuthClient",
"AuthenticationResponse",
Expand Down
4 changes: 3 additions & 1 deletion src/nwp500/cli/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
ValidationError,
)
from nwp500.mqtt.utils import redact_serial
from nwp500.unit_system import get_unit_system

from .output_formatters import (
print_device_info,
Expand Down Expand Up @@ -239,12 +240,13 @@ async def handle_set_dhw_temp_request(
mqtt: NavienMqttClient, device: Device, temperature: float
) -> None:
"""Set DHW target temperature."""
unit_suffix = "°C" if get_unit_system() == "metric" else "°F"
await _handle_command_with_status_feedback(
mqtt,
device,
lambda: mqtt.control.set_dhw_temperature(device, temperature),
"setting temperature",
f"Temperature set to {temperature}°F",
f"Temperature set to {temperature}{unit_suffix}",
)


Expand Down
5 changes: 4 additions & 1 deletion src/nwp500/cli/monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging

from nwp500 import Device, DeviceStatus, NavienMqttClient
from nwp500.unit_system import get_unit_system

from .output_formatters import write_status_to_csv

Expand All @@ -30,8 +31,10 @@ async def handle_monitoring(
_logger.info("Press Ctrl+C to stop.")

def on_status_update(status: DeviceStatus) -> None:
unit_suffix = "°C" if get_unit_system() == "metric" else "°F"
_logger.info(
f"Received status update: Temp={status.dhw_temperature}°F, "
f"Received status update: Temp={status.dhw_temperature}"
f"{unit_suffix}, "
f"Power={'ON' if status.dhw_use else 'OFF'}"
)
write_status_to_csv(output_file, status)
Expand Down
34 changes: 23 additions & 11 deletions src/nwp500/encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,9 @@ def build_reservation_entry(
hour: int,
minute: int,
mode_id: int,
temperature_f: float,
temperature: float,
temperature_min: float | None = None,
temperature_max: float | None = None,
) -> dict[str, int]:
"""
Build a reservation payload entry matching the documented MQTT format.
Expand All @@ -337,8 +339,13 @@ def build_reservation_entry(
hour: Hour (0-23)
minute: Minute (0-59)
mode_id: DHW operation mode ID (1-6, see DhwOperationSetting)
temperature_f: Target temperature in Fahrenheit (95-150°F).
temperature: Target temperature in the user's preferred unit
(Celsius or Fahrenheit based on global context).
Automatically converted to half-degrees Celsius for the device.
temperature_min: Minimum allowed temperature. If not provided,
defaults are used: 95°F or ~35°C.
temperature_max: Maximum allowed temperature. If not provided,
defaults are used: 150°F or ~65°C.

Returns:
Dictionary with reservation entry fields
Expand All @@ -355,12 +362,17 @@ def build_reservation_entry(
... hour=6,
... minute=30,
... mode_id=3,
... temperature_f=140.0
... temperature=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
from .models import preferred_to_half_celsius

# Use device-provided limits if available, otherwise use defaults
# Defaults are conservative: 95°F / 35°C minimum, 150°F / 65°C maximum
Comment on lines +372 to +373
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default temperature limits (95 and 150) appear to be hardcoded in Fahrenheit, but the function now accepts temperatures in the user's preferred unit. For metric users, these defaults would be interpreted as °C (95°C and 150°C), which are far outside typical water heater ranges. The defaults should respect the unit system context, or the comment should clarify these are always interpreted as Fahrenheit regardless of context.

Suggested change
# Use device-provided limits if available, otherwise use defaults
# Defaults are conservative: 95°F / 35°C minimum, 150°F / 65°C maximum
# Use device-provided limits if available, otherwise use defaults.
# NOTE: The numeric defaults (95 and 150) are Fahrenheit-based limits
# (95°F minimum, 150°F maximum). Callers using metric or other units
# MUST provide temperature_min/temperature_max in the same unit as
# `temperature` to ensure correct validation behavior.

Copilot uses AI. Check for mistakes.
min_temp = temperature_min if temperature_min is not None else 95
max_temp = temperature_max if temperature_max is not None else 150

if not 0 <= hour <= 23:
raise RangeValidationError(
Expand All @@ -386,13 +398,13 @@ def build_reservation_entry(
min_value=1,
max_value=6,
)
if not 95 <= temperature_f <= 150:
if not min_temp <= temperature <= max_temp:
raise RangeValidationError(
"temperature_f must be between 95 and 150°F",
field="temperature_f",
value=temperature_f,
min_value=95,
max_value=150,
f"temperature must be between {min_temp} and {max_temp}",
field="temperature",
value=temperature,
min_value=min_temp,
max_value=max_temp,
)

if isinstance(enabled, bool):
Expand All @@ -407,7 +419,7 @@ def build_reservation_entry(
)

week_bitfield = encode_week_bitfield(days)
param = fahrenheit_to_half_celsius(temperature_f)
param = preferred_to_half_celsius(temperature)

return {
"enable": enable_flag,
Expand Down
5 changes: 4 additions & 1 deletion src/nwp500/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,11 @@ def on(

Example::

from nwp500.unit_system import get_unit_system

def on_temp_change(old_temp: float, new_temp: float):
print(f"Temperature: {old_temp}°F → {new_temp}°F")
unit = "°C" if get_unit_system() == "metric" else "°F"
print(f"Temperature: {old_temp}{unit} → {new_temp}{unit}")

emitter.on('temperature_changed', on_temp_change)

Expand Down
57 changes: 57 additions & 0 deletions src/nwp500/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,63 @@ def fahrenheit_to_half_celsius(fahrenheit: float) -> int:
return int(HalfCelsius.from_fahrenheit(fahrenheit).raw_value)


def preferred_to_half_celsius(temperature: float) -> int:
"""Convert temperature from preferred unit to half-degrees Celsius.

Converts temperature from the user's preferred unit (Celsius or Fahrenheit,
based on global unit system context) to the half-Celsius format used by
the device for commands and reservations.

Args:
temperature: Temperature in user's preferred unit
(Celsius or Fahrenheit).

Returns:
Raw device value in half-Celsius format.

Example:
>>> # With us_customary unit system
>>> preferred_to_half_celsius(140.0) # 140°F
120
>>> # With metric unit system
>>> preferred_to_half_celsius(60.0) # 60°C
120
"""
if get_unit_system() == "metric":
# User prefers Celsius, input is in Celsius
return int(HalfCelsius.from_celsius(temperature).raw_value)
else:
# User prefers Fahrenheit (or no preference), input is in Fahrenheit
return fahrenheit_to_half_celsius(temperature)


def reservation_param_to_preferred(param: int) -> float:
"""Convert reservation param to user's preferred temperature unit.

Device returns reservation temperatures as half-degrees Celsius (param).
This converts them to the user's preferred unit (Celsius or Fahrenheit)
based on the global unit system context.

Args:
param: Raw device value in half-Celsius format.

Returns:
Temperature in user's preferred unit (Celsius or Fahrenheit).

Example:
>>> # With metric (Celsius) unit system
>>> reservation_param_to_preferred(120)
60.0
>>> # With us_customary (Fahrenheit) unit system
>>> reservation_param_to_preferred(120)
140.0
"""
half_celsius = HalfCelsius(param)
if get_unit_system() == "metric":
return round(half_celsius.to_celsius(), 1)
return round(half_celsius.to_fahrenheit(), 1)


class NavienBaseModel(BaseModel):
"""Base model for all Navien models.

Expand Down
Loading