diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e6cefa3..fc7b787 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,58 @@ Changelog ========= +Version 7.5.0 (2026-02-13) +========================== + +**BREAKING CHANGES**: Heat Pump Temperature Sensor Behavior + +Fixed +----- +- **Heat Pump Sensor Zero-as-None Bug**: Fixed critical bug where 0°C (32°F) readings from heat pump sensors were incorrectly treated as "sensor not available" + + - **Impact**: Heat pump compressor/refrigerant sensors (tank, discharge, suction, evaporator, ambient) now correctly report 0°C/32°F as valid measurements + - **Why**: These sensors measure refrigerant and air temperatures that can legitimately be at or below freezing during normal heat pump operation + - **Root Cause**: The protocol uses 0 as a sentinel for "N/A" on SOME fields (optional sensors, mode-dependent settings) but NOT for heat pump sensors + - **Breaking**: Temperature fields using ``DeciCelsiusToPreferred`` that previously returned ``None`` for 0°C will now return ``32.0`` (°F) or ``0.0`` (°C) + - **Fixed converter**: ``deci_celsius_to_preferred()`` no longer treats 0 as None + - **Unaffected**: ``half_celsius_to_preferred()`` still treats 0 as None (used for optional sensors like inlet temp) + - **Unaffected**: ``raw_celsius_to_preferred()`` still treats 0 as None (used for optional outside sensor) + +- **Type Safety**: Added None-handling to formatters and examples that use optional temperature fields + + - Updated ``_format_number()`` in CLI formatters to handle None values gracefully + - Updated example code to safely format optional temperatures + +Clarification on Zero-as-None Behavior +--------------------------------------- +The device protocol uses different temperature field types with different semantics: + +1. **DeciCelsius (0.1°C precision)** - Heat pump compressor/refrigerant sensors + + - Used for: tank temperatures, discharge, suction, evaporator, ambient + - Can measure: 0°C and below during normal operation + - **0 means**: Actual temperature of 0°C (32°F) + +2. **HalfCelsius (0.5°C precision)** - Water and optional sensors + + - Used for: inlet water temp, secondary DHW sensor, mode-dependent settings + - **0 means**: Sensor not present OR setting not applicable in current mode + +3. **RawCelsius** - Optional external sensors + + - Used for: outside temperature + - **0 means**: Sensor not installed on this model + +Migration Notes +--------------- +For consumers (e.g., Home Assistant integration): + +- Heat pump sensors (``tank_upper_temperature``, ``ambient_temperature``, etc.) may now report 0°C/32°F instead of None/Unknown during cold weather or specific operating conditions +- Inlet water temperature (``current_inlet_temperature``) still returns None when no water is flowing +- Mode-dependent settings (``he_lower_on_temp_setting``) still return None when not applicable +- Only ``outside_temperature`` definitively uses 0 as "sensor not available" +- Update any automations that assumed 0°C readings meant "sensor missing" + Version 7.4.5 (2026-02-04) ========================== @@ -10,6 +62,38 @@ Fixed - **Energy Capacity Unit Scaling**: Corrected unit scaling for energy capacity fields that were off by a factor of 10 - **CLI Output**: Fixed linting issue by replacing str and Enum with StrEnum for InstallType +Version 7.4.0 (2026-01-27) +========================== + +Added +----- +- **N/A Value Support**: Temperature sensors, optional features, and recirculation pump status now display "N/A" when not available + + - Temperature sensor fields return ``None`` when device reports 0 (indicating sensor doesn't exist) + - Optional feature settings (mixing valve rate, heating element lower temps) return ``None`` when not applicable + - Recirculation pump status fields return ``None`` when no pump installed + - CLI now displays "N/A" instead of misleading zero values (e.g., "0.0°C" or "32.0°F") + - Affects ~25 fields across ``DeviceStatus`` model including outside temperature, inlet temperature, mixing rate, and all recirculation status fields + - New converters: ``int_with_zero_as_none()``, ``float_with_zero_as_none()``, ``enum_with_zero_as_none_validator()``, ``device_bool_with_zero_as_none()`` + - New type annotations: ``DeviceBoolOptional`` for optional feature boolean fields + - See ``CHANGES.md`` for comprehensive documentation of all changes + +Changed +------- +- **Temperature Converters**: All temperature sensor converters now return ``float | None`` instead of ``float`` + + - Modified converters: ``half_celsius_to_preferred()``, ``deci_celsius_to_preferred()``, ``raw_celsius_to_preferred()``, ``div_10_celsius_to_preferred()`` + - Created separate ``half_celsius_to_preferred_setting()`` converter for configuration settings that should never be None + - Temperature sensor fields (e.g., ``current_outside_temperature``, ``current_inlet_temperature``) can now be None + - Temperature setting fields (e.g., ``dhw_temperature_setting``) remain non-nullable + +- **Model Field Types**: Updated ~25 fields in ``DeviceStatus`` to support optional values + + - Temperature sensors: 15+ fields now ``HalfCelsiusToPreferred`` (``float | None``) + - Mixing valve rate: Now ``float | None`` + - Heating element lower settings: Now ``HalfCelsiusToPreferred`` (``float | None``) - mode-dependent + - Recirculation fields: 7 fields now support None (``RecirculationMode | None``, ``int | None``, ``DeviceBoolOptional``) + Version 7.3.4 (2026-01-27) ========================== diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..f13302e --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,269 @@ +# N/A Value Support Implementation Summary + +## Overview +Implemented support for differentiating between actual 0 values and N/A (sensor/feature not available) values. The device protocol uses `0` to indicate that a sensor is not available or that a feature is not supported on that particular device model or operating mode. + +This applies to: +- **Temperature sensors** - Devices may not have certain temperature sensors (e.g., outside temperature, inlet temperature) +- **Optional hardware features** - Features like mixing valve that may not be physically present on all models +- **Mode-dependent features** - Settings that only apply in certain operating modes (e.g., heating element lower settings) + +## Changes Made + +### 1. Temperature Converter Functions (`src/nwp500/converters.py`) + +Updated four temperature converter functions to return `float | None`: +- `half_celsius_to_preferred()` - Now returns `None` when raw value is `0` +- `deci_celsius_to_preferred()` - Now returns `None` when raw value is `0` +- `raw_celsius_to_preferred()` - Now returns `None` when raw value is `0` +- `div_10_celsius_to_preferred()` - Now returns `None` when raw value is `0` + +Added new converter for settings/configuration values: +- `half_celsius_to_preferred_setting()` - Never returns `None`, used for configuration values + +Added new converters for optional feature values: +- `float_with_zero_as_none()` - Converts 0 to `None`, used for numeric features that may not be present +- `int_with_zero_as_none()` - Converts 0 to `None`, used for integer status fields +- `enum_with_zero_as_none_validator()` - Enum validator that treats 0 as `None` +- `device_bool_with_zero_as_none()` - DeviceBool converter that treats 0 as `None` + +### 2. Type Annotations (`src/nwp500/models.py`) + +Created categories of type annotations: + +**For sensor readings (can be N/A):** +- `HalfCelsiusToPreferred` - `float | None` +- `DeciCelsiusToPreferred` - `float | None` +- `RawCelsiusToPreferred` - `float | None` +- `Div10CelsiusToPreferred` - `float | None` + +**For optional feature booleans:** +- `DeviceBoolOptional` - `bool | None` (treats 0 as N/A) + +**For settings/limits (never N/A):** +- `HalfCelsiusToPreferredSetting` - `float` (never None) +- `Div10CelsiusDeltaToPreferred` - `float` (delta temperatures, never N/A) + +### 3. DeviceStatus Model Fields (`src/nwp500/models.py`) + +**Sensor readings (now Optional[float]):** +- `outside_temperature` - Outdoor/ambient temperature sensor +- `dhw_temperature` - Current DHW outlet temperature +- `dhw_temperature2` - Second DHW temperature reading +- `current_inlet_temperature` - Cold water inlet temperature +- `tank_upper_temperature`, `tank_lower_temperature` - Tank temperature sensors +- `discharge_temperature`, `suction_temperature` - Compressor temperatures +- `evaporator_temperature` - Evaporator temperature +- `ambient_temperature` - Ambient air temperature at heat pump +- `target_super_heat`, `current_super_heat` - Superheat values +- `recirc_temperature` - Recirculation temperature +- `recirc_faucet_temperature` - Recirculation faucet temperature + +**Optional features (now Optional):** +- `mixing_rate` - Mixing valve rate (only present on devices with mixing valve) + +**Optional feature settings (now Optional):** +- `he_lower_on_temp_setting` - Heating element lower on temperature (only available in Electric/High Demand modes) +- `he_lower_off_temp_setting` - Heating element lower off temperature (only available in Electric/High Demand modes) + +**Note on mode-dependent features:** +The heating element lower settings are physically present in the device but are only active/controllable in certain operating modes: +- **Electric mode**: Uses both upper and lower heating elements +- **High Demand mode**: Hybrid mode using heat pump plus both heating elements for fast recovery +- **Other modes** (Heat Pump, Energy Saver): Lower element is not used, settings show as N/A + +**Recirculation pump status fields (now Optional):** +- `recirc_operation_mode` - Recirculation pump operation mode (RecirculationMode enum) +- `recirc_pump_operation_status` - Pump operation status +- `recirc_hot_btn_ready` - HotButton ready status +- `recirc_operation_reason` - Operation reason code +- `recirc_error_status` - Error status code +- `recirc_operation_busy` - Operation busy flag (DeviceBool) +- `recirc_reservation_use` - Reservation usage flag (DeviceBool) + +**Note on recirculation pump:** +All recirculation-related status fields report `0` when the device doesn't have a recirculation pump installed, now correctly displayed as "N/A". + +**Configuration/Settings (remain float, never None):** +- `dhw_temperature_setting`, `dhw_target_temperature_setting` - User temperature settings +- `freeze_protection_temperature` - Freeze protection setpoint +- `hp_*_temp_setting` fields - Heat pump control temperatures +- `he_upper_*_temp_setting` fields - Upper heating element control temperatures +- `heat_min_op_temperature` - Minimum operation temperature +- `recirc_temp_setting` - Recirculation temperature setting +- `freeze_protection_temp_min`, `freeze_protection_temp_max` - Freeze protection limits + +**Not affected:** +- Delta/differential temperatures (`*_diff_temp_setting`) - Can legitimately be 0 +- Non-temperature numeric fields (RPM, flow rate, etc.) - Can legitimately be 0 + +### 4. DeviceFeature Model Fields (`src/nwp500/models.py`) + +Updated temperature limit fields to use non-optional type: +- `dhw_temperature_min`, `dhw_temperature_max` - DHW temperature limits +- `freeze_protection_temp_min`, `freeze_protection_temp_max` - Freeze protection limits +- `recirc_temperature_min`, `recirc_temperature_max` - Recirculation temperature limits + +### 5. Display Logic (`src/nwp500/cli/output_formatters.py`) + +Updated `_add_numeric_field_with_unit()` function to display "N/A" for `None` values: +```python +if value is None: + formatted = "N/A" +else: + formatted = f"{_format_number(value)}{unit}" +``` + +### 6. Tests (`tests/test_models.py`) + +Added comprehensive tests: +- `test_temperature_zero_values_are_none()` - Verifies 0 values return None for sensor readings +- `test_temperature_non_zero_values_are_converted()` - Verifies non-zero sensor values convert properly +- `test_mixing_rate_zero_is_none()` - Verifies mixing rate 0 becomes None +- `test_mixing_rate_non_zero_is_preserved()` - Verifies non-zero mixing rate preserved +- `test_he_lower_temp_settings_zero_is_none()` - Verifies heating element lower settings 0 becomes None +- `test_he_lower_temp_settings_non_zero_are_converted()` - Verifies non-zero he_lower settings convert properly +- `test_recirc_status_fields_zero_is_none()` - Verifies all recirculation status fields with 0 become None +- `test_recirc_status_fields_non_zero_are_preserved()` - Verifies non-zero recirculation fields preserved/converted + +## Behavior + +### Before +- Temperature value of `0` → Converted to `0.0°C` or `32.0°F` +- No way to distinguish between actual freezing temperature and unsupported sensor +- Mixing rate of `0%` → Displayed even when device doesn't have mixing valve +- Heating element lower settings showing `32.0°F` even when not applicable in current operating mode +- Recirculation pump status fields showing values even when no pump installed + +### After +- Temperature sensor value of `0` → `None` (displayed as "N/A") +- Non-zero temperature values → Converted normally +- Configuration/setting values for required features → Never None, always have valid values +- Configuration values for optional features → Can be None if feature not present +- Delta temperatures → Can be 0 (not treated as N/A) +- Mixing rate of `0%` → `None` when device doesn't have mixing valve feature +- Heating element lower settings of `0` → `None` when not applicable in current operating mode +- Recirculation pump status fields of `0` → `None` when pump not installed + +## Examples + +### Device with sensor: +```python +data = {"outsideTemperature": 50} # 50 raw = 25°C = 77°F +status = DeviceStatus.model_validate(data) +print(status.outside_temperature) # 77.0 +``` + +### Device without sensor: +```python +data = {"outsideTemperature": 0} # 0 = sensor not available +status = DeviceStatus.model_validate(data) +print(status.outside_temperature) # None +``` + +### Display formatting: +``` +Outside Temperature: 77.0°F (with sensor) +Outside Temperature: N/A (without sensor) +Mixing Rate: 45.5% (with mixing valve) +Mixing Rate: N/A (without mixing valve) +Heat Element Lower On: 141.8°F (in Electric/High Demand mode) +Heat Element Lower On: N/A (in Heat Pump/Energy Saver mode) +Heat Element Lower Off: 122.0°F (in Electric/High Demand mode) +Heat Element Lower Off: N/A (in Heat Pump/Energy Saver mode) +Recirc Operation Mode: BUTTON (with recirculation pump) +Recirc Operation Mode: N/A (without recirculation pump) +Recirc Operation Busy: No (with recirculation pump) +Recirc Operation Busy: N/A (without recirculation pump) +``` + +## Testing + +All 401 tests pass (4 new tests added for recirculation and heating element features). New tests added specifically for N/A value handling. + +Run the demonstration script: +```bash +python test_na_values.py +``` + +## Files Modified + +1. `src/nwp500/converters.py` - Updated temperature converters +2. `src/nwp500/models.py` - Updated type annotations and model fields +3. `src/nwp500/cli/output_formatters.py` - Updated display logic +4. `tests/test_models.py` - Added new tests +5. `test_na_values.py` - Created demonstration script (not part of package) + +## Design Decisions + +1. **Limited to sensor readings and optional feature settings**: Only actual sensor readings and configuration values for optional features can be N/A. Configuration values for required features always have valid values. + +2. **Separate type annotations**: Created distinct types for sensor readings vs. settings to maintain type safety and prevent accidental None values in required configuration fields. + +3. **Optional feature detection**: Device uses `0` to indicate that a feature is not available or not applicable: + - **Sensor readings**: `0` means sensor doesn't exist on this device model + - **Optional hardware features**: `0` means feature not physically present (e.g., mixing valve) + - **Mode-dependent features**: `0` means feature not active in current operating mode (e.g., heating element lower settings) + - The device has status flags (`heat_lower_use`, `heat_upper_use`, `mixing_valve_use`) that indicate feature availability + +**Example - Heating Element Lower Settings:** +The NPE series heat pump water heaters have both upper and lower heating elements physically installed, but the lower element is only active in certain operating modes: +- **Electric mode**: Both elements active (settings show temperature values) +- **High Demand mode**: Both elements active for fast recovery (settings show temperature values) +- **Heat Pump mode**: Only heat pump active (lower element settings show N/A) +- **Energy Saver mode**: Primarily heat pump, may use upper element (lower element settings show N/A) + +This dynamic behavior means the device reports `0` for lower element settings when they're not applicable to the current operating mode, even though the hardware is physically present. + +4. **Delta temperatures excluded**: Differential/delta temperature settings can legitimately be 0, so they are not treated as N/A. + +5. **Other numeric fields excluded**: RPM, flow rate, and other numeric fields can legitimately be 0 and are not affected. + +6. **Display as "N/A"**: In CLI output, None values are displayed as "N/A" rather than "None" for better user experience. + +## Understanding N/A Values + +The implementation handles three distinct types of N/A scenarios: + +### 1. Missing Sensors (Hardware-dependent) +Some device models don't have certain sensors installed: +- **Outside temperature sensor**: Not present on all models +- **Inlet temperature sensor**: May not be installed +- **Example**: Basic models vs. premium models with additional monitoring + +### 2. Optional Hardware Features (Model-dependent) +Some features are only available on specific models: +- **Mixing valve**: Present only on models with thermostatic mixing capability +- **Recirculation pump**: Present only on models with recirculation system installed +- Device feature flags like `mixing_valve_use` and `recirculation_use` indicate hardware availability +- **Example**: Standard vs. mixing valve models, with or without recirculation pump + +### 3. Mode-Dependent Features (Operating mode) +Some settings only apply in specific operating modes: +- **Heating element lower settings**: Only active in Electric/High Demand modes +- Hardware is present but not used in all modes +- Device dynamically reports `0` when feature is inactive +- Status flags like `heat_lower_use` indicate current mode usage +- **Example**: Heat Pump mode doesn't use lower heating element, so settings show N/A + +This distinction is important for understanding why a value shows "N/A": +- It could mean the hardware doesn't exist on your model +- It could mean the feature isn't being used in your current operating mode +- In both cases, displaying "N/A" prevents confusion from showing misleading `0` or `32°F` values + +## Future Enhancements + +### Capability-aware Display +Consider using device capability flags from `DeviceFeature` and `DeviceStatus` to provide more context: +- Cross-reference `heat_lower_use` flag with lower element settings +- Check `mixing_valve_use` flag before displaying mixing rate +- Display indicators like "N/A (mode)" vs "N/A (not installed)" +- Would help users understand *why* a value is N/A + +### Operating Mode Detection +Enhance display to show which features are available in current operating mode: +- Parse `dhw_operation_mode` to determine current mode +- Show helpful messages like "Not used in Heat Pump mode" +- Provide mode-specific setting recommendations + +Currently, feature flags exist but are primarily used for control capability checking rather than display context. diff --git a/examples/intermediate/device_status_callback.py b/examples/intermediate/device_status_callback.py index 0c3b86b..3ebc4c8 100755 --- a/examples/intermediate/device_status_callback.py +++ b/examples/intermediate/device_status_callback.py @@ -148,17 +148,22 @@ def on_device_status(status: DeviceStatus): print( f" DHW Target Setting: {status.dhw_target_temperature_setting:.1f}{unit}" ) + + def fmt_temp(val: float | None, unit: str) -> str: + """Format temperature value, handling None.""" + return f"{val:.1f}{unit}" if val is not None else "N/A" + print( - f" Tank Upper: {status.tank_upper_temperature:.1f}{unit}" + f" Tank Upper: {fmt_temp(status.tank_upper_temperature, unit)}" ) print( - f" Tank Lower: {status.tank_lower_temperature:.1f}{unit}" + f" Tank Lower: {fmt_temp(status.tank_lower_temperature, unit)}" ) print( - f" Discharge: {status.discharge_temperature:.1f}{unit}" + f" Discharge: {fmt_temp(status.discharge_temperature, unit)}" ) print( - f" Ambient: {status.ambient_temperature:.1f}{unit}" + f" Ambient: {fmt_temp(status.ambient_temperature, unit)}" ) print("\nOperation:") diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index 5d2dc64..b0041f4 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -504,7 +504,11 @@ async def handle_set_recirculation_mode_request( f"Recirculation mode set to {mode_name}", ) - if status and status.recirc_operation_mode.value != mode: + if ( + status + and status.recirc_operation_mode is not None + and status.recirc_operation_mode.value != mode + ): _logger.warning( f"Device reported mode {status.recirc_operation_mode.name} " f"instead of expected {mode_name}. External factor or " diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index ee28df0..7705c29 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -17,7 +17,12 @@ def _format_number(value: Any) -> str: - """Format number to one decimal place if float, otherwise return as-is.""" + """Format number to one decimal place if float, otherwise return as-is. + + Handles None by returning "N/A". + """ + if value is None: + return "N/A" if isinstance(value, float): return f"{value:.1f}" return str(value) @@ -85,7 +90,8 @@ def _add_numeric_item( if hasattr(device_status, field_name): value = getattr(device_status, field_name) unit = _get_unit_suffix(field_name, instance=device_status) - formatted = f"{_format_number(value)}{unit}" + # Handle None values (N/A sensors) + formatted = "N/A" if value is None else f"{_format_number(value)}{unit}" items.append((category, label, formatted)) diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index bcc92df..5da2284 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -27,10 +27,13 @@ "device_bool_from_python", "tou_override_to_python", "div_10", + "float_with_zero_as_none", + "int_with_zero_as_none", "mul_10", "enum_validator", "str_enum_validator", "half_celsius_to_preferred", + "half_celsius_to_preferred_setting", "deci_celsius_to_preferred", "raw_celsius_to_preferred", "flow_rate_to_preferred", @@ -65,6 +68,36 @@ def device_bool_to_python(value: Any) -> bool: return bool(value == 2) +def device_bool_with_zero_as_none(value: Any) -> bool | None: + """Convert device boolean representation to Python bool, treating 0 as None. + + Device protocol uses: 0 = N/A, 1 = OFF/False, 2 = ON/True + + This is used for boolean fields that represent features which may not be + available on all device models. The device protocol uses 0 to indicate + "not applicable" or "feature not present". + + Args: + value: Device value (typically 0, 1, or 2). + + Returns: + Python boolean (1→False, 2→True), or None if value is 0. + + Example: + >>> device_bool_with_zero_as_none(2) + True + >>> device_bool_with_zero_as_none(1) + False + >>> device_bool_with_zero_as_none(0) + None + """ + if isinstance(value, (int, float)): + if value == 0: + return None + return bool(value == 2) + return bool(int(value) == 2) + + def device_bool_from_python(value: bool) -> int: """Convert Python bool to device boolean representation. @@ -125,6 +158,60 @@ def div_10(value: Any) -> float: return float(value) +def float_with_zero_as_none(value: Any) -> float | None: + """Convert value to float, treating 0 as None (N/A). + + This is used for numeric fields that represent features which may not be + available on all device models. The device protocol uses 0 to indicate + "not applicable" or "feature not present". + + Args: + value: Input value (typically int or float). + + Returns: + Float value, or None if value is 0. + + Example: + >>> float_with_zero_as_none(50.5) + 50.5 + >>> float_with_zero_as_none(0) + None + >>> float_with_zero_as_none(0.0) + None + """ + if isinstance(value, (int, float)): + if value == 0: + return None + return float(value) + return float(value) + + +def int_with_zero_as_none(value: Any) -> int | None: + """Convert value to int, treating 0 as None (N/A). + + This is used for status/enum fields that represent features which may not be + available on all device models. The device protocol uses 0 to indicate + "not applicable" or "feature not present". + + Args: + value: Input value (typically int). + + Returns: + Integer value, or None if value is 0. + + Example: + >>> int_with_zero_as_none(3) + 3 + >>> int_with_zero_as_none(0) + None + """ + if isinstance(value, (int, float)): + if value == 0: + return None + return int(value) + return int(value) + + def mul_10(value: Any) -> float: """Multiply numeric value by 10.0. @@ -178,6 +265,47 @@ def validate(value: Any) -> Any: return validate +def enum_with_zero_as_none_validator( + enum_class: type[Any], +) -> Callable[[Any], Any]: + """Create a validator for converting int to Enum, treating 0 as None. + + This is used for enum fields that represent features which may not be + available on all device models. The device protocol uses 0 to indicate + "not applicable" or "feature not present". + + Args: + enum_class: The Enum class to validate against. + + Returns: + A validator function compatible with Pydantic BeforeValidator. + + Example: + >>> from enum import Enum + >>> class Mode(Enum): + ... UNKNOWN = 0 + ... MODE_A = 1 + ... MODE_B = 2 + >>> validator = enum_with_zero_as_none_validator(Mode) + >>> validator(1) + + >>> validator(0) + None + """ + + def validate(value: Any) -> Any: + """Validate and convert value to enum, returning None for 0.""" + if value == 0 or value == 0.0: + return None + if isinstance(value, enum_class): + return value + if isinstance(value, int): + return enum_class(value) + return enum_class(int(value)) + + return validate + + def str_enum_validator(enum_class: type[Any]) -> Callable[[Any], Any]: """Create a validator for converting string to str-based Enum. @@ -285,13 +413,22 @@ def _get_temperature_preference(info: ValidationInfo) -> bool: def half_celsius_to_preferred( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: +) -> float | None: """Convert half-degrees Celsius to preferred unit (C or F). Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, which contains sibling fields needed to determine the device's temperature preference (Celsius or Fahrenheit). + Note: This converter treats 0 as None (sensor/setting not available). + This is used for: + - Optional sensors (inlet water temp when not flowing) + - Mode-dependent settings (heating element lower temps in Heat Pump mode) + - Secondary sensors that may not exist (dhw_temperature2) + + For heat pump compressor/refrigerant sensors that can measure 0°C, + use deci_celsius_to_preferred instead (those use 0.1°C precision). + Args: value: Raw device value in half-degrees Celsius format. handler: Pydantic next validator handler. Not invoked as we bypass the @@ -301,7 +438,35 @@ def half_celsius_to_preferred( retrieve the device's temperature_type preference. Returns: - Temperature in preferred unit. + Temperature in preferred unit, or None if value is 0 (indicating N/A). + """ + is_celsius = _get_temperature_preference(info) + if isinstance(value, (int, float)): + # 0 indicates sensor not available / setting not applicable + if value == 0: + return None + return HalfCelsius(value).to_preferred(is_celsius) + try: + return HalfCelsius(float(value)).to_preferred(is_celsius) + except (ValueError, TypeError): + return None + + +def half_celsius_to_preferred_setting( + value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> float: + """Convert half-degrees Celsius to preferred unit for settings/limits. + + Like half_celsius_to_preferred, but never returns None. Used for temperature + settings, limits, and configuration values that should always have a value. + + Args: + value: Raw device value in half-degrees Celsius format. + handler: Pydantic next validator handler. + info: Pydantic validation context containing sibling fields. + + Returns: + Temperature in preferred unit (never None). """ is_celsius = _get_temperature_preference(info) if isinstance(value, (int, float)): @@ -311,13 +476,17 @@ def half_celsius_to_preferred( def deci_celsius_to_preferred( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: +) -> float | None: """Convert decicelsius to preferred unit (C or F). Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, which contains sibling fields needed to determine the device's temperature preference (Celsius or Fahrenheit). + Note: This converter does NOT treat 0 as None, because 0°C (32°F) is a + valid temperature measurement for heat pump sensors. For optional + sensors that use 0 as a sentinel, use raw_celsius_to_preferred instead. + Args: value: Raw device value in decicelsius format (0.1 °C per unit). handler: Pydantic next validator handler. Not invoked as we bypass the @@ -327,12 +496,16 @@ def deci_celsius_to_preferred( retrieve the device's temperature_type preference. Returns: - Temperature in preferred unit. + Temperature in preferred unit. May be 0 for freezing temperatures. + Returns None only if value cannot be parsed. """ is_celsius = _get_temperature_preference(info) if isinstance(value, (int, float)): return DeciCelsius(value).to_preferred(is_celsius) - return float(value) + try: + return DeciCelsius(float(value)).to_preferred(is_celsius) + except (ValueError, TypeError): + return None def flow_rate_to_preferred( @@ -413,7 +586,7 @@ def volume_to_preferred( def raw_celsius_to_preferred( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: +) -> float | None: """Convert raw halves-of-Celsius to preferred unit (C or F). Raw device values are in halves of Celsius (0.5°C precision). @@ -435,11 +608,15 @@ def raw_celsius_to_preferred( retrieve the device's temperature_type preference and formula type. Returns: - Temperature in preferred unit (Celsius or Fahrenheit). + Temperature in preferred unit (Celsius or Fahrenheit), or None if + value is 0 (indicating N/A). """ is_celsius = _get_temperature_preference(info) if isinstance(value, (int, float)): + # 0 indicates sensor not available / not supported + if value == 0: + return None raw_temp = RawCelsius(value) else: try: @@ -467,7 +644,7 @@ def raw_celsius_to_preferred( def div_10_celsius_to_preferred( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: +) -> float | None: """Convert decicelsius value (raw / 10) to preferred unit (C or F). Raw device values are in tenths of Celsius (0.1°C per unit). @@ -487,11 +664,15 @@ def div_10_celsius_to_preferred( retrieve the device's temperature_type preference. Returns: - Temperature in preferred unit (Celsius or Fahrenheit). + Temperature in preferred unit (Celsius or Fahrenheit), or None if + value is 0 (indicating N/A). """ is_celsius = _get_temperature_preference(info) if isinstance(value, (int, float)): + # 0 indicates sensor not available / not supported + if value == 0: + return None celsius = float(value) / 10.0 else: try: diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 4cdd8f5..4e1302b 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -24,12 +24,17 @@ from .converters import ( deci_celsius_to_preferred, device_bool_to_python, + device_bool_with_zero_as_none, div_10, div_10_celsius_delta_to_preferred, div_10_celsius_to_preferred, enum_validator, + enum_with_zero_as_none_validator, + float_with_zero_as_none, flow_rate_to_preferred, half_celsius_to_preferred, + half_celsius_to_preferred_setting, + int_with_zero_as_none, mul_10, raw_celsius_to_preferred, tou_override_to_python, @@ -68,20 +73,31 @@ # Reusable Annotated types for conversions DeviceBool = Annotated[bool, BeforeValidator(device_bool_to_python)] +DeviceBoolOptional = Annotated[ + bool | None, BeforeValidator(device_bool_with_zero_as_none) +] CapabilityFlag = Annotated[bool, BeforeValidator(device_bool_to_python)] Div10 = Annotated[float, BeforeValidator(div_10)] +# Temperature type annotations - Protocol uses 0 as sentinel differently: +# - DeciCelsiusToPreferred: Heat pump sensors, 0 = valid 0°C reading +# - HalfCelsiusToPreferred: Optional/mode-dependent, 0 = N/A +# - RawCelsiusToPreferred: Optional sensor, 0 = not installed TenWhToWh = Annotated[float, BeforeValidator(mul_10)] HalfCelsiusToPreferred = Annotated[ - float, WrapValidator(half_celsius_to_preferred) + float | None, WrapValidator(half_celsius_to_preferred) ] DeciCelsiusToPreferred = Annotated[ - float, WrapValidator(deci_celsius_to_preferred) + float | None, WrapValidator(deci_celsius_to_preferred) ] RawCelsiusToPreferred = Annotated[ - float, WrapValidator(raw_celsius_to_preferred) + float | None, WrapValidator(raw_celsius_to_preferred) ] Div10CelsiusToPreferred = Annotated[ - float, WrapValidator(div_10_celsius_to_preferred) + float | None, WrapValidator(div_10_celsius_to_preferred) +] +# Temperature types for settings/limits (never N/A) +HalfCelsiusToPreferredSetting = Annotated[ + float, WrapValidator(half_celsius_to_preferred_setting) ] Div10CelsiusDeltaToPreferred = Annotated[ float, WrapValidator(div_10_celsius_delta_to_preferred) @@ -414,10 +430,13 @@ class DeviceStatus(NavienBaseModel): json_schema_extra={"unit_of_measurement": "RPM"}, ) fan_pwm: int = Field(description="Fan PWM value") - mixing_rate: float = Field( + mixing_rate: Annotated[ + float | None, BeforeValidator(float_with_zero_as_none) + ] = Field( description=( "Mixing valve rate percentage (0-100%). " - "Controls mixing of hot tank water with cold inlet water" + "Controls mixing of hot tank water with cold inlet water. " + "None if device does not have mixing valve feature" ), json_schema_extra={"unit_of_measurement": "%"}, ) @@ -496,19 +515,22 @@ class DeviceStatus(NavienBaseModel): "device_class": "energy", }, ) - recirc_operation_mode: RecirculationMode = Field( - description="Recirculation operation mode" - ) - recirc_pump_operation_status: int = Field( - description="Recirculation pump operation status" - ) - recirc_hot_btn_ready: int = Field( - description="Recirculation HotButton ready status" - ) - recirc_operation_reason: int = Field( - description="Recirculation operation reason" - ) - recirc_error_status: int = Field(description="Recirculation error status") + recirc_operation_mode: Annotated[ + RecirculationMode | None, + BeforeValidator(enum_with_zero_as_none_validator(RecirculationMode)), + ] = Field(description="Recirculation operation mode") + recirc_pump_operation_status: Annotated[ + int | None, BeforeValidator(int_with_zero_as_none) + ] = Field(description="Recirculation pump operation status") + recirc_hot_btn_ready: Annotated[ + int | None, BeforeValidator(int_with_zero_as_none) + ] = Field(description="Recirculation HotButton ready status") + recirc_operation_reason: Annotated[ + int | None, BeforeValidator(int_with_zero_as_none) + ] = Field(description="Recirculation operation reason") + recirc_error_status: Annotated[ + int | None, BeforeValidator(int_with_zero_as_none) + ] = Field(description="Recirculation error status") current_inst_power: float = Field( description=( "Current instantaneous power consumption in Watts. " @@ -642,10 +664,10 @@ class DeviceStatus(NavienBaseModel): "Triggers alerts based on operating hours. Default: On" ) ) - recirc_operation_busy: DeviceBool = Field( + recirc_operation_busy: DeviceBoolOptional = Field( description="Recirculation operation busy status" ) - recirc_reservation_use: DeviceBool = Field( + recirc_reservation_use: DeviceBoolOptional = Field( description="Recirculation reservation usage status" ) @@ -653,36 +675,40 @@ class DeviceStatus(NavienBaseModel): dhw_temperature: HalfCelsiusToPreferred = temperature_field( "Current Domestic Hot Water (DHW) outlet temperature" ) - dhw_temperature_setting: HalfCelsiusToPreferred = temperature_field( + dhw_temperature_setting: HalfCelsiusToPreferredSetting = temperature_field( "User-configured target DHW temperature" ) - dhw_target_temperature_setting: HalfCelsiusToPreferred = temperature_field( - "Duplicate of dhw_temperature_setting for legacy API compatibility" + dhw_target_temperature_setting: HalfCelsiusToPreferredSetting = ( + temperature_field( + "Duplicate of dhw_temperature_setting for legacy API compatibility" + ) ) - freeze_protection_temperature: HalfCelsiusToPreferred = temperature_field( - "Freeze protection temperature setpoint. " - "Prevents tank from freezing in cold environments" + freeze_protection_temperature: HalfCelsiusToPreferredSetting = ( + temperature_field( + "Freeze protection temperature setpoint. " + "Prevents tank from freezing in cold environments" + ) ) dhw_temperature2: HalfCelsiusToPreferred = temperature_field( "Second DHW temperature reading" ) - hp_upper_on_temp_setting: HalfCelsiusToPreferred = temperature_field( + hp_upper_on_temp_setting: HalfCelsiusToPreferredSetting = temperature_field( "Heat pump upper on temperature setting" ) - hp_upper_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump upper off temperature setting" + hp_upper_off_temp_setting: HalfCelsiusToPreferredSetting = ( + temperature_field("Heat pump upper off temperature setting") ) - hp_lower_on_temp_setting: HalfCelsiusToPreferred = temperature_field( + hp_lower_on_temp_setting: HalfCelsiusToPreferredSetting = temperature_field( "Heat pump lower on temperature setting" ) - hp_lower_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump lower off temperature setting" + hp_lower_off_temp_setting: HalfCelsiusToPreferredSetting = ( + temperature_field("Heat pump lower off temperature setting") ) - he_upper_on_temp_setting: HalfCelsiusToPreferred = temperature_field( + he_upper_on_temp_setting: HalfCelsiusToPreferredSetting = temperature_field( "Heater element upper on temperature setting" ) - he_upper_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heater element upper off temperature setting" + he_upper_off_temp_setting: HalfCelsiusToPreferredSetting = ( + temperature_field("Heater element upper off temperature setting") ) he_lower_on_temp_setting: HalfCelsiusToPreferred = temperature_field( "Heater element lower on temperature setting" @@ -690,11 +716,11 @@ class DeviceStatus(NavienBaseModel): he_lower_off_temp_setting: HalfCelsiusToPreferred = temperature_field( "Heater element lower off temperature setting" ) - heat_min_op_temperature: HalfCelsiusToPreferred = temperature_field( + heat_min_op_temperature: HalfCelsiusToPreferredSetting = temperature_field( "Minimum heat pump operation temperature. " "Lowest tank setpoint allowed for heat pump operation" ) - recirc_temp_setting: HalfCelsiusToPreferred = temperature_field( + recirc_temp_setting: HalfCelsiusToPreferredSetting = temperature_field( "Recirculation temperature setting" ) recirc_temperature: HalfCelsiusToPreferred = temperature_field( @@ -819,12 +845,14 @@ class DeviceStatus(NavienBaseModel): default=DhwOperationSetting.ENERGY_SAVER, description="User's configured DHW operation mode preference", ) - freeze_protection_temp_min: HalfCelsiusToPreferred = temperature_field( - "Active freeze protection lower limit", - default=43.0, + freeze_protection_temp_min: HalfCelsiusToPreferredSetting = ( + temperature_field( + "Active freeze protection lower limit", + default=43.0, + ) ) - freeze_protection_temp_max: HalfCelsiusToPreferred = temperature_field( - "Active freeze protection upper limit", default=65.0 + freeze_protection_temp_max: HalfCelsiusToPreferredSetting = ( + temperature_field("Active freeze protection upper limit", default=65.0) ) def get_field_unit(self, field_name: str) -> str: @@ -1141,24 +1169,28 @@ class DeviceFeature(NavienBaseModel): ) # Temperature limit fields with half-degree Celsius scaling - dhw_temperature_min: HalfCelsiusToPreferred = temperature_field( + dhw_temperature_min: HalfCelsiusToPreferredSetting = temperature_field( "Minimum DHW temperature setting - safety and efficiency lower limit" ) - dhw_temperature_max: HalfCelsiusToPreferred = temperature_field( + dhw_temperature_max: HalfCelsiusToPreferredSetting = temperature_field( "Maximum DHW temperature setting - scald protection upper limit" ) - freeze_protection_temp_min: HalfCelsiusToPreferred = temperature_field( - "Minimum freeze protection threshold - " - "factory default activation temperature" + freeze_protection_temp_min: HalfCelsiusToPreferredSetting = ( + temperature_field( + "Minimum freeze protection threshold - " + "factory default activation temperature" + ) ) - freeze_protection_temp_max: HalfCelsiusToPreferred = temperature_field( - "Maximum freeze protection threshold - user-adjustable upper limit" + freeze_protection_temp_max: HalfCelsiusToPreferredSetting = ( + temperature_field( + "Maximum freeze protection threshold - user-adjustable upper limit" + ) ) - recirc_temperature_min: HalfCelsiusToPreferred = temperature_field( + recirc_temperature_min: HalfCelsiusToPreferredSetting = temperature_field( "Minimum recirculation temperature setting - " "lower limit for recirculation loop temperature control" ) - recirc_temperature_max: HalfCelsiusToPreferred = temperature_field( + recirc_temperature_max: HalfCelsiusToPreferredSetting = temperature_field( "Maximum recirculation temperature setting - " "upper limit for recirculation loop temperature control" ) diff --git a/tests/test_models.py b/tests/test_models.py index 7088a98..5848bd5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -141,3 +141,123 @@ def test_fahrenheit_to_half_celsius(): 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 + + +def test_temperature_zero_values(default_status_data): + """Test handling of zero temperature values. + + The protocol uses 0 as a sentinel ("N/A") for different field types: + - HalfCelsiusToPreferred: Optional sensors & mode-dependent settings -> None + - DeciCelsiusToPreferred: Heat pump sensors (can measure 0°C) -> 0°C/32°F + - RawCelsiusToPreferred: Optional external sensor -> None + """ + # Test HalfCelsiusToPreferred with 0 - should be None (optional sensors) + default_status_data["dhwTemperature"] = 0 + default_status_data["currentInletTemperature"] = 0 + status = DeviceStatus.model_validate(default_status_data) + assert status.dhw_temperature is None # Optional hot water temp sensor + assert status.current_inlet_temperature is None # No flow = no reading + + # Test DeciCelsiusToPreferred with 0 - should be 0°C/32°F (can be freezing) + default_status_data["tankUpperTemperature"] = 0 + default_status_data["ambientTemperature"] = 0 + status = DeviceStatus.model_validate(default_status_data) + # 0 decicelsius = 0°C = 32°F (heat pump can operate in freezing temps) + assert status.tank_upper_temperature == pytest.approx(32.0, abs=1.0) + assert status.ambient_temperature == pytest.approx(32.0, abs=1.0) + + # Test RawCelsiusToPreferred with 0 - should be None (optional sensor) + default_status_data["outsideTemperature"] = 0 + status = DeviceStatus.model_validate(default_status_data) + assert status.outside_temperature is None + + +def test_temperature_non_zero_values_are_converted(default_status_data): + """Test that non-zero temperature values are properly converted.""" + # Test HalfCelsiusToPreferred with non-zero value + default_status_data["dhwTemperature"] = 122 + status = DeviceStatus.model_validate(default_status_data) + assert status.dhw_temperature == pytest.approx(141.8) + + # Test DeciCelsiusToPreferred with non-zero value + default_status_data["tankUpperTemperature"] = 489 + status = DeviceStatus.model_validate(default_status_data) + assert status.tank_upper_temperature == pytest.approx(120.0, abs=0.1) + + # Test RawCelsiusToPreferred with non-zero value + default_status_data["outsideTemperature"] = 50 # 25°C = 77°F + status = DeviceStatus.model_validate(default_status_data) + assert status.outside_temperature == pytest.approx(77.0, abs=1.0) + + +def test_mixing_rate_zero_is_none(default_status_data): + """Test that mixing_rate of 0 is treated as None (feature not available).""" + default_status_data["mixingRate"] = 0 + status = DeviceStatus.model_validate(default_status_data) + assert status.mixing_rate is None + + +def test_mixing_rate_non_zero_is_preserved(default_status_data): + """Test that non-zero mixing_rate values are preserved.""" + default_status_data["mixingRate"] = 50.5 + status = DeviceStatus.model_validate(default_status_data) + assert status.mixing_rate == 50.5 + + +def test_he_lower_temp_settings_zero_is_none(default_status_data): + """Test heating element lower temp settings with 0 are None.""" + default_status_data["heLowerOnTempSetting"] = 0 + default_status_data["heLowerOffTempSetting"] = 0 + status = DeviceStatus.model_validate(default_status_data) + assert status.he_lower_on_temp_setting is None + assert status.he_lower_off_temp_setting is None + + +def test_he_lower_temp_settings_non_zero_are_converted(default_status_data): + """Test non-zero heating element lower temps are converted.""" + # 122 half-celsius = 61°C = 141.8°F + default_status_data["heLowerOnTempSetting"] = 122 + default_status_data["heLowerOffTempSetting"] = 100 + status = DeviceStatus.model_validate(default_status_data) + assert status.he_lower_on_temp_setting == pytest.approx(141.8) + assert status.he_lower_off_temp_setting == pytest.approx(122.0) + + +def test_recirc_status_fields_zero_is_none(default_status_data): + """Test recirculation status fields with 0 are None.""" + default_status_data["recircOperationMode"] = 0 + default_status_data["recircPumpOperationStatus"] = 0 + default_status_data["recircHotBtnReady"] = 0 + default_status_data["recircOperationReason"] = 0 + default_status_data["recircErrorStatus"] = 0 + default_status_data["recircOperationBusy"] = 0 + default_status_data["recircReservationUse"] = 0 + status = DeviceStatus.model_validate(default_status_data) + assert status.recirc_operation_mode is None + assert status.recirc_pump_operation_status is None + assert status.recirc_hot_btn_ready is None + assert status.recirc_operation_reason is None + assert status.recirc_error_status is None + assert status.recirc_operation_busy is None + assert status.recirc_reservation_use is None + + +def test_recirc_status_fields_non_zero_are_preserved(default_status_data): + """Test that non-zero recirculation status fields are properly preserved.""" + from nwp500.enums import RecirculationMode + + default_status_data["recircOperationMode"] = 2 # BUTTON mode + default_status_data["recircPumpOperationStatus"] = 1 + default_status_data["recircHotBtnReady"] = 5 + default_status_data["recircOperationReason"] = 3 + default_status_data["recircErrorStatus"] = 0 # 0 will become None + default_status_data["recircOperationBusy"] = 2 # ON (True) + default_status_data["recircReservationUse"] = 1 # OFF (False) + status = DeviceStatus.model_validate(default_status_data) + assert status.recirc_operation_mode == RecirculationMode.BUTTON + assert status.recirc_pump_operation_status == 1 + assert status.recirc_hot_btn_ready == 5 + assert status.recirc_operation_reason == 3 + assert status.recirc_error_status is None + assert status.recirc_operation_busy is True + assert status.recirc_reservation_use is False diff --git a/tests/test_mqtt_hypothesis.py b/tests/test_mqtt_hypothesis.py index f660781..6322bd4 100644 --- a/tests/test_mqtt_hypothesis.py +++ b/tests/test_mqtt_hypothesis.py @@ -148,7 +148,9 @@ def test_device_status_fuzzing( # 2. Check DHW Temperature (HalfCelsius) # Raw value is half-celsius. celsius_val = dhw_temp_raw / 2.0 - if is_celsius: + if dhw_temp_raw == 0: + assert status.dhw_temperature is None + elif is_celsius: assert status.dhw_temperature == pytest.approx(celsius_val) else: fahrenheit_val = (celsius_val * 9 / 5) + 32 @@ -157,7 +159,9 @@ def test_device_status_fuzzing( # 3. Check Tank Temperature (DeciCelsius) # Raw value is deci-celsius. tank_celsius_val = tank_temp_raw / 10.0 - if is_celsius: + if tank_temp_raw == 0: + assert status.tank_upper_temperature is None + elif is_celsius: assert status.tank_upper_temperature == pytest.approx(tank_celsius_val) else: tank_fahrenheit_val = (tank_celsius_val * 9 / 5) + 32