diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 83d3ee3..3ebb745 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ========================== diff --git a/docs/protocol/mqtt_protocol.rst b/docs/protocol/mqtt_protocol.rst index b35b20e..683a2cd 100644 --- a/docs/protocol/mqtt_protocol.rst +++ b/docs/protocol/mqtt_protocol.rst @@ -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 --------------- diff --git a/examples/advanced/combined_callbacks.py b/examples/advanced/combined_callbacks.py index 259731b..bd6fd4f 100644 --- a/examples/advanced/combined_callbacks.py +++ b/examples/advanced/combined_callbacks.py @@ -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'}" diff --git a/examples/advanced/device_capabilities.py b/examples/advanced/device_capabilities.py index e7c88e8..e5f1e18 100644 --- a/examples/advanced/device_capabilities.py +++ b/examples/advanced/device_capabilities.py @@ -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:") diff --git a/examples/advanced/reservation_schedule.py b/examples/advanced/reservation_schedule.py index 4423d6d..fcc67ec 100644 --- a/examples/advanced/reservation_schedule.py +++ b/examples/advanced/reservation_schedule.py @@ -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) @@ -42,6 +42,9 @@ 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:") @@ -49,18 +52,21 @@ def on_reservation_update(topic: str, message: dict[str, Any]) -> None: 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 "", ) ) diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index b38444d..d309c4d 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -113,6 +113,8 @@ TOUInfo, TOUSchedule, fahrenheit_to_half_celsius, + preferred_to_half_celsius, + reservation_param_to_preferred, ) from nwp500.mqtt import ( ConnectionDropEvent, @@ -179,6 +181,8 @@ "VolumeCode", # Conversion utilities "fahrenheit_to_half_celsius", + "preferred_to_half_celsius", + "reservation_param_to_preferred", # Authentication "NavienAuthClient", "AuthenticationResponse", diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index 6440dea..5d2dc64 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -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, @@ -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}", ) diff --git a/src/nwp500/cli/monitoring.py b/src/nwp500/cli/monitoring.py index 90cde4d..371a88f 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -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 @@ -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) diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index e79666b..6c087fc 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -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. @@ -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 @@ -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 + 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( @@ -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): @@ -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, diff --git a/src/nwp500/events.py b/src/nwp500/events.py index d37ea28..69b64b2 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -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) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 2c480d8..db63edd 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -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. diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 43effd2..f3e9e9e 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -31,7 +31,11 @@ ParameterValidationError, RangeValidationError, ) -from ..models import Device, DeviceFeature, fahrenheit_to_half_celsius +from ..models import ( + Device, + DeviceFeature, + preferred_to_half_celsius, +) from ..topic_builder import MqttTopicBuilder __author__ = "Emmanuel Levijarvi" @@ -395,15 +399,34 @@ async def disable_anti_legionella(self, device: Device) -> int: @requires_capability("dhw_temperature_setting_use") async def set_dhw_temperature( - self, device: Device, temperature_f: float + self, device: Device, temperature: float ) -> int: - """Set DHW target temperature (95-150°F).""" - self._validate_range("temperature_f", temperature_f, 95, 150) + """Set DHW target temperature. + + Temperature is in the user's preferred unit (Celsius or Fahrenheit) + based on the global unit system context. + """ + features = await self._get_device_features(device) + if features is None: + raise DeviceCapabilityError( + "dhw_temperature_setting_use", + ( + "Device features not available. " + "Unable to validate temperature range." + ), + ) + + self._validate_range( + "temperature", + temperature, + features.dhw_temperature_min, + features.dhw_temperature_max, + ) return await self._mode_command( device, CommandCode.DHW_TEMPERATURE, "dhw-temperature", - [fahrenheit_to_half_celsius(temperature_f)], + [preferred_to_half_celsius(temperature)], ) async def update_reservations( diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index bfb5cf8..2c1823a 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -25,7 +25,7 @@ from ..exceptions import MqttNotConnectedError from ..models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse from ..topic_builder import MqttTopicBuilder -from ..unit_system import UnitSystemType, set_unit_system +from ..unit_system import UnitSystemType, get_unit_system, set_unit_system from .utils import redact_topic, topic_matches_pattern if TYPE_CHECKING: @@ -428,9 +428,10 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: prev.dhw_temperature, status.dhw_temperature, ) + unit_suffix = "°C" if get_unit_system() == "metric" else "°F" _logger.debug( - f"Temperature changed: {prev.dhw_temperature}°F → " - f"{status.dhw_temperature}°F" + f"Temperature changed: {prev.dhw_temperature}" + f"{unit_suffix} → {status.dhw_temperature}{unit_suffix}" ) # Operation mode change diff --git a/src/nwp500/mqtt_events.py b/src/nwp500/mqtt_events.py index 087196e..6276d47 100644 --- a/src/nwp500/mqtt_events.py +++ b/src/nwp500/mqtt_events.py @@ -11,12 +11,14 @@ Example:: from nwp500.mqtt_events import MqttClientEvents + from nwp500.unit_system import get_unit_system # Type-safe event listening with autocomplete - mqtt_client.on( - MqttClientEvents.TEMPERATURE_CHANGED, - lambda old_temp, new_temp: print(f"Temp: {old_temp}°F → {new_temp}°F") - ) + def on_temperature_changed(old_temp, new_temp): + unit = "°C" if get_unit_system() == "metric" else "°F" + print(f"Temp: {old_temp}{unit} → {new_temp}{unit}") + + mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, on_temperature_changed) # List all available events for event_name in MqttClientEvents.get_all_events(): @@ -71,8 +73,10 @@ class TemperatureChangedEvent: """Emitted when the DHW temperature changes. Attributes: - old_temperature: Previous DHW temperature in °F - new_temperature: New DHW temperature in °F + old_temperature: Previous DHW temperature in user's preferred unit + (Celsius or Fahrenheit based on unit system context) + new_temperature: New DHW temperature in user's preferred unit + (Celsius or Fahrenheit based on unit system context) """ old_temperature: float @@ -221,8 +225,10 @@ class MqttClientEvents: """Emitted: DHW temperature changed. Args: - old_temperature (float): Previous DHW temperature (°F) - new_temperature (float): New DHW temperature (°F) + old_temperature (float): Previous DHW temperature in user's + preferred unit + new_temperature (float): New DHW temperature in user's preferred + unit See: :class:`TemperatureChangedEvent` """ diff --git a/tests/test_api_helpers.py b/tests/test_api_helpers.py index 93514f0..84a0bc7 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, - temperature_f=140.0, + temperature=140.0, ) assert reservation["enable"] == 1 @@ -85,7 +85,7 @@ def test_build_reservation_entry(): hour=8, minute=0, mode_id=3, - temperature_f=120.0, + temperature=120.0, ) assert reservation2["param"] == 98 # 120°F ≈ 48.9°C ≈ 98 half-degrees @@ -96,7 +96,7 @@ def test_build_reservation_entry(): hour=24, minute=0, mode_id=1, - temperature_f=120.0, + temperature=120.0, ) # Test temperature out of range @@ -107,7 +107,7 @@ def test_build_reservation_entry(): hour=6, minute=0, mode_id=1, - temperature_f=200.0, # Too high + temperature=200.0, # Too high )