From 4361ace5d36724475287cc4b28cd4a8553d38949 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 5 May 2026 23:52:50 -0700 Subject: [PATCH 01/29] Phase 1: Foundation cleanup - Add MQTT_PROTOCOL_VERSION constant to config.py; use in control.py - Fix set_vacation_days to delegate to set_dhw_mode - Consolidate duplicate NavienBaseModel into shared _base.py - Fix per-device state tracking: _previous_status is now dict[str, DeviceStatus] keyed by MAC - Remove set_unit_system() side-effects from constructors; store as instance variable - Update auth.py, api_client.py, mqtt/client.py to use UnitSystemType --- src/nwp500/_base.py | 78 ++++++++++++++++++++++++++++++++ src/nwp500/api_client.py | 11 ++--- src/nwp500/auth.py | 23 ++-------- src/nwp500/config.py | 1 + src/nwp500/models.py | 69 +--------------------------- src/nwp500/mqtt/client.py | 6 +-- src/nwp500/mqtt/control.py | 11 ++--- src/nwp500/mqtt/subscriptions.py | 30 +++++++----- 8 files changed, 112 insertions(+), 117 deletions(-) create mode 100644 src/nwp500/_base.py diff --git a/src/nwp500/_base.py b/src/nwp500/_base.py new file mode 100644 index 0000000..12d8878 --- /dev/null +++ b/src/nwp500/_base.py @@ -0,0 +1,78 @@ +"""Shared Pydantic base model for all Navien data models. + +Centralises the common configuration (camelCase aliases, extra="ignore", +enum serialization) so that both the authentication models and the device +protocol models share a single base class. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class NavienBaseModel(BaseModel): + """Base model for all Navien models. + + Provides: + - camelCase alias generation (``to_camel``) for JSON compatibility + - ``populate_by_name=True`` so Python snake_case names work too + - ``extra="ignore"`` to tolerate unknown protocol fields + - ``use_enum_values=False`` to keep enum objects during validation + - Custom ``model_dump`` that converts enums to their ``.name`` string + """ + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + def model_dump(self, **kwargs: Any) -> dict[str, Any]: + """Dump model to dict with enums serialised as their name strings.""" + if "mode" not in kwargs: + kwargs["mode"] = "python" + result = super().model_dump(**kwargs) + converted: dict[str, Any] = self._convert_enums_to_names(result) + return converted + + @staticmethod + def _convert_enums_to_names( + data: Any, visited: set[int] | None = None + ) -> Any: + """Recursively convert Enum values to their ``.name`` strings. + + Args: + data: The data structure to convert. + visited: Set of object IDs already visited (cycle guard). + """ + from enum import Enum + + if isinstance(data, Enum): + return data.name + if not isinstance(data, (dict, list, tuple)): + return data + + visited = visited or set() + if id(data) in visited: + return data + visited.add(id(data)) + + if isinstance(data, dict): + res: dict[Any, Any] | list[Any] | tuple[Any, ...] = { + k: NavienBaseModel._convert_enums_to_names(v, visited) + for k, v in data.items() + } + else: + res = type(data)( + [ + NavienBaseModel._convert_enums_to_names(i, visited) + for i in data + ] + ) + + visited.discard(id(data)) + return res diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index b88df7e..c9645c5 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging -from typing import Any, Literal, Self, cast +from typing import Any, Self, cast import aiohttp @@ -15,7 +15,7 @@ from .config import API_BASE_URL from .exceptions import APIError, AuthenticationError, TokenRefreshError from .models import ConvertedTOUPlan, Device, FirmwareInfo, TOUInfo -from .unit_system import set_unit_system +from .unit_system import UnitSystemType __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" @@ -50,7 +50,7 @@ def __init__( auth_client: NavienAuthClient, base_url: str = API_BASE_URL, session: aiohttp.ClientSession | None = None, - unit_system: Literal["metric", "us_customary"] | None = None, + unit_system: UnitSystemType = None, ): """ Initialize Navien API client. @@ -78,6 +78,7 @@ def __init__( self.base_url = base_url.rstrip("/") self._auth_client = auth_client + self._unit_system = unit_system self._session = session or auth_client.session if self._session is None: @@ -88,10 +89,6 @@ def __init__( self._owned_session = False self._owned_auth = False - # Set unit system preference if provided - if unit_system is not None: - set_unit_system(unit_system) - async def __aenter__(self) -> Self: """Enter async context manager.""" return self diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index 3a0bff8..ab81214 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -16,27 +16,25 @@ import json import logging from datetime import UTC, datetime, timedelta -from typing import Any, Literal, Self, cast +from typing import Any, Self, cast import aiohttp from pydantic import ( - BaseModel, - ConfigDict, Field, PrivateAttr, field_validator, model_validator, ) -from pydantic.alias_generators import to_camel from . import __version__ +from ._base import NavienBaseModel from .config import API_BASE_URL, REFRESH_ENDPOINT, SIGN_IN_ENDPOINT from .exceptions import ( AuthenticationError, InvalidCredentialsError, TokenRefreshError, ) -from .unit_system import set_unit_system +from .unit_system import UnitSystemType __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" @@ -45,14 +43,6 @@ _logger = logging.getLogger(__name__) -class NavienBaseModel(BaseModel): - """Base model for Navien authentication models.""" - - model_config = ConfigDict( - alias_generator=to_camel, populate_by_name=True, extra="ignore" - ) - - class UserInfo(NavienBaseModel): """User information returned from authentication.""" @@ -318,7 +308,7 @@ def __init__( session: aiohttp.ClientSession | None = None, timeout: int = 30, stored_tokens: AuthTokens | None = None, - unit_system: Literal["metric", "us_customary"] | None = None, + unit_system: UnitSystemType = None, ): """ Initialize the authentication client. @@ -350,10 +340,7 @@ def __init__( # Store credentials for automatic authentication self._user_id = user_id self._password = password - - # Set unit system preference if provided - if unit_system is not None: - set_unit_system(unit_system) + self._unit_system: UnitSystemType = unit_system # Current authentication state self._auth_response: AuthenticationResponse | None = None diff --git a/src/nwp500/config.py b/src/nwp500/config.py index d9c092f..97d825d 100644 --- a/src/nwp500/config.py +++ b/src/nwp500/config.py @@ -5,3 +5,4 @@ REFRESH_ENDPOINT = "/auth/refresh" AWS_IOT_ENDPOINT = "a1t30mldyslmuq-ats.iot.us-east-1.amazonaws.com" AWS_REGION = "us-east-1" +MQTT_PROTOCOL_VERSION = 2 diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 0dcb9e0..9374fc4 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -12,7 +12,6 @@ from typing import Annotated, Any, Self, cast from pydantic import ( - BaseModel, BeforeValidator, ConfigDict, Field, @@ -20,8 +19,8 @@ computed_field, model_validator, ) -from pydantic.alias_generators import to_camel +from ._base import NavienBaseModel from .converters import ( deci_celsius_to_preferred, device_bool_to_python, @@ -173,72 +172,6 @@ def reservation_param_to_preferred(param: int) -> float: return round(half_celsius.to_fahrenheit(), 1) -class NavienBaseModel(BaseModel): - """Base model for all Navien models. - - Note: use_enum_values=False keeps enums as objects during validation. - Serialization to names happens in model_dump() method. - """ - - model_config = ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - extra="ignore", # Ignore unknown fields by default - use_enum_values=False, # Keep enums as objects during validation - ) - - def model_dump(self, **kwargs: Any) -> dict[str, Any]: - """Dump model to dict with enums as names by default.""" - # Default to 'name' mode for enums unless explicitly overridden - if "mode" not in kwargs: - kwargs["mode"] = "python" - result = super().model_dump(**kwargs) - # Convert enums to their names - converted: dict[str, Any] = self._convert_enums_to_names(result) - return converted - - @staticmethod - def _convert_enums_to_names( - data: Any, visited: set[int] | None = None - ) -> Any: - """Recursively convert Enum values to their names. - - Args: - data: The data structure to convert. - visited: Set of object IDs already visited to prevent infinite - recursion. None indicates uninitialized/first call. - """ - from enum import Enum - - if isinstance(data, Enum): - return data.name - if not isinstance(data, (dict, list, tuple)): - return data - - visited = visited or set() - if id(data) in visited: - return data - visited.add(id(data)) - - if isinstance(data, dict): - res: dict[Any, Any] | list[Any] | tuple[Any, ...] = { - k: NavienBaseModel._convert_enums_to_names(v, visited) - for k, v in data.items() - } - else: - # We know data is list or tuple here because of the earlier check - # `if not isinstance(data, (dict, list, tuple)): return data` - res = type(data)( - [ - NavienBaseModel._convert_enums_to_names(i, visited) - for i in data - ] - ) - - visited.discard(id(data)) - return res - - class DeviceInfo(NavienBaseModel): """Device information from API.""" diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 256885e..94ab1b4 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -31,7 +31,7 @@ MqttPublishError, TokenRefreshError, ) -from ..unit_system import UnitSystemType, set_unit_system +from ..unit_system import UnitSystemType from .command_queue import MqttCommandQueue from .connection import MqttConnection from .control import MqttDeviceController @@ -180,10 +180,6 @@ def __init__( # Initialize EventEmitter super().__init__() - # Set unit system preference if provided - if unit_system is not None: - set_unit_system(unit_system) - self._auth_client = auth_client self._unit_system: UnitSystemType = unit_system self.config = config or MqttConnectionConfig() diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 1167c29..a33d208 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -23,6 +23,7 @@ from typing import Any from ..command_decorators import requires_capability +from ..config import MQTT_PROTOCOL_VERSION from ..device_capabilities import MqttDeviceCapabilityChecker from ..device_info_cache import MqttDeviceInfoCache from ..enums import CommandCode, DhwOperationSetting @@ -223,7 +224,7 @@ def _build_command( return { "clientID": self._client_id, "sessionID": self._session_id, - "protocolVersion": 2, + "protocolVersion": MQTT_PROTOCOL_VERSION, "request": request, "requestTopic": f"cmd/{device_type}/{device_topic}", "responseTopic": ( @@ -649,12 +650,8 @@ async def reset_air_filter(self, device: Device) -> int: @requires_capability("holiday_use") async def set_vacation_days(self, device: Device, days: int) -> int: """Set vacation/away mode duration (1-30 days).""" - self._validate_range("days", days, 1, 30) - return await self._mode_command( - device, - CommandCode.DHW_MODE, - "dhw-mode", - [DhwOperationSetting.VACATION.value, days], + return await self.set_dhw_mode( + device, DhwOperationSetting.VACATION.value, vacation_days=days ) @requires_capability("program_reservation_use") diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index 577bc26..fc08edd 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -83,8 +83,8 @@ def __init__( str, list[Callable[[str, dict[str, Any]], None]] ] = {} - # Track previous state for change detection - self._previous_status: DeviceStatus | None = None + # Track previous state for change detection, keyed by device MAC + self._previous_status: dict[str, DeviceStatus] = {} @property def subscriptions(self) -> dict[str, mqtt.QoS]: @@ -370,12 +370,15 @@ async def subscribe_device_status( self, device: Device, callback: Callable[[DeviceStatus], None] ) -> int: """Subscribe to device status messages with automatic parsing.""" + device_mac = device.device_info.mac_address def post_parse(status: DeviceStatus) -> None: self._schedule_coroutine( self._event_emitter.emit("status_received", status) ) - self._schedule_coroutine(self._detect_state_changes(status)) + self._schedule_coroutine( + self._detect_state_changes(device_mac, status) + ) handler = self._make_handler( DeviceStatus, callback, "status", post_parse @@ -425,22 +428,25 @@ def handler(topic: str, message: dict[str, Any]) -> None: cast(Any, handler)._original_callback = callback return handler - async def _detect_state_changes(self, status: DeviceStatus) -> None: + async def _detect_state_changes( + self, device_mac: str, status: DeviceStatus + ) -> None: """ Detect state changes and emit granular events. - This method compares the current status with the previous status + Compares the current status with the previous status for this device and emits events for any detected changes. Args: + device_mac: MAC address of the device (for per-device tracking) status: Current device status """ - if self._previous_status is None: - # First status received, just store it - self._previous_status = status + if device_mac not in self._previous_status: + # First status received for this device, just store it + self._previous_status[device_mac] = status return - prev = self._previous_status + prev = self._previous_status[device_mac] try: # Temperature change @@ -506,8 +512,8 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: except (TypeError, AttributeError, RuntimeError) as e: _logger.error(f"Error detecting state changes: {e}", exc_info=True) finally: - # Always update previous status - self._previous_status = status + # Always update previous status for this device + self._previous_status[device_mac] = status async def subscribe_device_feature( self, device: Device, callback: Callable[[DeviceFeature], None] @@ -573,4 +579,4 @@ def clear_subscriptions(self) -> None: """Clear all subscription tracking (called on disconnect).""" self._subscriptions.clear() self._message_handlers.clear() - self._previous_status = None + self._previous_status.clear() From 3f5fe3e3376bda6917f33174ebefef673baf8faf Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 00:02:02 -0700 Subject: [PATCH 02/29] Phase 2: Unit conversion redesign - Store raw device values as *_raw int fields with original JSON aliases - Add computed properties for all temperature, flow rate, and volume fields - Conversion happens lazily at access time via get_unit_system() / temperature_type - Remove WrapValidator functions from converters.py - Remove set_unit_system workaround from mqtt/subscriptions.py _make_handler - Update get_field_unit() to look up metadata from _raw fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/nwp500/converters.py | 352 ----------------- src/nwp500/models.py | 625 ++++++++++++++++++++++++------- src/nwp500/mqtt/client.py | 1 - src/nwp500/mqtt/subscriptions.py | 12 +- 4 files changed, 489 insertions(+), 501 deletions(-) diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index e0e2c0d..d1778b2 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -9,19 +9,9 @@ from __future__ import annotations -import contextlib -import logging from collections.abc import Callable from typing import Any -from pydantic import ValidationInfo, ValidatorFunctionWrapHandler - -from .enums import TemperatureType, TempFormulaType -from .temperature import DeciCelsius, DeciCelsiusDelta, HalfCelsius, RawCelsius -from .unit_system import get_unit_system - -_logger = logging.getLogger(__name__) - __all__ = [ "device_bool_to_python", "device_bool_from_python", @@ -30,13 +20,6 @@ "mul_10", "enum_validator", "str_enum_validator", - "half_celsius_to_preferred", - "deci_celsius_to_preferred", - "raw_celsius_to_preferred", - "flow_rate_to_preferred", - "volume_to_preferred", - "div_10_celsius_to_preferred", - "div_10_celsius_delta_to_preferred", ] @@ -206,338 +189,3 @@ def validate(value: Any) -> Any: return enum_class(str(value)) return validate - - -def _get_temperature_preference(info: ValidationInfo) -> bool: - """Determine if Celsius is preferred based on unit system context. - - Checks for an explicit unit system override from context first, then falls - back to 'temperature_type' or 'temperatureType' in the validation data. - - Args: - info: Pydantic ValidationInfo context. - - Returns: - True if Celsius is preferred, False otherwise (defaults to Fahrenheit). - """ - # Check if unit system override is set in context - unit_system = get_unit_system() - if unit_system is not None: - is_celsius = unit_system == "metric" - unit_str = "Celsius" if is_celsius else "Fahrenheit" - _logger.debug( - f"Using explicit unit system override from context: {unit_system}, " - f"using {unit_str}" - ) - return is_celsius - - # Fall back to device's temperature_type setting - if not info.data: - _logger.debug("No validation data available, defaulting to Fahrenheit") - return False - - temp_type = info.data.get("temperature_type") - - if temp_type is None: - # Try looking for the alias if model is not populating by name - temp_type = info.data.get("temperatureType") - - if temp_type is None: - _logger.debug( - "temperature_type not found in validation data, " - "defaulting to Fahrenheit" - ) - return False - - # Handle both raw int values and Enum instances - match temp_type: - case TemperatureType.CELSIUS: - _logger.debug( - f"Detected temperature_type from Enum: {temp_type.name}, " - "using Celsius" - ) - return True - case TemperatureType.FAHRENHEIT: - _logger.debug( - f"Detected temperature_type from Enum: {temp_type.name}, " - "using Fahrenheit" - ) - return False - case int(): - try: - is_celsius = int(temp_type) == TemperatureType.CELSIUS.value - unit_str = "Celsius" if is_celsius else "Fahrenheit" - _logger.debug( - f"Detected temperature_type from int: {temp_type}, " - f"using {unit_str}" - ) - return is_celsius - except (ValueError, TypeError) as e: - msg = f"Could not parse temperature_type: {e}" - _logger.warning(f"{msg}, defaulting to Fahrenheit") - return False - case _: - _logger.warning( - "Could not parse temperature_type, defaulting to Fahrenheit" - ) - return False - - -def half_celsius_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """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). - - Args: - value: Raw device value in half-degrees Celsius format. - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Temperature in preferred unit. - """ - is_celsius = _get_temperature_preference(info) - if isinstance(value, (int, float)): - return HalfCelsius(value).to_preferred(is_celsius) - return float(value) - - -def deci_celsius_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """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). - - Args: - value: Raw device value in decicelsius format (0.1 °C per unit). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Temperature in preferred unit. - """ - is_celsius = _get_temperature_preference(info) - if isinstance(value, (int, float)): - return DeciCelsius(value).to_preferred(is_celsius) - return float(value) - - -def flow_rate_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert flow rate (LPM * 10) to preferred unit (LPM or GPM). - - Raw value from device is LPM * 10 (Metric native). - - If Metric (Celsius) mode: Return LPM (value / 10.0) - - If Imperial (Fahrenheit) mode: Convert to GPM (1 LPM ≈ 0.264172 GPM) - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit), which determines the flow rate unit. - - Args: - value: Raw device value (LPM * 10). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Flow rate in preferred unit (LPM or GPM). - """ - is_celsius = _get_temperature_preference(info) - lpm = div_10(value) - - if is_celsius: - return lpm - - # Convert LPM to GPM - return round(lpm * 0.264172, 2) - - -def volume_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert volume (Liters) to preferred unit (Liters or Gallons). - - Raw value from device is assumed to be in Liters (Metric native). - - If Metric (Celsius) mode: Return Liters - - If Imperial (Fahrenheit) mode: Convert to Gallons (1 L ≈ 0.264172 Gal) - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit), which determines the volume unit. - - Args: - value: Raw device value in Liters. - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Volume in preferred unit. - """ - is_celsius = _get_temperature_preference(info) - - # Handle incoming value - if isinstance(value, (int, float)): - liters = float(value) - else: - try: - liters = float(value) - except (ValueError, TypeError): - return 0.0 - - if is_celsius: - return liters - - # Convert Liters to Gallons - return round(liters * 0.264172, 2) - - -def raw_celsius_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert raw halves-of-Celsius to preferred unit (C or F). - - Raw device values are in halves of Celsius (0.5°C precision). - Used for outdoor/ambient temperature measurements. - - If Metric (Celsius) mode: Return Celsius (value / 2.0) - - If Imperial (Fahrenheit) mode: Convert to Fahrenheit using - formula-specific rounding based on temp_formula_type. - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit) and the temperature formula type. - - Args: - value: Raw device value (halves of Celsius). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference and formula type. - - Returns: - Temperature in preferred unit (Celsius or Fahrenheit). - """ - is_celsius = _get_temperature_preference(info) - - if isinstance(value, (int, float)): - raw_temp = RawCelsius(value) - else: - try: - raw_temp = RawCelsius(float(value)) - except (ValueError, TypeError): - return 0.0 - - if is_celsius: - return raw_temp.to_celsius() - - # For Fahrenheit, check if temp_formula_type is available - formula_type = TempFormulaType.STANDARD # Default to standard rounding - if info.data: - temp_formula = info.data.get("temp_formula_type") - if temp_formula is not None: - with contextlib.suppress(ValueError, TypeError): - # Convert to TempFormulaType enum - if isinstance(temp_formula, TempFormulaType): - formula_type = temp_formula - else: - formula_type = TempFormulaType(int(temp_formula)) - - return raw_temp.to_fahrenheit_with_formula(formula_type) - - -def div_10_celsius_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert decicelsius value (raw / 10) to preferred unit (C or F). - - Raw device values are in tenths of Celsius (0.1°C per unit). - - If Metric (Celsius) mode: Return Celsius (value / 10.0) - - If Imperial (Fahrenheit) mode: Convert to Fahrenheit - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit). - - Args: - value: Raw device value (tenths of Celsius). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Temperature in preferred unit (Celsius or Fahrenheit). - """ - is_celsius = _get_temperature_preference(info) - - if isinstance(value, (int, float)): - celsius = float(value) / 10.0 - else: - try: - celsius = float(value) / 10.0 - except (ValueError, TypeError): - return 0.0 - - if is_celsius: - return celsius - - # Convert Celsius to Fahrenheit - return round(celsius * 9 / 5 + 32, 1) - - -def div_10_celsius_delta_to_preferred( - value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo -) -> float: - """Convert decicelsius delta value (raw / 10) to preferred unit (C or F). - - Raw device values are in tenths of Celsius (0.1°C per unit). - This represents a temperature DELTA (difference), not an absolute - temperature. - - Key difference from div_10_celsius_to_preferred: For deltas, we apply the - scale factor but NOT the +32 offset. - - - If Metric (Celsius) mode: Return Celsius delta (value / 10.0) - - If Imperial (Fahrenheit) mode: Convert to Fahrenheit delta (no +32) - - Uses WrapValidator instead of BeforeValidator to access ValidationInfo.data, - which contains sibling fields needed to determine the device's temperature - preference (Celsius or Fahrenheit). - - Args: - value: Raw device value (tenths of Celsius delta). - handler: Pydantic next validator handler. Not invoked as we bypass the - validation chain to directly convert using the device's temperature - preference. WrapValidator is required for access to ValidationInfo. - info: Pydantic validation context containing sibling fields, used to - retrieve the device's temperature_type preference. - - Returns: - Temperature delta in preferred unit (Celsius or Fahrenheit). - """ - is_celsius = _get_temperature_preference(info) - - if isinstance(value, (int, float)): - return DeciCelsiusDelta(value).to_preferred(is_celsius) - return float(value) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 9374fc4..ec443b9 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -15,25 +15,17 @@ BeforeValidator, ConfigDict, Field, - WrapValidator, computed_field, model_validator, ) from ._base import NavienBaseModel from .converters import ( - deci_celsius_to_preferred, device_bool_to_python, div_10, - div_10_celsius_delta_to_preferred, - div_10_celsius_to_preferred, enum_validator, - flow_rate_to_preferred, - half_celsius_to_preferred, mul_10, - raw_celsius_to_preferred, tou_override_to_python, - volume_to_preferred, ) from .enums import ( DHW_OPERATION_SETTING_TEXT, @@ -56,7 +48,10 @@ temperature_field, ) from .temperature import ( + DeciCelsius, + DeciCelsiusDelta, HalfCelsius, + RawCelsius, ) from .unit_system import get_unit_system @@ -72,23 +67,6 @@ CapabilityFlag = Annotated[bool, BeforeValidator(device_bool_to_python)] Div10 = Annotated[float, BeforeValidator(div_10)] TenWhToWh = Annotated[float, BeforeValidator(mul_10)] -HalfCelsiusToPreferred = Annotated[ - float, WrapValidator(half_celsius_to_preferred) -] -DeciCelsiusToPreferred = Annotated[ - float, WrapValidator(deci_celsius_to_preferred) -] -RawCelsiusToPreferred = Annotated[ - float, WrapValidator(raw_celsius_to_preferred) -] -Div10CelsiusToPreferred = Annotated[ - float, WrapValidator(div_10_celsius_to_preferred) -] -Div10CelsiusDeltaToPreferred = Annotated[ - float, WrapValidator(div_10_celsius_delta_to_preferred) -] -FlowRate = Annotated[float, WrapValidator(flow_rate_to_preferred)] -Volume = Annotated[float, WrapValidator(volume_to_preferred)] TouStatus = Annotated[bool, BeforeValidator(bool)] TouOverride = Annotated[bool, BeforeValidator(tou_override_to_python)] VolumeCodeField = Annotated[ @@ -381,10 +359,8 @@ def enabled(self) -> bool: class DeviceStatus(NavienBaseModel): """Represents the status of the Navien water heater device.""" - # CRITICAL: temperature_type must be defined before any temperature - # fields that depend on it. Wrap validators need it in ValidationInfo.data. - # Reordering breaks unit conversions. See - # converters._get_temperature_preference() for details. + # CRITICAL: temperature_type must remain the first field so computed + # temperature properties can fall back to the device's native unit setting. temperature_type: TemperatureType = Field( default=TemperatureType.FAHRENHEIT, description=( @@ -397,9 +373,6 @@ class DeviceStatus(NavienBaseModel): command: int = Field( description="The command that triggered this status update" ) - outside_temperature: RawCelsiusToPreferred = temperature_field( - "Outdoor/ambient temperature" - ) special_function_status: int = Field( description=( "Status of special functions " @@ -463,6 +436,9 @@ class DeviceStatus(NavienBaseModel): temp_formula_type: TempFormulaType = Field( description="Temperature formula type" ) + outside_temperature_raw: int = temperature_field( + "Outdoor/ambient temperature", alias="outsideTemperature" + ) current_statenum: int = Field(description="Current state number") target_fan_rpm: int = Field( description="Target fan RPM", @@ -506,7 +482,8 @@ class DeviceStatus(NavienBaseModel): ), json_schema_extra={"unit_of_measurement": "h"}, ) - cumulated_dhw_flow_rate: Volume = Field( + cumulated_dhw_flow_rate_raw: int = Field( + alias="cumulatedDhwFlowRate", description=( "Cumulative DHW flow - " "total volume of hot water delivered since installation" @@ -708,115 +685,138 @@ class DeviceStatus(NavienBaseModel): description="Recirculation reservation usage status" ) - # Temperature fields - encoded in half-degrees Celsius - dhw_temperature: HalfCelsiusToPreferred = temperature_field( - "Current Domestic Hot Water (DHW) outlet temperature" + # Raw temperature, flow, and volume fields + dhw_temperature_raw: int = temperature_field( + "Current Domestic Hot Water (DHW) outlet temperature", + alias="dhwTemperature", ) - dhw_temperature_setting: HalfCelsiusToPreferred = temperature_field( - "User-configured target DHW temperature" + dhw_temperature_setting_raw: int = temperature_field( + "User-configured target DHW temperature", + alias="dhwTemperatureSetting", ) - dhw_target_temperature_setting: HalfCelsiusToPreferred = temperature_field( - "Duplicate of dhw_temperature_setting for legacy API compatibility" + dhw_target_temperature_setting_raw: int = temperature_field( + "Duplicate of dhw_temperature_setting for legacy API compatibility", + alias="dhwTargetTemperatureSetting", ) - freeze_protection_temperature: HalfCelsiusToPreferred = temperature_field( + freeze_protection_temperature_raw: int = temperature_field( "Freeze protection temperature setpoint. " - "Prevents tank from freezing in cold environments" + "Prevents tank from freezing in cold environments", + alias="freezeProtectionTemperature", ) - dhw_temperature2: HalfCelsiusToPreferred = temperature_field( - "Second DHW temperature reading" + dhw_temperature2_raw: int = temperature_field( + "Second DHW temperature reading", + alias="dhwTemperature2", ) - hp_upper_on_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump upper on temperature setting" + hp_upper_on_temp_setting_raw: int = temperature_field( + "Heat pump upper on temperature setting", + alias="hpUpperOnTempSetting", ) - hp_upper_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump upper off temperature setting" + hp_upper_off_temp_setting_raw: int = temperature_field( + "Heat pump upper off temperature setting", + alias="hpUpperOffTempSetting", ) - hp_lower_on_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump lower on temperature setting" + hp_lower_on_temp_setting_raw: int = temperature_field( + "Heat pump lower on temperature setting", + alias="hpLowerOnTempSetting", ) - hp_lower_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heat pump lower off temperature setting" + hp_lower_off_temp_setting_raw: int = temperature_field( + "Heat pump lower off temperature setting", + alias="hpLowerOffTempSetting", ) - he_upper_on_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heater element upper on temperature setting" + he_upper_on_temp_setting_raw: int = temperature_field( + "Heater element upper on temperature setting", + alias="heUpperOnTempSetting", ) - he_upper_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heater element upper off temperature setting" + he_upper_off_temp_setting_raw: int = temperature_field( + "Heater element upper off temperature setting", + alias="heUpperOffTempSetting", ) - he_lower_on_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heater element lower on temperature setting" + he_lower_on_temp_setting_raw: int = temperature_field( + "Heater element lower on temperature setting", + alias="heLowerOnTempSetting", ) - he_lower_off_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Heater element lower off temperature setting" + he_lower_off_temp_setting_raw: int = temperature_field( + "Heater element lower off temperature setting", + alias="heLowerOffTempSetting", ) - heat_min_op_temperature: HalfCelsiusToPreferred = temperature_field( + heat_min_op_temperature_raw: int = temperature_field( "Minimum heat pump operation temperature. " - "Lowest tank setpoint allowed for heat pump operation" + "Lowest tank setpoint allowed for heat pump operation", + alias="heatMinOpTemperature", ) - recirc_temp_setting: HalfCelsiusToPreferred = temperature_field( - "Recirculation temperature setting" + recirc_temp_setting_raw: int = temperature_field( + "Recirculation temperature setting", + alias="recircTempSetting", ) - recirc_temperature: HalfCelsiusToPreferred = temperature_field( - "Recirculation temperature" + recirc_temperature_raw: int = temperature_field( + "Recirculation temperature", + alias="recircTemperature", ) - recirc_faucet_temperature: HalfCelsiusToPreferred = temperature_field( - "Recirculation faucet temperature" + recirc_faucet_temperature_raw: int = temperature_field( + "Recirculation faucet temperature", + alias="recircFaucetTemperature", ) - - # Fields with scale division (raw / 10.0) - current_inlet_temperature: HalfCelsiusToPreferred = temperature_field( - "Cold water inlet temperature" + current_inlet_temperature_raw: int = temperature_field( + "Cold water inlet temperature", + alias="currentInletTemperature", ) - current_dhw_flow_rate: FlowRate = Field( + current_dhw_flow_rate_raw: int = Field( + alias="currentDhwFlowRate", description="Current DHW flow rate", json_schema_extra={ "unit_of_measurement": "GPM", "device_class": "flow_rate", }, ) - hp_upper_on_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( + hp_upper_on_diff_temp_setting_raw: int = Field( + alias="hpUpperOnDiffTempSetting", description="Heat pump upper on differential temperature setting", json_schema_extra={ "unit_of_measurement": "°F", "device_class": "temperature", }, ) - hp_upper_off_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( + hp_upper_off_diff_temp_setting_raw: int = Field( + alias="hpUpperOffDiffTempSetting", description="Heat pump upper off differential temperature setting", json_schema_extra={ "unit_of_measurement": "°F", "device_class": "temperature", }, ) - hp_lower_on_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( + hp_lower_on_diff_temp_setting_raw: int = Field( + alias="hpLowerOnDiffTempSetting", description="Heat pump lower on differential temperature setting", json_schema_extra={ "unit_of_measurement": "°F", "device_class": "temperature", }, ) - hp_lower_off_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( + hp_lower_off_diff_temp_setting_raw: int = Field( + alias="hpLowerOffDiffTempSetting", description="Heat pump lower off differential temperature setting", json_schema_extra={ "unit_of_measurement": "°F", "device_class": "temperature", }, ) - he_upper_on_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( + he_upper_on_diff_temp_setting_raw: int = Field( + alias="heUpperOnDiffTempSetting", description="Heater element upper on differential temperature setting", json_schema_extra={ "unit_of_measurement": "°F", "device_class": "temperature", }, ) - he_upper_off_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( + he_upper_off_diff_temp_setting_raw: int = Field( + alias="heUpperOffDiffTempSetting", description="Heater element upper off differential temperature setting", json_schema_extra={ "unit_of_measurement": "°F", "device_class": "temperature", }, ) - he_lower_on_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( + he_lower_on_diff_temp_setting_raw: int = Field( alias="heLowerOnTDiffempSetting", description="Heater element lower on differential temperature setting", json_schema_extra={ @@ -824,49 +824,57 @@ class DeviceStatus(NavienBaseModel): "device_class": "temperature", }, ) # Handle API typo: heLowerOnTDiffempSetting -> heLowerOnDiffTempSetting - he_lower_off_diff_temp_setting: Div10CelsiusDeltaToPreferred = Field( + he_lower_off_diff_temp_setting_raw: int = Field( + alias="heLowerOffDiffTempSetting", description="Heater element lower off differential temperature setting", json_schema_extra={ "unit_of_measurement": "°F", "device_class": "temperature", }, ) - recirc_dhw_flow_rate: FlowRate = Field( + recirc_dhw_flow_rate_raw: int = Field( + alias="recircDhwFlowRate", description="Recirculation DHW flow rate (dynamic units: LPM/GPM)", json_schema_extra={ "device_class": "flow_rate", }, ) - - # Temperature fields with decicelsius to Fahrenheit conversion - tank_upper_temperature: DeciCelsiusToPreferred = temperature_field( - "Temperature of the upper part of the tank" + tank_upper_temperature_raw: int = temperature_field( + "Temperature of the upper part of the tank", + alias="tankUpperTemperature", ) - tank_lower_temperature: DeciCelsiusToPreferred = temperature_field( - "Temperature of the lower part of the tank" + tank_lower_temperature_raw: int = temperature_field( + "Temperature of the lower part of the tank", + alias="tankLowerTemperature", ) - discharge_temperature: DeciCelsiusToPreferred = temperature_field( + discharge_temperature_raw: int = temperature_field( "Compressor discharge temperature - " - "temperature of refrigerant leaving the compressor" + "temperature of refrigerant leaving the compressor", + alias="dischargeTemperature", ) - suction_temperature: DeciCelsiusToPreferred = temperature_field( + suction_temperature_raw: int = temperature_field( "Compressor suction temperature - " - "temperature of refrigerant entering the compressor" + "temperature of refrigerant entering the compressor", + alias="suctionTemperature", ) - evaporator_temperature: DeciCelsiusToPreferred = temperature_field( + evaporator_temperature_raw: int = temperature_field( "Evaporator temperature - " - "temperature where heat is absorbed from ambient air" + "temperature where heat is absorbed from ambient air", + alias="evaporatorTemperature", ) - ambient_temperature: DeciCelsiusToPreferred = temperature_field( - "Ambient air temperature measured at the heat pump air intake" + ambient_temperature_raw: int = temperature_field( + "Ambient air temperature measured at the heat pump air intake", + alias="ambientTemperature", ) - target_super_heat: DeciCelsiusToPreferred = temperature_field( + target_super_heat_raw: int = temperature_field( "Target superheat value - desired temperature difference " - "ensuring complete refrigerant vaporization" + "ensuring complete refrigerant vaporization", + alias="targetSuperHeat", ) - current_super_heat: DeciCelsiusToPreferred = temperature_field( + current_super_heat_raw: int = temperature_field( "Current superheat value - actual temperature difference " - "between suction and evaporator temperatures" + "between suction and evaporator temperatures", + alias="currentSuperHeat", ) # Enum fields @@ -878,14 +886,308 @@ class DeviceStatus(NavienBaseModel): default=DhwOperationSetting.ENERGY_SAVER, description="User's configured DHW operation mode preference", ) - freeze_protection_temp_min: HalfCelsiusToPreferred = temperature_field( + freeze_protection_temp_min_raw: int = temperature_field( "Active freeze protection lower limit", - default=43.0, + alias="freezeProtectionTempMin", + default=43, ) - freeze_protection_temp_max: HalfCelsiusToPreferred = temperature_field( - "Active freeze protection upper limit", default=65.0 + freeze_protection_temp_max_raw: int = temperature_field( + "Active freeze protection upper limit", + alias="freezeProtectionTempMax", + default=65, ) + def _is_celsius(self) -> bool: + """Return True if metric/Celsius units should be used.""" + unit_system = get_unit_system() + if unit_system is not None: + return unit_system == "metric" + return self.temperature_type == TemperatureType.CELSIUS + + @computed_field # type: ignore[prop-decorator] + @property + def outside_temperature(self) -> float: + raw = RawCelsius(self.outside_temperature_raw) + if self._is_celsius(): + return raw.to_celsius() + return raw.to_fahrenheit_with_formula(self.temp_formula_type) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature(self) -> float: + return HalfCelsius(self.dhw_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature_setting(self) -> float: + return HalfCelsius(self.dhw_temperature_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_target_temperature_setting(self) -> float: + return HalfCelsius( + self.dhw_target_temperature_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temperature(self) -> float: + return HalfCelsius(self.freeze_protection_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature2(self) -> float: + return HalfCelsius(self.dhw_temperature2_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_on_temp_setting(self) -> float: + return HalfCelsius(self.hp_upper_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_off_temp_setting(self) -> float: + return HalfCelsius(self.hp_upper_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_on_temp_setting(self) -> float: + return HalfCelsius(self.hp_lower_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_off_temp_setting(self) -> float: + return HalfCelsius(self.hp_lower_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_on_temp_setting(self) -> float: + return HalfCelsius(self.he_upper_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_off_temp_setting(self) -> float: + return HalfCelsius(self.he_upper_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_on_temp_setting(self) -> float: + return HalfCelsius(self.he_lower_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_off_temp_setting(self) -> float: + return HalfCelsius(self.he_lower_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def heat_min_op_temperature(self) -> float: + return HalfCelsius(self.heat_min_op_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temp_setting(self) -> float: + return HalfCelsius(self.recirc_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temperature(self) -> float: + return HalfCelsius(self.recirc_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_faucet_temperature(self) -> float: + return HalfCelsius(self.recirc_faucet_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def current_inlet_temperature(self) -> float: + return HalfCelsius(self.current_inlet_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def current_dhw_flow_rate(self) -> float: + lpm = self.current_dhw_flow_rate_raw / 10.0 + if self._is_celsius(): + return lpm + return round(lpm * 0.264172, 2) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_upper_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_upper_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_lower_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_lower_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_upper_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_upper_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_lower_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_lower_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_dhw_flow_rate(self) -> float: + lpm = self.recirc_dhw_flow_rate_raw / 10.0 + if self._is_celsius(): + return lpm + return round(lpm * 0.264172, 2) + + @computed_field # type: ignore[prop-decorator] + @property + def tank_upper_temperature(self) -> float: + return DeciCelsius(self.tank_upper_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def tank_lower_temperature(self) -> float: + return DeciCelsius(self.tank_lower_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def discharge_temperature(self) -> float: + return DeciCelsius(self.discharge_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def suction_temperature(self) -> float: + return DeciCelsius(self.suction_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def evaporator_temperature(self) -> float: + return DeciCelsius(self.evaporator_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def ambient_temperature(self) -> float: + return DeciCelsius(self.ambient_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def target_super_heat(self) -> float: + return DeciCelsius(self.target_super_heat_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def current_super_heat(self) -> float: + return DeciCelsius(self.current_super_heat_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def cumulated_dhw_flow_rate(self) -> float: + liters = float(self.cumulated_dhw_flow_rate_raw) + if self._is_celsius(): + return liters + return round(liters * 0.264172, 2) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_min(self) -> float: + return HalfCelsius(self.freeze_protection_temp_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_max(self) -> float: + return HalfCelsius(self.freeze_protection_temp_max_raw).to_preferred( + self._is_celsius() + ) + def get_field_unit(self, field_name: str) -> str: """Get the correct unit suffix based on temperature preference. @@ -900,10 +1202,13 @@ def get_field_unit(self, field_name: str) -> str: Unit string (e.g., " °C", " LPM", " L") or empty if field not found """ model_fields = self.__class__.model_fields - if field_name not in model_fields: + lookup_name = ( + field_name if field_name in model_fields else f"{field_name}_raw" + ) + if lookup_name not in model_fields: return "" - field_info = model_fields[field_name] + field_info = model_fields[lookup_name] if not hasattr(field_info, "json_schema_extra"): return "" @@ -911,13 +1216,7 @@ def get_field_unit(self, field_name: str) -> str: if not isinstance(extra, dict): return "" - # Check if unit system override is set in context - unit_system = get_unit_system() - if unit_system is not None: - is_celsius = unit_system == "metric" - else: - # Fall back to device's temperature_type setting - is_celsius = self.temperature_type == TemperatureType.CELSIUS + is_celsius = self._is_celsius() device_class = extra.get("device_class") @@ -950,8 +1249,8 @@ def from_dict(cls, data: dict[str, Any]) -> DeviceStatus: class DeviceFeature(NavienBaseModel): """Device capabilities, configuration, and firmware info.""" - # IMPORTANT: temperature_type must be defined before any temperature fields - # so that it is available in the validation context (info.data). + # IMPORTANT: temperature_type must remain the first field so computed + # temperature properties can fall back to the device's native unit setting. temperature_type: TemperatureType = Field( default=TemperatureType.FAHRENHEIT, description=( @@ -1199,29 +1498,84 @@ class DeviceFeature(NavienBaseModel): ), ) - # Temperature limit fields with half-degree Celsius scaling - dhw_temperature_min: HalfCelsiusToPreferred = temperature_field( - "Minimum DHW temperature setting - safety and efficiency lower limit" + # Raw temperature limit fields with half-degree Celsius scaling + dhw_temperature_min_raw: int = temperature_field( + "Minimum DHW temperature setting - safety and efficiency lower limit", + alias="dhwTemperatureMin", ) - dhw_temperature_max: HalfCelsiusToPreferred = temperature_field( - "Maximum DHW temperature setting - scald protection upper limit" + dhw_temperature_max_raw: int = temperature_field( + "Maximum DHW temperature setting - scald protection upper limit", + alias="dhwTemperatureMax", ) - freeze_protection_temp_min: HalfCelsiusToPreferred = temperature_field( + freeze_protection_temp_min_raw: int = temperature_field( "Minimum freeze protection threshold - " - "factory default activation temperature" + "factory default activation temperature", + alias="freezeProtectionTempMin", ) - freeze_protection_temp_max: HalfCelsiusToPreferred = temperature_field( - "Maximum freeze protection threshold - user-adjustable upper limit" + freeze_protection_temp_max_raw: int = temperature_field( + "Maximum freeze protection threshold - user-adjustable upper limit", + alias="freezeProtectionTempMax", ) - recirc_temperature_min: HalfCelsiusToPreferred = temperature_field( + recirc_temperature_min_raw: int = temperature_field( "Minimum recirculation temperature setting - " - "lower limit for recirculation loop temperature control" + "lower limit for recirculation loop temperature control", + alias="recircTemperatureMin", ) - recirc_temperature_max: HalfCelsiusToPreferred = temperature_field( + recirc_temperature_max_raw: int = temperature_field( "Maximum recirculation temperature setting - " - "upper limit for recirculation loop temperature control" + "upper limit for recirculation loop temperature control", + alias="recircTemperatureMax", ) + def _is_celsius(self) -> bool: + """Return True if metric/Celsius units should be used.""" + unit_system = get_unit_system() + if unit_system is not None: + return unit_system == "metric" + return self.temperature_type == TemperatureType.CELSIUS + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature_min(self) -> float: + return HalfCelsius(self.dhw_temperature_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature_max(self) -> float: + return HalfCelsius(self.dhw_temperature_max_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_min(self) -> float: + return HalfCelsius(self.freeze_protection_temp_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_max(self) -> float: + return HalfCelsius(self.freeze_protection_temp_max_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temperature_min(self) -> float: + return HalfCelsius(self.recirc_temperature_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temperature_max(self) -> float: + return HalfCelsius(self.recirc_temperature_max_raw).to_preferred( + self._is_celsius() + ) + def get_field_unit(self, field_name: str) -> str: """Get the correct unit suffix based on temperature preference. @@ -1236,10 +1590,13 @@ def get_field_unit(self, field_name: str) -> str: Unit string (e.g., " °C", " LPM", " L") or empty if field not found """ model_fields = self.__class__.model_fields - if field_name not in model_fields: + lookup_name = ( + field_name if field_name in model_fields else f"{field_name}_raw" + ) + if lookup_name not in model_fields: return "" - field_info = model_fields[field_name] + field_info = model_fields[lookup_name] if not hasattr(field_info, "json_schema_extra"): return "" @@ -1247,13 +1604,7 @@ def get_field_unit(self, field_name: str) -> str: if not isinstance(extra, dict): return "" - # Check if unit system override is set in context - unit_system = get_unit_system() - if unit_system is not None: - is_celsius = unit_system == "metric" - else: - # Fall back to device's temperature_type setting - is_celsius = self.temperature_type == TemperatureType.CELSIUS + is_celsius = self._is_celsius() device_class = extra.get("device_class") diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 94ab1b4..64e1b87 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -562,7 +562,6 @@ async def connect(self) -> bool: event_emitter=self, schedule_coroutine=self._schedule_coroutine, device_info_cache=device_info_cache, - unit_system=self._unit_system, ) # Initialize device controller with cache diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index fc08edd..e5fcb2f 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, get_unit_system, set_unit_system +from ..unit_system import get_unit_system from .utils import redact_topic, topic_matches_pattern if TYPE_CHECKING: @@ -55,7 +55,6 @@ def __init__( event_emitter: EventEmitter, schedule_coroutine: Callable[[Any], None], device_info_cache: MqttDeviceInfoCache | None = None, - unit_system: UnitSystemType = None, ): """ Initialize subscription manager. @@ -67,15 +66,12 @@ def __init__( schedule_coroutine: Function to schedule async tasks device_info_cache: Optional MqttDeviceInfoCache for caching device features - unit_system: Preferred unit system ("metric", "us_customary", - or None) """ self._connection = connection self._client_id = client_id self._event_emitter = event_emitter self._schedule_coroutine = schedule_coroutine self._device_info_cache = device_info_cache - self._unit_system: UnitSystemType = unit_system # Track subscriptions and handlers self._subscriptions: dict[str, mqtt.QoS] = {} @@ -396,12 +392,6 @@ def _make_handler( def handler(topic: str, message: dict[str, Any]) -> None: try: - # Set unit system context before parsing if configured - # This ensures validators use the correct unit system even - # when called from AWS CRT threads - if self._unit_system is not None: - set_unit_system(self._unit_system) - res = message.get("response", {}) # Try nested response field, then fallback to top-level data = (res.get(key) if key else res) or ( From 3b75782a862785f6d866a05050eb5598b43f1bf7 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 00:08:18 -0700 Subject: [PATCH 03/29] Phases 3+4: Topic consolidation and state tracker extraction Phase 3 (Topic consolidation): - Add response_ack_topic() to MqttTopicBuilder for control command acks - Document all four topic schema patterns in MqttTopicBuilder docstring - Update _build_command in control.py to use MqttTopicBuilder - Fix reservations.py to use MqttTopicBuilder.response_topic (remove hardcoded string) - Fix cli/handlers.py to use MqttTopicBuilder.response_topic (remove hardcoded string) - Remove set_unit_system workaround from reservations.py fetch_reservations Phase 4 (State tracker extraction): - Create mqtt/state_tracker.py with DeviceStateTracker class - Move _detect_state_changes logic out of MqttSubscriptionManager - Wire DeviceStateTracker into MqttSubscriptionManager via _state_tracker - clean up unused get_unit_system import from subscriptions.py --- src/nwp500/cli/handlers.py | 7 +- src/nwp500/mqtt/control.py | 13 ++-- src/nwp500/mqtt/state_tracker.py | 128 +++++++++++++++++++++++++++++++ src/nwp500/mqtt/subscriptions.py | 97 ++--------------------- src/nwp500/reservations.py | 16 ++-- src/nwp500/topic_builder.py | 48 +++++++++--- 6 files changed, 187 insertions(+), 122 deletions(-) create mode 100644 src/nwp500/mqtt/state_tracker.py diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index c10c49c..69c3220 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -29,6 +29,7 @@ fetch_reservations, update_reservation, ) +from nwp500.topic_builder import MqttTopicBuilder from nwp500.unit_system import get_unit_system from .output_formatters import ( @@ -322,8 +323,10 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: print_json(message) future.set_result(None) - device_type = device.device_info.device_type - response_topic = f"cmd/{device_type}/{mqtt.client_id}/res/rsv/rd" + device_type = str(device.device_info.device_type) + response_topic = MqttTopicBuilder.response_topic( + device_type, mqtt.client_id, "rsv/rd" + ) await mqtt.subscribe(response_topic, raw_callback) await mqtt.control.update_reservations( device, reservations, enabled=enabled diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index a33d208..0828ca3 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -217,18 +217,17 @@ def _build_command( **kwargs, } - # Use navilink- prefix for device ID in topics (from reference - # implementation) - device_topic = f"navilink-{device_id}" - + device_type_str = str(device_type) return { "clientID": self._client_id, "sessionID": self._session_id, "protocolVersion": MQTT_PROTOCOL_VERSION, "request": request, - "requestTopic": f"cmd/{device_type}/{device_topic}", - "responseTopic": ( - f"cmd/{device_type}/{device_topic}/{self._client_id}/res" + "requestTopic": MqttTopicBuilder.command_topic( + device_type_str, device_id + ), + "responseTopic": MqttTopicBuilder.response_ack_topic( + device_type_str, device_id, self._client_id ), } diff --git a/src/nwp500/mqtt/state_tracker.py b/src/nwp500/mqtt/state_tracker.py new file mode 100644 index 0000000..0578730 --- /dev/null +++ b/src/nwp500/mqtt/state_tracker.py @@ -0,0 +1,128 @@ +"""Per-device state change detection for Navien MQTT clients. + +Compares successive :class:`DeviceStatus` snapshots for each device and emits +granular events when individual fields change (temperature, mode, power, +errors). +""" + +from __future__ import annotations + +import logging + +from ..events import EventEmitter +from ..models import DeviceStatus +from ..unit_system import get_unit_system + +_logger = logging.getLogger(__name__) + + +class DeviceStateTracker: + """Tracks previous device states and emits change events. + + Each device (identified by MAC address) gets its own slot in + ``_previous_status``. On every new status update, this class compares + it against the stored snapshot and emits events for changed fields, + then stores the new snapshot. + """ + + def __init__(self, event_emitter: EventEmitter) -> None: + self._event_emitter = event_emitter + self._previous_status: dict[str, DeviceStatus] = {} + + def clear(self) -> None: + """Drop all stored snapshots (call on disconnect).""" + self._previous_status.clear() + + async def process(self, device_mac: str, status: DeviceStatus) -> None: + """Compare *status* with the previous snapshot for *device_mac*. + + Emits the following events when values change: + + - ``temperature_changed(prev_temp, curr_temp)`` + - ``mode_changed(prev_mode, curr_mode)`` + - ``power_changed(prev_power, curr_power)`` + - ``heating_started(curr_status)`` + - ``heating_stopped(curr_status)`` + - ``error_detected(error_code, curr_status)`` + - ``error_cleared(prev_error_code)`` + + Args: + device_mac: MAC address used as the per-device key. + status: Freshly received :class:`DeviceStatus`. + """ + if device_mac not in self._previous_status: + self._previous_status[device_mac] = status + return + + prev = self._previous_status[device_mac] + + try: + # Temperature change + if status.dhw_temperature != prev.dhw_temperature: + await self._event_emitter.emit( + "temperature_changed", + prev.dhw_temperature, + status.dhw_temperature, + ) + unit_suffix = "°C" if get_unit_system() == "metric" else "°F" + _logger.debug( + "Temperature changed: %s%s → %s%s", + prev.dhw_temperature, + unit_suffix, + status.dhw_temperature, + unit_suffix, + ) + + # Operation mode change + if status.operation_mode != prev.operation_mode: + await self._event_emitter.emit( + "mode_changed", + prev.operation_mode, + status.operation_mode, + ) + _logger.debug( + "Mode changed: %s → %s", + prev.operation_mode, + status.operation_mode, + ) + + # Power consumption change + if status.current_inst_power != prev.current_inst_power: + await self._event_emitter.emit( + "power_changed", + prev.current_inst_power, + status.current_inst_power, + ) + _logger.debug( + "Power changed: %sW → %sW", + prev.current_inst_power, + status.current_inst_power, + ) + + # Heating started / stopped + prev_heating = prev.current_inst_power > 0 + curr_heating = status.current_inst_power > 0 + + if curr_heating and not prev_heating: + await self._event_emitter.emit("heating_started", status) + _logger.debug("Heating started") + + if not curr_heating and prev_heating: + await self._event_emitter.emit("heating_stopped", status) + _logger.debug("Heating stopped") + + # Error detection / clearance + if status.error_code and not prev.error_code: + await self._event_emitter.emit( + "error_detected", status.error_code, status + ) + _logger.info("Error detected: %s", status.error_code) + + if not status.error_code and prev.error_code: + await self._event_emitter.emit("error_cleared", prev.error_code) + _logger.info("Error cleared: %s", prev.error_code) + + except (TypeError, AttributeError, RuntimeError) as e: + _logger.error("Error detecting state changes: %s", e, exc_info=True) + finally: + self._previous_status[device_mac] = status diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index e5fcb2f..979f182 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 get_unit_system +from .state_tracker import DeviceStateTracker from .utils import redact_topic, topic_matches_pattern if TYPE_CHECKING: @@ -79,8 +79,8 @@ def __init__( str, list[Callable[[str, dict[str, Any]], None]] ] = {} - # Track previous state for change detection, keyed by device MAC - self._previous_status: dict[str, DeviceStatus] = {} + # Per-device state change detection + self._state_tracker = DeviceStateTracker(event_emitter) @property def subscriptions(self) -> dict[str, mqtt.QoS]: @@ -373,7 +373,7 @@ def post_parse(status: DeviceStatus) -> None: self._event_emitter.emit("status_received", status) ) self._schedule_coroutine( - self._detect_state_changes(device_mac, status) + self._state_tracker.process(device_mac, status) ) handler = self._make_handler( @@ -418,93 +418,6 @@ def handler(topic: str, message: dict[str, Any]) -> None: cast(Any, handler)._original_callback = callback return handler - async def _detect_state_changes( - self, device_mac: str, status: DeviceStatus - ) -> None: - """ - Detect state changes and emit granular events. - - Compares the current status with the previous status for this device - and emits events for any detected changes. - - Args: - device_mac: MAC address of the device (for per-device tracking) - status: Current device status - """ - if device_mac not in self._previous_status: - # First status received for this device, just store it - self._previous_status[device_mac] = status - return - - prev = self._previous_status[device_mac] - - try: - # Temperature change - if status.dhw_temperature != prev.dhw_temperature: - await self._event_emitter.emit( - "temperature_changed", - 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"{unit_suffix} → {status.dhw_temperature}{unit_suffix}" - ) - - # Operation mode change - if status.operation_mode != prev.operation_mode: - await self._event_emitter.emit( - "mode_changed", - prev.operation_mode, - status.operation_mode, - ) - _logger.debug( - f"Mode changed: {prev.operation_mode} → " - f"{status.operation_mode}" - ) - - # Power consumption change - if status.current_inst_power != prev.current_inst_power: - await self._event_emitter.emit( - "power_changed", - prev.current_inst_power, - status.current_inst_power, - ) - _logger.debug( - f"Power changed: {prev.current_inst_power}W → " - f"{status.current_inst_power}W" - ) - - # Heating started/stopped - prev_heating = prev.current_inst_power > 0 - curr_heating = status.current_inst_power > 0 - - if curr_heating and not prev_heating: - await self._event_emitter.emit("heating_started", status) - _logger.debug("Heating started") - - if not curr_heating and prev_heating: - await self._event_emitter.emit("heating_stopped", status) - _logger.debug("Heating stopped") - - # Error detection - if status.error_code and not prev.error_code: - await self._event_emitter.emit( - "error_detected", status.error_code, status - ) - _logger.info(f"Error detected: {status.error_code}") - - if not status.error_code and prev.error_code: - await self._event_emitter.emit("error_cleared", prev.error_code) - _logger.info(f"Error cleared: {prev.error_code}") - - except (TypeError, AttributeError, RuntimeError) as e: - _logger.error(f"Error detecting state changes: {e}", exc_info=True) - finally: - # Always update previous status for this device - self._previous_status[device_mac] = status - async def subscribe_device_feature( self, device: Device, callback: Callable[[DeviceFeature], None] ) -> int: @@ -569,4 +482,4 @@ def clear_subscriptions(self) -> None: """Clear all subscription tracking (called on disconnect).""" self._subscriptions.clear() self._message_handlers.clear() - self._previous_status.clear() + self._state_tracker.clear() diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py index b7711a4..bd40b27 100644 --- a/src/nwp500/reservations.py +++ b/src/nwp500/reservations.py @@ -18,7 +18,7 @@ from .encoding import build_reservation_entry, encode_week_bitfield from .models import ReservationSchedule -from .unit_system import get_unit_system, set_unit_system +from .topic_builder import MqttTopicBuilder if TYPE_CHECKING: from .models import Device @@ -58,7 +58,6 @@ async def fetch_reservations( future: asyncio.Future[ReservationSchedule] = ( asyncio.get_running_loop().create_future() ) - caller_unit_system = get_unit_system() def raw_callback(topic: str, message: dict[str, Any]) -> None: if ( @@ -71,18 +70,13 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: # Ensure it's actually a reservation response (not some other /res/ msg) if "reservationUse" not in response and "reservation" not in response: return - previous = get_unit_system() - try: - if caller_unit_system: - set_unit_system(caller_unit_system) - schedule = ReservationSchedule(**response) - finally: - if previous is not None: - set_unit_system(previous) + schedule = ReservationSchedule(**response) future.set_result(schedule) device_type = str(device.device_info.device_type) - response_topic = f"cmd/{device_type}/{mqtt.client_id}/res/rsv/rd" + response_topic = MqttTopicBuilder.response_topic( + device_type, mqtt.client_id, "rsv/rd" + ) await mqtt.subscribe(response_topic, raw_callback) await mqtt.control.request_reservations(device) try: diff --git a/src/nwp500/topic_builder.py b/src/nwp500/topic_builder.py index e5dbd3a..b9a5677 100644 --- a/src/nwp500/topic_builder.py +++ b/src/nwp500/topic_builder.py @@ -1,5 +1,15 @@ """ MQTT topic building utilities for Navien devices. + +All MQTT topic construction goes through this class so that the topic schema +is defined in exactly one place. + +Topic schema: + Device command (ctrl/query): cmd/{device_type}/navilink-{mac}/{suffix} + Device subscribe (wildcard): cmd/{device_type}/navilink-{mac}/# + Response (control ack): cmd/{device_type}/navilink-{mac}/{client_id}/res + Response (query result): cmd/{device_type}/{client_id}/res/{suffix} + Event: evt/{device_type}/navilink-{mac}/{suffix} """ @@ -8,33 +18,51 @@ class MqttTopicBuilder: @staticmethod def device_topic(mac_address: str) -> str: - """Get the base device topic from MAC address.""" + """Get the navilink device path segment from MAC address.""" return f"navilink-{mac_address}" @staticmethod def command_topic( device_type: str, mac_address: str, suffix: str = "ctrl" ) -> str: - """ - Build a command topic. - Format: cmd/{device_type}/navilink-{mac}/{suffix} + """Build a device command topic. + + Format: ``cmd/{device_type}/navilink-{mac}/{suffix}`` """ dt = MqttTopicBuilder.device_topic(mac_address) return f"cmd/{device_type}/{dt}/{suffix}" @staticmethod - def response_topic(device_type: str, client_id: str, suffix: str) -> str: + def response_ack_topic( + device_type: str, mac_address: str, client_id: str + ) -> str: + """Build the default response topic for control commands. + + The device sends its acknowledgement to this topic; the client + subscribes via the ``command_topic(..., "#")`` wildcard. + + Format: ``cmd/{device_type}/navilink-{mac}/{client_id}/res`` """ - Build a response topic. - Format: cmd/{device_type}/{client_id}/res/{suffix} + dt = MqttTopicBuilder.device_topic(mac_address) + return f"cmd/{device_type}/{dt}/{client_id}/res" + + @staticmethod + def response_topic(device_type: str, client_id: str, suffix: str) -> str: + """Build a client-specific response topic for query commands. + + Used when the device should reply directly to a client-keyed topic + rather than the device topic (e.g. reservation reads, TOU reads, + energy queries). + + Format: ``cmd/{device_type}/{client_id}/res/{suffix}`` """ return f"cmd/{device_type}/{client_id}/res/{suffix}" @staticmethod def event_topic(device_type: str, mac_address: str, suffix: str) -> str: - """ - Build an event topic. - Format: evt/{device_type}/navilink-{mac}/{suffix} + """Build a device event topic. + + Format: ``evt/{device_type}/navilink-{mac}/{suffix}`` """ dt = MqttTopicBuilder.device_topic(mac_address) return f"evt/{device_type}/{dt}/{suffix}" From 16e594bfacb8d6b5b5ba2ca6f03f9f0ab8ed2629 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 00:14:56 -0700 Subject: [PATCH 04/29] Phase 5: Emit typed event dataclass instances All event emitters now wrap args in typed dataclass instances from mqtt_events.py before calling emit(). Handlers receive a single event object with named fields instead of multiple positional args. Affected emitters: - state_tracker.py: temperature_changed, mode_changed, power_changed, heating_started/stopped, error_detected/cleared - subscriptions.py: status_received, feature_received - mqtt/client.py: connection_interrupted, connection_resumed Updated handlers: - examples/advanced/reconnection_demo.py - examples/advanced/mqtt_diagnostics.py - examples/intermediate/command_queue.py - examples/intermediate/event_driven_control.py Updated docs in mqtt_events.py and events.py to reflect new signatures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/advanced/mqtt_diagnostics.py | 24 ++++--- examples/advanced/reconnection_demo.py | 10 +-- examples/intermediate/command_queue.py | 6 +- examples/intermediate/event_driven_control.py | 61 +++++++++--------- src/nwp500/events.py | 21 ++++--- src/nwp500/mqtt/client.py | 22 ++++++- src/nwp500/mqtt/state_tracker.py | 62 ++++++++++++++----- src/nwp500/mqtt/subscriptions.py | 11 +++- src/nwp500/mqtt_events.py | 48 +++++++------- 9 files changed, 160 insertions(+), 105 deletions(-) diff --git a/examples/advanced/mqtt_diagnostics.py b/examples/advanced/mqtt_diagnostics.py index 81da334..b2d634e 100755 --- a/examples/advanced/mqtt_diagnostics.py +++ b/examples/advanced/mqtt_diagnostics.py @@ -128,9 +128,9 @@ async def monitor_connection_state(self, interval: float = 10.0) -> None: except Exception as e: _logger.error(f"Error monitoring state: {e}", exc_info=True) - async def on_connection_drop(self, error: Exception) -> None: + async def on_connection_drop(self, event) -> None: """Handle connection drop event.""" - _logger.warning(f"Connection dropped: {error}") + _logger.warning(f"Connection dropped: {event.error}") # Record with diagnostics active_subs = ( @@ -145,24 +145,22 @@ async def on_connection_drop(self, error: Exception) -> None: queued_cmds = self.mqtt_client.queued_commands_count if self.mqtt_client else 0 await self.diagnostics.record_connection_drop( - error=error, + error=event.error, active_subscriptions=active_subs, queued_commands=queued_cmds, ) - async def on_connection_resumed( - self, return_code: int, session_present: bool - ) -> None: + async def on_connection_resumed(self, event) -> None: """Handle connection resumed event.""" _logger.info( - f"Connection resumed: return_code={return_code}, " - f"session_present={session_present}" + f"Connection resumed: return_code={event.return_code}, " + f"session_present={event.session_present}" ) await self.diagnostics.record_connection_success( event_type="resumed", - session_present=session_present, - return_code=return_code, + session_present=event.session_present, + return_code=event.return_code, ) async def run_example( @@ -212,12 +210,12 @@ async def run_example( # Hook into connection events self.mqtt_client.on( "connection_interrupted", - lambda e: asyncio.create_task(self.on_connection_drop(e)), + lambda event: asyncio.create_task(self.on_connection_drop(event)), ) self.mqtt_client.on( "connection_resumed", - lambda rc, sp: asyncio.create_task( - self.on_connection_resumed(rc, sp) + lambda event: asyncio.create_task( + self.on_connection_resumed(event) ), ) diff --git a/examples/advanced/reconnection_demo.py b/examples/advanced/reconnection_demo.py index 0393317..99f4c39 100644 --- a/examples/advanced/reconnection_demo.py +++ b/examples/advanced/reconnection_demo.py @@ -67,14 +67,14 @@ async def main(): ) # Register event handlers - def on_interrupted(error): - print(f"\n[WARNING] Connection interrupted: {error}") + def on_interrupted(event): + print(f"\n[WARNING] Connection interrupted: {event.error}") print(" Automatic reconnection will begin...") - def on_resumed(return_code, session_present): + def on_resumed(event): print("\n[SUCCESS] Connection resumed!") - print(f" Return code: {return_code}") - print(f" Session present: {session_present}") + print(f" Return code: {event.return_code}") + print(f" Session present: {event.session_present}") mqtt_client.on("connection_interrupted", on_interrupted) mqtt_client.on("connection_resumed", on_resumed) diff --git a/examples/intermediate/command_queue.py b/examples/intermediate/command_queue.py index 0f7fb3c..5b03f8c 100644 --- a/examples/intermediate/command_queue.py +++ b/examples/intermediate/command_queue.py @@ -80,11 +80,11 @@ async def command_queue_demo(): ) # Register event handlers - def on_interrupted(error): - print(f" [WARNING] Connection interrupted: {error}") + def on_interrupted(event): + print(f" [WARNING] Connection interrupted: {event.error}") print(f" [NOTE] Queued commands: {mqtt_client.queued_commands_count}") - def on_resumed(return_code, session_present): + def on_resumed(event): print(" [SUCCESS] Connection resumed!") print(f" [NOTE] Queued commands: {mqtt_client.queued_commands_count}") diff --git a/examples/intermediate/event_driven_control.py b/examples/intermediate/event_driven_control.py index 280374c..3bcc60b 100644 --- a/examples/intermediate/event_driven_control.py +++ b/examples/intermediate/event_driven_control.py @@ -35,80 +35,77 @@ MqttClientEvents, CurrentOperationMode, ) -from nwp500.models import DeviceStatus # Example 1: Multiple listeners for the same event -def log_temperature(old_temp: float, new_temp: float): +def log_temperature(event): """Logger for temperature changes.""" - print(f"📊 [Logger] Temperature: {old_temp} → {new_temp}") + print(f"📊 [Logger] Temperature: {event.old_temperature} → {event.new_temperature}") -def alert_on_high_temp(old_temp: float, new_temp: float): +def alert_on_high_temp(event): """Alert handler for high temperatures.""" - if new_temp > 145: - print(f"[WARNING] [Alert] HIGH TEMPERATURE: {new_temp}!") + if event.new_temperature > 145: + print(f"[WARNING] [Alert] HIGH TEMPERATURE: {event.new_temperature}!") -async def save_temperature_to_db(old_temp: float, new_temp: float): +async def save_temperature_to_db(event): """Async database saver (simulated).""" # Simulate async database operation await asyncio.sleep(0.1) - print(f"💾 [Database] Saved temperature change: {new_temp}") + print(f"💾 [Database] Saved temperature change: {event.new_temperature}") # Example 2: Mode change handlers -def log_mode_change(old_mode: CurrentOperationMode, new_mode: CurrentOperationMode): +def log_mode_change(event): """Log operation mode changes.""" - print(f"🔄 [Mode] Changed from {old_mode.name} to {new_mode.name}") + print(f"🔄 [Mode] Changed from {event.old_mode.name} to {event.new_mode.name}") -def optimize_on_mode_change( - old_mode: CurrentOperationMode, new_mode: CurrentOperationMode -): +def optimize_on_mode_change(event): """Optimization handler.""" - if new_mode == CurrentOperationMode.HEAT_PUMP_MODE: + if event.new_mode == CurrentOperationMode.HEAT_PUMP_MODE: print("♻️ [Optimizer] Heat pump mode - maximum efficiency!") - elif new_mode == CurrentOperationMode.HYBRID_EFFICIENCY_MODE: + elif event.new_mode == CurrentOperationMode.HYBRID_EFFICIENCY_MODE: print("⚡ [Optimizer] Energy Saver mode - balanced performance!") - elif new_mode == CurrentOperationMode.HYBRID_BOOST_MODE: + elif event.new_mode == CurrentOperationMode.HYBRID_BOOST_MODE: print("⚡ [Optimizer] High Demand mode - fast recovery!") # Example 3: Power state handlers -def on_heating_started(status: DeviceStatus): +def on_heating_started(event): """Handler for when heating starts.""" - print(f"🔥 [Power] Heating STARTED - Power: {status.current_inst_power}W") + print(f"🔥 [Power] Heating STARTED - Power: {event.status.current_inst_power}W") -def on_heating_stopped(status: DeviceStatus): +def on_heating_stopped(event): """Handler for when heating stops.""" print("💤 [Power] Heating STOPPED") # Example 4: Error handlers -def on_error_detected(error_code: str, status: DeviceStatus): +def on_error_detected(event): """Handler for error detection.""" - print(f"[ERROR] [Error] ERROR DETECTED: {error_code}") - unit = status.get_field_unit("dhw_temperature") - print(f" Temperature: {status.dhw_temperature}{unit}") - print(f" Mode: {status.operation_mode}") + print(f"[ERROR] [Error] ERROR DETECTED: {event.error_code}") + unit = event.status.get_field_unit("dhw_temperature") + print(f" Temperature: {event.status.dhw_temperature}{unit}") + print(f" Mode: {event.status.operation_mode}") -def on_error_cleared(error_code: str): +def on_error_cleared(event): """Handler for error cleared.""" - print(f"[SUCCESS] [Error] ERROR CLEARED: {error_code}") + print(f"[SUCCESS] [Error] ERROR CLEARED: {event.error_code}") # Example 5: Connection state handlers -def on_connection_interrupted(error): +def on_connection_interrupted(event): """Handler for connection interruption.""" - print(f"🔌 [Connection] DISCONNECTED: {error}") + print(f"🔌 [Connection] DISCONNECTED: {event.error}") -def on_connection_resumed(return_code, session_present): +def on_connection_resumed(event): """Handler for connection resumption.""" - print(f"🔌 [Connection] RECONNECTED (code: {return_code})") + print(f"🔌 [Connection] RECONNECTED (code: {event.return_code})") async def main(): @@ -191,8 +188,8 @@ async def main(): # One-time listener example mqtt_client.once( MqttClientEvents.STATUS_RECEIVED, - lambda s: print( - f" 🎉 First status received: {s.dhw_temperature}{s.get_field_unit('dhw_temperature')}" + lambda event: print( + f" 🎉 First status received: {event.status.dhw_temperature}{event.status.get_field_unit('dhw_temperature')}" ), ) print(" [SUCCESS] Registered one-time status handler") diff --git a/src/nwp500/events.py b/src/nwp500/events.py index fe2da2c..7a2a0f8 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -50,7 +50,7 @@ class EventEmitter: emitter.on('temperature_changed', update_ui) # Emit events - await emitter.emit('temperature_changed', old_temp, new_temp) + await emitter.emit('temperature_changed', temperature_event) # One-time listener emitter.once('device_ready', initialize) @@ -85,15 +85,18 @@ def on( from nwp500.unit_system import get_unit_system - def on_temp_change(old_temp: float, new_temp: float): + def on_temp_change(event): unit = "°C" if get_unit_system() == "metric" else "°F" - print(f"Temperature: {old_temp}{unit} → {new_temp}{unit}") + print( + f"Temperature: {event.old_temperature}{unit} → " + f"{event.new_temperature}{unit}" + ) emitter.on('temperature_changed', on_temp_change) # Async handler - async def save_to_db(temp: float): - await db.save(temp) + async def save_to_db(event): + await db.save(event.new_temperature) emitter.on('temperature_changed', save_to_db, priority=100) """ @@ -228,8 +231,8 @@ async def emit(self, event: str, *args: Any, **kwargs: Any) -> int: Example:: - # Emit with arguments - await emitter.emit('temperature_changed', 120, 130) + # Emit with an event object + await emitter.emit('temperature_changed', temperature_event) # Emit with keyword arguments await emitter.emit('status_updated', status=device_status) @@ -386,7 +389,9 @@ async def wait_for( await emitter.wait_for('device_ready', timeout=30) # Wait for specific condition - old_temp, new_temp = await emitter.wait_for('temperature_changed') + args, _ = await emitter.wait_for('temperature_changed') + temperature_event = args[0] + current_temp = temperature_event.new_temperature """ future: asyncio.Future[tuple[tuple[Any, ...], dict[str, Any]]] = ( asyncio.Future() diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 64e1b87..8a9dc06 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -31,6 +31,10 @@ MqttPublishError, TokenRefreshError, ) +from ..mqtt_events import ( + ConnectionInterruptedEvent, + ConnectionResumedEvent, +) from ..unit_system import UnitSystemType from .command_queue import MqttCommandQueue from .connection import MqttConnection @@ -102,7 +106,8 @@ class NavienMqttClient(EventEmitter): ... ... # Type-safe event listeners with IDE autocomplete ... mqtt_client.on( - ... MqttClientEvents.TEMPERATURE_CHANGED, log_temperature + ... MqttClientEvents.TEMPERATURE_CHANGED, + ... lambda event: log_temperature(event.new_temperature), ... ) ... mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, update_ui) ... mqtt_client.on( @@ -251,7 +256,12 @@ def _on_connection_interrupted_internal( self._connected = False # Emit event - self._schedule_coroutine(self.emit("connection_interrupted", error)) + self._schedule_coroutine( + self.emit( + "connection_interrupted", + ConnectionInterruptedEvent(error=error), + ) + ) # Delegate to reconnection handler if available if self._reconnection_handler and self.config.auto_reconnect: @@ -291,7 +301,13 @@ def _on_connection_resumed_internal( # Emit event self._schedule_coroutine( - self.emit("connection_resumed", return_code, session_present) + self.emit( + "connection_resumed", + ConnectionResumedEvent( + return_code=return_code, + session_present=session_present, + ), + ) ) # Delegate to reconnection handler to reset state diff --git a/src/nwp500/mqtt/state_tracker.py b/src/nwp500/mqtt/state_tracker.py index 0578730..3b56434 100644 --- a/src/nwp500/mqtt/state_tracker.py +++ b/src/nwp500/mqtt/state_tracker.py @@ -11,6 +11,15 @@ from ..events import EventEmitter from ..models import DeviceStatus +from ..mqtt_events import ( + ErrorClearedEvent, + ErrorDetectedEvent, + HeatingStartedEvent, + HeatingStoppedEvent, + ModeChangedEvent, + PowerChangedEvent, + TemperatureChangedEvent, +) from ..unit_system import get_unit_system _logger = logging.getLogger(__name__) @@ -38,13 +47,13 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: Emits the following events when values change: - - ``temperature_changed(prev_temp, curr_temp)`` - - ``mode_changed(prev_mode, curr_mode)`` - - ``power_changed(prev_power, curr_power)`` - - ``heating_started(curr_status)`` - - ``heating_stopped(curr_status)`` - - ``error_detected(error_code, curr_status)`` - - ``error_cleared(prev_error_code)`` + - ``temperature_changed(TemperatureChangedEvent(...))`` + - ``mode_changed(ModeChangedEvent(...))`` + - ``power_changed(PowerChangedEvent(...))`` + - ``heating_started(HeatingStartedEvent(...))`` + - ``heating_stopped(HeatingStoppedEvent(...))`` + - ``error_detected(ErrorDetectedEvent(...))`` + - ``error_cleared(ErrorClearedEvent(...))`` Args: device_mac: MAC address used as the per-device key. @@ -61,8 +70,10 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: if status.dhw_temperature != prev.dhw_temperature: await self._event_emitter.emit( "temperature_changed", - prev.dhw_temperature, - status.dhw_temperature, + TemperatureChangedEvent( + old_temperature=prev.dhw_temperature, + new_temperature=status.dhw_temperature, + ), ) unit_suffix = "°C" if get_unit_system() == "metric" else "°F" _logger.debug( @@ -77,8 +88,10 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: if status.operation_mode != prev.operation_mode: await self._event_emitter.emit( "mode_changed", - prev.operation_mode, - status.operation_mode, + ModeChangedEvent( + old_mode=prev.operation_mode, + new_mode=status.operation_mode, + ), ) _logger.debug( "Mode changed: %s → %s", @@ -90,8 +103,10 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: if status.current_inst_power != prev.current_inst_power: await self._event_emitter.emit( "power_changed", - prev.current_inst_power, - status.current_inst_power, + PowerChangedEvent( + old_power=prev.current_inst_power, + new_power=status.current_inst_power, + ), ) _logger.debug( "Power changed: %sW → %sW", @@ -104,22 +119,35 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: curr_heating = status.current_inst_power > 0 if curr_heating and not prev_heating: - await self._event_emitter.emit("heating_started", status) + await self._event_emitter.emit( + "heating_started", + HeatingStartedEvent(status=status), + ) _logger.debug("Heating started") if not curr_heating and prev_heating: - await self._event_emitter.emit("heating_stopped", status) + await self._event_emitter.emit( + "heating_stopped", + HeatingStoppedEvent(status=status), + ) _logger.debug("Heating stopped") # Error detection / clearance if status.error_code and not prev.error_code: await self._event_emitter.emit( - "error_detected", status.error_code, status + "error_detected", + ErrorDetectedEvent( + error_code=status.error_code, + status=status, + ), ) _logger.info("Error detected: %s", status.error_code) if not status.error_code and prev.error_code: - await self._event_emitter.emit("error_cleared", prev.error_code) + await self._event_emitter.emit( + "error_cleared", + ErrorClearedEvent(error_code=prev.error_code), + ) _logger.info("Error cleared: %s", prev.error_code) except (TypeError, AttributeError, RuntimeError) as e: diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index 979f182..ab27acc 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -24,6 +24,7 @@ from ..events import EventEmitter from ..exceptions import MqttNotConnectedError from ..models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse +from ..mqtt_events import FeatureReceivedEvent, StatusReceivedEvent from ..topic_builder import MqttTopicBuilder from .state_tracker import DeviceStateTracker from .utils import redact_topic, topic_matches_pattern @@ -370,7 +371,10 @@ async def subscribe_device_status( def post_parse(status: DeviceStatus) -> None: self._schedule_coroutine( - self._event_emitter.emit("status_received", status) + self._event_emitter.emit( + "status_received", + StatusReceivedEvent(status=status), + ) ) self._schedule_coroutine( self._state_tracker.process(device_mac, status) @@ -431,7 +435,10 @@ def post_parse(feature: DeviceFeature) -> None: ) ) self._schedule_coroutine( - self._event_emitter.emit("feature_received", feature) + self._event_emitter.emit( + "feature_received", + FeatureReceivedEvent(feature=feature), + ) ) handler = self._make_handler( diff --git a/src/nwp500/mqtt_events.py b/src/nwp500/mqtt_events.py index 6276d47..4a62d7a 100644 --- a/src/nwp500/mqtt_events.py +++ b/src/nwp500/mqtt_events.py @@ -14,9 +14,12 @@ from nwp500.unit_system import get_unit_system # Type-safe event listening with autocomplete - def on_temperature_changed(old_temp, new_temp): + def on_temperature_changed(event): unit = "°C" if get_unit_system() == "metric" else "°F" - print(f"Temp: {old_temp}{unit} → {new_temp}{unit}") + print( + f"Temp: {event.old_temperature}{unit} → " + f"{event.new_temperature}{unit}" + ) mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, on_temperature_changed) @@ -177,11 +180,14 @@ class MqttClientEvents: mqtt_client.on( MqttClientEvents.TEMPERATURE_CHANGED, - lambda old_temp, new_temp: update_display(new_temp) + lambda event: update_display(event.new_temperature) ) # Wait for a specific event - await mqtt_client.wait_for(MqttClientEvents.CONNECTION_RESUMED) + args, _ = await mqtt_client.wait_for( + MqttClientEvents.CONNECTION_RESUMED + ) + connection_event = args[0] # List all available events events = ', '.join(MqttClientEvents.get_all_events()) @@ -196,7 +202,7 @@ class MqttClientEvents: """Emitted: MQTT connection interrupted with error. Args: - error (Exception): The error that caused the interruption + event (ConnectionInterruptedEvent): Event object with the error field. See: :class:`ConnectionInterruptedEvent` """ @@ -205,8 +211,8 @@ class MqttClientEvents: """Emitted: MQTT connection resumed after interruption. Args: - return_code (int): MQTT return code (0 = success) - session_present (bool): Whether session state was preserved + event (ConnectionResumedEvent): Event object with return_code and + session_present fields. See: :class:`ConnectionResumedEvent` """ @@ -216,7 +222,7 @@ class MqttClientEvents: """Emitted: Device status message received. Args: - status (DeviceStatus): Current device status snapshot + event (StatusReceivedEvent): Event object with the status field. See: :class:`StatusReceivedEvent` """ @@ -225,10 +231,8 @@ class MqttClientEvents: """Emitted: DHW temperature changed. Args: - old_temperature (float): Previous DHW temperature in user's - preferred unit - new_temperature (float): New DHW temperature in user's preferred - unit + event (TemperatureChangedEvent): Event object with old_temperature + and new_temperature fields. See: :class:`TemperatureChangedEvent` """ @@ -237,8 +241,8 @@ class MqttClientEvents: """Emitted: Device operation mode changed. Args: - old_mode (CurrentOperationMode): Previous mode - new_mode (CurrentOperationMode): New mode + event (ModeChangedEvent): Event object with old_mode and new_mode + fields. See: :class:`ModeChangedEvent` """ @@ -247,8 +251,8 @@ class MqttClientEvents: """Emitted: Instantaneous power consumption changed. Args: - old_power (float): Previous power consumption (W) - new_power (float): New power consumption (W) + event (PowerChangedEvent): Event object with old_power and new_power + fields. See: :class:`PowerChangedEvent` """ @@ -258,7 +262,7 @@ class MqttClientEvents: """Emitted: Device started heating. Args: - status (DeviceStatus): Device status when heating started + event (HeatingStartedEvent): Event object with the status field. See: :class:`HeatingStartedEvent` """ @@ -267,7 +271,7 @@ class MqttClientEvents: """Emitted: Device stopped heating. Args: - status (DeviceStatus): Device status when heating stopped + event (HeatingStoppedEvent): Event object with the status field. See: :class:`HeatingStoppedEvent` """ @@ -277,8 +281,8 @@ class MqttClientEvents: """Emitted: Device error detected. Args: - error_code (ErrorCode): The error code - status (DeviceStatus): Status when error was detected + event (ErrorDetectedEvent): Event object with error_code and status + fields. See: :class:`ErrorDetectedEvent` """ @@ -287,7 +291,7 @@ class MqttClientEvents: """Emitted: Device error cleared. Args: - error_code (ErrorCode): The error code that was cleared + event (ErrorClearedEvent): Event object with the error_code field. See: :class:`ErrorClearedEvent` """ @@ -297,7 +301,7 @@ class MqttClientEvents: """Emitted: Device feature information received. Args: - feature (DeviceFeature): Device feature information + event (FeatureReceivedEvent): Event object with the feature field. See: :class:`FeatureReceivedEvent` """ From e033d039bf2aa116d8252d0ef9038736b4c2ff75 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 00:15:38 -0700 Subject: [PATCH 05/29] Phases 5+6+7: Typed event payloads, new protocol models, missing control methods Phase 5: Typed event payloads (already applied in state_tracker.py / client.py) - ConnectionInterruptedEvent, ConnectionResumedEvent wrapped on emit Phase 6: New protocol models in models.py - WeeklyReservationEntry + WeeklyReservationSchedule (RESERVATION_WEEKLY) - RecirculationScheduleEntry + RecirculationSchedule (RECIR_RESERVATION) - OtaCommitPayload (OTA_COMMIT - commitOta structure) - Export all new models from __init__.py Phase 7: Implement 9 missing control methods in mqtt/control.py - update_weekly_reservation(device, WeeklyReservationSchedule) - check_firmware_update(device) - commit_firmware_update(device, OtaCommitPayload) - reconnect_wifi(device) - reset_wifi(device) - set_freeze_protection_temperature(device, temperature) - run_smart_diagnostic(device) - enable_intelligent_scheduling(device) - disable_intelligent_scheduling(device) - configure_recirculation_schedule: typed RecirculationSchedule (was dict) --- src/nwp500/__init__.py | 10 ++ src/nwp500/models.py | 214 +++++++++++++++++++++++++++++++++++++ src/nwp500/mqtt/control.py | 188 +++++++++++++++++++++++++++++++- 3 files changed, 407 insertions(+), 5 deletions(-) diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 20aacc7..48a1400 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -111,10 +111,15 @@ MonthlyEnergyData, MqttCommand, MqttRequest, + OtaCommitPayload, + RecirculationSchedule, + RecirculationScheduleEntry, ReservationEntry, ReservationSchedule, TOUInfo, TOUSchedule, + WeeklyReservationEntry, + WeeklyReservationSchedule, fahrenheit_to_half_celsius, preferred_to_half_celsius, reservation_param_to_preferred, @@ -168,6 +173,11 @@ "FirmwareInfo", "ReservationEntry", "ReservationSchedule", + "WeeklyReservationEntry", + "WeeklyReservationSchedule", + "RecirculationScheduleEntry", + "RecirculationSchedule", + "OtaCommitPayload", "TOUSchedule", "TOUInfo", "MqttRequest", diff --git a/src/nwp500/models.py b/src/nwp500/models.py index ec443b9..f6b4402 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -356,6 +356,220 @@ def enabled(self) -> bool: return self.reservation_use == 2 +class WeeklyReservationEntry(NavienBaseModel): + """A single entry in a weekly temperature reservation schedule. + + Similar to :class:`ReservationEntry` but used with the RESERVATION_WEEKLY + command (33554438), which configures a separate weekly temperature schedule + independent of the timed reservation system. + + The raw protocol fields mirror the standard reservation format: + - enable: 2=enabled, 1=disabled (device boolean) + - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) + - hour: 0-23 + - min: 0-59 + - mode: DHW operation mode ID (1-6) + - param: temperature in half-degrees Celsius + """ + + enable: int = 2 + week: int = 0 + hour: int = 0 + min: int = 0 + mode: int = 1 + param: int = 0 + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether this entry is active (device bool: 2=on, 1=off).""" + return self.enable == 2 + + @computed_field # type: ignore[prop-decorator] + @property + def days(self) -> list[str]: + """Weekday names for this entry.""" + from .encoding import decode_week_bitfield + + return decode_week_bitfield(self.week) + + @computed_field # type: ignore[prop-decorator] + @property + def time(self) -> str: + """Formatted time string (HH:MM).""" + return f"{self.hour:02d}:{self.min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def temperature(self) -> float: + """Temperature in the user's preferred unit.""" + return reservation_param_to_preferred(self.param) + + @computed_field # type: ignore[prop-decorator] + @property + def unit(self) -> str: + """Temperature unit symbol.""" + return "°C" if get_unit_system() == "metric" else "°F" + + @computed_field # type: ignore[prop-decorator] + @property + def mode_name(self) -> str: + """Human-readable operation mode name.""" + try: + return DHW_OPERATION_SETTING_TEXT.get( + DhwOperationSetting(self.mode), f"Unknown ({self.mode})" + ) + except ValueError: + return f"Unknown ({self.mode})" + + +class WeeklyReservationSchedule(NavienBaseModel): + """Complete weekly reservation schedule (RESERVATION_WEEKLY command). + + Used with command code 33554438 to configure a temperature schedule + that repeats weekly. Accepts the same hex-encoded format as the + standard reservation schedule. + """ + + reservation_use: int = Field(default=0, alias="reservationUse") + reservation: list[WeeklyReservationEntry] = Field(default_factory=list) + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @model_validator(mode="before") + @classmethod + def _decode_hex_reservation(cls, data: Any) -> Any: + """Decode hex-encoded reservation string into entry list.""" + if isinstance(data, dict): + d = cast(dict[str, Any], data).copy() + raw = d.get("reservation", "") + if isinstance(raw, str): + if raw: + from .encoding import decode_reservation_hex + + d["reservation"] = decode_reservation_hex(raw) + else: + d["reservation"] = [] + return d + return data + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether the weekly reservation system is globally enabled. + + Device bool convention: 2=on, 1=off. + """ + return self.reservation_use == 2 + + +class RecirculationScheduleEntry(NavienBaseModel): + """A single entry in a recirculation pump schedule. + + Used with the RECIR_RESERVATION command (33554444) to set timed + recirculation cycles. Each entry defines a time window and pump mode. + + Fields: + - enable: 2=enabled, 1=disabled (device boolean) + - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) + - start_hour: 0-23 + - start_min: 0-59 + - end_hour: 0-23 + - end_min: 0-59 + - mode: recirculation mode + (1=Constant, 2=Timer, 3=Temperature, 4=Sensor) + """ + + enable: int = 2 + week: int = 0 + start_hour: int = Field(default=0, alias="startHour") + start_min: int = Field(default=0, alias="startMin") + end_hour: int = Field(default=0, alias="endHour") + end_min: int = Field(default=0, alias="endMin") + mode: int = 1 + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether this entry is active (device bool: 2=on, 1=off).""" + return self.enable == 2 + + @computed_field # type: ignore[prop-decorator] + @property + def days(self) -> list[str]: + """Weekday names for this entry.""" + from .encoding import decode_week_bitfield + + return decode_week_bitfield(self.week) + + @computed_field # type: ignore[prop-decorator] + @property + def start_time(self) -> str: + """Formatted start time string (HH:MM).""" + return f"{self.start_hour:02d}:{self.start_min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def end_time(self) -> str: + """Formatted end time string (HH:MM).""" + return f"{self.end_hour:02d}:{self.end_min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def mode_name(self) -> str: + """Human-readable recirculation mode name.""" + try: + return RecirculationMode(self.mode).name.replace("_", " ").title() + except ValueError: + return f"Unknown ({self.mode})" + + +class RecirculationSchedule(NavienBaseModel): + """Complete recirculation pump schedule (RECIR_RESERVATION command). + + Used with command code 33554444 to configure timed recirculation + pump operation windows. + """ + + schedule: list[RecirculationScheduleEntry] = Field(default_factory=list) + + +class OtaCommitPayload(NavienBaseModel): + """Payload for committing a firmware component update. + + Used with the OTA_COMMIT command (33554442). This command uses a + special ``commitOta`` structure instead of the standard mode/param + format. + + Args: + sw_code: Software component code identifying which firmware to commit. + 1 = Controller, 2 = Panel, 4 = WiFi/communication module. + sw_version: Version number to commit (as reported by the OTA check). + """ + + sw_code: int = Field(alias="swCode") + sw_version: int = Field(alias="swVersion") + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + class DeviceStatus(NavienBaseModel): """Represents the status of the Navien water heater device.""" diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 0828ca3..604b653 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -35,6 +35,9 @@ from ..models import ( Device, DeviceFeature, + OtaCommitPayload, + RecirculationSchedule, + WeeklyReservationSchedule, preferred_to_half_celsius, ) from ..topic_builder import MqttTopicBuilder @@ -653,6 +656,29 @@ async def set_vacation_days(self, device: Device, days: int) -> int: device, DhwOperationSetting.VACATION.value, vacation_days=days ) + @requires_capability("program_reservation_use") + async def update_weekly_reservation( + self, device: Device, schedule: WeeklyReservationSchedule + ) -> int: + """Configure the weekly temperature reservation schedule. + + Sends the complete weekly schedule to the device using command + code RESERVATION_WEEKLY (33554438). + + Args: + device: Device to configure + schedule: Weekly reservation schedule with entries for each + time slot + + Returns: + Publish packet ID + """ + return await self._send_command( + device=device, + command_code=CommandCode.RESERVATION_WEEKLY, + reservation=schedule.model_dump(by_alias=True), + ) + @requires_capability("program_reservation_use") async def configure_reservation_water_program(self, device: Device) -> int: """Enable/configure water program reservation mode.""" @@ -664,16 +690,22 @@ async def configure_reservation_water_program(self, device: Device) -> int: async def configure_recirculation_schedule( self, device: Device, - schedule: dict[str, Any], + schedule: RecirculationSchedule, ) -> int: - """ - Configure recirculation pump schedule. - ... + """Configure the recirculation pump timed schedule. + + Args: + device: Device to configure + schedule: Recirculation schedule with one or more time window + entries + + Returns: + Publish packet ID """ return await self._send_command( device=device, command_code=CommandCode.RECIR_RESERVATION, - schedule=schedule, + schedule=schedule.model_dump(by_alias=True), ) @requires_capability("recirculation_use") @@ -690,3 +722,149 @@ async def trigger_recirculation_hot_button(self, device: Device) -> int: return await self._mode_command( device, CommandCode.RECIR_HOT_BTN, "recirc-hotbtn", [1] ) + + async def check_firmware_update(self, device: Device) -> int: + """Check for available over-the-air firmware updates. + + Sends the OTA_CHECK command (33554443) to query whether a firmware + update is available. The device responds on the control ack topic. + + Args: + device: Device to check for updates + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.OTA_CHECK, "ota-check" + ) + + async def commit_firmware_update( + self, device: Device, payload: OtaCommitPayload + ) -> int: + """Commit a previously downloaded firmware update. + + Sends the OTA_COMMIT command (33554442) with a special + ``commitOta`` structure (not the standard mode/param format). + + Args: + device: Device to update + payload: OTA commit payload specifying which firmware component + and version to commit. + + Returns: + Publish packet ID + """ + return await self._send_command( + device=device, + command_code=CommandCode.OTA_COMMIT, + commitOta=payload.model_dump(by_alias=True), + ) + + async def reconnect_wifi(self, device: Device) -> int: + """Trigger a WiFi reconnection on the device. + + Sends the WIFI_RECONNECT command (33554446). Useful when the + device has lost its WiFi connection and needs to re-associate. + + Args: + device: Device to reconnect + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.WIFI_RECONNECT, "wifi-reconnect" + ) + + async def reset_wifi(self, device: Device) -> int: + """Reset WiFi settings to factory defaults. + + Sends the WIFI_RESET command (33554447). This will clear stored + WiFi credentials and require re-provisioning the device. + + .. warning:: + This operation clears all stored WiFi credentials. The device + will need to be re-provisioned to reconnect to the network. + + Args: + device: Device to reset + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.WIFI_RESET, "wifi-reset" + ) + + async def set_freeze_protection_temperature( + self, device: Device, temperature: float + ) -> int: + """Set the freeze protection activation temperature. + + Sends the FREZ_TEMP command (33554451). The device activates + freeze protection heating when the ambient temperature drops + below this threshold. + + Args: + device: Device to configure + temperature: Activation temperature in the user's preferred unit + (°C if unit system is metric, °F otherwise). + Valid range: 35–45°F (1.7–7.2°C). + + Returns: + Publish packet ID + """ + raw = preferred_to_half_celsius(temperature) + return await self._mode_command( + device, CommandCode.FREZ_TEMP, "frez-temp", [raw] + ) + + async def run_smart_diagnostic(self, device: Device) -> int: + """Trigger the smart diagnostic routine on the device. + + Sends the SMART_DIAGNOSTIC command (33554455). The diagnostic + result is reflected in the ``smart_diagnostic`` field of the next + :class:`~nwp500.models.DeviceStatus` update. + + Args: + device: Device to diagnose + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.SMART_DIAGNOSTIC, "smart-diagnostic" + ) + + async def enable_intelligent_scheduling(self, device: Device) -> int: + """Enable intelligent/adaptive heating mode. + + Sends the RESERVATION_INTELLIGENT_ON command (33554468). In this + mode the device learns usage patterns and pre-heats water + proactively to reduce energy consumption. + + Args: + device: Device to configure + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.RESERVATION_INTELLIGENT_ON, "intelligent-on" + ) + + async def disable_intelligent_scheduling(self, device: Device) -> int: + """Disable intelligent/adaptive heating mode. + + Sends the RESERVATION_INTELLIGENT_OFF command (33554467). + + Args: + device: Device to configure + + Returns: + Publish packet ID + """ + return await self._mode_command( + device, CommandCode.RESERVATION_INTELLIGENT_OFF, "intelligent-off" + ) From 9a927490ce20fed3ad71d65877d49417d4efff81 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 00:20:49 -0700 Subject: [PATCH 06/29] Phase 8: Typed subscriptions for reservation, weekly, and recirculation responses - Add from_dict() to ReservationSchedule, WeeklyReservationSchedule, RecirculationSchedule - Add subscribe_reservation_response() to MqttSubscriptionManager - Add subscribe_weekly_reservation_response() to MqttSubscriptionManager - Add subscribe_recirculation_schedule_response() to MqttSubscriptionManager - Proxy all three new methods on NavienMqttClient - Refactor fetch_reservations() to use typed subscribe_reservation_response() instead of raw mqtt.subscribe() with manual topic/message filtering - Update tests to match new typed callback interface --- src/nwp500/models.py | 15 ++++++ src/nwp500/mqtt/client.py | 33 ++++++++++++ src/nwp500/mqtt/subscriptions.py | 90 +++++++++++++++++++++++++++++++- src/nwp500/reservations.py | 29 ++++------ tests/test_reservations.py | 45 ++++++++-------- 5 files changed, 168 insertions(+), 44 deletions(-) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index f6b4402..d1d9a8b 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -355,6 +355,11 @@ def enabled(self) -> bool: """ return self.reservation_use == 2 + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ReservationSchedule: + """Construct from a raw MQTT response dict.""" + return cls.model_validate(data) + class WeeklyReservationEntry(NavienBaseModel): """A single entry in a weekly temperature reservation schedule. @@ -467,6 +472,11 @@ def enabled(self) -> bool: """ return self.reservation_use == 2 + @classmethod + def from_dict(cls, data: dict[str, Any]) -> WeeklyReservationSchedule: + """Construct from a raw MQTT response dict.""" + return cls.model_validate(data) + class RecirculationScheduleEntry(NavienBaseModel): """A single entry in a recirculation pump schedule. @@ -545,6 +555,11 @@ class RecirculationSchedule(NavienBaseModel): schedule: list[RecirculationScheduleEntry] = Field(default_factory=list) + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RecirculationSchedule: + """Construct from a raw MQTT response dict.""" + return cls.model_validate(data) + class OtaCommitPayload(NavienBaseModel): """Payload for committing a firmware component update. diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 8a9dc06..05167d6 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -54,6 +54,9 @@ DeviceFeature, DeviceStatus, EnergyUsageResponse, + RecirculationSchedule, + ReservationSchedule, + WeeklyReservationSchedule, ) __author__ = "Emmanuel Levijarvi" @@ -926,6 +929,36 @@ async def subscribe_energy_usage( "subscribe_energy_usage", device, callback ) + async def subscribe_reservation_response( + self, + device: Device, + callback: Callable[[ReservationSchedule], None], + ) -> int: + """Subscribe to reservation read responses with automatic parsing.""" + return await self._delegate_subscription( + "subscribe_reservation_response", device, callback + ) + + async def subscribe_weekly_reservation_response( + self, + device: Device, + callback: Callable[[WeeklyReservationSchedule], None], + ) -> int: + """Subscribe to weekly reservation read responses.""" + return await self._delegate_subscription( + "subscribe_weekly_reservation_response", device, callback + ) + + async def subscribe_recirculation_schedule_response( + self, + device: Device, + callback: Callable[[RecirculationSchedule], None], + ) -> int: + """Subscribe to recirculation schedule read responses.""" + return await self._delegate_subscription( + "subscribe_recirculation_schedule_response", device, callback + ) + async def ensure_device_info_cached( self, device: Device, timeout: float = 30.0 ) -> bool: diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index ab27acc..f2fd91a 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -23,7 +23,15 @@ from ..events import EventEmitter from ..exceptions import MqttNotConnectedError -from ..models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse +from ..models import ( + Device, + DeviceFeature, + DeviceStatus, + EnergyUsageResponse, + RecirculationSchedule, + ReservationSchedule, + WeeklyReservationSchedule, +) from ..mqtt_events import FeatureReceivedEvent, StatusReceivedEvent from ..topic_builder import MqttTopicBuilder from .state_tracker import DeviceStateTracker @@ -485,6 +493,86 @@ async def subscribe_energy_usage( ) return await self.subscribe(topic, handler) + async def subscribe_reservation_response( + self, + device: Device, + callback: Callable[[ReservationSchedule], None], + ) -> int: + """Subscribe to reservation read responses with automatic parsing. + + Subscribes to the ``rsv/rd`` response topic for the given device. + The callback receives a fully-parsed + :class:`~nwp500.models.ReservationSchedule` whenever the device + responds to a reservation read request. + + Args: + device: Device whose reservation responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + handler = self._make_handler(ReservationSchedule, callback) + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "rsv/rd", + ) + return await self.subscribe(topic, handler) + + async def subscribe_weekly_reservation_response( + self, + device: Device, + callback: Callable[[WeeklyReservationSchedule], None], + ) -> int: + """Subscribe to weekly reservation read responses. + + Subscribes to the ``rsv-weekly/rd`` response topic for the given + device. The callback receives a + :class:`~nwp500.models.WeeklyReservationSchedule` + whenever the device responds to a weekly reservation read request. + + Args: + device: Device whose weekly reservation responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + handler = self._make_handler(WeeklyReservationSchedule, callback) + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "rsv-weekly/rd", + ) + return await self.subscribe(topic, handler) + + async def subscribe_recirculation_schedule_response( + self, + device: Device, + callback: Callable[[RecirculationSchedule], None], + ) -> int: + """Subscribe to recirculation schedule read responses. + + Subscribes to the ``recirc-rsv/rd`` response topic for the given device. + The callback receives a :class:`~nwp500.models.RecirculationSchedule` + whenever the device responds to a recirculation schedule read request. + + Args: + device: Device whose recirculation schedule responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + handler = self._make_handler(RecirculationSchedule, callback) + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "recirc-rsv/rd", + ) + return await self.subscribe(topic, handler) + def clear_subscriptions(self) -> None: """Clear all subscription tracking (called on disconnect).""" self._subscriptions.clear() diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py index bd40b27..5be4465 100644 --- a/src/nwp500/reservations.py +++ b/src/nwp500/reservations.py @@ -14,11 +14,10 @@ import asyncio import logging from collections.abc import Sequence -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from .encoding import build_reservation_entry, encode_week_bitfield from .models import ReservationSchedule -from .topic_builder import MqttTopicBuilder if TYPE_CHECKING: from .models import Device @@ -59,31 +58,23 @@ async def fetch_reservations( asyncio.get_running_loop().create_future() ) - def raw_callback(topic: str, message: dict[str, Any]) -> None: - if ( - future.done() - or "response" not in message - or "/res/rsv/" not in topic - ): - return - response = message.get("response", {}) - # Ensure it's actually a reservation response (not some other /res/ msg) - if "reservationUse" not in response and "reservation" not in response: - return - schedule = ReservationSchedule(**response) - future.set_result(schedule) + def on_schedule(schedule: ReservationSchedule) -> None: + if not future.done(): + future.set_result(schedule) device_type = str(device.device_info.device_type) - response_topic = MqttTopicBuilder.response_topic( - device_type, mqtt.client_id, "rsv/rd" - ) - await mqtt.subscribe(response_topic, raw_callback) + await mqtt.subscribe_reservation_response(device, on_schedule) await mqtt.control.request_reservations(device) try: return await asyncio.wait_for(future, timeout=timeout) except TimeoutError: return None finally: + from .topic_builder import MqttTopicBuilder + + response_topic = MqttTopicBuilder.response_topic( + device_type, mqtt.client_id, "rsv/rd" + ) try: await mqtt.unsubscribe(response_topic) except Exception: diff --git a/tests/test_reservations.py b/tests/test_reservations.py index 2bf3a25..44f9b39 100644 --- a/tests/test_reservations.py +++ b/tests/test_reservations.py @@ -32,6 +32,7 @@ def mock_mqtt(mock_device: MagicMock) -> MagicMock: mqtt = MagicMock() mqtt.client_id = "test-client" mqtt.subscribe = AsyncMock() + mqtt.subscribe_reservation_response = AsyncMock() mqtt.unsubscribe = AsyncMock() mqtt.control.request_reservations = AsyncMock() mqtt.control.update_reservations = AsyncMock() @@ -79,31 +80,22 @@ async def test_fetch_reservations_success( schedule = _make_schedule([_entry()]) captured_callback: list[Any] = [] - async def fake_subscribe(topic: str, cb: Any) -> int: + async def fake_subscribe_reservation(device: Any, cb: Any) -> int: captured_callback.append(cb) return 1 - mock_mqtt.subscribe.side_effect = fake_subscribe + mock_mqtt.subscribe_reservation_response.side_effect = ( + fake_subscribe_reservation + ) async def fake_request(device: Any) -> None: # Simulate the device response arriving after subscribe - topic = "cmd/NWP500/test-client/res/rsv/rd" - msg = { - "response": { - "reservationUse": 2, - "reservation": "023e061e0478", - } - } for cb in captured_callback: - cb(topic, msg) + cb(schedule) mock_mqtt.control.request_reservations.side_effect = fake_request - with patch( - "nwp500.reservations.ReservationSchedule", - return_value=schedule, - ): - result = await fetch_reservations(mock_mqtt, mock_device) + result = await fetch_reservations(mock_mqtt, mock_device) assert result is schedule mock_mqtt.unsubscribe.assert_called_once_with( @@ -116,7 +108,7 @@ async def test_fetch_reservations_timeout( mock_mqtt: MagicMock, mock_device: MagicMock ) -> None: """fetch_reservations returns None on timeout and still unsubscribes.""" - mock_mqtt.subscribe = AsyncMock() + mock_mqtt.subscribe_reservation_response = AsyncMock() mock_mqtt.control.request_reservations = AsyncMock() # never fires callback result = await fetch_reservations(mock_mqtt, mock_device, timeout=0.01) @@ -128,27 +120,32 @@ async def test_fetch_reservations_timeout( @pytest.mark.asyncio -async def test_fetch_reservations_ignores_wrong_topic( +async def test_fetch_reservations_ignores_multiple_responses( mock_mqtt: MagicMock, mock_device: MagicMock ) -> None: - """fetch_reservations ignores messages on non-reservation topics.""" + """fetch_reservations resolves on first response, ignores later ones.""" + schedule = _make_schedule([_entry()]) + second_schedule = _make_schedule([_entry(hour=9)]) captured_callback: list[Any] = [] - async def fake_subscribe(topic: str, cb: Any) -> int: + async def fake_subscribe_reservation(device: Any, cb: Any) -> int: captured_callback.append(cb) return 1 - mock_mqtt.subscribe.side_effect = fake_subscribe + mock_mqtt.subscribe_reservation_response.side_effect = ( + fake_subscribe_reservation + ) async def fake_request(device: Any) -> None: - # Wrong topic — should be ignored + # Fire callback twice — only the first should resolve the future for cb in captured_callback: - cb("cmd/NWP500/test-client/res/other/rd", {"response": {"foo": 1}}) + cb(schedule) + cb(second_schedule) mock_mqtt.control.request_reservations.side_effect = fake_request - result = await fetch_reservations(mock_mqtt, mock_device, timeout=0.01) - assert result is None + result = await fetch_reservations(mock_mqtt, mock_device) + assert result is schedule # --------------------------------------------------------------------------- From 8dc9835c34cf7f0a5328475917d3fa2af741890c Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 00:36:42 -0700 Subject: [PATCH 07/29] Phase 9a: Proxy all MqttDeviceController methods on NavienMqttClient Add proxy methods for all 31 public MqttDeviceController methods directly on NavienMqttClient, so users don't need to use mqtt.control.method(): - request_device_status, request_device_info - set_power, set_dhw_mode, set_dhw_temperature, set_vacation_days - enable/disable_anti_legionella - update_reservations, request_reservations - update_weekly_reservation, configure_reservation_water_program - configure_recirculation_schedule, set_recirculation_mode - trigger_recirculation_hot_button - configure_tou_schedule, request_tou_settings, set_tou_enabled - request_energy_usage, signal_app_connection - enable/disable_demand_response, reset_air_filter - check_firmware_update, commit_firmware_update - reconnect_wifi, reset_wifi, set_freeze_protection_temperature - run_smart_diagnostic - enable/disable_intelligent_scheduling Update reservations.py to use mqtt.request_reservations() and mqtt.update_reservations() directly instead of mqtt.control.* Update tests to match new mock interface. --- src/nwp500/mqtt/client.py | 172 ++++++++++++++++++++++++++++++++++++- src/nwp500/reservations.py | 10 +-- tests/test_reservations.py | 26 +++--- 3 files changed, 188 insertions(+), 20 deletions(-) diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 05167d6..49a7f69 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -15,7 +15,7 @@ import json import logging import uuid -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any, cast from awscrt import mqtt @@ -54,6 +54,7 @@ DeviceFeature, DeviceStatus, EnergyUsageResponse, + OtaCommitPayload, RecirculationSchedule, ReservationSchedule, WeeklyReservationSchedule, @@ -959,6 +960,175 @@ async def subscribe_recirculation_schedule_response( "subscribe_recirculation_schedule_response", device, callback ) + # ------------------------------------------------------------------------- + # Device control proxies (delegate to self.control) + # ------------------------------------------------------------------------- + + async def request_device_status(self, device: Device) -> int: + """Request general device status.""" + return await self.control.request_device_status(device) + + async def request_device_info(self, device: Device) -> int: + """Request device information (features, firmware, etc.).""" + return await self.control.request_device_info(device) + + async def set_power(self, device: Device, power_on: bool) -> int: + """Turn device on or off.""" + return await self.control.set_power(device, power_on) + + async def set_dhw_mode( + self, device: Device, mode_id: int, vacation_days: int | None = None + ) -> int: + """Set DHW operation mode.""" + return await self.control.set_dhw_mode(device, mode_id, vacation_days) + + async def enable_anti_legionella( + self, device: Device, period_days: int + ) -> int: + """Enable Anti-Legionella disinfection.""" + return await self.control.enable_anti_legionella(device, period_days) + + async def disable_anti_legionella(self, device: Device) -> int: + """Disable the Anti-Legionella disinfection cycle.""" + return await self.control.disable_anti_legionella(device) + + async def set_dhw_temperature( + self, device: Device, temperature: float + ) -> int: + """Set DHW target temperature in the user's preferred unit.""" + return await self.control.set_dhw_temperature(device, temperature) + + async def update_reservations( + self, + device: Device, + reservations: Sequence[dict[str, Any]], + *, + enabled: bool = True, + ) -> int: + """Update programmed reservations.""" + return await self.control.update_reservations( + device, reservations, enabled=enabled + ) + + async def request_reservations(self, device: Device) -> int: + """Request the current reservation program from the device.""" + return await self.control.request_reservations(device) + + async def configure_tou_schedule( + self, + device: Device, + controller_serial_number: str, + periods: Sequence[dict[str, Any]], + *, + enabled: bool = True, + ) -> int: + """Configure the Time-of-Use rate schedule.""" + return await self.control.configure_tou_schedule( + device, controller_serial_number, periods, enabled=enabled + ) + + async def request_tou_settings( + self, device: Device, controller_serial_number: str + ) -> int: + """Request the current TOU settings from the device.""" + return await self.control.request_tou_settings( + device, controller_serial_number + ) + + async def set_tou_enabled(self, device: Device, enabled: bool) -> int: + """Enable or disable Time-of-Use optimization.""" + return await self.control.set_tou_enabled(device, enabled) + + async def request_energy_usage( + self, device: Device, year: int, months: list[int] + ) -> int: + """Request daily energy usage data for specified month(s).""" + return await self.control.request_energy_usage(device, year, months) + + async def signal_app_connection(self, device: Device) -> int: + """Signal that the app has connected.""" + return await self.control.signal_app_connection(device) + + async def enable_demand_response(self, device: Device) -> int: + """Enable utility demand response participation.""" + return await self.control.enable_demand_response(device) + + async def disable_demand_response(self, device: Device) -> int: + """Disable utility demand response participation.""" + return await self.control.disable_demand_response(device) + + async def reset_air_filter(self, device: Device) -> int: + """Reset air filter maintenance timer.""" + return await self.control.reset_air_filter(device) + + async def set_vacation_days(self, device: Device, days: int) -> int: + """Set vacation/away mode duration (1-30 days).""" + return await self.control.set_vacation_days(device, days) + + async def update_weekly_reservation( + self, device: Device, schedule: WeeklyReservationSchedule + ) -> int: + """Configure the weekly temperature reservation schedule.""" + return await self.control.update_weekly_reservation(device, schedule) + + async def configure_reservation_water_program(self, device: Device) -> int: + """Enable/configure water program reservation mode.""" + return await self.control.configure_reservation_water_program(device) + + async def configure_recirculation_schedule( + self, device: Device, schedule: RecirculationSchedule + ) -> int: + """Configure the recirculation pump timed schedule.""" + return await self.control.configure_recirculation_schedule( + device, schedule + ) + + async def set_recirculation_mode(self, device: Device, mode: int) -> int: + """Set recirculation pump operation mode (1-4).""" + return await self.control.set_recirculation_mode(device, mode) + + async def trigger_recirculation_hot_button(self, device: Device) -> int: + """Manually trigger the recirculation pump hot button.""" + return await self.control.trigger_recirculation_hot_button(device) + + async def check_firmware_update(self, device: Device) -> int: + """Check for available over-the-air firmware updates.""" + return await self.control.check_firmware_update(device) + + async def commit_firmware_update( + self, device: Device, payload: OtaCommitPayload + ) -> int: + """Commit a previously downloaded firmware update.""" + return await self.control.commit_firmware_update(device, payload) + + async def reconnect_wifi(self, device: Device) -> int: + """Trigger a WiFi reconnection on the device.""" + return await self.control.reconnect_wifi(device) + + async def reset_wifi(self, device: Device) -> int: + """Reset WiFi settings to factory defaults.""" + return await self.control.reset_wifi(device) + + async def set_freeze_protection_temperature( + self, device: Device, temperature: float + ) -> int: + """Set the freeze protection activation temperature.""" + return await self.control.set_freeze_protection_temperature( + device, temperature + ) + + async def run_smart_diagnostic(self, device: Device) -> int: + """Trigger the smart diagnostic routine on the device.""" + return await self.control.run_smart_diagnostic(device) + + async def enable_intelligent_scheduling(self, device: Device) -> int: + """Enable intelligent/adaptive heating mode.""" + return await self.control.enable_intelligent_scheduling(device) + + async def disable_intelligent_scheduling(self, device: Device) -> int: + """Disable intelligent/adaptive heating mode.""" + return await self.control.disable_intelligent_scheduling(device) + async def ensure_device_info_cached( self, device: Device, timeout: float = 30.0 ) -> bool: diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py index 5be4465..d17fab2 100644 --- a/src/nwp500/reservations.py +++ b/src/nwp500/reservations.py @@ -64,7 +64,7 @@ def on_schedule(schedule: ReservationSchedule) -> None: device_type = str(device.device_info.device_type) await mqtt.subscribe_reservation_response(device, on_schedule) - await mqtt.control.request_reservations(device) + await mqtt.request_reservations(device) try: return await asyncio.wait_for(future, timeout=timeout) except TimeoutError: @@ -146,9 +146,7 @@ async def add_reservation( ] current_reservations.append(reservation_entry) - await mqtt.control.update_reservations( - device, current_reservations, enabled=True - ) + await mqtt.update_reservations(device, current_reservations, enabled=True) async def delete_reservation( @@ -191,7 +189,7 @@ async def delete_reservation( still_enabled = schedule.enabled and len(current_reservations) > 0 - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, current_reservations, enabled=still_enabled ) @@ -285,7 +283,7 @@ async def update_reservation( ] current_reservations[index - 1] = new_entry - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, current_reservations, enabled=schedule.enabled ) diff --git a/tests/test_reservations.py b/tests/test_reservations.py index 44f9b39..d380de7 100644 --- a/tests/test_reservations.py +++ b/tests/test_reservations.py @@ -34,8 +34,8 @@ def mock_mqtt(mock_device: MagicMock) -> MagicMock: mqtt.subscribe = AsyncMock() mqtt.subscribe_reservation_response = AsyncMock() mqtt.unsubscribe = AsyncMock() - mqtt.control.request_reservations = AsyncMock() - mqtt.control.update_reservations = AsyncMock() + mqtt.request_reservations = AsyncMock() + mqtt.update_reservations = AsyncMock() return mqtt @@ -93,7 +93,7 @@ async def fake_request(device: Any) -> None: for cb in captured_callback: cb(schedule) - mock_mqtt.control.request_reservations.side_effect = fake_request + mock_mqtt.request_reservations.side_effect = fake_request result = await fetch_reservations(mock_mqtt, mock_device) @@ -109,7 +109,7 @@ async def test_fetch_reservations_timeout( ) -> None: """fetch_reservations returns None on timeout and still unsubscribes.""" mock_mqtt.subscribe_reservation_response = AsyncMock() - mock_mqtt.control.request_reservations = AsyncMock() # never fires callback + mock_mqtt.request_reservations = AsyncMock() # never fires callback result = await fetch_reservations(mock_mqtt, mock_device, timeout=0.01) @@ -142,7 +142,7 @@ async def fake_request(device: Any) -> None: cb(schedule) cb(second_schedule) - mock_mqtt.control.request_reservations.side_effect = fake_request + mock_mqtt.request_reservations.side_effect = fake_request result = await fetch_reservations(mock_mqtt, mock_device) assert result is schedule @@ -173,8 +173,8 @@ async def test_add_reservation_success( temperature=120.0, ) - mock_mqtt.control.update_reservations.assert_called_once() - _, reservations = mock_mqtt.control.update_reservations.call_args.args + mock_mqtt.update_reservations.assert_called_once() + _, reservations = mock_mqtt.update_reservations.call_args.args assert len(reservations) == 2 @@ -264,8 +264,8 @@ async def test_delete_reservation_success( with patch("nwp500.reservations.fetch_reservations", return_value=schedule): await delete_reservation(mock_mqtt, mock_device, index=1) - mock_mqtt.control.update_reservations.assert_called_once() - _, reservations = mock_mqtt.control.update_reservations.call_args.args + mock_mqtt.update_reservations.assert_called_once() + _, reservations = mock_mqtt.update_reservations.call_args.args assert len(reservations) == 1 assert reservations[0]["hour"] == 8 @@ -280,7 +280,7 @@ async def test_delete_reservation_disables_when_empty( with patch("nwp500.reservations.fetch_reservations", return_value=schedule): await delete_reservation(mock_mqtt, mock_device, index=1) - enabled = mock_mqtt.control.update_reservations.call_args.kwargs["enabled"] + enabled = mock_mqtt.update_reservations.call_args.kwargs["enabled"] assert enabled is False @@ -319,8 +319,8 @@ async def test_update_reservation_temperature( with patch("nwp500.reservations.fetch_reservations", return_value=schedule): await update_reservation(mock_mqtt, mock_device, 1, temperature=150.0) - mock_mqtt.control.update_reservations.assert_called_once() - _, reservations = mock_mqtt.control.update_reservations.call_args.args + mock_mqtt.update_reservations.assert_called_once() + _, reservations = mock_mqtt.update_reservations.call_args.args # param must differ from the original 120 (150°F = 65.6°C → param=131) assert reservations[0]["param"] != 120 @@ -335,7 +335,7 @@ async def test_update_reservation_preserves_fields( with patch("nwp500.reservations.fetch_reservations", return_value=schedule): await update_reservation(mock_mqtt, mock_device, 1, hour=8) - _, reservations = mock_mqtt.control.update_reservations.call_args.args + _, reservations = mock_mqtt.update_reservations.call_args.args assert reservations[0]["hour"] == 8 assert reservations[0]["param"] == 120 From d8019baaa5e3ebeea7c762b0e9c0f5fa94109ed8 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 00:40:04 -0700 Subject: [PATCH 08/29] Phase 9b: Split models.py into models/ subpackage Split 1966-line models.py monolith into 9 focused submodules: - models/_converters.py: temperature helper functions - models/device.py: DeviceInfo, Location, Device, FirmwareInfo - models/tou.py: TOUSchedule, ConvertedTOUPlan, TOUInfo - models/schedule.py: all reservation and schedule models + OtaCommitPayload - models/status.py: DeviceStatus - models/feature.py: DeviceFeature - models/mqtt_models.py: MqttRequest, MqttCommand - models/energy.py: EnergyUsage* models - models/__init__.py: re-exports everything (no API change) --- src/nwp500/models.py | 1966 ------------------------------ src/nwp500/models/__init__.py | 88 ++ src/nwp500/models/_converters.py | 77 ++ src/nwp500/models/device.py | 59 + src/nwp500/models/energy.py | 85 ++ src/nwp500/models/feature.py | 411 +++++++ src/nwp500/models/mqtt_models.py | 33 + src/nwp500/models/schedule.py | 354 ++++++ src/nwp500/models/status.py | 927 ++++++++++++++ src/nwp500/models/tou.py | 56 + 10 files changed, 2090 insertions(+), 1966 deletions(-) delete mode 100644 src/nwp500/models.py create mode 100644 src/nwp500/models/__init__.py create mode 100644 src/nwp500/models/_converters.py create mode 100644 src/nwp500/models/device.py create mode 100644 src/nwp500/models/energy.py create mode 100644 src/nwp500/models/feature.py create mode 100644 src/nwp500/models/mqtt_models.py create mode 100644 src/nwp500/models/schedule.py create mode 100644 src/nwp500/models/status.py create mode 100644 src/nwp500/models/tou.py diff --git a/src/nwp500/models.py b/src/nwp500/models.py deleted file mode 100644 index d1d9a8b..0000000 --- a/src/nwp500/models.py +++ /dev/null @@ -1,1966 +0,0 @@ -"""Data models for Navien NWP500 water heater communication. - -This module defines data classes for representing data structures -used in the Navien NWP500 water heater communication protocol. - -These models are based on the MQTT message formats and API responses. -""" - -from __future__ import annotations - -import logging -from typing import Annotated, Any, Self, cast - -from pydantic import ( - BeforeValidator, - ConfigDict, - Field, - computed_field, - model_validator, -) - -from ._base import NavienBaseModel -from .converters import ( - device_bool_to_python, - div_10, - enum_validator, - mul_10, - tou_override_to_python, -) -from .enums import ( - DHW_OPERATION_SETTING_TEXT, - ConnectionStatus, - CurrentOperationMode, - DeviceType, - DHWControlTypeFlag, - DhwOperationSetting, - DREvent, - ErrorCode, - HeatSource, - RecirculationMode, - TemperatureType, - TempFormulaType, - UnitType, - VolumeCode, -) -from .field_factory import ( - signal_strength_field, - temperature_field, -) -from .temperature import ( - DeciCelsius, - DeciCelsiusDelta, - HalfCelsius, - RawCelsius, -) -from .unit_system import get_unit_system - -_logger = logging.getLogger(__name__) - - -# ============================================================================ -# Conversion Helpers & Validators -# ============================================================================ - -# Reusable Annotated types for conversions -DeviceBool = Annotated[bool, BeforeValidator(device_bool_to_python)] -CapabilityFlag = Annotated[bool, BeforeValidator(device_bool_to_python)] -Div10 = Annotated[float, BeforeValidator(div_10)] -TenWhToWh = Annotated[float, BeforeValidator(mul_10)] -TouStatus = Annotated[bool, BeforeValidator(bool)] -TouOverride = Annotated[bool, BeforeValidator(tou_override_to_python)] -VolumeCodeField = Annotated[ - VolumeCode, BeforeValidator(enum_validator(VolumeCode)) -] -ConnectionStatusField = Annotated[ - ConnectionStatus, BeforeValidator(enum_validator(ConnectionStatus)) -] - - -def fahrenheit_to_half_celsius(fahrenheit: float) -> int: - """Convert Fahrenheit to half-degrees Celsius (for device commands). - - Args: - fahrenheit: Temperature in Fahrenheit. - - Returns: - Raw device value in half-Celsius format. - - Example: - >>> fahrenheit_to_half_celsius(140.0) - 120 - """ - 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 DeviceInfo(NavienBaseModel): - """Device information from API.""" - - home_seq: int = 0 - mac_address: str = "" - additional_value: str = "" - device_type: DeviceType | int = DeviceType.NPF700_WIFI - device_name: str = "Unknown" - connected: ConnectionStatusField = ConnectionStatus.DISCONNECTED - install_type: str | None = None - - -class Location(NavienBaseModel): - """Location information for a device.""" - - state: str | None = None - city: str | None = None - address: str | None = None - latitude: float | None = None - longitude: float | None = None - altitude: float | None = None - - -class Device(NavienBaseModel): - """Complete device information including location.""" - - device_info: DeviceInfo - location: Location - - def with_info(self, info: DeviceInfo) -> Self: - """Return a new Device instance with updated DeviceInfo.""" - return self.model_copy(update={"device_info": info}) - - -class FirmwareInfo(NavienBaseModel): - """Firmware information for a device.""" - - mac_address: str = "" - additional_value: str = "" - device_type: DeviceType | int = DeviceType.NPF700_WIFI - cur_sw_code: int = 0 - cur_version: int = 0 - downloaded_version: int | None = None - device_group: str | None = None - - -class TOUSchedule(NavienBaseModel): - """Time of Use schedule information.""" - - season: int = 0 - intervals: list[dict[str, Any]] = Field( - default_factory=list, alias="interval" - ) - - -class ConvertedTOUPlan(NavienBaseModel): - """A rate plan converted by the Navien backend from OpenEI format. - - Returned by POST /device/tou/convert. Contains the utility name, - plan name, and device-ready schedule with season/week bitfields - and scaled pricing. - """ - - utility: str = "" - name: str = "" - schedule: list[TOUSchedule] = Field(default_factory=list) - - -class TOUInfo(NavienBaseModel): - """Time of Use information.""" - - register_path: str = "" - source_type: str = "" - controller_id: str = "" - manufacture_id: str = "" - name: str = "" - utility: str = "" - zip_code: int = 0 - schedule: list[TOUSchedule] = Field(default_factory=list) - - @model_validator(mode="before") - @classmethod - def _extract_nested_tou_info(cls, data: Any) -> Any: - # Handle nested structure where fields are in 'touInfo' - if isinstance(data, dict): - # Explicitly cast to dict[str, Any] for type safety - d = cast(dict[str, Any], data).copy() - if "touInfo" in d: - tou_data = d.pop("touInfo") - if isinstance(tou_data, dict): - d.update(tou_data) - return d - return data - - -class ReservationEntry(NavienBaseModel): - """A single scheduled reservation entry. - - Wraps the raw 6-byte protocol fields and provides computed properties - for display-ready values including unit-aware temperature conversion. - - The raw protocol fields are: - - enable: 2=enabled, 1=disabled (device boolean) - - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) - - hour: 0-23 - - min: 0-59 - - mode: DHW operation mode ID (1-6) - - param: temperature in half-degrees Celsius - """ - - enable: int = 2 - week: int = 0 - hour: int = 0 - min: int = 0 - mode: int = 1 - param: int = 0 - - @computed_field # type: ignore[prop-decorator] - @property - def enabled(self) -> bool: - """Whether this reservation is active (device bool: 2=on, 1=off).""" - return self.enable == 2 - - @computed_field # type: ignore[prop-decorator] - @property - def days(self) -> list[str]: - """Weekday names for this reservation.""" - from .encoding import decode_week_bitfield - - return decode_week_bitfield(self.week) - - @computed_field # type: ignore[prop-decorator] - @property - def time(self) -> str: - """Formatted time string (HH:MM).""" - return f"{self.hour:02d}:{self.min:02d}" - - @computed_field # type: ignore[prop-decorator] - @property - def temperature(self) -> float: - """Temperature in the user's preferred unit.""" - return reservation_param_to_preferred(self.param) - - @computed_field # type: ignore[prop-decorator] - @property - def unit(self) -> str: - """Temperature unit symbol.""" - return "°C" if get_unit_system() == "metric" else "°F" - - @computed_field # type: ignore[prop-decorator] - @property - def mode_name(self) -> str: - """Human-readable operation mode name.""" - try: - return DHW_OPERATION_SETTING_TEXT.get( - DhwOperationSetting(self.mode), f"Unknown ({self.mode})" - ) - except ValueError: - return f"Unknown ({self.mode})" - - -class ReservationSchedule(NavienBaseModel): - """Complete reservation schedule from the device. - - Can be constructed from raw MQTT response data. The ``reservation`` - field accepts either a hex string (from GET responses) or a list of - dicts/ReservationEntry objects. - """ - - reservation_use: int = Field(default=0, alias="reservationUse") - reservation: list[ReservationEntry] = Field(default_factory=list) - - model_config = ConfigDict( - alias_generator=None, - populate_by_name=True, - extra="ignore", - use_enum_values=False, - ) - - @model_validator(mode="before") - @classmethod - def _decode_hex_reservation(cls, data: Any) -> Any: - """Decode hex-encoded reservation string into entry list.""" - if isinstance(data, dict): - d = cast(dict[str, Any], data).copy() - raw = d.get("reservation", "") - if isinstance(raw, str): - if raw: - from .encoding import decode_reservation_hex - - d["reservation"] = decode_reservation_hex(raw) - else: - d["reservation"] = [] - return d - return data - - @computed_field # type: ignore[prop-decorator] - @property - def enabled(self) -> bool: - """Whether the reservation system is globally enabled. - - Device bool convention: 2=on, 1=off. - """ - return self.reservation_use == 2 - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> ReservationSchedule: - """Construct from a raw MQTT response dict.""" - return cls.model_validate(data) - - -class WeeklyReservationEntry(NavienBaseModel): - """A single entry in a weekly temperature reservation schedule. - - Similar to :class:`ReservationEntry` but used with the RESERVATION_WEEKLY - command (33554438), which configures a separate weekly temperature schedule - independent of the timed reservation system. - - The raw protocol fields mirror the standard reservation format: - - enable: 2=enabled, 1=disabled (device boolean) - - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) - - hour: 0-23 - - min: 0-59 - - mode: DHW operation mode ID (1-6) - - param: temperature in half-degrees Celsius - """ - - enable: int = 2 - week: int = 0 - hour: int = 0 - min: int = 0 - mode: int = 1 - param: int = 0 - - @computed_field # type: ignore[prop-decorator] - @property - def enabled(self) -> bool: - """Whether this entry is active (device bool: 2=on, 1=off).""" - return self.enable == 2 - - @computed_field # type: ignore[prop-decorator] - @property - def days(self) -> list[str]: - """Weekday names for this entry.""" - from .encoding import decode_week_bitfield - - return decode_week_bitfield(self.week) - - @computed_field # type: ignore[prop-decorator] - @property - def time(self) -> str: - """Formatted time string (HH:MM).""" - return f"{self.hour:02d}:{self.min:02d}" - - @computed_field # type: ignore[prop-decorator] - @property - def temperature(self) -> float: - """Temperature in the user's preferred unit.""" - return reservation_param_to_preferred(self.param) - - @computed_field # type: ignore[prop-decorator] - @property - def unit(self) -> str: - """Temperature unit symbol.""" - return "°C" if get_unit_system() == "metric" else "°F" - - @computed_field # type: ignore[prop-decorator] - @property - def mode_name(self) -> str: - """Human-readable operation mode name.""" - try: - return DHW_OPERATION_SETTING_TEXT.get( - DhwOperationSetting(self.mode), f"Unknown ({self.mode})" - ) - except ValueError: - return f"Unknown ({self.mode})" - - -class WeeklyReservationSchedule(NavienBaseModel): - """Complete weekly reservation schedule (RESERVATION_WEEKLY command). - - Used with command code 33554438 to configure a temperature schedule - that repeats weekly. Accepts the same hex-encoded format as the - standard reservation schedule. - """ - - reservation_use: int = Field(default=0, alias="reservationUse") - reservation: list[WeeklyReservationEntry] = Field(default_factory=list) - - model_config = ConfigDict( - alias_generator=None, - populate_by_name=True, - extra="ignore", - use_enum_values=False, - ) - - @model_validator(mode="before") - @classmethod - def _decode_hex_reservation(cls, data: Any) -> Any: - """Decode hex-encoded reservation string into entry list.""" - if isinstance(data, dict): - d = cast(dict[str, Any], data).copy() - raw = d.get("reservation", "") - if isinstance(raw, str): - if raw: - from .encoding import decode_reservation_hex - - d["reservation"] = decode_reservation_hex(raw) - else: - d["reservation"] = [] - return d - return data - - @computed_field # type: ignore[prop-decorator] - @property - def enabled(self) -> bool: - """Whether the weekly reservation system is globally enabled. - - Device bool convention: 2=on, 1=off. - """ - return self.reservation_use == 2 - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> WeeklyReservationSchedule: - """Construct from a raw MQTT response dict.""" - return cls.model_validate(data) - - -class RecirculationScheduleEntry(NavienBaseModel): - """A single entry in a recirculation pump schedule. - - Used with the RECIR_RESERVATION command (33554444) to set timed - recirculation cycles. Each entry defines a time window and pump mode. - - Fields: - - enable: 2=enabled, 1=disabled (device boolean) - - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) - - start_hour: 0-23 - - start_min: 0-59 - - end_hour: 0-23 - - end_min: 0-59 - - mode: recirculation mode - (1=Constant, 2=Timer, 3=Temperature, 4=Sensor) - """ - - enable: int = 2 - week: int = 0 - start_hour: int = Field(default=0, alias="startHour") - start_min: int = Field(default=0, alias="startMin") - end_hour: int = Field(default=0, alias="endHour") - end_min: int = Field(default=0, alias="endMin") - mode: int = 1 - - model_config = ConfigDict( - alias_generator=None, - populate_by_name=True, - extra="ignore", - use_enum_values=False, - ) - - @computed_field # type: ignore[prop-decorator] - @property - def enabled(self) -> bool: - """Whether this entry is active (device bool: 2=on, 1=off).""" - return self.enable == 2 - - @computed_field # type: ignore[prop-decorator] - @property - def days(self) -> list[str]: - """Weekday names for this entry.""" - from .encoding import decode_week_bitfield - - return decode_week_bitfield(self.week) - - @computed_field # type: ignore[prop-decorator] - @property - def start_time(self) -> str: - """Formatted start time string (HH:MM).""" - return f"{self.start_hour:02d}:{self.start_min:02d}" - - @computed_field # type: ignore[prop-decorator] - @property - def end_time(self) -> str: - """Formatted end time string (HH:MM).""" - return f"{self.end_hour:02d}:{self.end_min:02d}" - - @computed_field # type: ignore[prop-decorator] - @property - def mode_name(self) -> str: - """Human-readable recirculation mode name.""" - try: - return RecirculationMode(self.mode).name.replace("_", " ").title() - except ValueError: - return f"Unknown ({self.mode})" - - -class RecirculationSchedule(NavienBaseModel): - """Complete recirculation pump schedule (RECIR_RESERVATION command). - - Used with command code 33554444 to configure timed recirculation - pump operation windows. - """ - - schedule: list[RecirculationScheduleEntry] = Field(default_factory=list) - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> RecirculationSchedule: - """Construct from a raw MQTT response dict.""" - return cls.model_validate(data) - - -class OtaCommitPayload(NavienBaseModel): - """Payload for committing a firmware component update. - - Used with the OTA_COMMIT command (33554442). This command uses a - special ``commitOta`` structure instead of the standard mode/param - format. - - Args: - sw_code: Software component code identifying which firmware to commit. - 1 = Controller, 2 = Panel, 4 = WiFi/communication module. - sw_version: Version number to commit (as reported by the OTA check). - """ - - sw_code: int = Field(alias="swCode") - sw_version: int = Field(alias="swVersion") - - model_config = ConfigDict( - alias_generator=None, - populate_by_name=True, - extra="ignore", - use_enum_values=False, - ) - - -class DeviceStatus(NavienBaseModel): - """Represents the status of the Navien water heater device.""" - - # CRITICAL: temperature_type must remain the first field so computed - # temperature properties can fall back to the device's native unit setting. - temperature_type: TemperatureType = Field( - default=TemperatureType.FAHRENHEIT, - description=( - "Type of temperature unit (1=Celsius, 2=Fahrenheit). " - "Controls all unit conversions." - ), - ) - - # Basic status fields - command: int = Field( - description="The command that triggered this status update" - ) - special_function_status: int = Field( - description=( - "Status of special functions " - "(e.g., freeze protection, anti-seize operations)" - ) - ) - error_code: ErrorCode = Field( - default=ErrorCode.NO_ERROR, - description="Error code if any fault is detected", - ) - sub_error_code: int = Field( - description="Sub error code providing additional error details" - ) - smart_diagnostic: int = Field( - description=( - "Smart diagnostic status code for system health monitoring. " - "0 = no diagnostic conditions. " - "Non-zero = diagnostic condition detected. " - "Specific diagnostic codes are device firmware dependent." - ) - ) - fault_status1: int = Field(description="Fault status register 1") - fault_status2: int = Field(description="Fault status register 2") - wifi_rssi: int = signal_strength_field( - "WiFi signal strength in dBm. " - "Typical values: -30 (excellent) to -90 (poor)" - ) - dhw_charge_per: float = Field( - description=( - "DHW charge percentage - " - "estimated percentage of hot water capacity available" - ), - json_schema_extra={"unit_of_measurement": "%"}, - ) - dr_event_status: DREvent = Field( - default=DREvent.UNKNOWN, - description=( - "Demand Response (DR) event status from utility (CTA-2045). " - "0=UNKNOWN (No event), 1=RUN_NORMAL, 2=SHED (reduce power), " - "3=LOADUP (pre-heat), 4=LOADUP_ADV (advanced pre-heat), " - "5=CPE (customer peak event/grid emergency)" - ), - ) - vacation_day_setting: int = Field( - description="Vacation day setting", - json_schema_extra={"unit_of_measurement": "days"}, - ) - vacation_day_elapsed: int = Field( - description="Elapsed vacation days", - json_schema_extra={"unit_of_measurement": "days"}, - ) - anti_legionella_period: int = Field( - description=( - "Anti-legionella cycle interval. Range: 1-30 days, Default: 7 days" - ), - json_schema_extra={"unit_of_measurement": "days"}, - ) - program_reservation_type: int = Field( - description="Type of program reservation" - ) - temp_formula_type: TempFormulaType = Field( - description="Temperature formula type" - ) - outside_temperature_raw: int = temperature_field( - "Outdoor/ambient temperature", alias="outsideTemperature" - ) - current_statenum: int = Field(description="Current state number") - target_fan_rpm: int = Field( - description="Target fan RPM", - json_schema_extra={"unit_of_measurement": "RPM"}, - ) - current_fan_rpm: int = Field( - description="Current fan RPM", - json_schema_extra={"unit_of_measurement": "RPM"}, - ) - fan_pwm: int = Field(description="Fan PWM value") - mixing_rate: float = Field( - description=( - "Mixing valve rate percentage (0-100%). " - "Controls mixing of hot tank water with cold inlet water" - ), - json_schema_extra={"unit_of_measurement": "%"}, - ) - eev_step: int = Field( - description=( - "Electronic Expansion Valve (EEV) step position. " - "Valve opening rate expressed as step count" - ) - ) - air_filter_alarm_period: int = Field( - description=( - "Air filter maintenance cycle interval. " - "Range: Off or 1,000-10,000 hours, Default: 1,000 hours" - ), - json_schema_extra={"unit_of_measurement": "h"}, - ) - air_filter_alarm_elapsed: int = Field( - description=( - "Operating hours elapsed since last air filter maintenance reset. " - "Track this to schedule preventative replacement" - ), - json_schema_extra={"unit_of_measurement": "h"}, - ) - cumulated_op_time_eva_fan: int = Field( - description=( - "Cumulative operation time of the evaporator fan since installation" - ), - json_schema_extra={"unit_of_measurement": "h"}, - ) - cumulated_dhw_flow_rate_raw: int = Field( - alias="cumulatedDhwFlowRate", - description=( - "Cumulative DHW flow - " - "total volume of hot water delivered since installation" - ), - json_schema_extra={ - "unit_of_measurement": "gal", - "device_class": "water", - }, - ) - tou_status: TouStatus = Field( - description=( - "Time of Use (TOU) scheduling enabled. " - "True = TOU is active/enabled, False = TOU is disabled" - ) - ) - dr_override_status: int = Field( - description=( - "Demand Response override status in hours. " - "0 = no override active. " - "Non-zero (1-72) = override active with specified remaining hours. " - "User can override DR commands for up to 72 hours." - ), - json_schema_extra={"unit_of_measurement": "hours"}, - ) - tou_override_status: TouOverride = Field( - description=( - "TOU override status. " - "True = user has overridden TOU to force immediate heating, " - "False = device follows TOU schedule normally" - ) - ) - total_energy_capacity: TenWhToWh = Field( - description="Total energy capacity of the tank in Watt-hours", - json_schema_extra={ - "unit_of_measurement": "Wh", - "device_class": "energy", - }, - ) - available_energy_capacity: TenWhToWh = Field( - description=( - "Available energy capacity - " - "remaining hot water energy available in Watt-hours" - ), - json_schema_extra={ - "unit_of_measurement": "Wh", - "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") - current_inst_power: float = Field( - description=( - "Current instantaneous power consumption in Watts. " - "Does not include heating element power when active" - ), - json_schema_extra={ - "unit_of_measurement": "W", - "device_class": "power", - }, - ) - - # Boolean fields with device-specific encoding - did_reload: DeviceBool = Field( - description="Indicates if the device has recently reloaded or restarted" - ) - operation_busy: DeviceBool = Field( - description=( - "Indicates if the device is currently performing heating operations" - ) - ) - freeze_protection_use: DeviceBool = Field( - description=( - "Whether freeze protection is active. " - "Electric heater activates when tank water falls below threshold" - ) - ) - dhw_use: DeviceBool = Field( - description=( - "Domestic Hot Water (DHW) usage status - " - "indicates if hot water is currently being drawn from the tank" - ) - ) - dhw_use_sustained: DeviceBool = Field( - description=( - "Sustained DHW usage status - indicates prolonged hot water usage" - ) - ) - dhw_operation_busy: DeviceBool = Field( - default=False, - description=( - "DHW operation busy status - " - "indicates if the device is currently heating water to meet demand" - ), - ) - program_reservation_use: DeviceBool = Field( - description=( - "Whether a program reservation (scheduled operation) is in use" - ) - ) - eco_use: DeviceBool = Field( - description=( - "Whether ECO (Energy Cut Off) high-temp safety limit is triggered" - ) - ) - comp_use: DeviceBool = Field( - description=( - "Compressor usage status (True=On, False=Off). " - "The compressor is the main component of the heat pump" - ) - ) - eev_use: DeviceBool = Field( - description=( - "Electronic Expansion Valve (EEV) usage status. " - "The EEV controls refrigerant flow" - ) - ) - eva_fan_use: DeviceBool = Field( - description=( - "Evaporator fan usage status. " - "The fan pulls ambient air through the evaporator coil" - ) - ) - shut_off_valve_use: DeviceBool = Field( - description=( - "Shut-off valve usage status. " - "The valve controls refrigerant flow in the system" - ) - ) - con_ovr_sensor_use: DeviceBool = Field( - description="Condensate overflow sensor usage status" - ) - wtr_ovr_sensor_use: DeviceBool = Field( - description=( - "Water overflow/leak sensor usage status. " - "Triggers error E799 if leak detected" - ) - ) - anti_legionella_use: DeviceBool = Field( - description=( - "Whether anti-legionella function is enabled. " - "Device periodically heats tank to prevent Legionella bacteria" - ) - ) - anti_legionella_operation_busy: DeviceBool = Field( - description=( - "Whether the anti-legionella disinfection cycle " - "is currently running" - ) - ) - error_buzzer_use: DeviceBool = Field( - description="Whether the error buzzer is enabled" - ) - current_heat_use: HeatSource = Field( - description=( - "Currently active heat source. Indicates which heating " - "component(s) are actively running: 0=Unknown/not heating, " - "1=Heat Pump, 2=Electric Element, 3=Both simultaneously" - ) - ) - heat_upper_use: DeviceBool = Field( - description=( - "Upper electric heating element usage status. " - "Power: 3,755W @ 208V or 5,000W @ 240V" - ) - ) - heat_lower_use: DeviceBool = Field( - description=( - "Lower electric heating element usage status. " - "Power: 3,755W @ 208V or 5,000W @ 240V" - ) - ) - scald_use: DeviceBool = Field( - description=( - "Scald protection active status. " - "Warning when water reaches potentially hazardous levels" - ) - ) - air_filter_alarm_use: DeviceBool = Field( - description=( - "Air filter maintenance reminder enabled flag. " - "Triggers alerts based on operating hours. Default: On" - ) - ) - recirc_operation_busy: DeviceBool = Field( - description="Recirculation operation busy status" - ) - recirc_reservation_use: DeviceBool = Field( - description="Recirculation reservation usage status" - ) - - # Raw temperature, flow, and volume fields - dhw_temperature_raw: int = temperature_field( - "Current Domestic Hot Water (DHW) outlet temperature", - alias="dhwTemperature", - ) - dhw_temperature_setting_raw: int = temperature_field( - "User-configured target DHW temperature", - alias="dhwTemperatureSetting", - ) - dhw_target_temperature_setting_raw: int = temperature_field( - "Duplicate of dhw_temperature_setting for legacy API compatibility", - alias="dhwTargetTemperatureSetting", - ) - freeze_protection_temperature_raw: int = temperature_field( - "Freeze protection temperature setpoint. " - "Prevents tank from freezing in cold environments", - alias="freezeProtectionTemperature", - ) - dhw_temperature2_raw: int = temperature_field( - "Second DHW temperature reading", - alias="dhwTemperature2", - ) - hp_upper_on_temp_setting_raw: int = temperature_field( - "Heat pump upper on temperature setting", - alias="hpUpperOnTempSetting", - ) - hp_upper_off_temp_setting_raw: int = temperature_field( - "Heat pump upper off temperature setting", - alias="hpUpperOffTempSetting", - ) - hp_lower_on_temp_setting_raw: int = temperature_field( - "Heat pump lower on temperature setting", - alias="hpLowerOnTempSetting", - ) - hp_lower_off_temp_setting_raw: int = temperature_field( - "Heat pump lower off temperature setting", - alias="hpLowerOffTempSetting", - ) - he_upper_on_temp_setting_raw: int = temperature_field( - "Heater element upper on temperature setting", - alias="heUpperOnTempSetting", - ) - he_upper_off_temp_setting_raw: int = temperature_field( - "Heater element upper off temperature setting", - alias="heUpperOffTempSetting", - ) - he_lower_on_temp_setting_raw: int = temperature_field( - "Heater element lower on temperature setting", - alias="heLowerOnTempSetting", - ) - he_lower_off_temp_setting_raw: int = temperature_field( - "Heater element lower off temperature setting", - alias="heLowerOffTempSetting", - ) - heat_min_op_temperature_raw: int = temperature_field( - "Minimum heat pump operation temperature. " - "Lowest tank setpoint allowed for heat pump operation", - alias="heatMinOpTemperature", - ) - recirc_temp_setting_raw: int = temperature_field( - "Recirculation temperature setting", - alias="recircTempSetting", - ) - recirc_temperature_raw: int = temperature_field( - "Recirculation temperature", - alias="recircTemperature", - ) - recirc_faucet_temperature_raw: int = temperature_field( - "Recirculation faucet temperature", - alias="recircFaucetTemperature", - ) - current_inlet_temperature_raw: int = temperature_field( - "Cold water inlet temperature", - alias="currentInletTemperature", - ) - current_dhw_flow_rate_raw: int = Field( - alias="currentDhwFlowRate", - description="Current DHW flow rate", - json_schema_extra={ - "unit_of_measurement": "GPM", - "device_class": "flow_rate", - }, - ) - hp_upper_on_diff_temp_setting_raw: int = Field( - alias="hpUpperOnDiffTempSetting", - description="Heat pump upper on differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - hp_upper_off_diff_temp_setting_raw: int = Field( - alias="hpUpperOffDiffTempSetting", - description="Heat pump upper off differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - hp_lower_on_diff_temp_setting_raw: int = Field( - alias="hpLowerOnDiffTempSetting", - description="Heat pump lower on differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - hp_lower_off_diff_temp_setting_raw: int = Field( - alias="hpLowerOffDiffTempSetting", - description="Heat pump lower off differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - he_upper_on_diff_temp_setting_raw: int = Field( - alias="heUpperOnDiffTempSetting", - description="Heater element upper on differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - he_upper_off_diff_temp_setting_raw: int = Field( - alias="heUpperOffDiffTempSetting", - description="Heater element upper off differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - he_lower_on_diff_temp_setting_raw: int = Field( - alias="heLowerOnTDiffempSetting", - description="Heater element lower on differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) # Handle API typo: heLowerOnTDiffempSetting -> heLowerOnDiffTempSetting - he_lower_off_diff_temp_setting_raw: int = Field( - alias="heLowerOffDiffTempSetting", - description="Heater element lower off differential temperature setting", - json_schema_extra={ - "unit_of_measurement": "°F", - "device_class": "temperature", - }, - ) - recirc_dhw_flow_rate_raw: int = Field( - alias="recircDhwFlowRate", - description="Recirculation DHW flow rate (dynamic units: LPM/GPM)", - json_schema_extra={ - "device_class": "flow_rate", - }, - ) - tank_upper_temperature_raw: int = temperature_field( - "Temperature of the upper part of the tank", - alias="tankUpperTemperature", - ) - tank_lower_temperature_raw: int = temperature_field( - "Temperature of the lower part of the tank", - alias="tankLowerTemperature", - ) - discharge_temperature_raw: int = temperature_field( - "Compressor discharge temperature - " - "temperature of refrigerant leaving the compressor", - alias="dischargeTemperature", - ) - suction_temperature_raw: int = temperature_field( - "Compressor suction temperature - " - "temperature of refrigerant entering the compressor", - alias="suctionTemperature", - ) - evaporator_temperature_raw: int = temperature_field( - "Evaporator temperature - " - "temperature where heat is absorbed from ambient air", - alias="evaporatorTemperature", - ) - ambient_temperature_raw: int = temperature_field( - "Ambient air temperature measured at the heat pump air intake", - alias="ambientTemperature", - ) - target_super_heat_raw: int = temperature_field( - "Target superheat value - desired temperature difference " - "ensuring complete refrigerant vaporization", - alias="targetSuperHeat", - ) - current_super_heat_raw: int = temperature_field( - "Current superheat value - actual temperature difference " - "between suction and evaporator temperatures", - alias="currentSuperHeat", - ) - - # Enum fields - operation_mode: CurrentOperationMode = Field( - default=CurrentOperationMode.STANDBY, - description="The current actual operational state of the device", - ) - dhw_operation_setting: DhwOperationSetting = Field( - default=DhwOperationSetting.ENERGY_SAVER, - description="User's configured DHW operation mode preference", - ) - freeze_protection_temp_min_raw: int = temperature_field( - "Active freeze protection lower limit", - alias="freezeProtectionTempMin", - default=43, - ) - freeze_protection_temp_max_raw: int = temperature_field( - "Active freeze protection upper limit", - alias="freezeProtectionTempMax", - default=65, - ) - - def _is_celsius(self) -> bool: - """Return True if metric/Celsius units should be used.""" - unit_system = get_unit_system() - if unit_system is not None: - return unit_system == "metric" - return self.temperature_type == TemperatureType.CELSIUS - - @computed_field # type: ignore[prop-decorator] - @property - def outside_temperature(self) -> float: - raw = RawCelsius(self.outside_temperature_raw) - if self._is_celsius(): - return raw.to_celsius() - return raw.to_fahrenheit_with_formula(self.temp_formula_type) - - @computed_field # type: ignore[prop-decorator] - @property - def dhw_temperature(self) -> float: - return HalfCelsius(self.dhw_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def dhw_temperature_setting(self) -> float: - return HalfCelsius(self.dhw_temperature_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def dhw_target_temperature_setting(self) -> float: - return HalfCelsius( - self.dhw_target_temperature_setting_raw - ).to_preferred(self._is_celsius()) - - @computed_field # type: ignore[prop-decorator] - @property - def freeze_protection_temperature(self) -> float: - return HalfCelsius(self.freeze_protection_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def dhw_temperature2(self) -> float: - return HalfCelsius(self.dhw_temperature2_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def hp_upper_on_temp_setting(self) -> float: - return HalfCelsius(self.hp_upper_on_temp_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def hp_upper_off_temp_setting(self) -> float: - return HalfCelsius(self.hp_upper_off_temp_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def hp_lower_on_temp_setting(self) -> float: - return HalfCelsius(self.hp_lower_on_temp_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def hp_lower_off_temp_setting(self) -> float: - return HalfCelsius(self.hp_lower_off_temp_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def he_upper_on_temp_setting(self) -> float: - return HalfCelsius(self.he_upper_on_temp_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def he_upper_off_temp_setting(self) -> float: - return HalfCelsius(self.he_upper_off_temp_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def he_lower_on_temp_setting(self) -> float: - return HalfCelsius(self.he_lower_on_temp_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def he_lower_off_temp_setting(self) -> float: - return HalfCelsius(self.he_lower_off_temp_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def heat_min_op_temperature(self) -> float: - return HalfCelsius(self.heat_min_op_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def recirc_temp_setting(self) -> float: - return HalfCelsius(self.recirc_temp_setting_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def recirc_temperature(self) -> float: - return HalfCelsius(self.recirc_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def recirc_faucet_temperature(self) -> float: - return HalfCelsius(self.recirc_faucet_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def current_inlet_temperature(self) -> float: - return HalfCelsius(self.current_inlet_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def current_dhw_flow_rate(self) -> float: - lpm = self.current_dhw_flow_rate_raw / 10.0 - if self._is_celsius(): - return lpm - return round(lpm * 0.264172, 2) - - @computed_field # type: ignore[prop-decorator] - @property - def hp_upper_on_diff_temp_setting(self) -> float: - return DeciCelsiusDelta( - self.hp_upper_on_diff_temp_setting_raw - ).to_preferred(self._is_celsius()) - - @computed_field # type: ignore[prop-decorator] - @property - def hp_upper_off_diff_temp_setting(self) -> float: - return DeciCelsiusDelta( - self.hp_upper_off_diff_temp_setting_raw - ).to_preferred(self._is_celsius()) - - @computed_field # type: ignore[prop-decorator] - @property - def hp_lower_on_diff_temp_setting(self) -> float: - return DeciCelsiusDelta( - self.hp_lower_on_diff_temp_setting_raw - ).to_preferred(self._is_celsius()) - - @computed_field # type: ignore[prop-decorator] - @property - def hp_lower_off_diff_temp_setting(self) -> float: - return DeciCelsiusDelta( - self.hp_lower_off_diff_temp_setting_raw - ).to_preferred(self._is_celsius()) - - @computed_field # type: ignore[prop-decorator] - @property - def he_upper_on_diff_temp_setting(self) -> float: - return DeciCelsiusDelta( - self.he_upper_on_diff_temp_setting_raw - ).to_preferred(self._is_celsius()) - - @computed_field # type: ignore[prop-decorator] - @property - def he_upper_off_diff_temp_setting(self) -> float: - return DeciCelsiusDelta( - self.he_upper_off_diff_temp_setting_raw - ).to_preferred(self._is_celsius()) - - @computed_field # type: ignore[prop-decorator] - @property - def he_lower_on_diff_temp_setting(self) -> float: - return DeciCelsiusDelta( - self.he_lower_on_diff_temp_setting_raw - ).to_preferred(self._is_celsius()) - - @computed_field # type: ignore[prop-decorator] - @property - def he_lower_off_diff_temp_setting(self) -> float: - return DeciCelsiusDelta( - self.he_lower_off_diff_temp_setting_raw - ).to_preferred(self._is_celsius()) - - @computed_field # type: ignore[prop-decorator] - @property - def recirc_dhw_flow_rate(self) -> float: - lpm = self.recirc_dhw_flow_rate_raw / 10.0 - if self._is_celsius(): - return lpm - return round(lpm * 0.264172, 2) - - @computed_field # type: ignore[prop-decorator] - @property - def tank_upper_temperature(self) -> float: - return DeciCelsius(self.tank_upper_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def tank_lower_temperature(self) -> float: - return DeciCelsius(self.tank_lower_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def discharge_temperature(self) -> float: - return DeciCelsius(self.discharge_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def suction_temperature(self) -> float: - return DeciCelsius(self.suction_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def evaporator_temperature(self) -> float: - return DeciCelsius(self.evaporator_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def ambient_temperature(self) -> float: - return DeciCelsius(self.ambient_temperature_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def target_super_heat(self) -> float: - return DeciCelsius(self.target_super_heat_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def current_super_heat(self) -> float: - return DeciCelsius(self.current_super_heat_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def cumulated_dhw_flow_rate(self) -> float: - liters = float(self.cumulated_dhw_flow_rate_raw) - if self._is_celsius(): - return liters - return round(liters * 0.264172, 2) - - @computed_field # type: ignore[prop-decorator] - @property - def freeze_protection_temp_min(self) -> float: - return HalfCelsius(self.freeze_protection_temp_min_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def freeze_protection_temp_max(self) -> float: - return HalfCelsius(self.freeze_protection_temp_max_raw).to_preferred( - self._is_celsius() - ) - - def get_field_unit(self, field_name: str) -> str: - """Get the correct unit suffix based on temperature preference. - - Resolves dynamic units for temperature, flow rate, and volume fields - that change based on unit system context override or the device's - temperature_type setting (Celsius or Fahrenheit). - - Args: - field_name: Name of the field to get the unit for - - Returns: - Unit string (e.g., " °C", " LPM", " L") or empty if field not found - """ - model_fields = self.__class__.model_fields - lookup_name = ( - field_name if field_name in model_fields else f"{field_name}_raw" - ) - if lookup_name not in model_fields: - return "" - - field_info = model_fields[lookup_name] - if not hasattr(field_info, "json_schema_extra"): - return "" - - extra = field_info.json_schema_extra - if not isinstance(extra, dict): - return "" - - is_celsius = self._is_celsius() - - device_class = extra.get("device_class") - - # Handle temperature units - if device_class == "temperature": - return " °C" if is_celsius else " °F" - - # Handle flow rate units - if device_class == "flow_rate": - return " LPM" if is_celsius else " GPM" - - # Handle volume units - if device_class == "water": - return " L" if is_celsius else " gal" - - # Fallback to static unit_of_measurement if present - if "unit_of_measurement" in extra: - unit_val = extra["unit_of_measurement"] - unit: str = str(unit_val) if unit_val is not None else "" - return f" {unit}" if unit else "" - - return "" - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DeviceStatus: - """Compatibility method for existing code.""" - return cls.model_validate(data) - - -class DeviceFeature(NavienBaseModel): - """Device capabilities, configuration, and firmware info.""" - - # IMPORTANT: temperature_type must remain the first field so computed - # temperature properties can fall back to the device's native unit setting. - temperature_type: TemperatureType = Field( - default=TemperatureType.FAHRENHEIT, - description=( - "Default temperature unit preference - " - "factory set to Fahrenheit for USA" - ), - ) - - country_code: int = Field( - description=( - "Country/region code where device is certified for operation. " - "Device-specific code defined by Navien. " - "Example: USA devices report code 3. Earlier project " - "documentation incorrectly listed code 1 for USA; field " - "observations of production devices confirm that code 3 is " - "the correct value." - ) - ) - model_type_code: UnitType | int = Field( - description=( - "Model type identifier: Maps to UnitType enum " - "(e.g., NPF=513 for heat pump water heater). " - "Identifies the device family and available capabilities" - ) - ) - control_type_code: int = Field( - description=( - "Control system type identifier: Specifies the version of the " - "digital control system (LCD display, WiFi, firmware variant). " - "Device-specific numeric code" - ) - ) - volume_code: VolumeCodeField = Field( - description=( - "Tank nominal capacity: 50 gallons (code 1), 65 gallons (code 2), " - "or 80 gallons (code 3)" - ), - json_schema_extra={"unit_of_measurement": "gal"}, - ) - controller_sw_version: int = Field( - description=( - "Main controller firmware version - " - "controls heat pump, heating elements, and system logic" - ) - ) - panel_sw_version: int = Field( - description=( - "Front panel display firmware version - " - "manages LCD display and user interface" - ) - ) - wifi_sw_version: int = Field( - description=( - "WiFi module firmware version - " - "handles app connectivity and cloud communication" - ) - ) - controller_sw_code: int = Field( - description=( - "Controller firmware variant/branch identifier " - "for support and compatibility" - ) - ) - panel_sw_code: int = Field( - description=( - "Panel firmware variant/branch identifier " - "for display features and UI capabilities" - ) - ) - wifi_sw_code: int = Field( - description=( - "WiFi firmware variant/branch identifier " - "for communication protocol version" - ) - ) - recirc_sw_version: int = Field( - description=( - "Recirculation module firmware version - " - "controls recirculation pump operation and temperature loop" - ) - ) - recirc_model_type_code: int = Field( - description=( - "Recirculation module model identifier: Specifies the type and " - "capabilities of the installed recirculation system. " - "Device-specific numeric code (0 if recirculation not installed)" - ) - ) - controller_serial_number: str = Field( - description=( - "Unique serial number of the main controller board " - "for warranty and service identification" - ) - ) - power_use: CapabilityFlag = Field( - default=False, - description=("Power control capability (2=supported, 1=not supported)"), - ) - holiday_use: CapabilityFlag = Field( - default=False, - description=( - "Vacation mode support (2=supported, 1=not supported) - " - "energy-saving mode for 0-99 days" - ), - ) - program_reservation_use: CapabilityFlag = Field( - default=False, - description=( - "Scheduled operation support (2=supported, 1=not supported) - " - "programmable heating schedules" - ), - ) - dhw_use: CapabilityFlag = Field( - default=False, - description=( - "Domestic hot water functionality (2=supported, 1=not supported) - " - "primary function of water heater" - ), - ) - dhw_temperature_setting_use: DHWControlTypeFlag = Field( - description=( - "DHW temperature control precision setting: " - "granularity of temperature adjustments available for DHW control" - ) - ) - smart_diagnostic_use: CapabilityFlag = Field( - default=False, - description=( - "Self-diagnostic capability (2=supported, 1=not supported) - " - "10-minute startup diagnostic, error code system" - ), - ) - wifi_rssi_use: CapabilityFlag = Field( - default=False, - description=( - "WiFi signal monitoring (2=supported, 1=not supported) - " - "reports signal strength in dBm" - ), - ) - temp_formula_type: TempFormulaType = Field( - default=TempFormulaType.ASYMMETRIC, - description=( - "Temperature calculation method identifier " - "for internal sensor calibration" - ), - ) - energy_usage_use: CapabilityFlag = Field( - default=False, - description=("Energy monitoring support (2=supp, 1=not) - tracks kWh"), - ) - freeze_protection_use: CapabilityFlag = Field( - default=False, - description=( - "Freeze protection capability (2=supported, 1=not supported) - " - "automatic heating when tank drops below threshold" - ), - ) - mixing_valve_use: CapabilityFlag = Field( - alias="mixingValveUse", - default=False, - description=("Thermostatic mixing valve support (2=supp, 1=not)"), - ) - dr_setting_use: CapabilityFlag = Field( - default=False, - description=( - "Demand Response support (2=supported, 1=not supported) - " - "CTA-2045 compliance for utility load management" - ), - ) - anti_legionella_setting_use: CapabilityFlag = Field( - default=False, - description=( - "Anti-Legionella function (2=supported, 1=not supported) - " - "periodic heating to 140°F (60°C) to prevent bacteria" - ), - ) - hpwh_use: CapabilityFlag = Field( - default=False, - description=( - "Heat Pump Water Heater mode (2=supported, 1=not supported) - " - "primary efficient heating using refrigeration cycle" - ), - ) - dhw_refill_use: CapabilityFlag = Field( - default=False, - description=( - "Tank refill detection (2=supported, 1=not supported) - " - "monitors for dry fire conditions during refill" - ), - ) - eco_use: CapabilityFlag = Field( - default=False, - description=( - "ECO safety switch capability (2=supported, 1=not supported) - " - "Energy Cut Off high-temperature limit protection" - ), - ) - electric_use: CapabilityFlag = Field( - default=False, - description=( - "Electric-only mode (2=supported, 1=not supported) - " - "heating element only for maximum recovery speed" - ), - ) - heatpump_use: CapabilityFlag = Field( - default=False, - description=( - "Heat pump only mode (2=supported, 1=not supported) - " - "most efficient operation using only refrigeration cycle" - ), - ) - energy_saver_use: CapabilityFlag = Field( - default=False, - description=( - "Energy Saver mode (2=supported, 1=not supported) - " - "hybrid efficiency mode balancing speed and efficiency (default)" - ), - ) - high_demand_use: CapabilityFlag = Field( - default=False, - description=( - "High Demand mode (2=supported, 1=not supported) - " - "hybrid boost mode prioritizing fast recovery" - ), - ) - recirculation_use: CapabilityFlag = Field( - default=False, - description=( - "Recirculation pump support (2=supported, 1=not supported) - " - "instant hot water delivery via dedicated loop" - ), - ) - recirc_reservation_use: CapabilityFlag = Field( - default=False, - description=( - "Recirculation schedule support (2=supported, 1=not supported) - " - "programmable recirculation on specified schedule" - ), - ) - title24_use: CapabilityFlag = Field( - default=False, - description=( - "Title 24 compliance (2=supported, 1=not supported) - " - "California energy code compliance for recirculation systems" - ), - ) - - # Raw temperature limit fields with half-degree Celsius scaling - dhw_temperature_min_raw: int = temperature_field( - "Minimum DHW temperature setting - safety and efficiency lower limit", - alias="dhwTemperatureMin", - ) - dhw_temperature_max_raw: int = temperature_field( - "Maximum DHW temperature setting - scald protection upper limit", - alias="dhwTemperatureMax", - ) - freeze_protection_temp_min_raw: int = temperature_field( - "Minimum freeze protection threshold - " - "factory default activation temperature", - alias="freezeProtectionTempMin", - ) - freeze_protection_temp_max_raw: int = temperature_field( - "Maximum freeze protection threshold - user-adjustable upper limit", - alias="freezeProtectionTempMax", - ) - recirc_temperature_min_raw: int = temperature_field( - "Minimum recirculation temperature setting - " - "lower limit for recirculation loop temperature control", - alias="recircTemperatureMin", - ) - recirc_temperature_max_raw: int = temperature_field( - "Maximum recirculation temperature setting - " - "upper limit for recirculation loop temperature control", - alias="recircTemperatureMax", - ) - - def _is_celsius(self) -> bool: - """Return True if metric/Celsius units should be used.""" - unit_system = get_unit_system() - if unit_system is not None: - return unit_system == "metric" - return self.temperature_type == TemperatureType.CELSIUS - - @computed_field # type: ignore[prop-decorator] - @property - def dhw_temperature_min(self) -> float: - return HalfCelsius(self.dhw_temperature_min_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def dhw_temperature_max(self) -> float: - return HalfCelsius(self.dhw_temperature_max_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def freeze_protection_temp_min(self) -> float: - return HalfCelsius(self.freeze_protection_temp_min_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def freeze_protection_temp_max(self) -> float: - return HalfCelsius(self.freeze_protection_temp_max_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def recirc_temperature_min(self) -> float: - return HalfCelsius(self.recirc_temperature_min_raw).to_preferred( - self._is_celsius() - ) - - @computed_field # type: ignore[prop-decorator] - @property - def recirc_temperature_max(self) -> float: - return HalfCelsius(self.recirc_temperature_max_raw).to_preferred( - self._is_celsius() - ) - - def get_field_unit(self, field_name: str) -> str: - """Get the correct unit suffix based on temperature preference. - - Resolves dynamic units for temperature, flow rate, and volume fields - that change based on unit system context override or the device's - temperature_type setting (Celsius or Fahrenheit). - - Args: - field_name: Name of the field to get the unit for - - Returns: - Unit string (e.g., " °C", " LPM", " L") or empty if field not found - """ - model_fields = self.__class__.model_fields - lookup_name = ( - field_name if field_name in model_fields else f"{field_name}_raw" - ) - if lookup_name not in model_fields: - return "" - - field_info = model_fields[lookup_name] - if not hasattr(field_info, "json_schema_extra"): - return "" - - extra = field_info.json_schema_extra - if not isinstance(extra, dict): - return "" - - is_celsius = self._is_celsius() - - device_class = extra.get("device_class") - - # Handle temperature units - if device_class == "temperature": - return " °C" if is_celsius else " °F" - - # Handle flow rate units - if device_class == "flow_rate": - return " LPM" if is_celsius else " GPM" - - # Handle volume units - if device_class == "water": - return " L" if is_celsius else " gal" - - # Fallback to static unit_of_measurement if present - if "unit_of_measurement" in extra: - unit_val = extra["unit_of_measurement"] - unit: str = str(unit_val) if unit_val is not None else "" - return f" {unit}" if unit else "" - - return "" - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DeviceFeature: - """Compatibility method.""" - return cls.model_validate(data) - - -class MqttRequest(NavienBaseModel): - """MQTT command request payload.""" - - command: int - device_type: DeviceType | int - mac_address: str - additional_value: str = "..." - mode: str | None = None - param: list[int | float] = Field(default_factory=list) - param_str: str = "" - month: list[int] | None = None - year: int | None = None - - -class MqttCommand(NavienBaseModel): - """Represents an MQTT command message.""" - - client_id: str = Field(alias="clientID") - session_id: str = Field(alias="sessionID") - request_topic: str - response_topic: str - request: MqttRequest | dict[str, Any] - protocol_version: int = 2 - - -class EnergyUsageBase(NavienBaseModel): - """Base energy usage fields common to daily and total responses.""" - - heat_pump_usage: int = Field(default=0, alias="hpUsage") - heat_element_usage: int = Field(default=0, alias="heUsage") - heat_pump_time: int = Field(default=0, alias="hpTime") - heat_element_time: int = Field(default=0, alias="heTime") - - @property - def total_usage(self) -> int: - return self.heat_pump_usage + self.heat_element_usage - - -class EnergyUsageTotal(EnergyUsageBase): - """Total energy usage data.""" - - @property - def heat_pump_percentage(self) -> float: - return ( - (self.heat_pump_usage / self.total_usage * 100.0) - if self.total_usage > 0 - else 0.0 - ) - - @property - def heat_element_percentage(self) -> float: - return ( - (self.heat_element_usage / self.total_usage * 100.0) - if self.total_usage > 0 - else 0.0 - ) - - @property - def total_time(self) -> int: - return self.heat_pump_time + self.heat_element_time - - -class EnergyUsageDay(EnergyUsageBase): - """Daily energy usage data.""" - - pass - - -class MonthlyEnergyData(NavienBaseModel): - """Monthly energy usage data grouping.""" - - year: int - month: int - data: list[EnergyUsageDay] - - -class EnergyUsageResponse(NavienBaseModel): - """Response for energy usage query.""" - - total: EnergyUsageTotal - usage: list[MonthlyEnergyData] - - def get_month_data(self, year: int, month: int) -> MonthlyEnergyData | None: - """Get energy usage data for a specific month. - - Args: - year: Year (e.g., 2025) - month: Month (1-12) - - Returns: - MonthlyEnergyData for that month, or None if not found - """ - for monthly_data in self.usage: - if monthly_data.year == year and monthly_data.month == month: - return monthly_data - return None - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> EnergyUsageResponse: - """Compatibility method.""" - return cls.model_validate(data) diff --git a/src/nwp500/models/__init__.py b/src/nwp500/models/__init__.py new file mode 100644 index 0000000..52cb5e0 --- /dev/null +++ b/src/nwp500/models/__init__.py @@ -0,0 +1,88 @@ +"""Data models for Navien NWP500 water heater communication. + +This module defines data classes for representing data structures +used in the Navien NWP500 water heater communication protocol. + +These models are based on the MQTT message formats and API responses. +""" + +from __future__ import annotations + +from .._base import NavienBaseModel +from ._converters import ( + fahrenheit_to_half_celsius, + preferred_to_half_celsius, + reservation_param_to_preferred, +) +from .device import ( + ConnectionStatusField, + Device, + DeviceInfo, + FirmwareInfo, + Location, +) +from .energy import ( + EnergyUsageBase, + EnergyUsageDay, + EnergyUsageResponse, + EnergyUsageTotal, + MonthlyEnergyData, +) +from .feature import CapabilityFlag, DeviceFeature, VolumeCodeField +from .mqtt_models import MqttCommand, MqttRequest +from .schedule import ( + OtaCommitPayload, + RecirculationSchedule, + RecirculationScheduleEntry, + ReservationEntry, + ReservationSchedule, + WeeklyReservationEntry, + WeeklyReservationSchedule, +) +from .status import ( + DeviceBool, + DeviceStatus, + Div10, + TenWhToWh, + TouOverride, + TouStatus, +) +from .tou import ConvertedTOUPlan, TOUInfo, TOUSchedule + +__all__ = [ + "NavienBaseModel", + "DeviceBool", + "CapabilityFlag", + "Div10", + "TenWhToWh", + "TouStatus", + "TouOverride", + "VolumeCodeField", + "ConnectionStatusField", + "fahrenheit_to_half_celsius", + "preferred_to_half_celsius", + "reservation_param_to_preferred", + "DeviceInfo", + "Location", + "Device", + "FirmwareInfo", + "TOUSchedule", + "ConvertedTOUPlan", + "TOUInfo", + "ReservationEntry", + "ReservationSchedule", + "WeeklyReservationEntry", + "WeeklyReservationSchedule", + "RecirculationScheduleEntry", + "RecirculationSchedule", + "OtaCommitPayload", + "DeviceStatus", + "DeviceFeature", + "MqttRequest", + "MqttCommand", + "EnergyUsageBase", + "EnergyUsageTotal", + "EnergyUsageDay", + "MonthlyEnergyData", + "EnergyUsageResponse", +] diff --git a/src/nwp500/models/_converters.py b/src/nwp500/models/_converters.py new file mode 100644 index 0000000..b24b572 --- /dev/null +++ b/src/nwp500/models/_converters.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from ..temperature import HalfCelsius +from ..unit_system import get_unit_system + + +def fahrenheit_to_half_celsius(fahrenheit: float) -> int: + """Convert Fahrenheit to half-degrees Celsius (for device commands). + + Args: + fahrenheit: Temperature in Fahrenheit. + + Returns: + Raw device value in half-Celsius format. + + Example: + >>> fahrenheit_to_half_celsius(140.0) + 120 + """ + 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) diff --git a/src/nwp500/models/device.py b/src/nwp500/models/device.py new file mode 100644 index 0000000..ae7a501 --- /dev/null +++ b/src/nwp500/models/device.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Annotated, Self + +from pydantic import BeforeValidator + +from .._base import NavienBaseModel +from ..converters import enum_validator +from ..enums import ConnectionStatus, DeviceType + +ConnectionStatusField = Annotated[ + ConnectionStatus, BeforeValidator(enum_validator(ConnectionStatus)) +] + + +class DeviceInfo(NavienBaseModel): + """Device information from API.""" + + home_seq: int = 0 + mac_address: str = "" + additional_value: str = "" + device_type: DeviceType | int = DeviceType.NPF700_WIFI + device_name: str = "Unknown" + connected: ConnectionStatusField = ConnectionStatus.DISCONNECTED + install_type: str | None = None + + +class Location(NavienBaseModel): + """Location information for a device.""" + + state: str | None = None + city: str | None = None + address: str | None = None + latitude: float | None = None + longitude: float | None = None + altitude: float | None = None + + +class Device(NavienBaseModel): + """Complete device information including location.""" + + device_info: DeviceInfo + location: Location + + def with_info(self, info: DeviceInfo) -> Self: + """Return a new Device instance with updated DeviceInfo.""" + return self.model_copy(update={"device_info": info}) + + +class FirmwareInfo(NavienBaseModel): + """Firmware information for a device.""" + + mac_address: str = "" + additional_value: str = "" + device_type: DeviceType | int = DeviceType.NPF700_WIFI + cur_sw_code: int = 0 + cur_version: int = 0 + downloaded_version: int | None = None + device_group: str | None = None diff --git a/src/nwp500/models/energy.py b/src/nwp500/models/energy.py new file mode 100644 index 0000000..658f0f9 --- /dev/null +++ b/src/nwp500/models/energy.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from .._base import NavienBaseModel + + +class EnergyUsageBase(NavienBaseModel): + """Base energy usage fields common to daily and total responses.""" + + heat_pump_usage: int = Field(default=0, alias="hpUsage") + heat_element_usage: int = Field(default=0, alias="heUsage") + heat_pump_time: int = Field(default=0, alias="hpTime") + heat_element_time: int = Field(default=0, alias="heTime") + + @property + def total_usage(self) -> int: + return self.heat_pump_usage + self.heat_element_usage + + +class EnergyUsageTotal(EnergyUsageBase): + """Total energy usage data.""" + + @property + def heat_pump_percentage(self) -> float: + return ( + (self.heat_pump_usage / self.total_usage * 100.0) + if self.total_usage > 0 + else 0.0 + ) + + @property + def heat_element_percentage(self) -> float: + return ( + (self.heat_element_usage / self.total_usage * 100.0) + if self.total_usage > 0 + else 0.0 + ) + + @property + def total_time(self) -> int: + return self.heat_pump_time + self.heat_element_time + + +class EnergyUsageDay(EnergyUsageBase): + """Daily energy usage data.""" + + pass + + +class MonthlyEnergyData(NavienBaseModel): + """Monthly energy usage data grouping.""" + + year: int + month: int + data: list[EnergyUsageDay] + + +class EnergyUsageResponse(NavienBaseModel): + """Response for energy usage query.""" + + total: EnergyUsageTotal + usage: list[MonthlyEnergyData] + + def get_month_data(self, year: int, month: int) -> MonthlyEnergyData | None: + """Get energy usage data for a specific month. + + Args: + year: Year (e.g., 2025) + month: Month (1-12) + + Returns: + MonthlyEnergyData for that month, or None if not found + """ + for monthly_data in self.usage: + if monthly_data.year == year and monthly_data.month == month: + return monthly_data + return None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> EnergyUsageResponse: + """Compatibility method.""" + return cls.model_validate(data) diff --git a/src/nwp500/models/feature.py b/src/nwp500/models/feature.py new file mode 100644 index 0000000..fb3e797 --- /dev/null +++ b/src/nwp500/models/feature.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +from typing import Annotated, Any + +from pydantic import BeforeValidator, Field, computed_field + +from .._base import NavienBaseModel +from ..converters import device_bool_to_python, enum_validator +from ..enums import ( + DHWControlTypeFlag, + TemperatureType, + TempFormulaType, + UnitType, + VolumeCode, +) +from ..field_factory import temperature_field +from ..temperature import HalfCelsius +from ..unit_system import get_unit_system + +CapabilityFlag = Annotated[bool, BeforeValidator(device_bool_to_python)] +VolumeCodeField = Annotated[ + VolumeCode, BeforeValidator(enum_validator(VolumeCode)) +] + + +class DeviceFeature(NavienBaseModel): + """Device capabilities, configuration, and firmware info.""" + + # IMPORTANT: temperature_type must remain the first field so computed + # temperature properties can fall back to the device's native unit setting. + temperature_type: TemperatureType = Field( + default=TemperatureType.FAHRENHEIT, + description=( + "Default temperature unit preference - " + "factory set to Fahrenheit for USA" + ), + ) + + country_code: int = Field( + description=( + "Country/region code where device is certified for operation. " + "Device-specific code defined by Navien. " + "Example: USA devices report code 3. Earlier project " + "documentation incorrectly listed code 1 for USA; field " + "observations of production devices confirm that code 3 is " + "the correct value." + ) + ) + model_type_code: UnitType | int = Field( + description=( + "Model type identifier: Maps to UnitType enum " + "(e.g., NPF=513 for heat pump water heater). " + "Identifies the device family and available capabilities" + ) + ) + control_type_code: int = Field( + description=( + "Control system type identifier: Specifies the version of the " + "digital control system (LCD display, WiFi, firmware variant). " + "Device-specific numeric code" + ) + ) + volume_code: VolumeCodeField = Field( + description=( + "Tank nominal capacity: 50 gallons (code 1), 65 gallons (code 2), " + "or 80 gallons (code 3)" + ), + json_schema_extra={"unit_of_measurement": "gal"}, + ) + controller_sw_version: int = Field( + description=( + "Main controller firmware version - " + "controls heat pump, heating elements, and system logic" + ) + ) + panel_sw_version: int = Field( + description=( + "Front panel display firmware version - " + "manages LCD display and user interface" + ) + ) + wifi_sw_version: int = Field( + description=( + "WiFi module firmware version - " + "handles app connectivity and cloud communication" + ) + ) + controller_sw_code: int = Field( + description=( + "Controller firmware variant/branch identifier " + "for support and compatibility" + ) + ) + panel_sw_code: int = Field( + description=( + "Panel firmware variant/branch identifier " + "for display features and UI capabilities" + ) + ) + wifi_sw_code: int = Field( + description=( + "WiFi firmware variant/branch identifier " + "for communication protocol version" + ) + ) + recirc_sw_version: int = Field( + description=( + "Recirculation module firmware version - " + "controls recirculation pump operation and temperature loop" + ) + ) + recirc_model_type_code: int = Field( + description=( + "Recirculation module model identifier: Specifies the type and " + "capabilities of the installed recirculation system. " + "Device-specific numeric code (0 if recirculation not installed)" + ) + ) + controller_serial_number: str = Field( + description=( + "Unique serial number of the main controller board " + "for warranty and service identification" + ) + ) + power_use: CapabilityFlag = Field( + default=False, + description=("Power control capability (2=supported, 1=not supported)"), + ) + holiday_use: CapabilityFlag = Field( + default=False, + description=( + "Vacation mode support (2=supported, 1=not supported) - " + "energy-saving mode for 0-99 days" + ), + ) + program_reservation_use: CapabilityFlag = Field( + default=False, + description=( + "Scheduled operation support (2=supported, 1=not supported) - " + "programmable heating schedules" + ), + ) + dhw_use: CapabilityFlag = Field( + default=False, + description=( + "Domestic hot water functionality (2=supported, 1=not supported) - " + "primary function of water heater" + ), + ) + dhw_temperature_setting_use: DHWControlTypeFlag = Field( + description=( + "DHW temperature control precision setting: " + "granularity of temperature adjustments available for DHW control" + ) + ) + smart_diagnostic_use: CapabilityFlag = Field( + default=False, + description=( + "Self-diagnostic capability (2=supported, 1=not supported) - " + "10-minute startup diagnostic, error code system" + ), + ) + wifi_rssi_use: CapabilityFlag = Field( + default=False, + description=( + "WiFi signal monitoring (2=supported, 1=not supported) - " + "reports signal strength in dBm" + ), + ) + temp_formula_type: TempFormulaType = Field( + default=TempFormulaType.ASYMMETRIC, + description=( + "Temperature calculation method identifier " + "for internal sensor calibration" + ), + ) + energy_usage_use: CapabilityFlag = Field( + default=False, + description=("Energy monitoring support (2=supp, 1=not) - tracks kWh"), + ) + freeze_protection_use: CapabilityFlag = Field( + default=False, + description=( + "Freeze protection capability (2=supported, 1=not supported) - " + "automatic heating when tank drops below threshold" + ), + ) + mixing_valve_use: CapabilityFlag = Field( + alias="mixingValveUse", + default=False, + description=("Thermostatic mixing valve support (2=supp, 1=not)"), + ) + dr_setting_use: CapabilityFlag = Field( + default=False, + description=( + "Demand Response support (2=supported, 1=not supported) - " + "CTA-2045 compliance for utility load management" + ), + ) + anti_legionella_setting_use: CapabilityFlag = Field( + default=False, + description=( + "Anti-Legionella function (2=supported, 1=not supported) - " + "periodic heating to 140°F (60°C) to prevent bacteria" + ), + ) + hpwh_use: CapabilityFlag = Field( + default=False, + description=( + "Heat Pump Water Heater mode (2=supported, 1=not supported) - " + "primary efficient heating using refrigeration cycle" + ), + ) + dhw_refill_use: CapabilityFlag = Field( + default=False, + description=( + "Tank refill detection (2=supported, 1=not supported) - " + "monitors for dry fire conditions during refill" + ), + ) + eco_use: CapabilityFlag = Field( + default=False, + description=( + "ECO safety switch capability (2=supported, 1=not supported) - " + "Energy Cut Off high-temperature limit protection" + ), + ) + electric_use: CapabilityFlag = Field( + default=False, + description=( + "Electric-only mode (2=supported, 1=not supported) - " + "heating element only for maximum recovery speed" + ), + ) + heatpump_use: CapabilityFlag = Field( + default=False, + description=( + "Heat pump only mode (2=supported, 1=not supported) - " + "most efficient operation using only refrigeration cycle" + ), + ) + energy_saver_use: CapabilityFlag = Field( + default=False, + description=( + "Energy Saver mode (2=supported, 1=not supported) - " + "hybrid efficiency mode balancing speed and efficiency (default)" + ), + ) + high_demand_use: CapabilityFlag = Field( + default=False, + description=( + "High Demand mode (2=supported, 1=not supported) - " + "hybrid boost mode prioritizing fast recovery" + ), + ) + recirculation_use: CapabilityFlag = Field( + default=False, + description=( + "Recirculation pump support (2=supported, 1=not supported) - " + "instant hot water delivery via dedicated loop" + ), + ) + recirc_reservation_use: CapabilityFlag = Field( + default=False, + description=( + "Recirculation schedule support (2=supported, 1=not supported) - " + "programmable recirculation on specified schedule" + ), + ) + title24_use: CapabilityFlag = Field( + default=False, + description=( + "Title 24 compliance (2=supported, 1=not supported) - " + "California energy code compliance for recirculation systems" + ), + ) + + # Raw temperature limit fields with half-degree Celsius scaling + dhw_temperature_min_raw: int = temperature_field( + "Minimum DHW temperature setting - safety and efficiency lower limit", + alias="dhwTemperatureMin", + ) + dhw_temperature_max_raw: int = temperature_field( + "Maximum DHW temperature setting - scald protection upper limit", + alias="dhwTemperatureMax", + ) + freeze_protection_temp_min_raw: int = temperature_field( + "Minimum freeze protection threshold - " + "factory default activation temperature", + alias="freezeProtectionTempMin", + ) + freeze_protection_temp_max_raw: int = temperature_field( + "Maximum freeze protection threshold - user-adjustable upper limit", + alias="freezeProtectionTempMax", + ) + recirc_temperature_min_raw: int = temperature_field( + "Minimum recirculation temperature setting - " + "lower limit for recirculation loop temperature control", + alias="recircTemperatureMin", + ) + recirc_temperature_max_raw: int = temperature_field( + "Maximum recirculation temperature setting - " + "upper limit for recirculation loop temperature control", + alias="recircTemperatureMax", + ) + + def _is_celsius(self) -> bool: + """Return True if metric/Celsius units should be used.""" + unit_system = get_unit_system() + if unit_system is not None: + return unit_system == "metric" + return self.temperature_type == TemperatureType.CELSIUS + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature_min(self) -> float: + return HalfCelsius(self.dhw_temperature_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature_max(self) -> float: + return HalfCelsius(self.dhw_temperature_max_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_min(self) -> float: + return HalfCelsius(self.freeze_protection_temp_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_max(self) -> float: + return HalfCelsius(self.freeze_protection_temp_max_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temperature_min(self) -> float: + return HalfCelsius(self.recirc_temperature_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temperature_max(self) -> float: + return HalfCelsius(self.recirc_temperature_max_raw).to_preferred( + self._is_celsius() + ) + + def get_field_unit(self, field_name: str) -> str: + """Get the correct unit suffix based on temperature preference. + + Resolves dynamic units for temperature, flow rate, and volume fields + that change based on unit system context override or the device's + temperature_type setting (Celsius or Fahrenheit). + + Args: + field_name: Name of the field to get the unit for + + Returns: + Unit string (e.g., " °C", " LPM", " L") or empty if field not found + """ + model_fields = self.__class__.model_fields + lookup_name = ( + field_name if field_name in model_fields else f"{field_name}_raw" + ) + if lookup_name not in model_fields: + return "" + + field_info = model_fields[lookup_name] + if not hasattr(field_info, "json_schema_extra"): + return "" + + extra = field_info.json_schema_extra + if not isinstance(extra, dict): + return "" + + is_celsius = self._is_celsius() + + device_class = extra.get("device_class") + + # Handle temperature units + if device_class == "temperature": + return " °C" if is_celsius else " °F" + + # Handle flow rate units + if device_class == "flow_rate": + return " LPM" if is_celsius else " GPM" + + # Handle volume units + if device_class == "water": + return " L" if is_celsius else " gal" + + # Fallback to static unit_of_measurement if present + if "unit_of_measurement" in extra: + unit_val = extra["unit_of_measurement"] + unit: str = str(unit_val) if unit_val is not None else "" + return f" {unit}" if unit else "" + + return "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DeviceFeature: + """Compatibility method.""" + return cls.model_validate(data) diff --git a/src/nwp500/models/mqtt_models.py b/src/nwp500/models/mqtt_models.py new file mode 100644 index 0000000..151d38f --- /dev/null +++ b/src/nwp500/models/mqtt_models.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from .._base import NavienBaseModel +from ..enums import DeviceType + + +class MqttRequest(NavienBaseModel): + """MQTT command request payload.""" + + command: int + device_type: DeviceType | int + mac_address: str + additional_value: str = "..." + mode: str | None = None + param: list[int | float] = Field(default_factory=list) + param_str: str = "" + month: list[int] | None = None + year: int | None = None + + +class MqttCommand(NavienBaseModel): + """Represents an MQTT command message.""" + + client_id: str = Field(alias="clientID") + session_id: str = Field(alias="sessionID") + request_topic: str + response_topic: str + request: MqttRequest | dict[str, Any] + protocol_version: int = 2 diff --git a/src/nwp500/models/schedule.py b/src/nwp500/models/schedule.py new file mode 100644 index 0000000..ad28357 --- /dev/null +++ b/src/nwp500/models/schedule.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from typing import Any, cast + +from pydantic import ConfigDict, Field, computed_field, model_validator + +from .._base import NavienBaseModel +from ..enums import ( + DHW_OPERATION_SETTING_TEXT, + DhwOperationSetting, + RecirculationMode, +) +from ..unit_system import get_unit_system +from ._converters import reservation_param_to_preferred + + +class ReservationEntry(NavienBaseModel): + """A single scheduled reservation entry. + + Wraps the raw 6-byte protocol fields and provides computed properties + for display-ready values including unit-aware temperature conversion. + + The raw protocol fields are: + - enable: 2=enabled, 1=disabled (device boolean) + - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) + - hour: 0-23 + - min: 0-59 + - mode: DHW operation mode ID (1-6) + - param: temperature in half-degrees Celsius + """ + + enable: int = 2 + week: int = 0 + hour: int = 0 + min: int = 0 + mode: int = 1 + param: int = 0 + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether this reservation is active (device bool: 2=on, 1=off).""" + return self.enable == 2 + + @computed_field # type: ignore[prop-decorator] + @property + def days(self) -> list[str]: + """Weekday names for this reservation.""" + from ..encoding import decode_week_bitfield + + return decode_week_bitfield(self.week) + + @computed_field # type: ignore[prop-decorator] + @property + def time(self) -> str: + """Formatted time string (HH:MM).""" + return f"{self.hour:02d}:{self.min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def temperature(self) -> float: + """Temperature in the user's preferred unit.""" + return reservation_param_to_preferred(self.param) + + @computed_field # type: ignore[prop-decorator] + @property + def unit(self) -> str: + """Temperature unit symbol.""" + return "°C" if get_unit_system() == "metric" else "°F" + + @computed_field # type: ignore[prop-decorator] + @property + def mode_name(self) -> str: + """Human-readable operation mode name.""" + try: + return DHW_OPERATION_SETTING_TEXT.get( + DhwOperationSetting(self.mode), f"Unknown ({self.mode})" + ) + except ValueError: + return f"Unknown ({self.mode})" + + +class ReservationSchedule(NavienBaseModel): + """Complete reservation schedule from the device. + + Can be constructed from raw MQTT response data. The ``reservation`` + field accepts either a hex string (from GET responses) or a list of + dicts/ReservationEntry objects. + """ + + reservation_use: int = Field(default=0, alias="reservationUse") + reservation: list[ReservationEntry] = Field(default_factory=list) + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @model_validator(mode="before") + @classmethod + def _decode_hex_reservation(cls, data: Any) -> Any: + """Decode hex-encoded reservation string into entry list.""" + if isinstance(data, dict): + d = cast(dict[str, Any], data).copy() + raw = d.get("reservation", "") + if isinstance(raw, str): + if raw: + from ..encoding import decode_reservation_hex + + d["reservation"] = decode_reservation_hex(raw) + else: + d["reservation"] = [] + return d + return data + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether the reservation system is globally enabled. + + Device bool convention: 2=on, 1=off. + """ + return self.reservation_use == 2 + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ReservationSchedule: + """Construct from a raw MQTT response dict.""" + return cls.model_validate(data) + + +class WeeklyReservationEntry(NavienBaseModel): + """A single entry in a weekly temperature reservation schedule. + + Similar to :class:`ReservationEntry` but used with the RESERVATION_WEEKLY + command (33554438), which configures a separate weekly temperature schedule + independent of the timed reservation system. + + The raw protocol fields mirror the standard reservation format: + - enable: 2=enabled, 1=disabled (device boolean) + - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) + - hour: 0-23 + - min: 0-59 + - mode: DHW operation mode ID (1-6) + - param: temperature in half-degrees Celsius + """ + + enable: int = 2 + week: int = 0 + hour: int = 0 + min: int = 0 + mode: int = 1 + param: int = 0 + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether this entry is active (device bool: 2=on, 1=off).""" + return self.enable == 2 + + @computed_field # type: ignore[prop-decorator] + @property + def days(self) -> list[str]: + """Weekday names for this entry.""" + from ..encoding import decode_week_bitfield + + return decode_week_bitfield(self.week) + + @computed_field # type: ignore[prop-decorator] + @property + def time(self) -> str: + """Formatted time string (HH:MM).""" + return f"{self.hour:02d}:{self.min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def temperature(self) -> float: + """Temperature in the user's preferred unit.""" + return reservation_param_to_preferred(self.param) + + @computed_field # type: ignore[prop-decorator] + @property + def unit(self) -> str: + """Temperature unit symbol.""" + return "°C" if get_unit_system() == "metric" else "°F" + + @computed_field # type: ignore[prop-decorator] + @property + def mode_name(self) -> str: + """Human-readable operation mode name.""" + try: + return DHW_OPERATION_SETTING_TEXT.get( + DhwOperationSetting(self.mode), f"Unknown ({self.mode})" + ) + except ValueError: + return f"Unknown ({self.mode})" + + +class WeeklyReservationSchedule(NavienBaseModel): + """Complete weekly reservation schedule (RESERVATION_WEEKLY command). + + Used with command code 33554438 to configure a temperature schedule + that repeats weekly. Accepts the same hex-encoded format as the + standard reservation schedule. + """ + + reservation_use: int = Field(default=0, alias="reservationUse") + reservation: list[WeeklyReservationEntry] = Field(default_factory=list) + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @model_validator(mode="before") + @classmethod + def _decode_hex_reservation(cls, data: Any) -> Any: + """Decode hex-encoded reservation string into entry list.""" + if isinstance(data, dict): + d = cast(dict[str, Any], data).copy() + raw = d.get("reservation", "") + if isinstance(raw, str): + if raw: + from ..encoding import decode_reservation_hex + + d["reservation"] = decode_reservation_hex(raw) + else: + d["reservation"] = [] + return d + return data + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether the weekly reservation system is globally enabled. + + Device bool convention: 2=on, 1=off. + """ + return self.reservation_use == 2 + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> WeeklyReservationSchedule: + """Construct from a raw MQTT response dict.""" + return cls.model_validate(data) + + +class RecirculationScheduleEntry(NavienBaseModel): + """A single entry in a recirculation pump schedule. + + Used with the RECIR_RESERVATION command (33554444) to set timed + recirculation cycles. Each entry defines a time window and pump mode. + + Fields: + - enable: 2=enabled, 1=disabled (device boolean) + - week: bitfield of active days (Sun=bit7, Mon=bit6, ..., Sat=bit1) + - start_hour: 0-23 + - start_min: 0-59 + - end_hour: 0-23 + - end_min: 0-59 + - mode: recirculation mode + (1=Constant, 2=Timer, 3=Temperature, 4=Sensor) + """ + + enable: int = 2 + week: int = 0 + start_hour: int = Field(default=0, alias="startHour") + start_min: int = Field(default=0, alias="startMin") + end_hour: int = Field(default=0, alias="endHour") + end_min: int = Field(default=0, alias="endMin") + mode: int = 1 + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether this entry is active (device bool: 2=on, 1=off).""" + return self.enable == 2 + + @computed_field # type: ignore[prop-decorator] + @property + def days(self) -> list[str]: + """Weekday names for this entry.""" + from ..encoding import decode_week_bitfield + + return decode_week_bitfield(self.week) + + @computed_field # type: ignore[prop-decorator] + @property + def start_time(self) -> str: + """Formatted start time string (HH:MM).""" + return f"{self.start_hour:02d}:{self.start_min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def end_time(self) -> str: + """Formatted end time string (HH:MM).""" + return f"{self.end_hour:02d}:{self.end_min:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def mode_name(self) -> str: + """Human-readable recirculation mode name.""" + try: + return RecirculationMode(self.mode).name.replace("_", " ").title() + except ValueError: + return f"Unknown ({self.mode})" + + +class RecirculationSchedule(NavienBaseModel): + """Complete recirculation pump schedule (RECIR_RESERVATION command). + + Used with command code 33554444 to configure timed recirculation + pump operation windows. + """ + + schedule: list[RecirculationScheduleEntry] = Field(default_factory=list) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> RecirculationSchedule: + """Construct from a raw MQTT response dict.""" + return cls.model_validate(data) + + +class OtaCommitPayload(NavienBaseModel): + """Payload for committing a firmware component update. + + Used with the OTA_COMMIT command (33554442). This command uses a + special ``commitOta`` structure instead of the standard mode/param + format. + + Args: + sw_code: Software component code identifying which firmware to commit. + 1 = Controller, 2 = Panel, 4 = WiFi/communication module. + sw_version: Version number to commit (as reported by the OTA check). + """ + + sw_code: int = Field(alias="swCode") + sw_version: int = Field(alias="swVersion") + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py new file mode 100644 index 0000000..1cd410d --- /dev/null +++ b/src/nwp500/models/status.py @@ -0,0 +1,927 @@ +from __future__ import annotations + +from typing import Annotated, Any + +from pydantic import BeforeValidator, Field, computed_field + +from .._base import NavienBaseModel +from ..converters import ( + device_bool_to_python, + div_10, + mul_10, + tou_override_to_python, +) +from ..enums import ( + CurrentOperationMode, + DhwOperationSetting, + DREvent, + ErrorCode, + HeatSource, + RecirculationMode, + TemperatureType, + TempFormulaType, +) +from ..field_factory import signal_strength_field, temperature_field +from ..temperature import ( + DeciCelsius, + DeciCelsiusDelta, + HalfCelsius, + RawCelsius, +) +from ..unit_system import get_unit_system + +DeviceBool = Annotated[bool, BeforeValidator(device_bool_to_python)] +Div10 = Annotated[float, BeforeValidator(div_10)] +TenWhToWh = Annotated[float, BeforeValidator(mul_10)] +TouStatus = Annotated[bool, BeforeValidator(bool)] +TouOverride = Annotated[bool, BeforeValidator(tou_override_to_python)] + + +class DeviceStatus(NavienBaseModel): + """Represents the status of the Navien water heater device.""" + + # CRITICAL: temperature_type must remain the first field so computed + # temperature properties can fall back to the device's native unit setting. + temperature_type: TemperatureType = Field( + default=TemperatureType.FAHRENHEIT, + description=( + "Type of temperature unit (1=Celsius, 2=Fahrenheit). " + "Controls all unit conversions." + ), + ) + + # Basic status fields + command: int = Field( + description="The command that triggered this status update" + ) + special_function_status: int = Field( + description=( + "Status of special functions " + "(e.g., freeze protection, anti-seize operations)" + ) + ) + error_code: ErrorCode = Field( + default=ErrorCode.NO_ERROR, + description="Error code if any fault is detected", + ) + sub_error_code: int = Field( + description="Sub error code providing additional error details" + ) + smart_diagnostic: int = Field( + description=( + "Smart diagnostic status code for system health monitoring. " + "0 = no diagnostic conditions. " + "Non-zero = diagnostic condition detected. " + "Specific diagnostic codes are device firmware dependent." + ) + ) + fault_status1: int = Field(description="Fault status register 1") + fault_status2: int = Field(description="Fault status register 2") + wifi_rssi: int = signal_strength_field( + "WiFi signal strength in dBm. " + "Typical values: -30 (excellent) to -90 (poor)" + ) + dhw_charge_per: float = Field( + description=( + "DHW charge percentage - " + "estimated percentage of hot water capacity available" + ), + json_schema_extra={"unit_of_measurement": "%"}, + ) + dr_event_status: DREvent = Field( + default=DREvent.UNKNOWN, + description=( + "Demand Response (DR) event status from utility (CTA-2045). " + "0=UNKNOWN (No event), 1=RUN_NORMAL, 2=SHED (reduce power), " + "3=LOADUP (pre-heat), 4=LOADUP_ADV (advanced pre-heat), " + "5=CPE (customer peak event/grid emergency)" + ), + ) + vacation_day_setting: int = Field( + description="Vacation day setting", + json_schema_extra={"unit_of_measurement": "days"}, + ) + vacation_day_elapsed: int = Field( + description="Elapsed vacation days", + json_schema_extra={"unit_of_measurement": "days"}, + ) + anti_legionella_period: int = Field( + description=( + "Anti-legionella cycle interval. Range: 1-30 days, Default: 7 days" + ), + json_schema_extra={"unit_of_measurement": "days"}, + ) + program_reservation_type: int = Field( + description="Type of program reservation" + ) + temp_formula_type: TempFormulaType = Field( + description="Temperature formula type" + ) + outside_temperature_raw: int = temperature_field( + "Outdoor/ambient temperature", alias="outsideTemperature" + ) + current_statenum: int = Field(description="Current state number") + target_fan_rpm: int = Field( + description="Target fan RPM", + json_schema_extra={"unit_of_measurement": "RPM"}, + ) + current_fan_rpm: int = Field( + description="Current fan RPM", + json_schema_extra={"unit_of_measurement": "RPM"}, + ) + fan_pwm: int = Field(description="Fan PWM value") + mixing_rate: float = Field( + description=( + "Mixing valve rate percentage (0-100%). " + "Controls mixing of hot tank water with cold inlet water" + ), + json_schema_extra={"unit_of_measurement": "%"}, + ) + eev_step: int = Field( + description=( + "Electronic Expansion Valve (EEV) step position. " + "Valve opening rate expressed as step count" + ) + ) + air_filter_alarm_period: int = Field( + description=( + "Air filter maintenance cycle interval. " + "Range: Off or 1,000-10,000 hours, Default: 1,000 hours" + ), + json_schema_extra={"unit_of_measurement": "h"}, + ) + air_filter_alarm_elapsed: int = Field( + description=( + "Operating hours elapsed since last air filter maintenance reset. " + "Track this to schedule preventative replacement" + ), + json_schema_extra={"unit_of_measurement": "h"}, + ) + cumulated_op_time_eva_fan: int = Field( + description=( + "Cumulative operation time of the evaporator fan since installation" + ), + json_schema_extra={"unit_of_measurement": "h"}, + ) + cumulated_dhw_flow_rate_raw: int = Field( + alias="cumulatedDhwFlowRate", + description=( + "Cumulative DHW flow - " + "total volume of hot water delivered since installation" + ), + json_schema_extra={ + "unit_of_measurement": "gal", + "device_class": "water", + }, + ) + tou_status: TouStatus = Field( + description=( + "Time of Use (TOU) scheduling enabled. " + "True = TOU is active/enabled, False = TOU is disabled" + ) + ) + dr_override_status: int = Field( + description=( + "Demand Response override status in hours. " + "0 = no override active. " + "Non-zero (1-72) = override active with specified remaining hours. " + "User can override DR commands for up to 72 hours." + ), + json_schema_extra={"unit_of_measurement": "hours"}, + ) + tou_override_status: TouOverride = Field( + description=( + "TOU override status. " + "True = user has overridden TOU to force immediate heating, " + "False = device follows TOU schedule normally" + ) + ) + total_energy_capacity: TenWhToWh = Field( + description="Total energy capacity of the tank in Watt-hours", + json_schema_extra={ + "unit_of_measurement": "Wh", + "device_class": "energy", + }, + ) + available_energy_capacity: TenWhToWh = Field( + description=( + "Available energy capacity - " + "remaining hot water energy available in Watt-hours" + ), + json_schema_extra={ + "unit_of_measurement": "Wh", + "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") + current_inst_power: float = Field( + description=( + "Current instantaneous power consumption in Watts. " + "Does not include heating element power when active" + ), + json_schema_extra={ + "unit_of_measurement": "W", + "device_class": "power", + }, + ) + + # Boolean fields with device-specific encoding + did_reload: DeviceBool = Field( + description="Indicates if the device has recently reloaded or restarted" + ) + operation_busy: DeviceBool = Field( + description=( + "Indicates if the device is currently performing heating operations" + ) + ) + freeze_protection_use: DeviceBool = Field( + description=( + "Whether freeze protection is active. " + "Electric heater activates when tank water falls below threshold" + ) + ) + dhw_use: DeviceBool = Field( + description=( + "Domestic Hot Water (DHW) usage status - " + "indicates if hot water is currently being drawn from the tank" + ) + ) + dhw_use_sustained: DeviceBool = Field( + description=( + "Sustained DHW usage status - indicates prolonged hot water usage" + ) + ) + dhw_operation_busy: DeviceBool = Field( + default=False, + description=( + "DHW operation busy status - " + "indicates if the device is currently heating water to meet demand" + ), + ) + program_reservation_use: DeviceBool = Field( + description=( + "Whether a program reservation (scheduled operation) is in use" + ) + ) + eco_use: DeviceBool = Field( + description=( + "Whether ECO (Energy Cut Off) high-temp safety limit is triggered" + ) + ) + comp_use: DeviceBool = Field( + description=( + "Compressor usage status (True=On, False=Off). " + "The compressor is the main component of the heat pump" + ) + ) + eev_use: DeviceBool = Field( + description=( + "Electronic Expansion Valve (EEV) usage status. " + "The EEV controls refrigerant flow" + ) + ) + eva_fan_use: DeviceBool = Field( + description=( + "Evaporator fan usage status. " + "The fan pulls ambient air through the evaporator coil" + ) + ) + shut_off_valve_use: DeviceBool = Field( + description=( + "Shut-off valve usage status. " + "The valve controls refrigerant flow in the system" + ) + ) + con_ovr_sensor_use: DeviceBool = Field( + description="Condensate overflow sensor usage status" + ) + wtr_ovr_sensor_use: DeviceBool = Field( + description=( + "Water overflow/leak sensor usage status. " + "Triggers error E799 if leak detected" + ) + ) + anti_legionella_use: DeviceBool = Field( + description=( + "Whether anti-legionella function is enabled. " + "Device periodically heats tank to prevent Legionella bacteria" + ) + ) + anti_legionella_operation_busy: DeviceBool = Field( + description=( + "Whether the anti-legionella disinfection cycle " + "is currently running" + ) + ) + error_buzzer_use: DeviceBool = Field( + description="Whether the error buzzer is enabled" + ) + current_heat_use: HeatSource = Field( + description=( + "Currently active heat source. Indicates which heating " + "component(s) are actively running: 0=Unknown/not heating, " + "1=Heat Pump, 2=Electric Element, 3=Both simultaneously" + ) + ) + heat_upper_use: DeviceBool = Field( + description=( + "Upper electric heating element usage status. " + "Power: 3,755W @ 208V or 5,000W @ 240V" + ) + ) + heat_lower_use: DeviceBool = Field( + description=( + "Lower electric heating element usage status. " + "Power: 3,755W @ 208V or 5,000W @ 240V" + ) + ) + scald_use: DeviceBool = Field( + description=( + "Scald protection active status. " + "Warning when water reaches potentially hazardous levels" + ) + ) + air_filter_alarm_use: DeviceBool = Field( + description=( + "Air filter maintenance reminder enabled flag. " + "Triggers alerts based on operating hours. Default: On" + ) + ) + recirc_operation_busy: DeviceBool = Field( + description="Recirculation operation busy status" + ) + recirc_reservation_use: DeviceBool = Field( + description="Recirculation reservation usage status" + ) + + # Raw temperature, flow, and volume fields + dhw_temperature_raw: int = temperature_field( + "Current Domestic Hot Water (DHW) outlet temperature", + alias="dhwTemperature", + ) + dhw_temperature_setting_raw: int = temperature_field( + "User-configured target DHW temperature", + alias="dhwTemperatureSetting", + ) + dhw_target_temperature_setting_raw: int = temperature_field( + "Duplicate of dhw_temperature_setting for legacy API compatibility", + alias="dhwTargetTemperatureSetting", + ) + freeze_protection_temperature_raw: int = temperature_field( + "Freeze protection temperature setpoint. " + "Prevents tank from freezing in cold environments", + alias="freezeProtectionTemperature", + ) + dhw_temperature2_raw: int = temperature_field( + "Second DHW temperature reading", + alias="dhwTemperature2", + ) + hp_upper_on_temp_setting_raw: int = temperature_field( + "Heat pump upper on temperature setting", + alias="hpUpperOnTempSetting", + ) + hp_upper_off_temp_setting_raw: int = temperature_field( + "Heat pump upper off temperature setting", + alias="hpUpperOffTempSetting", + ) + hp_lower_on_temp_setting_raw: int = temperature_field( + "Heat pump lower on temperature setting", + alias="hpLowerOnTempSetting", + ) + hp_lower_off_temp_setting_raw: int = temperature_field( + "Heat pump lower off temperature setting", + alias="hpLowerOffTempSetting", + ) + he_upper_on_temp_setting_raw: int = temperature_field( + "Heater element upper on temperature setting", + alias="heUpperOnTempSetting", + ) + he_upper_off_temp_setting_raw: int = temperature_field( + "Heater element upper off temperature setting", + alias="heUpperOffTempSetting", + ) + he_lower_on_temp_setting_raw: int = temperature_field( + "Heater element lower on temperature setting", + alias="heLowerOnTempSetting", + ) + he_lower_off_temp_setting_raw: int = temperature_field( + "Heater element lower off temperature setting", + alias="heLowerOffTempSetting", + ) + heat_min_op_temperature_raw: int = temperature_field( + "Minimum heat pump operation temperature. " + "Lowest tank setpoint allowed for heat pump operation", + alias="heatMinOpTemperature", + ) + recirc_temp_setting_raw: int = temperature_field( + "Recirculation temperature setting", + alias="recircTempSetting", + ) + recirc_temperature_raw: int = temperature_field( + "Recirculation temperature", + alias="recircTemperature", + ) + recirc_faucet_temperature_raw: int = temperature_field( + "Recirculation faucet temperature", + alias="recircFaucetTemperature", + ) + current_inlet_temperature_raw: int = temperature_field( + "Cold water inlet temperature", + alias="currentInletTemperature", + ) + current_dhw_flow_rate_raw: int = Field( + alias="currentDhwFlowRate", + description="Current DHW flow rate", + json_schema_extra={ + "unit_of_measurement": "GPM", + "device_class": "flow_rate", + }, + ) + hp_upper_on_diff_temp_setting_raw: int = Field( + alias="hpUpperOnDiffTempSetting", + description="Heat pump upper on differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + hp_upper_off_diff_temp_setting_raw: int = Field( + alias="hpUpperOffDiffTempSetting", + description="Heat pump upper off differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + hp_lower_on_diff_temp_setting_raw: int = Field( + alias="hpLowerOnDiffTempSetting", + description="Heat pump lower on differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + hp_lower_off_diff_temp_setting_raw: int = Field( + alias="hpLowerOffDiffTempSetting", + description="Heat pump lower off differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + he_upper_on_diff_temp_setting_raw: int = Field( + alias="heUpperOnDiffTempSetting", + description="Heater element upper on differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + he_upper_off_diff_temp_setting_raw: int = Field( + alias="heUpperOffDiffTempSetting", + description="Heater element upper off differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + he_lower_on_diff_temp_setting_raw: int = Field( + alias="heLowerOnTDiffempSetting", + description="Heater element lower on differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) # Handle API typo: heLowerOnTDiffempSetting -> heLowerOnDiffTempSetting + he_lower_off_diff_temp_setting_raw: int = Field( + alias="heLowerOffDiffTempSetting", + description="Heater element lower off differential temperature setting", + json_schema_extra={ + "unit_of_measurement": "°F", + "device_class": "temperature", + }, + ) + recirc_dhw_flow_rate_raw: int = Field( + alias="recircDhwFlowRate", + description="Recirculation DHW flow rate (dynamic units: LPM/GPM)", + json_schema_extra={ + "device_class": "flow_rate", + }, + ) + tank_upper_temperature_raw: int = temperature_field( + "Temperature of the upper part of the tank", + alias="tankUpperTemperature", + ) + tank_lower_temperature_raw: int = temperature_field( + "Temperature of the lower part of the tank", + alias="tankLowerTemperature", + ) + discharge_temperature_raw: int = temperature_field( + "Compressor discharge temperature - " + "temperature of refrigerant leaving the compressor", + alias="dischargeTemperature", + ) + suction_temperature_raw: int = temperature_field( + "Compressor suction temperature - " + "temperature of refrigerant entering the compressor", + alias="suctionTemperature", + ) + evaporator_temperature_raw: int = temperature_field( + "Evaporator temperature - " + "temperature where heat is absorbed from ambient air", + alias="evaporatorTemperature", + ) + ambient_temperature_raw: int = temperature_field( + "Ambient air temperature measured at the heat pump air intake", + alias="ambientTemperature", + ) + target_super_heat_raw: int = temperature_field( + "Target superheat value - desired temperature difference " + "ensuring complete refrigerant vaporization", + alias="targetSuperHeat", + ) + current_super_heat_raw: int = temperature_field( + "Current superheat value - actual temperature difference " + "between suction and evaporator temperatures", + alias="currentSuperHeat", + ) + + # Enum fields + operation_mode: CurrentOperationMode = Field( + default=CurrentOperationMode.STANDBY, + description="The current actual operational state of the device", + ) + dhw_operation_setting: DhwOperationSetting = Field( + default=DhwOperationSetting.ENERGY_SAVER, + description="User's configured DHW operation mode preference", + ) + freeze_protection_temp_min_raw: int = temperature_field( + "Active freeze protection lower limit", + alias="freezeProtectionTempMin", + default=43, + ) + freeze_protection_temp_max_raw: int = temperature_field( + "Active freeze protection upper limit", + alias="freezeProtectionTempMax", + default=65, + ) + + def _is_celsius(self) -> bool: + """Return True if metric/Celsius units should be used.""" + unit_system = get_unit_system() + if unit_system is not None: + return unit_system == "metric" + return self.temperature_type == TemperatureType.CELSIUS + + @computed_field # type: ignore[prop-decorator] + @property + def outside_temperature(self) -> float: + raw = RawCelsius(self.outside_temperature_raw) + if self._is_celsius(): + return raw.to_celsius() + return raw.to_fahrenheit_with_formula(self.temp_formula_type) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature(self) -> float: + return HalfCelsius(self.dhw_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature_setting(self) -> float: + return HalfCelsius(self.dhw_temperature_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_target_temperature_setting(self) -> float: + return HalfCelsius( + self.dhw_target_temperature_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temperature(self) -> float: + return HalfCelsius(self.freeze_protection_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def dhw_temperature2(self) -> float: + return HalfCelsius(self.dhw_temperature2_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_on_temp_setting(self) -> float: + return HalfCelsius(self.hp_upper_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_off_temp_setting(self) -> float: + return HalfCelsius(self.hp_upper_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_on_temp_setting(self) -> float: + return HalfCelsius(self.hp_lower_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_off_temp_setting(self) -> float: + return HalfCelsius(self.hp_lower_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_on_temp_setting(self) -> float: + return HalfCelsius(self.he_upper_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_off_temp_setting(self) -> float: + return HalfCelsius(self.he_upper_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_on_temp_setting(self) -> float: + return HalfCelsius(self.he_lower_on_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_off_temp_setting(self) -> float: + return HalfCelsius(self.he_lower_off_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def heat_min_op_temperature(self) -> float: + return HalfCelsius(self.heat_min_op_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temp_setting(self) -> float: + return HalfCelsius(self.recirc_temp_setting_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_temperature(self) -> float: + return HalfCelsius(self.recirc_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_faucet_temperature(self) -> float: + return HalfCelsius(self.recirc_faucet_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def current_inlet_temperature(self) -> float: + return HalfCelsius(self.current_inlet_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def current_dhw_flow_rate(self) -> float: + lpm = self.current_dhw_flow_rate_raw / 10.0 + if self._is_celsius(): + return lpm + return round(lpm * 0.264172, 2) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_upper_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_upper_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_upper_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_lower_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def hp_lower_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.hp_lower_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_upper_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_upper_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_upper_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_on_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_lower_on_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def he_lower_off_diff_temp_setting(self) -> float: + return DeciCelsiusDelta( + self.he_lower_off_diff_temp_setting_raw + ).to_preferred(self._is_celsius()) + + @computed_field # type: ignore[prop-decorator] + @property + def recirc_dhw_flow_rate(self) -> float: + lpm = self.recirc_dhw_flow_rate_raw / 10.0 + if self._is_celsius(): + return lpm + return round(lpm * 0.264172, 2) + + @computed_field # type: ignore[prop-decorator] + @property + def tank_upper_temperature(self) -> float: + return DeciCelsius(self.tank_upper_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def tank_lower_temperature(self) -> float: + return DeciCelsius(self.tank_lower_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def discharge_temperature(self) -> float: + return DeciCelsius(self.discharge_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def suction_temperature(self) -> float: + return DeciCelsius(self.suction_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def evaporator_temperature(self) -> float: + return DeciCelsius(self.evaporator_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def ambient_temperature(self) -> float: + return DeciCelsius(self.ambient_temperature_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def target_super_heat(self) -> float: + return DeciCelsius(self.target_super_heat_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def current_super_heat(self) -> float: + return DeciCelsius(self.current_super_heat_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def cumulated_dhw_flow_rate(self) -> float: + liters = float(self.cumulated_dhw_flow_rate_raw) + if self._is_celsius(): + return liters + return round(liters * 0.264172, 2) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_min(self) -> float: + return HalfCelsius(self.freeze_protection_temp_min_raw).to_preferred( + self._is_celsius() + ) + + @computed_field # type: ignore[prop-decorator] + @property + def freeze_protection_temp_max(self) -> float: + return HalfCelsius(self.freeze_protection_temp_max_raw).to_preferred( + self._is_celsius() + ) + + def get_field_unit(self, field_name: str) -> str: + """Get the correct unit suffix based on temperature preference. + + Resolves dynamic units for temperature, flow rate, and volume fields + that change based on unit system context override or the device's + temperature_type setting (Celsius or Fahrenheit). + + Args: + field_name: Name of the field to get the unit for + + Returns: + Unit string (e.g., " °C", " LPM", " L") or empty if field not found + """ + model_fields = self.__class__.model_fields + lookup_name = ( + field_name if field_name in model_fields else f"{field_name}_raw" + ) + if lookup_name not in model_fields: + return "" + + field_info = model_fields[lookup_name] + if not hasattr(field_info, "json_schema_extra"): + return "" + + extra = field_info.json_schema_extra + if not isinstance(extra, dict): + return "" + + is_celsius = self._is_celsius() + + device_class = extra.get("device_class") + + # Handle temperature units + if device_class == "temperature": + return " °C" if is_celsius else " °F" + + # Handle flow rate units + if device_class == "flow_rate": + return " LPM" if is_celsius else " GPM" + + # Handle volume units + if device_class == "water": + return " L" if is_celsius else " gal" + + # Fallback to static unit_of_measurement if present + if "unit_of_measurement" in extra: + unit_val = extra["unit_of_measurement"] + unit: str = str(unit_val) if unit_val is not None else "" + return f" {unit}" if unit else "" + + return "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DeviceStatus: + """Compatibility method for existing code.""" + return cls.model_validate(data) diff --git a/src/nwp500/models/tou.py b/src/nwp500/models/tou.py new file mode 100644 index 0000000..c589369 --- /dev/null +++ b/src/nwp500/models/tou.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import Any, cast + +from pydantic import Field, model_validator + +from .._base import NavienBaseModel + + +class TOUSchedule(NavienBaseModel): + """Time of Use schedule information.""" + + season: int = 0 + intervals: list[dict[str, Any]] = Field( + default_factory=list, alias="interval" + ) + + +class ConvertedTOUPlan(NavienBaseModel): + """A rate plan converted by the Navien backend from OpenEI format. + + Returned by POST /device/tou/convert. Contains the utility name, + plan name, and device-ready schedule with season/week bitfields + and scaled pricing. + """ + + utility: str = "" + name: str = "" + schedule: list[TOUSchedule] = Field(default_factory=list) + + +class TOUInfo(NavienBaseModel): + """Time of Use information.""" + + register_path: str = "" + source_type: str = "" + controller_id: str = "" + manufacture_id: str = "" + name: str = "" + utility: str = "" + zip_code: int = 0 + schedule: list[TOUSchedule] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def _extract_nested_tou_info(cls, data: Any) -> Any: + # Handle nested structure where fields are in 'touInfo' + if isinstance(data, dict): + # Explicitly cast to dict[str, Any] for type safety + d = cast(dict[str, Any], data).copy() + if "touInfo" in d: + tou_data = d.pop("touInfo") + if isinstance(tou_data, dict): + d.update(tou_data) + return d + return data From 7f733240e37ebda5bfebbdf3f2e17131590acfc8 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 08:43:16 -0700 Subject: [PATCH 09/29] Documentation refactor: align with code refactor phases 5-9 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/api/nwp500.rst | 17 +- docs/guides/device_maintenance.rst | 119 +++ docs/guides/event_system.rst | 77 +- docs/guides/mqtt_diagnostics.rst | 56 +- docs/guides/scheduling.rst | 121 ++- docs/index.rst | 2 +- docs/protocol/mqtt_protocol.rst | 2 +- docs/python_api/device_control.rst | 1188 ---------------------------- docs/python_api/events.rst | 411 ++++------ docs/python_api/exceptions.rst | 34 +- docs/python_api/models.rst | 159 +++- docs/python_api/mqtt_client.rst | 561 +++++++------ docs/quickstart.rst | 8 +- 13 files changed, 971 insertions(+), 1784 deletions(-) create mode 100644 docs/guides/device_maintenance.rst delete mode 100644 docs/python_api/device_control.rst diff --git a/docs/api/nwp500.rst b/docs/api/nwp500.rst index 53aae54..2c63a67 100644 --- a/docs/api/nwp500.rst +++ b/docs/api/nwp500.rst @@ -8,6 +8,7 @@ Subpackages :maxdepth: 4 nwp500.cli + nwp500.models nwp500.mqtt Submodules @@ -117,14 +118,6 @@ nwp500.field\_factory module :show-inheritance: :undoc-members: -nwp500.models module --------------------- - -.. automodule:: nwp500.models - :members: - :show-inheritance: - :undoc-members: - nwp500.mqtt\_events module -------------------------- @@ -141,6 +134,14 @@ nwp500.openei module :show-inheritance: :undoc-members: +nwp500.reservations module +-------------------------- + +.. automodule:: nwp500.reservations + :members: + :show-inheritance: + :undoc-members: + nwp500.temperature module ------------------------- diff --git a/docs/guides/device_maintenance.rst b/docs/guides/device_maintenance.rst new file mode 100644 index 0000000..f37f07f --- /dev/null +++ b/docs/guides/device_maintenance.rst @@ -0,0 +1,119 @@ +================== +Device Maintenance +================== + +Maintenance commands let you handle firmware updates, connectivity recovery, +freeze protection, and onboard diagnostics from MQTT. + +.. contents:: On This Page + :local: + :depth: 2 + +Before You Start +================ + +Many maintenance operations are device-specific. Request device features first so +you can inspect capability flags and supported temperature ranges. + +.. code-block:: python + + await mqtt.subscribe_device_feature(device, lambda feature: print(feature)) + await mqtt.request_device_info(device) + +Firmware OTA Updates +==================== + +Use firmware OTA when the device has already downloaded or advertised an update. +The workflow is asynchronous: + +1. Call :meth:`nwp500.mqtt.client.NavienMqttClient.check_firmware_update` +2. Wait for the device's response on its control response topic +3. If an update is available, call + :meth:`nwp500.mqtt.client.NavienMqttClient.commit_firmware_update` + with an :class:`~nwp500.models.OtaCommitPayload` + +.. warning:: + + Committing firmware reboots the device. Heating and MQTT connectivity will be + interrupted until the upgrade completes. + +.. code-block:: python + + from nwp500 import OtaCommitPayload + + def on_message(topic, message): + print(topic) + print(message) + + await mqtt.subscribe_device(device, on_message) + await mqtt.check_firmware_update(device) + + # After confirming the component code/version from the async response: + payload = OtaCommitPayload(swCode=1, swVersion=1234) + await mqtt.commit_firmware_update(device, payload) + +WiFi Management +=============== + +Two commands cover WiFi recovery: + +* :meth:`nwp500.mqtt.client.NavienMqttClient.reconnect_wifi` performs a soft + reconnect using the currently stored credentials. +* :meth:`nwp500.mqtt.client.NavienMqttClient.reset_wifi` clears WiFi settings and + returns the device to an unprovisioned state. + +.. warning:: + + ``reset_wifi()`` is effectively a factory reset for network settings. You will + need to reconfigure the device in the Navien app afterward. + +.. code-block:: python + + # Try this first when the device drops off WiFi + await mqtt.reconnect_wifi(device) + + # Use only when credentials or provisioning are broken + await mqtt.reset_wifi(device) + +Freeze Protection +================= + +Freeze protection is available on devices that expose the +``freeze_protection_use`` capability. The threshold is specified in the user's +preferred temperature unit and converted automatically. + +The implementation documentation describes a typical supported range of +35-45 °F (about 1.7-7.2 °C). You can also inspect +``DeviceFeature.freeze_protection_temp_min`` and +``DeviceFeature.freeze_protection_temp_max`` after requesting device info. + +.. code-block:: python + + # Fahrenheit example + await mqtt.set_freeze_protection_temperature(device, 40.0) + +Smart Diagnostics +================= + +Smart diagnostics are available on devices that expose the +``smart_diagnostic_use`` capability. Triggering the diagnostic tells the device +to run its onboard self-check routine. + +The result is reflected in the next +:class:`~nwp500.models.DeviceStatus` update via the ``smart_diagnostic`` field. + +.. code-block:: python + + def on_status(status): + print(f"Diagnostic status: {status.smart_diagnostic}") + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.run_smart_diagnostic(device) + await mqtt.request_device_status(device) + +Related Documentation +===================== + +* :doc:`../python_api/mqtt_client` - Full MQTT client API reference +* :doc:`scheduling` - Reservations, recirculation schedules, and intelligent scheduling +* :doc:`mqtt_diagnostics` - Connection troubleshooting and diagnostics diff --git a/docs/guides/event_system.rst b/docs/guides/event_system.rst index 0977155..358feca 100644 --- a/docs/guides/event_system.rst +++ b/docs/guides/event_system.rst @@ -36,8 +36,21 @@ Benefits Basic Usage =========== +Two Callback Patterns +--------------------- + +The MQTT client exposes two distinct callback styles: + +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_device_status` and other + ``subscribe_*`` methods deliver parsed model objects directly. For example, + ``subscribe_device_status()`` still calls ``callback(DeviceStatus)``. +* :meth:`nwp500.events.EventEmitter.on` listens for higher-level client events. + These callbacks now receive a **single typed event dataclass** such as + :class:`nwp500.mqtt_events.StatusReceivedEvent` or + :class:`nwp500.mqtt_events.ConnectionResumedEvent`. + Discovering Available Events ------------------------------ +---------------------------- The :class:`nwp500.mqtt_events.MqttClientEvents` class provides a complete registry of all events with type-safe constants and full documentation: @@ -46,23 +59,9 @@ of all events with type-safe constants and full documentation: from nwp500 import MqttClientEvents - # List all available events for event_name in MqttClientEvents.get_all_events(): print(f"- {event_name}") - # Output: - # - CONNECTION_INTERRUPTED - # - CONNECTION_RESUMED - # - STATUS_RECEIVED - # - TEMPERATURE_CHANGED - # - MODE_CHANGED - # - POWER_CHANGED - # - HEATING_STARTED - # - HEATING_STOPPED - # - ERROR_DETECTED - # - ERROR_CLEARED - # - FEATURE_RECEIVED - Simple Event Handler -------------------- @@ -79,27 +78,39 @@ Simple Event Handler mqtt = NavienMqttClient(auth) await mqtt.connect() - # Use type-safe event constants with IDE autocomplete - def on_status_update(status): + def on_status_event(event): + status = event.status print(f"Temperature: {status.dhw_temperature}°F") print(f"Power: {status.current_inst_power}W") - # Subscribe using event constants - mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_update) - await mqtt.control.request_device_status(device) + mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_event) + await mqtt.request_device_status(device) - # Monitor for 5 minutes await asyncio.sleep(300) await mqtt.disconnect() asyncio.run(main()) +Raw status subscription +----------------------- + +Use a typed subscription when you want the raw model directly instead of an event +wrapper: + +.. code-block:: python + + def on_status(status): + print(status.dhw_temperature) + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) + Event Registry -------------- The :class:`nwp500.mqtt_events.MqttClientEvents` class provides type-safe event -constants and programmatic discovery, so your callbacks use valid event -names and get IDE autocomplete: +constants and programmatic discovery, so your callbacks use valid event names and +get IDE autocomplete: .. code-block:: python @@ -107,27 +118,33 @@ names and get IDE autocomplete: mqtt_client = NavienMqttClient(auth) - # Type-safe constants with IDE autocomplete + def on_temp_change(event): + print(f"Temperature: {event.old_temperature} -> {event.new_temperature}") + + def on_heating_start(event): + print(f"Heating started at {event.status.dhw_temperature}") + + def on_error(event): + print(f"Error: {event.error_code}") + mqtt_client.on(MqttClientEvents.TEMPERATURE_CHANGED, on_temp_change) mqtt_client.on(MqttClientEvents.HEATING_STARTED, on_heating_start) mqtt_client.on(MqttClientEvents.ERROR_DETECTED, on_error) - # Programmatically discover all events print("Available events:") for event_name in MqttClientEvents.get_all_events(): print(f" - {event_name}") - # Get event string value if needed event_value = MqttClientEvents.get_event_value("TEMPERATURE_CHANGED") - print(f"Event value: {event_value}") # Output: "temperature_changed" + print(f"Event value: {event_value}") -Each event has full type documentation. See -:class:`nwp500.mqtt_events` for complete details on event data types and -their arguments. +Each event has full type documentation. See :doc:`../python_api/events` for the +complete event dataclass reference. Advanced Patterns ================= + Pattern 1: State Tracking -------------------------- diff --git a/docs/guides/mqtt_diagnostics.rst b/docs/guides/mqtt_diagnostics.rst index 1a95cc9..b22dd1e 100644 --- a/docs/guides/mqtt_diagnostics.rst +++ b/docs/guides/mqtt_diagnostics.rst @@ -38,12 +38,12 @@ Quick Start # Hook events mqtt_client.on('connection_interrupted', - lambda e: diagnostics.record_connection_drop(error=e) + lambda event: diagnostics.record_connection_drop(error=event.error) ) mqtt_client.on('connection_resumed', - lambda rc, sp: diagnostics.record_connection_success( - event_type='resumed', session_present=sp + lambda event: diagnostics.record_connection_success( + event_type='resumed', session_present=event.session_present ) ) @@ -233,20 +233,20 @@ Enable diagnostics and collect baseline data. # Hook connection events mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task( - diagnostics.record_connection_drop( - error=e, + lambda event: asyncio.create_task( + diagnostics.record_connection_drop( + error=event.error, queued_commands=mqtt_client.queued_commands_count ) ) ) mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task( - diagnostics.record_connection_success( - event_type='resumed', - session_present=sp, - return_code=rc + lambda event: asyncio.create_task( + diagnostics.record_connection_success( + event_type='resumed', + session_present=event.session_present, + return_code=event.return_code ) ) ) @@ -512,15 +512,15 @@ Basic Monitoring Loop mqtt_client = NavienMqttClient(auth_client, config=config) mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task( - diagnostics.record_connection_drop(error=e) + lambda event: asyncio.create_task( + diagnostics.record_connection_drop(error=event.error) ) ) mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task( - diagnostics.record_connection_success( - event_type='resumed', session_present=sp + lambda event: asyncio.create_task( + diagnostics.record_connection_success( + event_type='resumed', session_present=event.session_present ) ) ) @@ -598,11 +598,11 @@ Class-Based Monitoring self.mqtt_client = NavienMqttClient(self.auth_client, config=config) self.mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task(self._on_drop(e)) + lambda event: asyncio.create_task(self._on_drop(event.error)) ) self.mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task(self._on_resume(rc, sp)) + lambda event: asyncio.create_task(self._on_resume(event.return_code, event.session_present)) ) await self.mqtt_client.connect() @@ -795,17 +795,17 @@ Integration Pattern def _setup_event_hooks(self): """Hook diagnostics into MQTT client events.""" self.mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task( - self.diagnostics.record_connection_drop(error=e) + lambda event: asyncio.create_task( + self.diagnostics.record_connection_drop(error=event.error) ) ) self.mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task( + lambda event: asyncio.create_task( self.diagnostics.record_connection_success( event_type='resumed', - session_present=sp, - return_code=rc + session_present=event.session_present, + return_code=event.return_code ) ) ) @@ -991,17 +991,17 @@ Example: Minimal HA Component with Diagnostics # Hook diagnostics mqtt_client.on('connection_interrupted', - lambda e: asyncio.create_task( - diagnostics.record_connection_drop(error=e) + lambda event: asyncio.create_task( + diagnostics.record_connection_drop(error=event.error) ) ) mqtt_client.on('connection_resumed', - lambda rc, sp: asyncio.create_task( + lambda event: asyncio.create_task( diagnostics.record_connection_success( event_type='resumed', - session_present=sp, - return_code=rc, + session_present=event.session_present, + return_code=event.return_code, ) ) ) diff --git a/docs/guides/scheduling.rst b/docs/guides/scheduling.rst index 5f25da0..c599e67 100644 --- a/docs/guides/scheduling.rst +++ b/docs/guides/scheduling.rst @@ -86,7 +86,7 @@ Quick Example mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, [entry], enabled=True ) await mqtt.disconnect() @@ -352,7 +352,7 @@ multiple entries at once: mode_id=3, temperature=55.0 ), ] - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, reservations, enabled=True ) @@ -360,7 +360,7 @@ multiple entries at once: .. code-block:: python - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, [], enabled=False ) @@ -368,7 +368,7 @@ multiple entries at once: .. code-block:: python - await mqtt.control.request_reservations(device) + await mqtt.request_reservations(device) **Read the current schedule using models:** @@ -384,8 +384,8 @@ multiple entries at once: f" - {entry.temperature}{entry.unit}" f" - {entry.mode_name}") - await mqtt.subscribe_device_feature(device, on_reservations) - await mqtt.control.request_reservations(device) + await mqtt.subscribe_reservation_response(device, on_reservations) + await mqtt.request_reservations(device) CLI Helpers ^^^^^^^^^^^ @@ -577,6 +577,113 @@ Important Notes * Reservations are suspended when vacation mode or TOU is active. +Weekly Reservations +=================== + +``update_weekly_reservation()`` configures a separate weekly reservation payload +using :class:`~nwp500.models.WeeklyReservationSchedule`. This is useful when you +want to send the whole weekly program as one typed object. + +.. code-block:: python + + from nwp500 import ( + WeeklyReservationEntry, + WeeklyReservationSchedule, + build_reservation_entry, + ) + + morning = WeeklyReservationEntry.model_validate( + build_reservation_entry( + enabled=True, + days=["MO", "TU", "WE", "TH", "FR"], + hour=6, + minute=0, + mode_id=4, + temperature=60.0, + ) + ) + + day = WeeklyReservationEntry.model_validate( + build_reservation_entry( + enabled=True, + days=["MO", "TU", "WE", "TH", "FR"], + hour=9, + minute=0, + mode_id=3, + temperature=50.0, + ) + ) + + schedule = WeeklyReservationSchedule( + reservationUse=2, + reservation=[morning, day], + ) + + await mqtt.update_weekly_reservation(device, schedule) + +You can also subscribe to weekly reservation responses with +:meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_weekly_reservation_response`. + +Recirculation Scheduling +======================== + +Recirculation schedules are represented by +:class:`~nwp500.models.RecirculationSchedule` and +:class:`~nwp500.models.RecirculationScheduleEntry`. + +.. code-block:: python + + from nwp500 import RecirculationSchedule, RecirculationScheduleEntry + + schedule = RecirculationSchedule( + schedule=[ + RecirculationScheduleEntry( + enable=2, + week=124, # Mon-Fri + start_hour=6, + start_min=0, + end_hour=8, + end_min=30, + mode=2, + ), + RecirculationScheduleEntry( + enable=2, + week=130, # Sat-Sun + start_hour=7, + start_min=0, + end_hour=9, + end_min=0, + mode=2, + ), + ] + ) + + await mqtt.configure_recirculation_schedule(device, schedule) + + def on_recirculation(schedule): + for entry in schedule.schedule: + print(entry.start_time, entry.end_time, entry.mode_name) + + await mqtt.subscribe_recirculation_schedule_response(device, on_recirculation) + +Use :meth:`nwp500.mqtt.client.NavienMqttClient.set_recirculation_mode` to switch +between always-on, button, schedule, and temperature modes. + +Intelligent Scheduling +====================== + +Intelligent scheduling enables the device's adaptive heating mode. + +.. code-block:: python + + await mqtt.enable_intelligent_scheduling(device) + + # Later, return to manual scheduling behavior + await mqtt.disable_intelligent_scheduling(device) + +This mode is separate from standard reservations. Use it when you want the +heater to adapt automatically instead of following only fixed time windows. + Time of Use (TOU) ================== @@ -786,8 +893,8 @@ See Also ======== * :doc:`time_of_use` — Full TOU guide with OpenEI integration -* :doc:`../python_api/device_control` — Device control API reference * :doc:`../python_api/mqtt_client` — MQTT client API reference +* :doc:`device_maintenance` — Maintenance and OTA operations * :doc:`../protocol/data_conversions` — Temperature and power field conversions * :doc:`auto_recovery` — Handling temporary connectivity issues diff --git a/docs/index.rst b/docs/index.rst index 0d5da5d..aab00e9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -55,7 +55,6 @@ Documentation Index python_api/auth_client python_api/api_client python_api/mqtt_client - python_api/device_control python_api/models enumerations python_api/events @@ -79,6 +78,7 @@ Documentation Index guides/command_queue guides/auto_recovery guides/scheduling + guides/device_maintenance guides/energy_monitoring guides/time_of_use guides/unit_conversion diff --git a/docs/protocol/mqtt_protocol.rst b/docs/protocol/mqtt_protocol.rst index 683a2cd..b94fdd0 100644 --- a/docs/protocol/mqtt_protocol.rst +++ b/docs/protocol/mqtt_protocol.rst @@ -918,7 +918,7 @@ this protocol. mqtt = NavienMqttClient(auth) await mqtt.connect() await mqtt.subscribe_device_status(device, callback) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) Related Documentation ===================== diff --git a/docs/python_api/device_control.rst b/docs/python_api/device_control.rst deleted file mode 100644 index 3721536..0000000 --- a/docs/python_api/device_control.rst +++ /dev/null @@ -1,1188 +0,0 @@ -=========================== -Device Control and Commands -=========================== - -The ``MqttDeviceController`` manages all device control operations including status requests, -mode changes, temperature control, scheduling, and energy queries. - -Overview -======== - -The device controller provides: - -* **Status & Info Requests** - Request device status and feature information -* **Power Control** - Turn device on/off -* **Mode Management** - Change DHW operation modes -* **Temperature Control** - Set target water temperature -* **Anti-Legionella** - Enable/disable disinfection cycles -* **Scheduling** - Configure reservations and time-of-use pricing -* **Energy Monitoring** - Query historical energy usage -* **Recirculation** - Control hot water recirculation pump -* **Demand Response** - Participate in utility demand response -* **Capability Checking** - Validate device features before commanding -* **Automatic Capability Checking** - Decorator-based validation with automatic device info requests - -All control methods are fully asynchronous and require device capability information -to be cached before execution. - -Quick Start -=========== - -Basic Control -------------- - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient - import asyncio - - async def control_device(): - async with NavienAuthClient("email@example.com", "password") as auth: - api = NavienAPIClient(auth) - device = await api.get_first_device() - - mqtt = NavienMqttClient(auth) - await mqtt.connect() - - # Request device info to populate capability cache - await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) - - # Now control operations work with automatic capability checking - await mqtt.control.set_power(device, power_on=True) - await mqtt.control.set_dhw_mode(device, mode_id=3) # Energy Saver - await mqtt.control.set_dhw_temperature(device, 140.0) - - await mqtt.disconnect() - - asyncio.run(control_device()) - -Capability Checking -------------------- - -Before executing control commands, check device capabilities: - -.. code-block:: python - - from nwp500 import NavienMqttClient, DeviceCapabilityError - - async def safe_control(): - mqtt = NavienMqttClient(auth) - await mqtt.connect() - - # Request device info first - await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) - - # Wait for device info to be cached, then control - try: - # Control commands automatically check capabilities via decorator - msg_id = await mqtt.control.set_recirculation_mode(device, 1) - print(f"Command sent with ID {msg_id}") - except DeviceCapabilityError as e: - print(f"Device doesn't support: {e}") - -API Reference -============= - -MqttDeviceController --------------------- - -The ``NavienMqttClient`` includes a built-in device controller for all operations. - -Status and Info Methods ------------------------ - -request_device_status() -^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_device_status(device) - - Request current device status. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) - -request_device_info() -^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_device_info(device) - - Request device features and capabilities. - - This populates the device info cache used for capability checking in control commands. - Always call this before using control commands. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) - -Power Control --------------- - -set_power() -^^^^^^^^^^^ - -.. py:method:: set_power(device, power_on) - - Turn device on or off. - - **Capability Required:** ``power_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param power_on: True to turn on, False to turn off - :type power_on: bool - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support power control - - **Example:** - - .. code-block:: python - - # Turn on - await mqtt.control.set_power(device, power_on=True) - - # Turn off - await mqtt.control.set_power(device, power_on=False) - -DHW Mode Control ------------------ - -set_dhw_mode() -^^^^^^^^^^^^^^ - -.. py:method:: set_dhw_mode(device, mode_id, vacation_days=None) - - Set DHW (Domestic Hot Water) operation mode. - - **Capability Required:** ``dhw_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param mode_id: Mode ID (1-5) - :type mode_id: int - :param vacation_days: Number of days for vacation mode (required if mode_id=5, 1-30) - :type vacation_days: int or None - :return: Publish packet ID - :rtype: int - :raises ParameterValidationError: If vacation_days invalid for non-vacation modes - :raises RangeValidationError: If vacation_days not in 1-30 range - :raises DeviceCapabilityError: If device doesn't support DHW mode control - - **Operation Modes:** - - * 1 = Heat Pump Only - Most efficient, uses only heat pump - * 2 = Electric Only - Fast recovery, uses only electric heaters - * 3 = Energy Saver - Balanced, recommended for most users - * 4 = High Demand - Maximum heating capacity - * 5 = Vacation - Low power mode for extended absence - - **Example:** - - .. code-block:: python - - from nwp500 import DhwOperationSetting - - # Set to Energy Saver (balanced, recommended) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) - # or just: - await mqtt.control.set_dhw_mode(device, 3) - - # Set vacation mode for 7 days - await mqtt.control.set_dhw_mode( - device, - DhwOperationSetting.VACATION.value, - vacation_days=7 - ) - -Temperature Control --------------------- - -set_dhw_temperature() -^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: set_dhw_temperature(device, temperature) - - Set DHW target temperature. - - **Capability Required:** ``dhw_temperature_setting_use`` - DHW temperature control enabled - - :param device: Device object - :type device: Device - :param temperature: Target temperature in user's preferred unit (Celsius or Fahrenheit) - :type temperature: float - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If temperature is outside valid range - :raises DeviceCapabilityError: If device doesn't support temperature control - - The temperature is automatically converted to the device's internal format - (half-degrees Celsius). The valid range depends on the device's - temperature preference and configuration. - - **Example:** - - .. code-block:: python - - # Set temperature (interpreted in device's preferred unit) - await mqtt.control.set_dhw_temperature(device, 140.0) - - # Common temperatures (device-dependent units) - await mqtt.control.set_dhw_temperature(device, 120.0) # Standard - await mqtt.control.set_dhw_temperature(device, 130.0) # Medium - await mqtt.control.set_dhw_temperature(device, 140.0) # Hot - await mqtt.control.set_dhw_temperature(device, 150.0) # Maximum - -Anti-Legionella Control ------------------------- - -enable_anti_legionella() -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: enable_anti_legionella(device, period_days) - - Enable anti-Legionella disinfection cycle. - - :param device: Device object - :type device: Device - :param period_days: Cycle period in days (1-30) - :type period_days: int - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If period_days not in 1-30 range - - **Example:** - - .. code-block:: python - - # Enable weekly anti-Legionella cycle - await mqtt.control.enable_anti_legionella(device, period_days=7) - - # Enable bi-weekly cycle - await mqtt.control.enable_anti_legionella(device, period_days=14) - -disable_anti_legionella() -^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: disable_anti_legionella(device) - - Disable anti-Legionella disinfection cycle. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - await mqtt.control.disable_anti_legionella(device) - -Vacation Mode --------------- - -set_vacation_days() -^^^^^^^^^^^^^^^^^^^ - -.. py:method:: set_vacation_days(device, days) - - Set vacation/away mode duration in days. - - **Capability Required:** ``holiday_use`` - Must be present in device features - - Configures the device to operate in energy-saving mode for the specified number - of days during absence. - - :param device: Device object - :type device: Device - :param days: Number of vacation days (1-365 recommended, positive values) - :type days: int - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If days is not positive - :raises DeviceCapabilityError: If device doesn't support vacation mode - - **Example:** - - .. code-block:: python - - # Set vacation for 14 days - await mqtt.control.set_vacation_days(device, 14) - - # Set for full month - await mqtt.control.set_vacation_days(device, 30) - -Recirculation Control ---------------------- - -set_recirculation_mode() -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: set_recirculation_mode(device, mode) - - Set recirculation pump operation mode. - - **Capability Required:** ``recirculation_use`` - Must be present in device features - - Configures how the recirculation pump operates: - - * 1 = Always On - Pump runs continuously - * 2 = Button Only - Pump activates only via button press - * 3 = Schedule - Pump follows configured schedule - * 4 = Temperature - Pump maintains water temperature - - :param device: Device object - :type device: Device - :param mode: Recirculation mode (1-4) - :type mode: int - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If mode not in 1-4 range - :raises DeviceCapabilityError: If device doesn't support recirculation - - **Example:** - - .. code-block:: python - - # Enable always-on recirculation - await mqtt.control.set_recirculation_mode(device, 1) - - # Set to temperature-based control - await mqtt.control.set_recirculation_mode(device, 4) - -trigger_recirculation_hot_button() -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: trigger_recirculation_hot_button(device) - - Manually trigger the recirculation pump hot button. - - **Capability Required:** ``recirculation_use`` - Must be present in device features - - Activates the recirculation pump for immediate hot water delivery. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support recirculation - - **Example:** - - .. code-block:: python - - # Manually activate recirculation for immediate hot water - await mqtt.control.trigger_recirculation_hot_button(device) - -configure_recirculation_schedule() -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: configure_recirculation_schedule(device, schedule) - - Configure recirculation pump schedule. - - **Capability Required:** ``recirc_reservation_use`` - Recirculation scheduling enabled - - Sets up the recirculation pump operating schedule with specified periods and settings. - - :param device: Device object - :type device: Device - :param schedule: Recirculation schedule configuration - :type schedule: dict - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support recirculation scheduling - - **Example:** - - .. code-block:: python - - schedule = { - "enabled": True, - "periods": [ - { - "startHour": 6, - "startMinute": 0, - "endHour": 22, - "endMinute": 0, - "weekDays": [1, 1, 1, 1, 1, 0, 0] # Mon-Fri - } - ] - } - - await mqtt.control.configure_recirculation_schedule(device, schedule) - -Time-of-Use Control --------------------- - -set_tou_enabled() -^^^^^^^^^^^^^^^^^ - -.. py:method:: set_tou_enabled(device, enabled) - - Enable or disable Time-of-Use optimization. - - **Capability Required:** ``program_reservation_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param enabled: True to enable, False to disable - :type enabled: bool - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support TOU - - **Example:** - - .. code-block:: python - - # Enable TOU - await mqtt.control.set_tou_enabled(device, True) - - # Disable TOU - await mqtt.control.set_tou_enabled(device, False) - -configure_tou_schedule() -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: configure_tou_schedule(device, controller_serial_number, periods, enabled=True) - - Configure Time-of-Use pricing schedule via MQTT. - - **Capability Required:** ``program_reservation_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param controller_serial_number: Controller serial number - :type controller_serial_number: str - :param periods: List of TOU period definitions - :type periods: list[dict] - :param enabled: Whether TOU is enabled (default: True) - :type enabled: bool - :return: Publish packet ID - :rtype: int - :raises ParameterValidationError: If controller_serial_number empty or periods empty - :raises DeviceCapabilityError: If device doesn't support TOU - - **Example:** - - .. code-block:: python - - periods = [ - { - "season": 0, - "week": 0, - "startHour": 9, - "startMinute": 0, - "endHour": 17, - "endMinute": 0, - "priceMin": 0.10, - "priceMax": 0.25, - "decimalPoint": 2 - } - ] - - await mqtt.control.configure_tou_schedule( - device, - controller_serial_number="ABC123", - periods=periods - ) - -request_tou_settings() -^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_tou_settings(device, controller_serial_number) - - Request current Time-of-Use schedule from the device. - - :param device: Device object - :type device: Device - :param controller_serial_number: Controller serial number - :type controller_serial_number: str - :return: Publish packet ID - :rtype: int - :raises ParameterValidationError: If controller_serial_number empty - -Reservation Management ----------------------- - -update_reservations() -^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: update_reservations(device, reservations, enabled=True) - - Update device reservation schedule. - - **Capability Required:** ``program_reservation_use`` - Must be present in device features - - :param device: Device object - :type device: Device - :param reservations: List of reservation objects - :type reservations: list[dict] - :param enabled: Enable/disable reservation schedule (default: True) - :type enabled: bool - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support reservations - - **Example:** - - .. code-block:: python - - reservations = [ - { - "startHour": 6, - "startMinute": 0, - "endHour": 22, - "endMinute": 0, - "weekDays": [1, 1, 1, 1, 1, 0, 0], # Mon-Fri - "temperature": 120 - }, - { - "startHour": 8, - "startMinute": 0, - "endHour": 20, - "endMinute": 0, - "weekDays": [0, 0, 0, 0, 0, 1, 1], # Sat-Sun - "temperature": 130 - } - ] - - await mqtt.control.update_reservations(device, reservations, enabled=True) - -request_reservations() -^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_reservations(device) - - Request current reservation schedule from the device. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - -configure_reservation_water_program() -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: configure_reservation_water_program(device) - - Enable/configure water program reservation mode. - - **Capability Required:** ``program_reservation_use`` - Must be present in device features - - Enables the water program reservation system for scheduling. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - :raises DeviceCapabilityError: If device doesn't support reservation programs - -Energy Monitoring ------------------- - -request_energy_usage() -^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: request_energy_usage(device, year, months) - - Request daily energy usage data for specified period. - - Retrieves historical energy usage data showing heat pump and electric heating - element consumption broken down by day. - - :param device: Device object - :type device: Device - :param year: Year to query (e.g., 2024) - :type year: int - :param months: List of months to query (1-12) - :type months: list[int] - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - # Subscribe first - await mqtt.subscribe_energy_usage(device, on_energy) - - # Request current month - from datetime import datetime - now = datetime.now() - await mqtt.control.request_energy_usage(device, now.year, [now.month]) - - # Request multiple months - await mqtt.control.request_energy_usage(device, 2024, [8, 9, 10]) - -Demand Response ----------------- - -enable_demand_response() -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: enable_demand_response(device) - - Enable utility demand response participation. - - Allows the device to respond to utility demand response signals to reduce - consumption (shed) or pre-heat (load up) before peak periods. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - # Enable demand response - await mqtt.control.enable_demand_response(device) - -disable_demand_response() -^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: disable_demand_response(device) - - Disable utility demand response participation. - - Prevents the device from responding to utility demand response signals. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - # Disable demand response - await mqtt.control.disable_demand_response(device) - -Air Filter Maintenance ------------------------ - -reset_air_filter() -^^^^^^^^^^^^^^^^^^ - -.. py:method:: reset_air_filter(device) - - Reset air filter maintenance timer. - - Used for heat pump models to reset the maintenance timer after filter - cleaning or replacement. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - # Reset air filter timer after maintenance - await mqtt.control.reset_air_filter(device) - -Utility Methods ---------------- - -signal_app_connection() -^^^^^^^^^^^^^^^^^^^^^^^ - -.. py:method:: signal_app_connection(device) - - Signal that an application has connected. - - Recommended to call at startup to notify the device of app connection. - - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int - - **Example:** - - .. code-block:: python - - await mqtt.connect() - await mqtt.control.signal_app_connection(device) - -Device Capabilities Module -========================== - -The ``DeviceCapabilityChecker`` provides a mapping-based approach to validate -device capabilities without requiring individual checker functions. - -.. py:class:: DeviceCapabilityChecker - - Generalized device capability checker using a capability map. - - Class Methods - ^^^^^^^^^^^^^ - -supports() ----------- - -.. py:staticmethod:: supports(feature, device_features) - - Check if device supports control of a specific feature. - - :param feature: Name of the controllable feature - :type feature: str - :param device_features: Device feature information - :type device_features: DeviceFeature - :return: True if feature control is supported, False otherwise - :rtype: bool - :raises ValueError: If feature is not recognized - - **Supported Features:** - - * ``power_use`` - Device power on/off control - * ``dhw_use`` - DHW mode changes - * ``dhw_temperature_setting_use`` - DHW temperature control - * ``holiday_use`` - Vacation/away mode - * ``program_reservation_use`` - Reservations and TOU scheduling - * ``recirculation_use`` - Recirculation pump control - * ``recirc_reservation_use`` - Recirculation scheduling - - **Example:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - if DeviceCapabilityChecker.supports("recirculation_use", device_features): - print("Device supports recirculation pump control") - else: - print("Device doesn't support recirculation pump") - -assert_supported() ------------------- - -.. py:staticmethod:: assert_supported(feature, device_features) - - Assert that device supports control of a feature. - - :param feature: Name of the controllable feature - :type feature: str - :param device_features: Device feature information - :type device_features: DeviceFeature - :raises DeviceCapabilityError: If feature control is not supported - :raises ValueError: If feature is not recognized - - **Example:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - from nwp500 import DeviceCapabilityError - - try: - DeviceCapabilityChecker.assert_supported("recirculation_use", features) - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - print(f"Cannot set recirculation: {e}") - -get_available_controls() ------------------------- - -.. py:staticmethod:: get_available_controls(device_features) - - Get all controllable features available on a device. - - :param device_features: Device feature information - :type device_features: DeviceFeature - :return: Dictionary mapping feature names to whether they can be controlled - :rtype: dict[str, bool] - - **Example:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - controls = DeviceCapabilityChecker.get_available_controls(device_features) - for feature, supported in controls.items(): - status = "✓" if supported else "✗" - print(f"{status} {feature}") - -register_capability() ---------------------- - -.. py:staticmethod:: register_capability(name, check_fn) - - Register a custom controllable feature check. - - Allows extensions or applications to define custom capability checks without - modifying the core library. - - :param name: Feature name - :type name: str - :param check_fn: Function that takes DeviceFeature and returns bool - :type check_fn: Callable[[DeviceFeature], bool] - - **Example:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - def check_custom_feature(features): - return features.some_custom_field is not None - - # Register custom capability - DeviceCapabilityChecker.register_capability("custom_feature", check_custom_feature) - - # Now can use it with control methods - if DeviceCapabilityChecker.supports("custom_feature", device_features): - # Execute custom command - pass - -Controller Capability Methods ------------------------------- - -MqttDeviceController also provides direct capability checking methods: - -check_support() -^^^^^^^^^^^^^^^ - -.. py:method:: check_support(feature, device_features) - - Check if device supports a controllable feature. - - :param feature: Name of the controllable feature - :type feature: str - :param device_features: Device feature information - :type device_features: DeviceFeature - :return: True if feature is supported, False otherwise - :rtype: bool - :raises ValueError: If feature is not recognized - - **Example:** - - .. code-block:: python - - if mqtt.check_support("recirculation_use", device_features): - await mqtt.control.set_recirculation_mode(device, 1) - -assert_support() -^^^^^^^^^^^^^^^^ - -.. py:method:: assert_support(feature, device_features) - - Assert that device supports a controllable feature. - - :param feature: Name of the controllable feature - :type feature: str - :param device_features: Device feature information - :type device_features: DeviceFeature - :raises DeviceCapabilityError: If feature is not supported - :raises ValueError: If feature is not recognized - - **Example:** - - .. code-block:: python - - try: - mqtt.assert_support("recirculation_use", device_features) - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - print(f"Device doesn't support: {e}") - -Capability Checking Decorator -============================== - -The ``@requires_capability`` decorator automatically validates device capabilities -before command execution. - -.. py:function:: requires_capability(feature) - - Decorator that validates device capability before executing command. - - This decorator automatically checks if a device supports a specific controllable - feature before allowing the command to execute. If the device doesn't support - the feature, a ``DeviceCapabilityError`` is raised. - - **Requirements:** - - The decorated method must: - - 1. Have ``self`` (controller instance with ``_device_info_cache``) - 2. Have ``device`` parameter (Device object with ``mac_address``) - 3. Be async (sync methods log a warning and bypass checking for backward compatibility) - - The device info must be cached (via ``request_device_info``) before calling - the command, otherwise a ``DeviceCapabilityError`` is raised. The decorator - supports automatic device info requests if the controller callback is configured. - - :param feature: Name of the required capability (e.g., "recirculation_use") - :type feature: str - :return: Decorator function - :rtype: Callable - - :raises DeviceCapabilityError: If device doesn't support the feature - :raises ValueError: If feature name is not recognized - - **How It Works:** - - 1. Extracts device MAC address from ``device`` parameter - 2. Checks if device info is already cached - 3. If not cached, automatically attempts to request it (if callback configured) - 4. Validates the capability using ``DeviceCapabilityChecker`` - 5. Executes command only if capability check passes - 6. Logs all operations for debugging - - **Example Usage:** - - .. code-block:: python - - from nwp500.mqtt_device_control import MqttDeviceController - from nwp500.command_decorators import requires_capability - - class MyController(MqttDeviceController): - @requires_capability("recirculation_use") - async def set_recirculation_mode(self, device, mode): - # Capability automatically checked before this executes - return await self._publish(...) - - **Automatic Device Info Requests:** - - When a control method is called and device info isn't cached, the decorator - attempts to automatically request it: - - .. code-block:: python - - # Device info is automatically requested if not cached - await mqtt.control.set_recirculation_mode(device, 1) - - # This triggers: - # 1. Check cache (not found) - # 2. Auto-request device info - # 3. Wait for response - # 4. Validate capability - # 5. Execute command - -Error Handling --------------- - -**DeviceCapabilityError** is raised when: - -1. Device doesn't support the required feature -2. Device info cannot be obtained (for automatic requests) -3. Feature name is not recognized - -.. code-block:: python - - from nwp500 import DeviceCapabilityError - - try: - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - print(f"Cannot execute command: {e}") - print(f"Missing capability: {e.feature}") - -Best Practices -============== - -1. **Always request device info first:** - - .. code-block:: python - - # Request device info before control commands - await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) - - # Now control commands can proceed - await mqtt.control.set_power(device, True) - -2. **Check capabilities manually for custom logic:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - controls = DeviceCapabilityChecker.get_available_controls(features) - - if controls.get("recirculation_use"): - await mqtt.control.set_recirculation_mode(device, 1) - else: - print("Recirculation not supported") - -3. **Handle capability errors gracefully:** - - .. code-block:: python - - from nwp500 import DeviceCapabilityError - - try: - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - logger.warning(f"Feature not supported: {e.feature}") - # Fallback to alternative command - -4. **Use try/except for error handling:** - - .. code-block:: python - - from nwp500 import DeviceCapabilityError, RangeValidationError - - try: - await mqtt.control.set_dhw_temperature(device, 140.0) - except DeviceCapabilityError as e: - print(f"Device doesn't support temperature control: {e}") - except RangeValidationError as e: - print(f"Invalid temperature {e.value}°F: {e.message}") - -5. **Implement device capability discovery:** - - .. code-block:: python - - from nwp500.device_capabilities import DeviceCapabilityChecker - - def print_device_capabilities(device_features): - """Print all supported controls.""" - controls = DeviceCapabilityChecker.get_available_controls(device_features) - - print("Available Controls:") - for feature in sorted(controls.keys()): - supported = controls[feature] - status = "✓" if supported else "✗" - print(f" {status} {feature}") - -Examples -======== - -Example 1: Safe Device Control with Capability Checking --------------------------------------------------------- - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient - from nwp500.device_capabilities import DeviceCapabilityChecker - from nwp500 import DeviceCapabilityError - import asyncio - - async def safe_device_control(): - async with NavienAuthClient(email, password) as auth: - api = NavienAPIClient(auth) - device = await api.get_first_device() - - mqtt = NavienMqttClient(auth) - await mqtt.connect() - - # Store features from device info - features = None - - def on_feature(f): - nonlocal features - features = f - - # Request device info - await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) - - # Wait a bit for response - await asyncio.sleep(2) - - if features: - # Check what's supported - controls = DeviceCapabilityChecker.get_available_controls(features) - - # Power control - if controls.get("power_use"): - try: - await mqtt.control.set_power(device, True) - print("✓ Device powered ON") - except DeviceCapabilityError as e: - print(f"✗ Power control failed: {e}") - - # Recirculation control - if controls.get("recirculation_use"): - try: - await mqtt.control.set_recirculation_mode(device, 1) - print("✓ Recirculation enabled") - except DeviceCapabilityError as e: - print(f"✗ Recirculation failed: {e}") - - # Temperature control - if controls.get("dhw_temperature_setting_use"): - try: - await mqtt.control.set_dhw_temperature(device, 140.0) - print("✓ Temperature set to 140°F") - except DeviceCapabilityError as e: - print(f"✗ Temperature control failed: {e}") - - await mqtt.disconnect() - - asyncio.run(safe_device_control()) - -Example 2: Automatic Capability Checking with Decorator --------------------------------------------------------- - -.. code-block:: python - - # Control methods are automatically decorated with @requires_capability - # No additional code needed - just call them! - - from nwp500 import NavienAuthClient, NavienAPIClient, NavienMqttClient - from nwp500 import DeviceCapabilityError - import asyncio - - async def simple_control(): - async with NavienAuthClient(email, password) as auth: - api = NavienAPIClient(auth) - device = await api.get_first_device() - - mqtt = NavienMqttClient(auth) - await mqtt.connect() - - # Request device info once - await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) - - # All control methods now have automatic capability checking - try: - await mqtt.control.set_power(device, True) - await mqtt.control.set_dhw_mode(device, 3) - await mqtt.control.set_recirculation_mode(device, 1) - except DeviceCapabilityError as e: - print(f"Device doesn't support: {e}") - - await mqtt.disconnect() - - asyncio.run(simple_control()) - -Related Documentation -===================== - -* :doc:`mqtt_client` - MQTT client overview -* :doc:`models` - Data models (DeviceStatus, DeviceFeature, etc.) -* :doc:`exceptions` - Exception handling (DeviceCapabilityError, etc.) -* :doc:`../protocol/device_features` - Device features reference -* :doc:`../guides/scheduling` - Scheduling guide -* :doc:`../guides/energy_monitoring` - Energy monitoring guide -* :doc:`../guides/time_of_use` - Time-of-use guide diff --git a/docs/python_api/events.rst b/docs/python_api/events.rst index 31814e3..d9e7713 100644 --- a/docs/python_api/events.rst +++ b/docs/python_api/events.rst @@ -1,358 +1,287 @@ -============ Event System ============ -The ``nwp500.events`` module provides an event-driven architecture for -reacting to device state changes, errors, and system events. +The MQTT client exposes two complementary callback patterns: + +* ``subscribe_*()`` methods parse device messages and call your callback with a + model object such as :class:`~nwp500.models.DeviceStatus` or + :class:`~nwp500.models.ReservationSchedule`. +* :meth:`nwp500.events.EventEmitter.on` listens for higher-level client events + from :class:`nwp500.mqtt_events.MqttClientEvents`. These callbacks always + receive **one typed event dataclass**. Overview ======== -The MQTT client uses an EventEmitter pattern that allows you to: +Use the event system when you want to react to connection changes, status +transitions, or derived state changes such as temperature deltas and error +conditions. -* Subscribe to specific events with callback functions -* React to device state changes in real-time -* Handle connection events (interruption, resumption) -* Monitor errors and diagnostics -* Build reactive, event-driven applications +Two Subscription Patterns +========================= -All events are emitted asynchronously and callbacks are invoked with -relevant data. - -EventEmitter -============ +Typed device subscriptions +-------------------------- -Base class for event-driven components. +These methods deliver parsed model objects directly to the callback. -.. py:class:: EventEmitter - - Provides event subscription and emission capabilities. +.. code-block:: python - **Methods:** + def on_status(status): + print(status.dhw_temperature) + print(status.current_inst_power) - .. py:method:: on(event, callback) + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) - Register a callback for an event. +Examples include: - :param event: Event name - :type event: str - :param callback: Function to call when event fires - :type callback: Callable +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_device_status` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_device_feature` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_energy_usage` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_reservation_response` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_weekly_reservation_response` +* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_recirculation_schedule_response` - .. py:method:: off(event, callback=None) +Client event subscriptions +-------------------------- - Unregister callback(s) for an event. +Event emitter callbacks receive a single event object. - :param event: Event name - :type event: str - :param callback: Specific callback to remove, or None for all - :type callback: Callable or None +.. code-block:: python - .. py:method:: emit(event, *args, **kwargs) + from nwp500 import MqttClientEvents - Emit an event to all registered callbacks. + def on_status_event(event): + print(event.status.dhw_temperature) - :param event: Event name - :type event: str - :param args: Positional arguments for callbacks - :param kwargs: Keyword arguments for callbacks + def on_resumed(event): + print(event.return_code) + print(event.session_present) -MQTT Client Events -================== + mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_event) + mqtt.on(MqttClientEvents.CONNECTION_RESUMED, on_resumed) -The :doc:`mqtt_client` emits the following events: +EventEmitter API +================ -Connection Events ------------------ - -connection_interrupted -^^^^^^^^^^^^^^^^^^^^^^ +.. py:class:: EventEmitter -Emitted when MQTT connection is lost. + Base class for event-driven components. -**Callback signature:** + .. py:method:: on(event, callback) -.. code-block:: python + Register a callback for an event name. - def on_interrupted(error): - """ - :param error: Error that caused interruption - :type error: Exception - """ + .. py:method:: off(event, callback=None) -**Example:** + Remove one callback or all callbacks for an event. -.. code-block:: python + .. py:method:: wait_for(event, timeout=None) - def handle_disconnect(error): - print(f"Connection lost: {error}") - # Save state, notify user, etc. + Wait for the next event emission and return the positional event + arguments as a tuple. - mqtt.on('connection_interrupted', handle_disconnect) + .. code-block:: python -connection_resumed -^^^^^^^^^^^^^^^^^^ + args = await mqtt.wait_for(MqttClientEvents.CONNECTION_RESUMED, timeout=30) + resumed = args[0] + print(resumed.session_present) -Emitted when MQTT connection is restored. +MQTT Client Events +================== -**Callback signature:** +The :class:`nwp500.mqtt_events.MqttClientEvents` registry exposes all supported +client event names with IDE-friendly constants. .. code-block:: python - def on_resumed(return_code, session_present): - """ - :param return_code: MQTT return code - :type return_code: int - :param session_present: Whether session was resumed - :type session_present: bool - """ + from nwp500 import MqttClientEvents -**Example:** + for event_name in MqttClientEvents.get_all_events(): + print(event_name) -.. code-block:: python +ConnectionInterruptedEvent +-------------------------- - def handle_reconnect(return_code, session_present): - print("Connection restored") - # Re-request status, resume operations - await mqtt.control.request_device_status(device) +.. py:class:: nwp500.mqtt_events.ConnectionInterruptedEvent - mqtt.on('connection_resumed', handle_reconnect) + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.CONNECTION_INTERRUPTED`. -Device Events -------------- + **Fields:** -status_received -^^^^^^^^^^^^^^^ + * ``error`` (:class:`Exception`) - The exception that interrupted the MQTT + connection. -Emitted when device status update is received. + **Example:** -**Callback signature:** + .. code-block:: python -.. code-block:: python + def on_interrupted(event): + print(f"Connection lost: {event.error}") - def on_status(status): - """ - :param status: Device status object - :type status: DeviceStatus - """ + mqtt.on(MqttClientEvents.CONNECTION_INTERRUPTED, on_interrupted) -**Example:** +ConnectionResumedEvent +---------------------- -.. code-block:: python +.. py:class:: nwp500.mqtt_events.ConnectionResumedEvent - def handle_status(status): - print(f"Temperature: {status.dhw_temperature}°F") - print(f"Power: {status.current_inst_power}W") + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.CONNECTION_RESUMED`. - mqtt.on('status_received', handle_status) + **Fields:** -feature_received -^^^^^^^^^^^^^^^^ + * ``return_code`` (int) - MQTT return code from the resume attempt. + * ``session_present`` (bool) - Whether broker session state was preserved. -Emitted when device feature/info update is received. + **Example:** -**Callback signature:** + .. code-block:: python -.. code-block:: python + def on_resumed(event): + if not event.session_present: + print("Broker session was reset") - def on_feature(feature): - """ - :param feature: Device feature object - :type feature: DeviceFeature - """ + mqtt.on(MqttClientEvents.CONNECTION_RESUMED, on_resumed) -temperature_changed -^^^^^^^^^^^^^^^^^^^ +StatusReceivedEvent +------------------- -Emitted when water temperature changes significantly. +.. py:class:: nwp500.mqtt_events.StatusReceivedEvent -**Callback signature:** + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.STATUS_RECEIVED`. -.. code-block:: python + **Fields:** - def on_temp_change(old_temp, new_temp): - """ - :param old_temp: Previous temperature - :type old_temp: float - :param new_temp: Current temperature - :type new_temp: float - """ + * ``status`` (:class:`~nwp500.models.DeviceStatus`) - Parsed device status. -mode_changed -^^^^^^^^^^^^ +TemperatureChangedEvent +----------------------- -Emitted when operation mode changes. +.. py:class:: nwp500.mqtt_events.TemperatureChangedEvent -**Callback signature:** + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.TEMPERATURE_CHANGED`. -.. code-block:: python + **Fields:** - def on_mode_change(old_mode, new_mode): - """ - :param old_mode: Previous mode - :type old_mode: DhwOperationSetting - :param new_mode: Current mode - :type new_mode: DhwOperationSetting - """ + * ``old_temperature`` (float) - Previous DHW temperature in the current unit system. + * ``new_temperature`` (float) - New DHW temperature in the current unit system. -error_detected -^^^^^^^^^^^^^^ +ModeChangedEvent +---------------- -Emitted when device reports an error code. +.. py:class:: nwp500.mqtt_events.ModeChangedEvent -**Callback signature:** + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.MODE_CHANGED`. -.. code-block:: python + **Fields:** - def on_error(error_code, sub_error_code): - """ - :param error_code: Main error code - :type error_code: int - :param sub_error_code: Sub-error code - :type sub_error_code: int - """ + * ``old_mode`` (:class:`~nwp500.CurrentOperationMode`) - Previous operating mode. + * ``new_mode`` (:class:`~nwp500.CurrentOperationMode`) - New operating mode. -Examples -======== +PowerChangedEvent +----------------- -Example 1: Basic Event Handling --------------------------------- +.. py:class:: nwp500.mqtt_events.PowerChangedEvent -.. code-block:: python + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.POWER_CHANGED`. - from nwp500 import NavienAuthClient, NavienMqttClient + **Fields:** - async def main(): - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) + * ``old_power`` (float) - Previous instantaneous power draw in watts. + * ``new_power`` (float) - New instantaneous power draw in watts. - # Register event handlers - mqtt.on('status_received', lambda s: print(f"Temp: {s.dhwTemperature}°F")) - mqtt.on('error_detected', lambda e, se: print(f"Error: {e}")) +HeatingStartedEvent +------------------- - await mqtt.connect() - # Events will be emitted automatically - await asyncio.sleep(300) +.. py:class:: nwp500.mqtt_events.HeatingStartedEvent -Example 2: Connection Monitoring ---------------------------------- + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.HEATING_STARTED`. -.. code-block:: python + **Fields:** - async def monitor_connection(): - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) + * ``status`` (:class:`~nwp500.models.DeviceStatus`) - Status snapshot when + heating started. - def on_disconnected(error): - print(f"Lost connection: {error}") - # Alert user, save state +HeatingStoppedEvent +------------------- - def on_reconnected(rc, session): - print("Connection restored!") - # Resume operations +.. py:class:: nwp500.mqtt_events.HeatingStoppedEvent - mqtt.on('connection_interrupted', on_disconnected) - mqtt.on('connection_resumed', on_reconnected) + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.HEATING_STOPPED`. - await mqtt.connect() - await asyncio.sleep(86400) # Monitor for 24h + **Fields:** -Example 3: Temperature Alerts ------------------------------- + * ``status`` (:class:`~nwp500.models.DeviceStatus`) - Status snapshot when + heating stopped. -.. code-block:: python +ErrorDetectedEvent +------------------ - async def temperature_alerts(): - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) +.. py:class:: nwp500.mqtt_events.ErrorDetectedEvent - def check_temp(status): - if status.dhw_temperature < 110: - print("WARNING: Temperature below 110°F") - send_alert("Low water temperature") + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.ERROR_DETECTED`. - if status.dhw_temperature > 145: - print("WARNING: Temperature above 145°F") - send_alert("High water temperature") + **Fields:** - mqtt.on('status_received', check_temp) + * ``error_code`` (:class:`~nwp500.ErrorCode`) - Newly detected device error. + * ``status`` (:class:`~nwp500.models.DeviceStatus`) - Status snapshot that + contained the error. - await mqtt.connect() - await mqtt.subscribe_device_status(device, lambda s: None) - await mqtt.start_periodic_requests(device, period_seconds=60) +ErrorClearedEvent +----------------- - await asyncio.sleep(86400) +.. py:class:: nwp500.mqtt_events.ErrorClearedEvent -Example 4: Multiple Event Handlers ------------------------------------ + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.ERROR_CLEARED`. -.. code-block:: python + **Fields:** - async def multi_handler(): - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) + * ``error_code`` (:class:`~nwp500.ErrorCode`) - Error code that cleared. - # Log all status updates - mqtt.on('status_received', lambda s: log_status(s)) +FeatureReceivedEvent +-------------------- - # Track temperature - mqtt.on('temperature_changed', lambda old, new: - print(f"Temp: {old}°F → {new}°F")) +.. py:class:: nwp500.mqtt_events.FeatureReceivedEvent - # Monitor mode changes - mqtt.on('mode_changed', lambda old, new: - print(f"Mode: {old.name} → {new.name}")) + Emitted for :attr:`nwp500.mqtt_events.MqttClientEvents.FEATURE_RECEIVED`. - # Alert on errors - mqtt.on('error_detected', lambda e, se: - send_alert(f"Error: {e}:{se}")) + **Fields:** - await mqtt.connect() - # All handlers will be called automatically + * ``feature`` (:class:`~nwp500.models.DeviceFeature`) - Parsed device feature payload. -Best Practices +Usage Examples ============== -1. **Register handlers before connecting:** - - .. code-block:: python - - # GOOD: Register first - mqtt.on('status_received', handler) - await mqtt.connect() - - # BAD: May miss early events - await mqtt.connect() - mqtt.on('status_received', handler) - -2. **Use lambda for simple handlers:** +React to typed event payloads +----------------------------- - .. code-block:: python - - mqtt.on('status_received', lambda s: print(f"{s.dhwTemperature}°F")) +.. code-block:: python -3. **Use named functions for complex handlers:** + from nwp500 import MqttClientEvents - .. code-block:: python + def on_temperature_changed(event): + print(f"{event.old_temperature} -> {event.new_temperature}") - def complex_handler(status): - # Complex logic - process_status(status) - update_database(status) - check_alerts(status) + def on_error(event): + print(f"Error: {event.error_code}") + print(f"Current mode: {event.status.operation_mode}") - mqtt.on('status_received', complex_handler) + mqtt.on(MqttClientEvents.TEMPERATURE_CHANGED, on_temperature_changed) + mqtt.on(MqttClientEvents.ERROR_DETECTED, on_error) -4. **Clean up handlers when done:** +Wait for a connection event +--------------------------- - .. code-block:: python +.. code-block:: python - mqtt.off('status_received', handler) # Remove specific - mqtt.off('status_received') # Remove all + args = await mqtt.wait_for(MqttClientEvents.CONNECTION_RESUMED, timeout=30) + resumed = args[0] + print(resumed.return_code) Related Documentation ===================== -* :doc:`mqtt_client` - MQTT client with events -* :doc:`models` - Data models passed to event handlers -* :doc:`exceptions` - Exception handling +* :doc:`mqtt_client` - MQTT client API reference +* :doc:`models` - Models used by subscription callbacks +* :doc:`../guides/event_system` - Event-driven programming guide diff --git a/docs/python_api/exceptions.rst b/docs/python_api/exceptions.rst index 725815d..5881848 100644 --- a/docs/python_api/exceptions.rst +++ b/docs/python_api/exceptions.rst @@ -75,7 +75,7 @@ Nwp500Error try: mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except Nwp500Error as e: # Catches all library exceptions print(f"Library error: {e}") @@ -267,11 +267,11 @@ MqttNotConnectedError mqtt = NavienMqttClient(auth) try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except MqttNotConnectedError: # Not connected - establish connection first await mqtt.connect() - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) MqttPublishError ---------------- @@ -390,7 +390,7 @@ RangeValidationError from nwp500 import NavienMqttClient, RangeValidationError try: - await mqtt.control.set_dhw_temperature(device, 200.0) + 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}") @@ -473,11 +473,11 @@ DeviceCapabilityError # Request device info first await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) try: # This raises DeviceCapabilityError if device doesn't support recirculation - await mqtt.control.set_recirculation_mode(device, 1) + await mqtt.set_recirculation_mode(device, 1) except DeviceCapabilityError as e: print(f"Feature not supported: {e.feature}") print(f"Error: {e}") @@ -500,7 +500,7 @@ DeviceCapabilityError # Check if device supports a feature if DeviceCapabilityChecker.supports("recirculation_use", device_features): - await mqtt.control.set_recirculation_mode(device, 1) + await mqtt.set_recirculation_mode(device, 1) else: print("Device doesn't support recirculation") @@ -539,7 +539,7 @@ Handle specific exception types for granular control: mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.control.set_dhw_temperature(device, 120.0) + await mqtt.set_dhw_temperature(device, 120.0) except InvalidCredentialsError: print("Invalid credentials - check email/password") @@ -622,18 +622,18 @@ Handle capability errors for device control commands: # Request device info first await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) # Option 1: Try control and catch capability error try: - await mqtt.control.set_recirculation_mode(device, 1) + await mqtt.set_recirculation_mode(device, 1) except DeviceCapabilityError as e: print(f"Device doesn't support: {e.feature}") # Fallback to alternative command # Option 2: Check capability before attempting if DeviceCapabilityChecker.supports("recirculation_use", device_features): - await mqtt.control.set_recirculation_mode(device, 1) + await mqtt.set_recirculation_mode(device, 1) else: print("Recirculation not supported") @@ -656,7 +656,7 @@ Use ``to_dict()`` for structured error logging: logger = logging.getLogger(__name__) try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except Nwp500Error as e: # Log structured error data logger.error("Operation failed", extra=e.to_dict()) @@ -674,7 +674,7 @@ Catch all library exceptions with ``Nwp500Error``: try: # Any library operation await mqtt.connect() - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except Nwp500Error as e: # All nwp500 exceptions inherit from Nwp500Error @@ -744,7 +744,7 @@ Best Practices .. code-block:: python try: - await mqtt.control.set_dhw_temperature(device, 200.0) + 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") @@ -797,7 +797,7 @@ If upgrading from v4.x, update your exception handling: .. code-block:: python try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except RuntimeError as e: if "Not connected" in str(e): await mqtt.connect() @@ -809,10 +809,10 @@ If upgrading from v4.x, update your exception handling: from nwp500 import MqttNotConnectedError try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except MqttNotConnectedError: await mqtt.connect() - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) See the CHANGELOG.rst for complete migration guide with more examples. diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index 8697d84..31e33b8 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -36,7 +36,7 @@ See :doc:`../enumerations` for the complete enumeration reference including: from nwp500 import DhwOperationSetting, CurrentOperationMode, HeatSource, TemperatureType # Set operation mode (user preference) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) # Check current heat source if status.current_heat_use == HeatSource.HEATPUMP: @@ -387,6 +387,157 @@ Device capabilities, features, and firmware information. if feature.high_demand_use: print(" [OK] High Demand mode") +Scheduling Models +================= + +ReservationEntry +---------------- + +A single timed reservation entry used by :class:`ReservationSchedule`. + +.. py:class:: ReservationEntry + + **Raw Fields:** + + * ``enable`` (int) - Device boolean (``2`` enabled, ``1`` disabled) + * ``week`` (int) - Weekday bitfield + * ``hour`` (int) - Start hour (0-23) + * ``min`` (int) - Start minute (0-59) + * ``mode`` (int) - DHW operation mode ID + * ``param`` (int) - Temperature encoded in half-degrees Celsius + + **Computed Properties:** + + * ``enabled`` (bool) + * ``days`` (list[str]) + * ``time`` (str) + * ``temperature`` (float) + * ``unit`` (str) + * ``mode_name`` (str) + +ReservationSchedule +------------------- + +Full programmed reservation schedule used by ``request_reservations()`` and +``update_reservations()``. + +.. py:class:: ReservationSchedule + + **Fields:** + + * ``reservation_use`` (int) - Device boolean for global enable/disable state + * ``reservation`` (list[ReservationEntry]) - Reservation entries + + **Computed Properties / Methods:** + + * ``enabled`` (bool) + * :meth:`from_dict` - Parse a raw MQTT response payload + +WeeklyReservationEntry +---------------------- + +A single entry in the weekly reservation schedule used by +:meth:`nwp500.mqtt.client.NavienMqttClient.update_weekly_reservation`. + +.. py:class:: WeeklyReservationEntry + + **Raw Fields:** + + * ``enable`` (int) - Device boolean (``2`` enabled, ``1`` disabled) + * ``week`` (int) - Weekday bitfield + * ``hour`` (int) - Scheduled hour (0-23) + * ``min`` (int) - Scheduled minute (0-59) + * ``mode`` (int) - DHW operation mode ID + * ``param`` (int) - Temperature encoded in half-degrees Celsius + + **Computed Properties:** + + * ``enabled`` (bool) + * ``days`` (list[str]) + * ``time`` (str) + * ``temperature`` (float) + * ``unit`` (str) + * ``mode_name`` (str) + +WeeklyReservationSchedule +------------------------- + +Full weekly reservation schedule. + +.. py:class:: WeeklyReservationSchedule + + **Fields:** + + * ``reservation_use`` (int) - Device boolean for global enable/disable state + * ``reservation`` (list[WeeklyReservationEntry]) - Weekly schedule entries + + **Computed Properties / Methods:** + + * ``enabled`` (bool) + * :meth:`from_dict` - Parse a raw MQTT response payload + +RecirculationScheduleEntry +-------------------------- + +A single recirculation pump schedule entry. + +.. py:class:: RecirculationScheduleEntry + + **Fields:** + + * ``enable`` (int) - Device boolean (``2`` enabled, ``1`` disabled) + * ``week`` (int) - Weekday bitfield + * ``start_hour`` (int) - Start hour (0-23) + * ``start_min`` (int) - Start minute (0-59) + * ``end_hour`` (int) - End hour (0-23) + * ``end_min`` (int) - End minute (0-59) + * ``mode`` (int) - Recirculation mode ID + + **Computed Properties:** + + * ``enabled`` (bool) + * ``days`` (list[str]) + * ``start_time`` (str) + * ``end_time`` (str) + * ``mode_name`` (str) + +RecirculationSchedule +--------------------- + +Full recirculation schedule used by +:meth:`nwp500.mqtt.client.NavienMqttClient.configure_recirculation_schedule`. + +.. py:class:: RecirculationSchedule + + **Fields:** + + * ``schedule`` (list[RecirculationScheduleEntry]) - Scheduled recirculation windows + + **Methods:** + + * :meth:`from_dict` - Parse a raw MQTT response payload + +OtaCommitPayload +---------------- + +Payload model used by +:meth:`nwp500.mqtt.client.NavienMqttClient.commit_firmware_update`. + +.. py:class:: OtaCommitPayload + + **Fields:** + + * ``sw_code`` (int) - Firmware component code (for example controller, panel, + or WiFi module) + * ``sw_version`` (int) - Firmware version to commit + + **Example:** + + .. code-block:: python + + payload = OtaCommitPayload(swCode=1, swVersion=1234) + await mqtt.commit_firmware_update(device, payload) + Energy Models ============= @@ -574,10 +725,10 @@ Best Practices # ✓ Type-safe from nwp500 import DhwOperationSetting - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) + await mqtt.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) # ✗ Magic numbers - await mqtt.control.set_dhw_mode(device, 3) + await mqtt.set_dhw_mode(device, 3) 2. **Check feature support:** @@ -586,7 +737,7 @@ Best Practices def on_feature(feature): if feature.energy_usage_use: # Device supports energy monitoring - await mqtt.control.request_energy_usage(device, year, months) + await mqtt.request_energy_usage(device, year, months) 3. **Monitor operation state:** diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index b1069ec..5b71357 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -56,7 +56,7 @@ Basic Monitoring print(f"Mode: {status.dhw_operation_setting.name}") await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Monitor for 60 seconds await asyncio.sleep(60) @@ -67,9 +67,12 @@ Basic Monitoring Device Control -------------- -Control operations require device capability information to be cached. Always request -device info before using control commands. See :doc:`device_control` for complete -control method reference, capability checking, and advanced features. +Control operations are now exposed directly on :class:`NavienMqttClient`; use +the direct ``mqtt.*`` methods for control operations. + +Control methods rely on cached device feature data for capability-aware +validation. Request device info first, or call +:meth:`nwp500.mqtt.client.NavienMqttClient.ensure_device_info_cached` before issuing commands. .. code-block:: python @@ -77,22 +80,18 @@ control method reference, capability checking, and advanced features. async with NavienAuthClient(email, password) as auth: api = NavienAPIClient(auth) device = await api.get_first_device() - + mqtt = NavienMqttClient(auth) await mqtt.connect() - - # Request device info first (populates capability cache) + await mqtt.subscribe_device_feature(device, lambda f: None) - await mqtt.control.request_device_info(device) - - # Control operations (with automatic capability checking) - await mqtt.control.set_power(device, power_on=True) - await mqtt.control.set_dhw_mode(device, mode_id=3) # Energy Saver - await mqtt.control.set_dhw_temperature(device, 140.0) # Temperature in user's preferred unit - - await mqtt.disconnect() + await mqtt.request_device_info(device) + + await mqtt.set_power(device, power_on=True) + await mqtt.set_dhw_mode(device, mode_id=3) + await mqtt.set_dhw_temperature(device, 140.0) - asyncio.run(control_device()) + await mqtt.disconnect() API Reference ============= @@ -130,11 +129,11 @@ NavienMqttClient mqtt = NavienMqttClient(auth, config=config) # Register event handlers - def on_interrupted(error): - print(f"Connection lost: {error}") + def on_interrupted(event): + print(f"Connection lost: {event.error}") - def on_resumed(return_code, session_present): - print("Connection restored!") + def on_resumed(event): + print(f"Connection restored! session_present={event.session_present}") mqtt.on("connection_interrupted", on_interrupted) mqtt.on("connection_resumed", on_resumed) @@ -239,7 +238,7 @@ subscribe_device_status() print(f"ERROR: {status.error_code}") await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) request_device_status() ^^^^^^^^^^^^^^^^^^^^^^^ @@ -261,11 +260,11 @@ request_device_status() await mqtt.subscribe_device_status(device, on_status) # Then request - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Can request periodically while monitoring: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) await asyncio.sleep(30) # Every 30 seconds subscribe_device_feature() @@ -305,7 +304,7 @@ subscribe_device_feature() print("Reservations: Supported") await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) request_device_info() ^^^^^^^^^^^^^^^^^^^^^ @@ -324,7 +323,7 @@ request_device_info() .. code-block:: python await mqtt.subscribe_device_feature(device, on_feature) - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) subscribe_device() ^^^^^^^^^^^^^^^^^^ @@ -366,313 +365,364 @@ subscribe_device() Control Methods --------------- +Capability Checking +^^^^^^^^^^^^^^^^^^^ + +Most control commands depend on device capabilities reported by +:class:`~nwp500.models.DeviceFeature`. Request device info first so the client +can validate support and ranges before sending commands. + +.. code-block:: python + + await mqtt.subscribe_device_feature(device, lambda feature: print(feature)) + await mqtt.request_device_info(device) + + # Alternative helper: request and wait until the cache is populated + await mqtt.ensure_device_info_cached(device) + +Common capability flags include ``power_use``, ``dhw_use``, +``dhw_temperature_setting_use``, ``program_reservation_use``, +``recirculation_use``, ``recirc_reservation_use``, ``freeze_protection_use``, +and ``smart_diagnostic_use``. + set_power() ^^^^^^^^^^^ .. py:method:: set_power(device, power_on) - Turn device on or off. + Turn device power on or off. + + **Capability Required:** ``power_use`` :param device: Device object :type device: Device - :param power_on: True to turn on, False to turn off + :param power_on: ``True`` to power on, ``False`` to power off :type power_on: bool :return: Publish packet ID :rtype: int - **Example:** - - .. code-block:: python - - # Turn on - await mqtt.control.set_power(device, power_on=True) - print("Device powered ON") - - # Turn off - await mqtt.control.set_power(device, power_on=False) - print("Device powered OFF") - set_dhw_mode() ^^^^^^^^^^^^^^ .. py:method:: set_dhw_mode(device, mode_id, vacation_days=None) - Set DHW (Domestic Hot Water) operation mode. + Set the DHW operating mode. - :param device: Device object - :type device: Device - :param mode_id: Mode ID (1-5) + **Capability Required:** ``dhw_use`` + + :param mode_id: One of the DHW operation mode IDs :type mode_id: int - :param vacation_days: Number of days for vacation mode (required if mode_id=5) + :param vacation_days: Required for vacation mode; valid range ``1``-``30`` :type vacation_days: int or None - :return: Publish packet ID - :rtype: int + :raises ParameterValidationError: If vacation mode is missing ``vacation_days`` + :raises RangeValidationError: If ``vacation_days`` is outside ``1``-``30`` - **Operation Modes:** +set_dhw_temperature() +^^^^^^^^^^^^^^^^^^^^^ - * 1 = Heat Pump Only - Most efficient, uses only heat pump - * 2 = Electric Only - Fast recovery, uses only electric heaters - * 3 = Energy Saver - Balanced, recommended for most users - * 4 = High Demand - Maximum heating capacity - * 5 = Vacation - Low power mode for extended absence +.. py:method:: set_dhw_temperature(device, temperature) - **Example:** + Set the target water temperature in the current unit system. - .. code-block:: python + **Capability Required:** ``dhw_temperature_setting_use`` - from nwp500 import DhwOperationSetting - - # Set to Heat Pump Only (most efficient) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.HEAT_PUMP.value) - - # Set to Energy Saver (balanced, recommended) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.ENERGY_SAVER.value) - # or just: - await mqtt.control.set_dhw_mode(device, 3) - - # Set to High Demand (maximum heating) - await mqtt.control.set_dhw_mode(device, DhwOperationSetting.HIGH_DEMAND.value) - - # Set vacation mode for 7 days - await mqtt.control.set_dhw_mode( - device, - DhwOperationSetting.VACATION.value, - vacation_days=7 - ) + The valid range is checked against the device's reported + ``dhw_temperature_min`` and ``dhw_temperature_max`` values. -set_dhw_temperature() -^^^^^^^^^^^^^^^^^^^^^ +enable_anti_legionella() +^^^^^^^^^^^^^^^^^^^^^^^^ -.. py:method:: set_dhw_temperature(device, temperature) +.. py:method:: enable_anti_legionella(device, period_days) - Set target DHW temperature. + Enable the anti-Legionella cycle. - :param device: Device object - :type device: Device - :param temperature: Temperature in user's preferred unit (Celsius or Fahrenheit) - :type temperature: float - :return: Publish packet ID - :rtype: int - :raises RangeValidationError: If temperature is outside valid range + **Capability Required:** ``anti_legionella_setting_use`` - The temperature is automatically converted to the device's internal - format (half-degrees Celsius). The actual valid range depends on the - device's temperature preference and configuration. + :param period_days: Cycle period in days (``1``-``30``) + :type period_days: int - **Example:** +disable_anti_legionella() +^^^^^^^^^^^^^^^^^^^^^^^^^ - .. code-block:: python +.. py:method:: disable_anti_legionella(device) - # Set temperature (value interpreted in device's preferred unit) - await mqtt.control.set_dhw_temperature(device, 140.0) - - # Common temperatures (device-dependent units) - await mqtt.control.set_dhw_temperature(device, 120.0) # Standard - await mqtt.control.set_dhw_temperature(device, 130.0) # Medium - await mqtt.control.set_dhw_temperature(device, 140.0) # Hot - await mqtt.control.set_dhw_temperature(device, 150.0) # Maximum + Disable the anti-Legionella cycle. -enable_anti_legionella() -^^^^^^^^^^^^^^^^^^^^^^^^ +set_vacation_days() +^^^^^^^^^^^^^^^^^^^ -.. py:method:: enable_anti_legionella(device, period_days) +.. py:method:: set_vacation_days(device, days) - Enable anti-Legionella protection cycle. + Convenience wrapper for vacation mode. - :param device: Device object - :type device: Device - :param period_days: Cycle period in days (typically 7 or 14) - :type period_days: int - :return: Publish packet ID - :rtype: int + **Capability Required:** ``holiday_use`` + +update_reservations() +^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: update_reservations(device, reservations, *, enabled=True) + + Update the standard reservation program. + + :param reservations: Sequence of raw reservation entries using the protocol + fields ``enable``, ``week``, ``hour``, ``min``, ``mode``, and ``param`` + :type reservations: Sequence[dict[str, Any]] + :param enabled: Global reservation enable flag + :type enabled: bool **Example:** .. code-block:: python - # Enable weekly anti-Legionella cycle - await mqtt.control.enable_anti_legionella(device, period_days=7) - - # Enable bi-weekly cycle - await mqtt.control.enable_anti_legionella(device, period_days=14) + from nwp500 import build_reservation_entry -disable_anti_legionella() -^^^^^^^^^^^^^^^^^^^^^^^^^ + reservations = [ + build_reservation_entry( + enabled=True, + days=["MO", "TU", "WE", "TH", "FR"], + hour=6, + minute=0, + mode_id=4, + temperature=60.0, + ) + ] -.. py:method:: disable_anti_legionella(device) + await mqtt.update_reservations(device, reservations, enabled=True) - Disable anti-Legionella protection. +request_reservations() +^^^^^^^^^^^^^^^^^^^^^^ - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int +.. py:method:: request_reservations(device) - **Example:** + Request the current programmed reservations. - .. code-block:: python +subscribe_reservation_response() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - await mqtt.control.disable_anti_legionella(device) +.. py:method:: subscribe_reservation_response(device, callback) -Energy Monitoring Methods --------------------------- + Subscribe to parsed reservation read responses. -request_energy_usage() + :param callback: Called with :class:`~nwp500.models.ReservationSchedule` + :type callback: Callable[[ReservationSchedule], None] + +update_weekly_reservation() +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: update_weekly_reservation(device, schedule) + + Send a typed weekly reservation schedule. + + **Capability Required:** ``program_reservation_use`` + + :param schedule: Weekly reservation schedule payload + :type schedule: WeeklyReservationSchedule + +subscribe_weekly_reservation_response() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: subscribe_weekly_reservation_response(device, callback) + + Subscribe to parsed weekly reservation responses. + + :param callback: Called with :class:`~nwp500.models.WeeklyReservationSchedule` + :type callback: Callable[[WeeklyReservationSchedule], None] + +configure_reservation_water_program() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: configure_reservation_water_program(device) + + Enable the device's reservation water-program mode. + + **Capability Required:** ``program_reservation_use`` + +configure_recirculation_schedule() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: configure_recirculation_schedule(device, schedule) + + Configure the timed recirculation schedule. + + **Capability Required:** ``recirc_reservation_use`` + + :param schedule: Recirculation schedule payload + :type schedule: RecirculationSchedule + +subscribe_recirculation_schedule_response() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: subscribe_recirculation_schedule_response(device, callback) + + Subscribe to parsed recirculation schedule responses. + + :param callback: Called with :class:`~nwp500.models.RecirculationSchedule` + :type callback: Callable[[RecirculationSchedule], None] + +set_recirculation_mode() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: set_recirculation_mode(device, mode) + + Set the recirculation operating mode. + + **Capability Required:** ``recirculation_use`` + + :param mode: Mode ID in the range ``1``-``4`` + :type mode: int + +trigger_recirculation_hot_button() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: trigger_recirculation_hot_button(device) + + Trigger an immediate recirculation run. + + **Capability Required:** ``recirculation_use`` + +configure_tou_schedule() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: configure_tou_schedule(device, controller_serial_number, periods, *, enabled=True) + + Configure the Time-of-Use schedule. + + **Capability Required:** ``program_reservation_use`` + +request_tou_settings() ^^^^^^^^^^^^^^^^^^^^^^ -.. py:method:: request_energy_usage(device, year, months) +.. py:method:: request_tou_settings(device, controller_serial_number) - Request daily energy usage data for specified period. + Request the current TOU schedule. - :param device: Device object - :type device: Device - :param year: Year to query (e.g., 2024) - :type year: int - :param months: List of months to query (1-12) - :type months: list[int] - :return: Publish packet ID - :rtype: int +set_tou_enabled() +^^^^^^^^^^^^^^^^^ - **Example:** +.. py:method:: set_tou_enabled(device, enabled) - .. code-block:: python + Enable or disable TOU optimization. - # Subscribe first - await mqtt.subscribe_energy_usage(device, on_energy) - - # Request current month - from datetime import datetime - now = datetime.now() - await mqtt.control.request_energy_usage(device, now.year, [now.month]) - - # Request multiple months - await mqtt.control.request_energy_usage(device, 2024, [8, 9, 10]) - - # Request full year - await mqtt.control.request_energy_usage(device, 2024, list(range(1, 13))) + **Capability Required:** ``program_reservation_use`` + +request_energy_usage() +^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: request_energy_usage(device, year, months) + + Request daily energy usage data for one or more months. subscribe_energy_usage() ^^^^^^^^^^^^^^^^^^^^^^^^ .. py:method:: subscribe_energy_usage(device, callback) - Subscribe to energy usage query responses. + Subscribe to parsed energy usage responses. - :param device: Device object - :type device: Device - :param callback: Function receiving EnergyUsageResponse objects + :param callback: Called with :class:`~nwp500.models.EnergyUsageResponse` :type callback: Callable[[EnergyUsageResponse], None] - :return: Subscription packet ID - :rtype: int - **Example:** +check_firmware_update() +^^^^^^^^^^^^^^^^^^^^^^^ - .. code-block:: python +.. py:method:: check_firmware_update(device) - def on_energy(energy): - """Process energy usage data.""" - print(f"Total Usage: {energy.total.total_usage} Wh") - print(f"Heat Pump: {energy.total.heat_pump_percentage:.1f}%") - print(f"Electric: {energy.total.heat_element_percentage:.1f}%") - - print("\nDaily Breakdown:") - for monthly_data in energy.usage: - print(f" Month: {monthly_data.year}-{monthly_data.month}") - for day_data in monthly_data.data: - # Skip empty days (all zeros) - if day_data.total_usage > 0: - print(f" Day {monthly_data.data.index(day_data) + 1}:") - print(f" Total: {day_data.total_usage} Wh") - print(f" HP: {day_data.heat_pump_usage} Wh ({day_data.heat_pump_time}h)") - print(f" HE: {day_data.heat_element_usage} Wh ({day_data.heat_element_time}h)") - - await mqtt.subscribe_energy_usage(device, on_energy) - await mqtt.control.request_energy_usage(device, year=2024, months=[10]) + Trigger an OTA firmware availability check. The response arrives + asynchronously on the device's MQTT response topic. -Reservation Methods -------------------- +commit_firmware_update() +^^^^^^^^^^^^^^^^^^^^^^^^ -update_reservations() -^^^^^^^^^^^^^^^^^^^^^ +.. py:method:: commit_firmware_update(device, payload) -.. py:method:: update_reservations(device, enabled, reservations) + Commit a previously downloaded firmware update. - Update device reservation schedule. + :param payload: OTA commit payload identifying the component and version + :type payload: OtaCommitPayload - :param device: Device object - :type device: Device - :param enabled: Enable/disable reservation schedule - :type enabled: bool - :param reservations: List of reservation objects - :type reservations: list[dict] - :return: Publish packet ID - :rtype: int + .. warning:: - **Example:** + The device reboots when a firmware commit is applied. - .. code-block:: python +reconnect_wifi() +^^^^^^^^^^^^^^^^ - # Define reservations - reservations = [ - { - "startHour": 6, - "startMinute": 0, - "endHour": 22, - "endMinute": 0, - "weekDays": [1, 1, 1, 1, 1, 0, 0], # Mon-Fri - "temperature": 120 - }, - { - "startHour": 8, - "startMinute": 0, - "endHour": 20, - "endMinute": 0, - "weekDays": [0, 0, 0, 0, 0, 1, 1], # Sat-Sun - "temperature": 130 - } - ] - - # Update schedule - await mqtt.control.update_reservations(device, True, reservations) +.. py:method:: reconnect_wifi(device) -request_reservations() + Ask the device to reconnect to WiFi using its current configuration. + +reset_wifi() +^^^^^^^^^^^^ + +.. py:method:: reset_wifi(device) + + Clear the stored WiFi configuration. + + .. warning:: + + After ``reset_wifi()``, the device must be provisioned again. + +set_freeze_protection_temperature() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: set_freeze_protection_temperature(device, temperature) + + Set the freeze-protection threshold in the current unit system. + + Available on devices that expose ``freeze_protection_use``. + +run_smart_diagnostic() ^^^^^^^^^^^^^^^^^^^^^^ -.. py:method:: request_reservations(device) +.. py:method:: run_smart_diagnostic(device) - Request current reservation schedule. + Trigger the device's smart diagnostic routine. - :param device: Device object - :type device: Device - :return: Publish packet ID - :rtype: int + Available on devices that expose ``smart_diagnostic_use``. -Time-of-Use Methods -------------------- + The result appears in the next ``DeviceStatus.smart_diagnostic`` update. -set_tou_enabled() -^^^^^^^^^^^^^^^^^ +enable_intelligent_scheduling() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. py:method:: set_tou_enabled(device, enabled) +.. py:method:: enable_intelligent_scheduling(device) - Enable or disable Time-of-Use optimization. + Enable adaptive/intelligent scheduling mode. - :param device: Device object - :type device: Device - :param enabled: True to enable, False to disable - :type enabled: bool - :return: Publish packet ID - :rtype: int +disable_intelligent_scheduling() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - **Example:** +.. py:method:: disable_intelligent_scheduling(device) - .. code-block:: python + Disable adaptive/intelligent scheduling mode. - # Enable TOU - await mqtt.control.set_tou_enabled(device, True) - - # Disable TOU - await mqtt.control.set_tou_enabled(device, False) +enable_demand_response() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: enable_demand_response(device) + + Enable utility demand-response participation. + +disable_demand_response() +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: disable_demand_response(device) + + Disable utility demand-response participation. + +reset_air_filter() +^^^^^^^^^^^^^^^^^^ + +.. py:method:: reset_air_filter(device) + + Reset the air-filter maintenance timer. + +signal_app_connection() +^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: signal_app_connection(device) + + Publish an app-connection heartbeat event to the device. Periodic Request Methods ------------------------ @@ -758,7 +808,7 @@ signal_app_connection() .. code-block:: python await mqtt.connect() - await mqtt.control.signal_app_connection(device) + await mqtt.signal_app_connection(device) subscribe(), unsubscribe(), publish() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -782,7 +832,7 @@ is_connected .. code-block:: python if mqtt.is_connected: - await mqtt.control.set_power(device, True) + await mqtt.set_power(device, True) else: print("Not connected") @@ -870,7 +920,7 @@ Example 1: Complete Monitoring Application # Temperature changed if last_temp != status.dhw_temperature: print(f"[{now}] Temperature: {status.dhw_temperature}°F " - f"(Target: {status.dhw_temperatureSetting}°F)") + f"(Target: {status.dhw_temperature_setting}°F)") last_temp = status.dhw_temperature # Power changed @@ -890,7 +940,7 @@ Example 1: Complete Monitoring Application print(f"[{now}] Heating: {', '.join(components)}") await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Monitor indefinitely try: @@ -976,7 +1026,7 @@ Example 3: Multi-Device Monitoring for device in devices: callback = create_callback(device.device_info.device_name) await mqtt.subscribe_device_status(device, callback) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Monitor await asyncio.sleep(3600) @@ -993,10 +1043,10 @@ Best Practices # CORRECT order await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # WRONG - response will be missed - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) await mqtt.subscribe_device_status(device, on_status) 2. **Use context managers:** @@ -1017,12 +1067,12 @@ Best Practices mqtt = NavienMqttClient(auth) - def on_interrupted(error): - print(f"Connection lost: {error}") + def on_interrupted(event): + print(f"Connection lost: {event.error}") # Save state, notify user, etc. - def on_resumed(return_code, session_present): - print("Connection restored") + def on_resumed(event): + print(f"Connection restored (session_present={event.session_present})") # Re-request status, etc. mqtt.on("connection_interrupted", on_interrupted) @@ -1046,7 +1096,7 @@ Best Practices .. code-block:: python if mqtt.is_connected: - await mqtt.control.set_power(device, True) + await mqtt.set_power(device, True) else: print("Not connected - reconnecting...") await mqtt.connect() @@ -1056,11 +1106,12 @@ Related Documentation * :doc:`auth_client` - Authentication client * :doc:`api_client` - REST API client -* :doc:`device_control` - Device control commands and capability checking * :doc:`models` - Data models (DeviceStatus, DeviceFeature, etc.) * :doc:`events` - Event system * :doc:`exceptions` - Exception handling * :doc:`../protocol/mqtt_protocol` - MQTT protocol details * :doc:`../guides/energy_monitoring` - Energy monitoring guide +* :doc:`../guides/scheduling` - Scheduling, recirculation, and intelligent modes +* :doc:`../guides/device_maintenance` - OTA, WiFi, freeze protection, and diagnostics * :doc:`../guides/command_queue` - Command queueing guide * :doc:`../guides/auto_recovery` - Auto-reconnection guide diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2f16b73..595e90e 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -126,7 +126,7 @@ Connect to MQTT for real-time device monitoring: # Subscribe and request status await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Monitor for 60 seconds print("Monitoring device...") @@ -163,18 +163,18 @@ Send control commands to change device settings: await mqtt.connect() # Turn on the device - await mqtt.control.set_power(device, power_on=True) + await mqtt.set_power(device, power_on=True) print("Device powered on") # Set to Energy Saver mode - await mqtt.control.set_dhw_mode( + await mqtt.set_dhw_mode( device, mode_id=DhwOperationSetting.ENERGY_SAVER.value ) print("Set to Energy Saver mode") # Set temperature to 120°F - await mqtt.control.set_dhw_temperature(device, 120.0) + await mqtt.set_dhw_temperature(device, 120.0) print("Temperature set to 120°F") await asyncio.sleep(2) From 0c01181ef8297efd6ab57e34e435a4ab6c930abe Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 08:53:58 -0700 Subject: [PATCH 10/29] Remove LLM vocabulary and sentence patterns from documentation --- README.rst | 2 +- docs/development/history.rst | 4 +- docs/guides/event_system.rst | 188 ++++++++++++++------------------ docs/index.rst | 7 +- docs/installation.rst | 4 +- docs/python_api/mqtt_client.rst | 113 ++++++++----------- docs/quickstart.rst | 5 +- 7 files changed, 136 insertions(+), 187 deletions(-) diff --git a/README.rst b/README.rst index 11981a2..e2389de 100644 --- a/README.rst +++ b/README.rst @@ -214,7 +214,7 @@ Quick Reference Device Status Fields ==================== -The library provides access to comprehensive device status information. See the `full documentation `_ for all available fields. +See the `full documentation `_ for all available status fields. Documentation ============= diff --git a/docs/development/history.rst b/docs/development/history.rst index afd7e6e..b9aa363 100644 --- a/docs/development/history.rst +++ b/docs/development/history.rst @@ -7,7 +7,7 @@ decisions made during the development of the nwp500 Python library. Project Overview ---------------- -A comprehensive Python client library for Navien NWP500 water heaters, +A Python client library for Navien NWP500 water heaters, providing: - REST API client for device management @@ -289,7 +289,7 @@ interruptions: - Queue processed in FIFO order when connection is restored - Configurable queue size (default: 100 commands) - Enabled by default for best user experience -- Integrates seamlessly with automatic reconnection +- Integrates with automatic reconnection - Properties: ``queued_commands_count`` for monitoring - Methods: ``clear_command_queue()`` for manual management diff --git a/docs/guides/event_system.rst b/docs/guides/event_system.rst index 358feca..c9bef44 100644 --- a/docs/guides/event_system.rst +++ b/docs/guides/event_system.rst @@ -2,58 +2,46 @@ Event-Driven Programming ======================== -This guide demonstrates how to build event-driven applications using the -nwp500 library's event system. +The nwp500 event system lets you react to device state changes, connection +events, and derived transitions (temperature delta, mode change, etc.) without +polling. -Overview -======== +Two Callback Patterns +===================== -The event system allows you to: +``subscribe_*()`` methods +-------------------------- -* React to device state changes in real-time -* Build responsive, reactive applications -* Separate concerns (monitoring, logging, alerting) -* Handle multiple devices with a unified interface +These deliver parsed model objects directly to your callback: -Benefits --------- +.. code-block:: python -**Compared to polling:** + def on_status(status): + print(status.dhw_temperature) -* Lower latency - react immediately to changes -* More efficient - no wasted requests -* Cleaner code - declarative callbacks vs loops -* Better scalability - handle multiple devices easily + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) -**Use cases:** +``.on()`` event emitter +------------------------ -* Home automation triggers -* Alert systems -* Data logging and analytics -* UI updates -* Integration with other systems +These deliver a single typed event dataclass. Use them for connection events and +derived state transitions (temperature delta, mode change, etc.): -Basic Usage -=========== +.. code-block:: python -Two Callback Patterns ---------------------- + from nwp500 import MqttClientEvents -The MQTT client exposes two distinct callback styles: + def on_status_event(event): + status = event.status + print(f"Temperature: {status.dhw_temperature}°F") -* :meth:`nwp500.mqtt.client.NavienMqttClient.subscribe_device_status` and other - ``subscribe_*`` methods deliver parsed model objects directly. For example, - ``subscribe_device_status()`` still calls ``callback(DeviceStatus)``. -* :meth:`nwp500.events.EventEmitter.on` listens for higher-level client events. - These callbacks now receive a **single typed event dataclass** such as - :class:`nwp500.mqtt_events.StatusReceivedEvent` or - :class:`nwp500.mqtt_events.ConnectionResumedEvent`. + mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_event) -Discovering Available Events ----------------------------- +See :doc:`../python_api/events` for the full event dataclass reference. -The :class:`nwp500.mqtt_events.MqttClientEvents` class provides a complete registry -of all events with type-safe constants and full documentation: +Available Events +---------------- .. code-block:: python @@ -94,8 +82,7 @@ Simple Event Handler Raw status subscription ----------------------- -Use a typed subscription when you want the raw model directly instead of an event -wrapper: +Use a typed subscription when you want the model object directly: .. code-block:: python @@ -108,9 +95,7 @@ wrapper: Event Registry -------------- -The :class:`nwp500.mqtt_events.MqttClientEvents` class provides type-safe event -constants and programmatic discovery, so your callbacks use valid event names and -get IDE autocomplete: +Use ``MqttClientEvents`` constants to avoid typos and get IDE autocomplete: .. code-block:: python @@ -131,24 +116,18 @@ get IDE autocomplete: mqtt_client.on(MqttClientEvents.HEATING_STARTED, on_heating_start) mqtt_client.on(MqttClientEvents.ERROR_DETECTED, on_error) - print("Available events:") for event_name in MqttClientEvents.get_all_events(): print(f" - {event_name}") - event_value = MqttClientEvents.get_event_value("TEMPERATURE_CHANGED") - print(f"Event value: {event_value}") - -Each event has full type documentation. See :doc:`../python_api/events` for the -complete event dataclass reference. +See :doc:`../python_api/events` for the event dataclass reference. Advanced Patterns ================= +Tracking significant changes +---------------------------- -Pattern 1: State Tracking --------------------------- - -Track state changes and react only when values change significantly. +Filter callbacks to only act when a value changes by more than a threshold: .. code-block:: python @@ -191,8 +170,8 @@ Track state changes and react only when values change significantly. await asyncio.sleep(3600) # Monitor for 1 hour -Pattern 2: Multi-Device Monitoring ------------------------------------ +Multiple devices +---------------- Monitor multiple devices with individual callbacks. @@ -250,10 +229,10 @@ Monitor multiple devices with individual callbacks. while True: await asyncio.sleep(60) -Pattern 3: Alert System ------------------------- +Alert rules +----------- -Build an alert system that triggers on specific conditions. +Trigger actions when the device crosses a threshold: .. code-block:: python @@ -349,8 +328,8 @@ Build an alert system that triggers on specific conditions. while True: await asyncio.sleep(3600) -Pattern 4: Data Logger ------------------------ +Data logging +------------ Log device data to a database or file. @@ -433,10 +412,10 @@ Log device data to a database or file. while True: await asyncio.sleep(3600) -Pattern 5: Home Automation Integration ---------------------------------------- +Home automation bridge +---------------------- -Integrate with Home Assistant, OpenHAB, or custom systems. +Publish status updates to Home Assistant or similar systems: .. code-block:: python @@ -516,69 +495,60 @@ Integrate with Home Assistant, OpenHAB, or custom systems. Best Practices ============== -1. **Keep handlers lightweight:** - - .. code-block:: python - - # GOOD: Fast handler - def on_status(status): - asyncio.create_task(process_status(status)) - - # BAD: Slow handler (blocks event loop) - def on_status(status): - time.sleep(5) # BAD - process_status(status) +Keep handlers lightweight +-------------------------- -2. **Handle errors in callbacks:** +Offload heavy work with ``asyncio.create_task`` rather than blocking in the callback: - .. code-block:: python +.. code-block:: python - def safe_handler(status): - try: - process_status(status) - except Exception as e: - print(f"Handler error: {e}") - # Don't let errors crash the event loop + def on_status(status): + asyncio.create_task(process_status(status)) -3. **Unsubscribe when done:** +Wrap callbacks in try/except +----------------------------- - .. code-block:: python +An unhandled exception in a callback won't crash the event loop, but it will +silence subsequent events for that subscription: - # Track callback references - callback = lambda s: print(s.dhw_temperature) +.. code-block:: python - await mqtt.subscribe_device_status(device, callback) + def safe_handler(status): + try: + process_status(status) + except Exception as e: + print(f"Handler error: {e}") - # Later, unsubscribe - # (if the MQTT client supports it) +Async callbacks +--------------- -4. **Use async callbacks when possible:** +Callbacks can be async. The client will schedule them as tasks: - .. code-block:: python +.. code-block:: python - async def async_handler(status): - # Can await async operations - await save_to_database(status) - await send_notification(status) + async def async_handler(status): + await save_to_database(status) + await send_notification(status) -5. **Batch updates to reduce overhead:** +Batch processing +---------------- - .. code-block:: python +Buffer updates and flush periodically to reduce I/O overhead: - class BatchProcessor: - def __init__(self): - self.buffer = [] +.. code-block:: python - def on_status(self, status): - self.buffer.append(status) + class BatchProcessor: + def __init__(self): + self.buffer = [] - if len(self.buffer) >= 10: - self.flush() + def on_status(self, status): + self.buffer.append(status) + if len(self.buffer) >= 10: + self.flush() - def flush(self): - # Process batch - save_batch_to_db(self.buffer) - self.buffer.clear() + def flush(self): + save_batch_to_db(self.buffer) + self.buffer.clear() Related Documentation ===================== diff --git a/docs/index.rst b/docs/index.rst index aab00e9..60cbafd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,17 +25,16 @@ supports both REST API and real-time MQTT communication. API * **MQTT Client** - Real-time device communication via AWS IoT Core * **Authentication** - JWT-based auth with automatic token refresh -* **Type Safety** - Comprehensive type-annotated data models +* **Type Safety** - Type-annotated models for all device data * **Event System** - Subscribe to device state changes with callbacks * **Energy Monitoring** - Track power consumption and usage statistics * **Time-of-Use (TOU)** - Optimize for variable electricity pricing -* **Async/Await** - Fully asynchronous, non-blocking operations +* **Async/Await** - Built on asyncio throughout Quick Start =========== -Install with ``pip install nwp500-python``, then see the :doc:`quickstart` guide -to connect and control your device in minutes. +Install with ``pip install nwp500-python``, then see the :doc:`quickstart` guide. Documentation Index =================== diff --git a/docs/installation.rst b/docs/installation.rst index 6e3a475..e638e7c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -100,13 +100,13 @@ Troubleshooting ImportError: No module named 'nwp500' -------------------------------------- -Make sure you installed the package: +Check that you installed the package: .. code-block:: bash pip install nwp500-python -If using a virtual environment, ensure it's activated. +If using a virtual environment, activate it first. SSL/TLS Errors -------------- diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index 5b71357..2eeb447 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -2,29 +2,22 @@ MQTT Client ============ -The ``NavienMqttClient`` is the **primary interface** for real-time communication -with Navien devices. Use this for monitoring status and sending control commands. +``NavienMqttClient`` is the main interface for real-time communication with +Navien devices — status monitoring, device control, and event callbacks. .. important:: - **MQTT is the main way to interact with your Navien device.** Use the REST API - only for device discovery. MQTT provides real-time updates, lower latency, - bidirectional communication, and event-driven architecture. + Use the REST API only for device discovery. Everything else goes through MQTT. Overview ======== -The MQTT client provides: - -* **Real-Time Monitoring** - Subscribe to device status updates as they happen +* **Real-Time Monitoring** - Subscribe to device status updates * **Device Control** - Send commands (power, temperature, mode) * **Event System** - React to state changes with callbacks -* **Auto-Reconnection** - Automatic recovery from network issues with exponential backoff -* **Command Queueing** - Commands queued when offline, sent automatically on reconnect -* **Type-Safe** - Returns strongly-typed data models (DeviceStatus, DeviceFeature) -* **Periodic Requests** - Automatic periodic status/info requests -* **Energy Monitoring** - Query and subscribe to energy usage data - -All operations are fully asynchronous and non-blocking. +* **Auto-Reconnection** - Exponential backoff reconnection with command queueing +* **Type-Safe** - Returns typed models (DeviceStatus, DeviceFeature) +* **Periodic Requests** - Scheduled status polling +* **Energy Monitoring** - Query historical energy usage data Quick Start =========== @@ -195,8 +188,8 @@ subscribe_device_status() Subscribe to device status updates with automatic parsing. - The callback receives DeviceStatus objects with 100+ fields including temperature, - power consumption, operation mode, and component states. + The callback receives :class:`~nwp500.models.DeviceStatus` objects containing + temperature, power, operation mode, component states, and more. :param device: Device object :type device: Device @@ -1037,69 +1030,57 @@ Example 3: Multi-Device Monitoring Best Practices ============== -1. **Always subscribe before requesting:** +Subscribe before requesting +---------------------------- - .. code-block:: python +The device responds on a topic you must already be listening to: - # CORRECT order - await mqtt.subscribe_device_status(device, on_status) - await mqtt.request_device_status(device) - - # WRONG - response will be missed - await mqtt.request_device_status(device) - await mqtt.subscribe_device_status(device, on_status) +.. code-block:: python -2. **Use context managers:** + # correct + await mqtt.subscribe_device_status(device, on_status) + await mqtt.request_device_status(device) - .. code-block:: python + # wrong — response arrives before subscription + await mqtt.request_device_status(device) + await mqtt.subscribe_device_status(device, on_status) - async with NavienAuthClient(email, password) as auth: - mqtt = NavienMqttClient(auth) - try: - await mqtt.connect() - # ... operations ... - finally: - await mqtt.disconnect() +Use context managers +--------------------- -3. **Handle connection events:** +.. code-block:: python - .. code-block:: python + async with NavienAuthClient(email, password) as auth: + mqtt = NavienMqttClient(auth) + try: + await mqtt.connect() + # ... operations ... + finally: + await mqtt.disconnect() - mqtt = NavienMqttClient(auth) - - def on_interrupted(event): - print(f"Connection lost: {event.error}") - # Save state, notify user, etc. - - def on_resumed(event): - print(f"Connection restored (session_present={event.session_present})") - # Re-request status, etc. - - mqtt.on("connection_interrupted", on_interrupted) - mqtt.on("connection_resumed", on_resumed) +Handle connection events +------------------------ -4. **Use periodic requests for long-running monitoring:** +.. code-block:: python - .. code-block:: python + def on_interrupted(event): + print(f"Connection lost: {event.error}") - # Instead of manual loop - await mqtt.subscribe_device_status(device, on_status) - await mqtt.start_periodic_requests(device, period_seconds=300) - - # Monitor as long as needed - await asyncio.sleep(86400) # 24 hours - - await mqtt.stop_periodic_requests(device) + def on_resumed(event): + print(f"Connection restored (session_present={event.session_present})") -5. **Check connection status:** + mqtt.on("connection_interrupted", on_interrupted) + mqtt.on("connection_resumed", on_resumed) - .. code-block:: python +Periodic requests for long-running monitoring +---------------------------------------------- - if mqtt.is_connected: - await mqtt.set_power(device, True) - else: - print("Not connected - reconnecting...") - await mqtt.connect() +.. code-block:: python + + await mqtt.subscribe_device_status(device, on_status) + await mqtt.start_periodic_requests(device, period_seconds=300) + await asyncio.sleep(86400) + await mqtt.stop_periodic_requests(device) Related Documentation ===================== diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 595e90e..26660fd 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -2,8 +2,7 @@ Quickstart ========== -This guide will get you up and running with the nwp500-python library -in just a few minutes. +Install the library and start talking to your device. Prerequisites ============= @@ -276,7 +275,7 @@ Common Issues reach the Navien cloud platform. **Import Errors** - Make sure you installed the library: ``pip install nwp500-python`` + Check that the library is installed: ``pip install nwp500-python`` For more help, see the :doc:`development/contributing` guide or file an issue on GitHub. From b00236f3fcdaf2161bbd586b240ae4ff976b0486 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 6 May 2026 08:57:41 -0700 Subject: [PATCH 11/29] Update changelog for v8.0.0 --- CHANGELOG.rst | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 375c78f..c322f81 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,85 @@ Changelog ========= +Version 8.0.0 (2026-05-06) +=========================== + +**BREAKING CHANGES**: ``.on()`` event handler callbacks now receive a single typed +event dataclass instead of positional arguments. ``MqttDeviceController`` is no longer +accessible as ``.control`` on ``NavienMqttClient``; all control methods are now +available directly on the client. + +Breaking Changes +---------------- +- **Typed event payloads**: All ``.on()`` event handler callbacks now receive a single + typed event dataclass instance. The dataclasses are exported from + ``nwp500.mqtt_events``. + + .. code-block:: python + + # OLD (removed) + mqtt.on("temperature_changed", lambda old, new: print(old, new)) + mqtt.on("connection_resumed", lambda rc, sp: print(rc, sp)) + + # NEW + mqtt.on("temperature_changed", lambda e: print(e.old_temperature, e.new_temperature)) + mqtt.on("connection_resumed", lambda e: print(e.return_code, e.session_present)) + +- **``MqttDeviceController`` no longer public**: ``NavienMqttClient`` no longer exposes + a ``.control`` attribute. All control methods are now available directly on the + client. + + .. code-block:: python + + # OLD (removed) + await mqtt.control.set_temperature(device, 50) + + # NEW + await mqtt.set_temperature(device, 50) + +Added +----- +- **New control methods**: Nine previously unimplemented ``CommandCode`` values now have + full implementations: ``check_firmware``, ``commit_firmware``, ``reconnect_wifi``, + ``reset_wifi``, ``set_freeze_protection_temperature``, ``run_smart_diagnostic``, + ``enable_intelligent_reservation``, ``disable_intelligent_reservation``, and + ``set_water_program_reservation``. +- **Typed subscription methods**: ``subscribe_reservation``, + ``subscribe_weekly_reservation``, and ``subscribe_recirculation`` return typed + responses directly without requiring raw MQTT event handlers. +- **New protocol models**: ``WeeklyReservationSchedule``, ``WeeklyReservationEntry``, + ``RecirculationSchedule``, ``RecirculationScheduleEntry``, and ``OtaCommitPayload``. +- **``DeviceStateTracker``**: State change detection extracted into a dedicated class + in ``nwp500.mqtt.state_tracker`` with per-device tracking keyed by MAC address. +- **``MQTT_PROTOCOL_VERSION`` constant**: Protocol version is now a named constant in + ``nwp500.config`` rather than a hardcoded integer in the command payload builder. +- **``response_ack_topic()``**: New method on ``MqttTopicBuilder`` for control command + acknowledgement topics. + +Changed +------- +- **Unit conversion redesign**: Temperature, flow rate, and volume fields in + ``DeviceStatus`` and ``DeviceFeature`` now store raw device values as ``*_raw: int`` + fields and expose converted values via lazy computed properties. Conversion happens at + access time rather than during Pydantic deserialization, preserving the original + device value in all cases. +- **Models split into subpackage**: ``nwp500.models`` is now a package + (``nwp500/models/``) with modules for status, schedule, TOU, and MQTT models. Public + imports from ``nwp500.models`` are unchanged. +- **Topic building centralised**: All MQTT topic construction now goes through + ``MqttTopicBuilder``. Hardcoded topic strings removed from ``control.py``, + ``reservations.py``, and ``cli/handlers.py``. +- **``set_vacation_days`` delegates to ``set_dhw_mode``**: The method now calls + ``set_dhw_mode(device, DhwOperationSetting.VACATION, vacation_days=days)`` directly. +- **Per-device state tracking**: ``_previous_status`` changed from a single + ``DeviceStatus | None`` to a ``dict[str, DeviceStatus]`` keyed by MAC address, + preventing spurious state-change events when multiple devices are connected. +- **Unit system stored as instance variable**: ``NavienAPIClient``, + ``NavienMqttClient``, and ``NavienAuthClient`` no longer call ``set_unit_system()`` + as a constructor side-effect. +- **``NavienBaseModel`` consolidated**: Duplicate definitions in ``auth.py`` and + ``models.py`` merged into a single definition in ``nwp500._base``. + Version 7.4.10 (2026-04-13) =========================== From 680f06b6443cd0df0626dfc5292e4d70b42c5f4a Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 15:45:55 -0700 Subject: [PATCH 12/29] code review modifications --- .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 6 +- .readthedocs.yml | 2 +- CHANGELOG.rst | 32 ++- CONTRIBUTING.rst | 2 +- README.rst | 21 +- docs/development/history.rst | 4 +- docs/guides/advanced_features_explained.rst | 4 +- docs/guides/command_queue.rst | 12 +- docs/guides/energy_monitoring.rst | 6 +- docs/guides/mqtt_diagnostics.rst | 2 +- docs/guides/time_of_use.rst | 12 +- docs/installation.rst | 8 +- docs/protocol/device_features.rst | 2 +- docs/python_api/auth_client.rst | 8 +- docs/python_api/models.rst | 6 +- docs/quickstart.rst | 2 +- examples/advanced/air_filter_reset.py | 6 +- examples/advanced/anti_legionella.py | 8 +- examples/advanced/combined_callbacks.py | 6 +- examples/advanced/demand_response.py | 6 +- examples/advanced/device_capabilities.py | 4 +- examples/advanced/device_status_debug.py | 4 +- examples/advanced/energy_analytics.py | 2 +- examples/advanced/firmware_payload_capture.py | 8 +- examples/advanced/power_control.py | 6 +- examples/advanced/recirculation_control.py | 8 +- examples/advanced/reconnection_demo.py | 4 +- examples/advanced/reservation_schedule.py | 4 +- examples/advanced/token_restoration.py | 2 +- examples/advanced/tou_openei.py | 2 +- examples/advanced/tou_schedule.py | 10 +- examples/advanced/water_reservation.py | 4 +- examples/beginner/04_set_temperature.py | 4 +- .../intermediate/advanced_auth_patterns.py | 2 +- examples/intermediate/command_queue.py | 10 +- .../intermediate/device_status_callback.py | 4 +- examples/intermediate/error_handling.py | 4 +- examples/intermediate/event_driven_control.py | 2 +- examples/intermediate/improved_auth.py | 2 +- .../intermediate/mqtt_realtime_monitoring.py | 6 +- examples/intermediate/periodic_requests.py | 8 +- examples/intermediate/set_mode.py | 4 +- examples/intermediate/vacation_mode.py | 4 +- examples/testing/periodic_device_info.py | 4 +- examples/testing/test_mqtt_messaging.py | 6 +- pyproject.toml | 6 +- setup.cfg | 6 +- src/nwp500/auth.py | 70 ++----- src/nwp500/cli/__main__.py | 2 +- src/nwp500/cli/handlers.py | 44 ++--- src/nwp500/cli/monitoring.py | 2 +- src/nwp500/cli/token_storage.py | 4 +- src/nwp500/device_info_cache.py | 16 +- src/nwp500/exceptions.py | 6 +- src/nwp500/models/energy.py | 7 +- src/nwp500/models/feature.py | 7 +- src/nwp500/models/schedule.py | 20 +- src/nwp500/models/status.py | 6 +- src/nwp500/mqtt/client.py | 168 +++++++++++----- src/nwp500/mqtt/control.py | 5 + src/nwp500/mqtt/state_tracker.py | 16 +- src/nwp500/mqtt/subscriptions.py | 184 +++++++++++++++--- src/nwp500/reservations.py | 11 +- tests/test_auth.py | 44 ++--- tests/test_cli_commands.py | 22 +-- 66 files changed, 556 insertions(+), 371 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62638f5..81bb087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install tox run: | @@ -37,7 +37,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install ruff run: | @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.13', '3.14'] + python-version: ['3.14'] steps: - uses: actions/checkout@v4 @@ -87,7 +87,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install build dependencies run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3fd0a56..c21a83e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.14' - name: Install dependencies run: | @@ -53,7 +53,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.14' - name: Install build dependencies run: | @@ -80,7 +80,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.14' - name: Extract changelog for this version id: changelog diff --git a/.readthedocs.yml b/.readthedocs.yml index 4704766..c9d6a1d 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -19,7 +19,7 @@ formats: build: os: ubuntu-22.04 tools: - python: "3.13" + python: "3.14" python: install: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c322f81..71ef2ff 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -38,6 +38,34 @@ Breaking Changes # NEW await mqtt.set_temperature(device, 50) +Migration Guide (from 7.x.x) +---------------------------- +The following steps are recommended for a smooth migration, particularly for complex +integrations like Home Assistant: + +1. **Update Event Listeners**: Locate all ``mqtt.on()`` or ``mqtt.once()`` calls. + Update the callback signatures to accept a single argument (the event object) and + update the body to access fields via the object (e.g., ``event.new_temperature`` + instead of ``new_val``). +2. **Refactor Control Calls**: Remove ``.control`` from all device command invocations. + Instead of ``await mqtt.control.set_power(...)``, use ``await mqtt.set_power(...)``. +3. **Handle Unit Conversions**: If your integration previously performed its own + conversions or relied on the library's eager conversion, note that + ``DeviceStatus`` fields like ``dhw_temperature`` are now properties. They return + values based on the global unit system context (``us_customary`` by default). + * **Home Assistant Tip**: To ensure your state tracking is immune to unit system + toggles within the library, use the new ``*_raw`` fields (e.g., + ``status.dhw_temperature_raw``) for comparison logic, and use the properties + only for display or when a converted value is explicitly needed. +4. **Remove ``from_dict()`` Calls**: The ``from_dict()`` method has been removed + from all models. Use ``model_validate()`` instead. + * **Note**: ``AuthenticationResponse.model_validate()`` now automatically handles + the ``"data": { ... }`` wrapper found in raw API responses. +5. **Subpackage Imports**: While top-level imports from ``nwp500.models`` are + preserved, if you were importing from the internal ``nwp500.models`` module file + directly, you must update your imports to point to the new structured files + (e.g., ``nwp500.models.status``). + Added ----- - **New control methods**: Nine previously unimplemented ``CommandCode`` values now have @@ -284,7 +312,7 @@ Breaking Changes .. code-block:: python # OLD (hardcoded 95-150°F) - await mqtt.control.set_dhw_temperature(device, temperature_f=140.0) + await mqtt.set_dhw_temperature(device, temperature_f=140.0) entry = build_reservation_entry( enabled=True, days=["Monday"], @@ -296,7 +324,7 @@ Breaking Changes # NEW (device-provided limits, unit-aware) # Temperature value automatically uses user's preferred unit - await mqtt.control.set_dhw_temperature(device, 140.0) + await mqtt.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) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 200a990..ec65726 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -135,7 +135,7 @@ Follow a clear exception hierarchy with consistent naming: .. code-block:: python try: - await mqtt_client.control.set_temperature(device, 150) + await mqtt_client.set_temperature(device, 150) except MqttNotConnectedError: # Handle specific case: not connected print("Connect to device first") diff --git a/README.rst b/README.rst index e2389de..8f18838 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,8 @@ nwp500-python ============= +|PyPI-v| |Python-versions| |CI-status| |Docs-status| |Code-style| |License| + Python library for Navien NWP500 Heat Pump Water Heater ======================================================== @@ -239,10 +241,10 @@ The library includes type-safe data models with automatic unit conversions: Requirements ============ -* Python 3.13+ -* aiohttp >= 3.8.0 +* Python 3.14+ +* aiohttp >= 3.13.5 * pydantic >= 2.0.0 -* awsiotsdk >= 1.27.0 +* awsiotsdk >= 1.29.0 License ======= @@ -259,3 +261,16 @@ Acknowledgments This project has been set up using PyScaffold 4.6. For details and usage information on PyScaffold see https://pyscaffold.org/. + +.. |PyPI-v| image:: https://img.shields.io/pypi/v/nwp500-python.svg + :target: https://pypi.org/project/nwp500-python/ +.. |Python-versions| image:: https://img.shields.io/pypi/pyversions/nwp500-python.svg + :target: https://pypi.org/project/nwp500-python/ +.. |CI-status| image:: https://github.com/eman/nwp500-python/actions/workflows/ci.yml/badge.svg + :target: https://github.com/eman/nwp500-python/actions/workflows/ci.yml +.. |Docs-status| image:: https://readthedocs.org/projects/nwp500-python/badge/?version=latest + :target: https://nwp500-python.readthedocs.io/en/latest/?badge=latest +.. |Code-style| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff +.. |License| image:: https://img.shields.io/pypi/l/nwp500-python.svg + :target: https://opensource.org/licenses/MIT diff --git a/docs/development/history.rst b/docs/development/history.rst index b9aa363..1986dbd 100644 --- a/docs/development/history.rst +++ b/docs/development/history.rst @@ -18,7 +18,7 @@ providing: - Automatic reconnection with exponential backoff - Command queuing for reliable communication - Historical energy usage data (EMS API) -- Modern Python 3.13+ codebase with native type hints +- Modern Python 3.14+ codebase with native type hints Current Status -------------- @@ -38,7 +38,7 @@ The library is feature-complete with: - Comprehensive documentation - Working examples for all features - Unit tests with good coverage -- Python 3.13+ with modern type hints +- Python 3.14+ with modern type hints Implementation Milestones ------------------------- diff --git a/docs/guides/advanced_features_explained.rst b/docs/guides/advanced_features_explained.rst index 8b86dea..5dd663c 100644 --- a/docs/guides/advanced_features_explained.rst +++ b/docs/guides/advanced_features_explained.rst @@ -109,7 +109,7 @@ The ``outsideTemperature`` field is transmitted in the device status update. Pyt .. code-block:: python # From device status updates - status = await mqtt_client.control.request_device_status() + status = await mqtt_client.request_device_status() # Access ambient temperature data outdoor_temp = status.outside_temperature # Raw integer value @@ -383,7 +383,7 @@ Monitoring Stratification from Python async def monitor_stratification(mqtt_client: NavienMQTTClient, device_id: str): """Monitor tank stratification quality""" - status = await mqtt_client.control.request_device_status(device_id) + status = await mqtt_client.request_device_status(device_id) upper_temp = status.tank_upper_temperature # float in °F lower_temp = status.tank_lower_temperature # float in °F diff --git a/docs/guides/command_queue.rst b/docs/guides/command_queue.rst index 9d215e1..631f00f 100644 --- a/docs/guides/command_queue.rst +++ b/docs/guides/command_queue.rst @@ -162,7 +162,7 @@ Basic Usage (Default Configuration) # Command queue is enabled by default # Commands sent during disconnection are automatically queued - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # If disconnected, command is queued and sent on reconnection # No user action needed @@ -219,7 +219,7 @@ Handle Queue Full Condition # Queue has max size of 100 by default # Oldest commands automatically dropped when full for i in range(150): - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # First 100 queued, remaining 50 replace oldest print(f"Queued: {mqtt_client.queued_commands_count}") # Will be 100 @@ -255,8 +255,8 @@ Reliable Device Control .. code-block:: python # Even during network issues, commands are preserved - await mqtt_client.control.set_dhw_temperature(device, 140.0) - await mqtt_client.control.set_dhw_mode(device, 2) # Energy Saver mode + 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 @@ -277,8 +277,8 @@ Batch Operations # Send multiple commands without worrying about connection state for device in devices: - await mqtt_client.control.request_device_status(device) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_status(device) + await mqtt_client.request_device_info(device) # All commands reach their destination eventually diff --git a/docs/guides/energy_monitoring.rst b/docs/guides/energy_monitoring.rst index eb097b5..b5c1e5f 100644 --- a/docs/guides/energy_monitoring.rst +++ b/docs/guides/energy_monitoring.rst @@ -103,10 +103,10 @@ Request detailed daily energy usage data for specific months: await mqtt_client.subscribe_energy_usage(device, on_energy_usage) # Request energy usage for September 2025 - await mqtt_client.control.request_energy_usage(device, year=2025, months=[9]) + await mqtt_client.request_energy_usage(device, year=2025, months=[9]) # Request multiple months - await mqtt_client.control.request_energy_usage(device, year=2025, months=[7, 8, 9]) + await mqtt_client.request_energy_usage(device, year=2025, months=[7, 8, 9]) **Methods:** @@ -242,7 +242,7 @@ Complete Energy Monitoring Example await mqtt_client.subscribe_device_status(device, on_status) # Request initial status - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # Monitor for 5 minutes print("Monitoring energy consumption for 5 minutes...") diff --git a/docs/guides/mqtt_diagnostics.rst b/docs/guides/mqtt_diagnostics.rst index b22dd1e..a76d2d1 100644 --- a/docs/guides/mqtt_diagnostics.rst +++ b/docs/guides/mqtt_diagnostics.rst @@ -715,7 +715,7 @@ Device Control Integration diagnostics.record_publish(queued=not mqtt_client.is_connected) # Set temperature - await mqtt_client.control.set_dhw_temperature(device, 140.0) + await mqtt_client.set_dhw_temperature(device, 140.0) if not mqtt_client.is_connected: _logger.info( diff --git a/docs/guides/time_of_use.rst b/docs/guides/time_of_use.rst index fd5ac97..ea63915 100644 --- a/docs/guides/time_of_use.rst +++ b/docs/guides/time_of_use.rst @@ -524,7 +524,7 @@ Configure two rate periods - off-peak and peak pricing: feature_future.set_result(feature) await mqtt_client.subscribe_device_feature(device, capture_feature) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) feature = await asyncio.wait_for(feature_future, timeout=15) controller_serial = feature.controllerSerialNumber @@ -555,7 +555,7 @@ Configure two rate periods - off-peak and peak pricing: ) # Configure TOU schedule - await mqtt_client.control.configure_tou_schedule( + await mqtt_client.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=[off_peak, peak], @@ -636,7 +636,7 @@ Configure different rates for summer and winter: ) # Configure all periods - await mqtt_client.control.configure_tou_schedule( + await mqtt_client.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=[summer_off_peak, summer_peak, winter_off_peak, winter_peak], @@ -697,7 +697,7 @@ Query the device for its current TOU configuration: await mqtt_client.subscribe(response_topic, on_tou_response) # Request current settings - await mqtt_client.control.request_tou_settings(device, controller_serial) + await mqtt_client.request_tou_settings(device, controller_serial) # Wait for response await asyncio.sleep(5) @@ -721,7 +721,7 @@ Enable or disable TOU operation: await mqtt_client.connect() # Enable or disable TOU - await mqtt_client.control.set_tou_enabled(device, enabled=enable) + await mqtt_client.set_tou_enabled(device, enabled=enable) print(f"TOU {'enabled' if enable else 'disabled'}") await mqtt_client.disconnect() @@ -791,7 +791,7 @@ Navien mobile app uses: # 7. Enable TOU via MQTT mqtt_client = NavienMqttClient(auth) await mqtt_client.connect() - await mqtt_client.control.set_tou_enabled(device, enabled=True) + await mqtt_client.set_tou_enabled(device, enabled=True) await mqtt_client.disconnect() asyncio.run(apply_openei_rate_plan()) diff --git a/docs/installation.rst b/docs/installation.rst index e638e7c..162101c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -5,7 +5,7 @@ Installation Requirements ============ -* Python 3.13 or higher +* Python 3.14 or higher * pip (Python package installer) * Navien Smart Control account @@ -50,8 +50,8 @@ Core Dependencies The library requires: -* ``aiohttp>=3.8.0`` - Async HTTP client for REST API -* ``awsiotsdk>=1.27.0`` - AWS IoT SDK for MQTT +* ``aiohttp>=3.13.5`` - Async HTTP client for REST API +* ``awsiotsdk>=1.29.0`` - AWS IoT SDK for MQTT * ``pydantic>=2.0.0`` - Data validation and models Optional Dependencies @@ -128,7 +128,7 @@ The MQTT client requires the AWS IoT SDK: .. code-block:: bash - pip install awsiotsdk>=1.27.0 + pip install awsiotsdk>=1.29.0 Upgrading ========= diff --git a/docs/protocol/device_features.rst b/docs/protocol/device_features.rst index fc04155..2f3634b 100644 --- a/docs/protocol/device_features.rst +++ b/docs/protocol/device_features.rst @@ -365,7 +365,7 @@ Usage Example print(f"Available: {', '.join(features)}") await mqtt_client.subscribe_device_feature(device, analyze_features) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) # Wait for response await asyncio.sleep(5) diff --git a/docs/python_api/auth_client.rst b/docs/python_api/auth_client.rst index 7233af2..546f916 100644 --- a/docs/python_api/auth_client.rst +++ b/docs/python_api/auth_client.rst @@ -84,7 +84,7 @@ NavienAuthClient auth = NavienAuthClient() # With stored tokens (skip re-authentication) - stored = AuthTokens.from_dict(saved_data) + stored = AuthTokens.model_validate(saved_data) auth = NavienAuthClient( "email@example.com", "password", @@ -345,7 +345,7 @@ AuthTokens **Methods:** - .. py:method:: from_dict(data) + .. py:method:: model_validate(data) :classmethod: Create AuthTokens from dictionary (API response or saved data). @@ -373,7 +373,7 @@ AuthTokens token_data = tokens.to_dict() # Later, restore tokens - restored = AuthTokens.from_dict(token_data) + restored = AuthTokens.model_validate(token_data) AuthenticationResponse ---------------------- @@ -497,7 +497,7 @@ Example 5: Token Restoration (Skip Re-authentication) token_data = json.load(f) # Deserialize tokens - stored_tokens = AuthTokens.from_dict(token_data) + stored_tokens = AuthTokens.model_validate(token_data) # Initialize client with stored tokens # This skips initial authentication if tokens are still valid diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index 31e33b8..52c78e9 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -431,7 +431,7 @@ Full programmed reservation schedule used by ``request_reservations()`` and **Computed Properties / Methods:** * ``enabled`` (bool) - * :meth:`from_dict` - Parse a raw MQTT response payload + * :meth:`model_validate` - Parse a raw MQTT response payload WeeklyReservationEntry ---------------------- @@ -474,7 +474,7 @@ Full weekly reservation schedule. **Computed Properties / Methods:** * ``enabled`` (bool) - * :meth:`from_dict` - Parse a raw MQTT response payload + * :meth:`model_validate` - Parse a raw MQTT response payload RecirculationScheduleEntry -------------------------- @@ -515,7 +515,7 @@ Full recirculation schedule used by **Methods:** - * :meth:`from_dict` - Parse a raw MQTT response payload + * :meth:`model_validate` - Parse a raw MQTT response payload OtaCommitPayload ---------------- diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 26660fd..2cbe723 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -7,7 +7,7 @@ Install the library and start talking to your device. Prerequisites ============= -* Python 3.13 or higher +* Python 3.14 or higher * Navien Smart Control account (via Navilink mobile app) * At least one Navien NWP500 device registered to your account * Valid email and password for your Navien account diff --git a/examples/advanced/air_filter_reset.py b/examples/advanced/air_filter_reset.py index 535fef2..1a79dcc 100644 --- a/examples/advanced/air_filter_reset.py +++ b/examples/advanced/air_filter_reset.py @@ -54,7 +54,7 @@ def on_device_info(features): ) await mqtt_client.subscribe_device_feature(device, on_device_info) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.sleep(3) # Wait for device info # Reset air filter maintenance timer @@ -69,7 +69,7 @@ def on_filter_reset(status): filter_reset_complete = True await mqtt_client.subscribe_device_status(device, on_filter_reset) - await mqtt_client.control.reset_air_filter(device) + await mqtt_client.reset_air_filter(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds @@ -93,7 +93,7 @@ def on_updated_device_info(features): logger.info("Filter reset appears to have been successful!") await mqtt_client.subscribe_device_feature(device, on_updated_device_info) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.sleep(3) finally: diff --git a/examples/advanced/anti_legionella.py b/examples/advanced/anti_legionella.py index b872ed9..cd70a9c 100644 --- a/examples/advanced/anti_legionella.py +++ b/examples/advanced/anti_legionella.py @@ -116,7 +116,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.STATUS_REQUEST - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) try: await asyncio.wait_for(status_received.wait(), timeout=10) @@ -134,7 +134,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.ANTI_LEGIONELLA_ON - await mqtt_client.control.enable_anti_legionella(device, period_days=7) + await mqtt_client.enable_anti_legionella(device, period_days=7) try: await asyncio.wait_for(status_received.wait(), timeout=10) @@ -152,7 +152,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.ANTI_LEGIONELLA_OFF - await mqtt_client.control.disable_anti_legionella(device) + await mqtt_client.disable_anti_legionella(device) try: await asyncio.wait_for(status_received.wait(), timeout=10) @@ -169,7 +169,7 @@ def on_status(topic: str, message: dict[str, Any]) -> None: print("=" * 70) status_received.clear() expected_command = CommandCode.ANTI_LEGIONELLA_ON - await mqtt_client.control.enable_anti_legionella(device, period_days=14) + await mqtt_client.enable_anti_legionella(device, period_days=14) try: await asyncio.wait_for(status_received.wait(), timeout=10) diff --git a/examples/advanced/combined_callbacks.py b/examples/advanced/combined_callbacks.py index b4e8c9d..f19aa5b 100644 --- a/examples/advanced/combined_callbacks.py +++ b/examples/advanced/combined_callbacks.py @@ -133,13 +133,13 @@ def on_feature(feature: DeviceFeature): # Request both types of data print("Requesting device info and status...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.sleep(2) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print("[SUCCESS] Requests sent") print() diff --git a/examples/advanced/demand_response.py b/examples/advanced/demand_response.py index d2153d0..0a6915b 100644 --- a/examples/advanced/demand_response.py +++ b/examples/advanced/demand_response.py @@ -54,7 +54,7 @@ def on_current_status(status): ) await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Enable demand response @@ -69,7 +69,7 @@ def on_dr_enabled(status): dr_enabled = True await mqtt_client.subscribe_device_status(device, on_dr_enabled) - await mqtt_client.control.enable_demand_response(device) + await mqtt_client.enable_demand_response(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds @@ -98,7 +98,7 @@ def on_dr_disabled(status): dr_disabled = True await mqtt_client.subscribe_device_status(device, on_dr_disabled) - await mqtt_client.control.disable_demand_response(device) + await mqtt_client.disable_demand_response(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds diff --git a/examples/advanced/device_capabilities.py b/examples/advanced/device_capabilities.py index 1cd9a75..0b23949 100644 --- a/examples/advanced/device_capabilities.py +++ b/examples/advanced/device_capabilities.py @@ -227,10 +227,10 @@ def on_device_feature(feature: DeviceFeature): # Step 5: Request device info to get feature data print("Step 5: Requesting device information...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) print("[SUCCESS] Device info request sent") print() diff --git a/examples/advanced/device_status_debug.py b/examples/advanced/device_status_debug.py index eb59f45..b52a1a4 100644 --- a/examples/advanced/device_status_debug.py +++ b/examples/advanced/device_status_debug.py @@ -150,10 +150,10 @@ def on_device_status(status: DeviceStatus): # Step 5: Request device status print("Step 5: Requesting device status...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print("[SUCCESS] Status request sent") print() diff --git a/examples/advanced/energy_analytics.py b/examples/advanced/energy_analytics.py index f580023..d6aaa5e 100755 --- a/examples/advanced/energy_analytics.py +++ b/examples/advanced/energy_analytics.py @@ -140,7 +140,7 @@ def on_energy_usage(energy: EnergyUsageResponse): current_month = now.month print(f"\nRequesting energy usage for {current_year}-{current_month:02d}...") - await mqtt_client.control.request_energy_usage( + await mqtt_client.request_energy_usage( device, year=current_year, months=[current_month] ) print("[OK] Request sent") diff --git a/examples/advanced/firmware_payload_capture.py b/examples/advanced/firmware_payload_capture.py index 3a1c715..bb9f151 100644 --- a/examples/advanced/firmware_payload_capture.py +++ b/examples/advanced/firmware_payload_capture.py @@ -136,7 +136,7 @@ def on_feature(feature: DeviceFeature) -> None: device_info_event.set() await mqtt_client.subscribe_device_feature(device, on_feature) - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.wait_for(device_info_event.wait(), timeout=30.0) if device_feature: @@ -147,12 +147,12 @@ def on_feature(feature: DeviceFeature) -> None: ) # --- Step 2: request device status --- - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # --- Step 3: request reservation (weekly) schedule --- print("\nRequesting weekly reservation schedule...") - await mqtt_client.control.request_reservations(device) + await mqtt_client.request_reservations(device) await asyncio.sleep(5) # --- Step 4: request TOU schedule (requires controller serial number) --- @@ -161,7 +161,7 @@ def on_feature(feature: DeviceFeature) -> None: if serial: print("Requesting TOU schedule...") try: - await mqtt_client.control.request_tou_settings(device, serial) + await mqtt_client.request_tou_settings(device, serial) await asyncio.sleep(5) except Exception as exc: print(f" TOU request failed: {exc}") diff --git a/examples/advanced/power_control.py b/examples/advanced/power_control.py index e041406..1ea23c9 100644 --- a/examples/advanced/power_control.py +++ b/examples/advanced/power_control.py @@ -52,7 +52,7 @@ def on_current_status(status): logger.info(f"Current DHW temperature: {status.dhw_temperature}{unit}") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Turn device off @@ -68,7 +68,7 @@ def on_power_off_response(status): power_off_complete = True await mqtt_client.subscribe_device_status(device, on_power_off_response) - await mqtt_client.control.set_power(device, power_on=False) + await mqtt_client.set_power(device, power_on=False) # Wait for confirmation for i in range(15): # Wait up to 15 seconds @@ -97,7 +97,7 @@ def on_power_on_response(status): power_on_complete = True await mqtt_client.subscribe_device_status(device, on_power_on_response) - await mqtt_client.control.set_power(device, power_on=True) + await mqtt_client.set_power(device, power_on=True) # Wait for confirmation for i in range(15): # Wait up to 15 seconds diff --git a/examples/advanced/recirculation_control.py b/examples/advanced/recirculation_control.py index f4cef62..b4cffa6 100644 --- a/examples/advanced/recirculation_control.py +++ b/examples/advanced/recirculation_control.py @@ -50,7 +50,7 @@ def on_current_status(status): logger.info(f"Current operation mode: {status.operation_mode.name}") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Set recirculation mode to "Always On" @@ -67,7 +67,7 @@ def on_mode_set(status): mode_set = True await mqtt_client.subscribe_device_status(device, on_mode_set) - await mqtt_client.control.set_recirculation_mode(device, 1) # 1 = Always On + await mqtt_client.set_recirculation_mode(device, 1) # 1 = Always On # Wait for confirmation for i in range(10): # Wait up to 10 seconds @@ -93,7 +93,7 @@ def on_hot_button(status): hot_button_triggered = True await mqtt_client.subscribe_device_status(device, on_hot_button) - await mqtt_client.control.trigger_recirculation_hot_button(device) + await mqtt_client.trigger_recirculation_hot_button(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds @@ -119,7 +119,7 @@ def on_button_only_set(status): button_only_set = True await mqtt_client.subscribe_device_status(device, on_button_only_set) - await mqtt_client.control.set_recirculation_mode( + await mqtt_client.set_recirculation_mode( device, 2 ) # 2 = Button Only diff --git a/examples/advanced/reconnection_demo.py b/examples/advanced/reconnection_demo.py index 99f4c39..c40b4f0 100644 --- a/examples/advanced/reconnection_demo.py +++ b/examples/advanced/reconnection_demo.py @@ -97,7 +97,7 @@ def on_status(status): print(f" Reconnecting: attempt {mqtt_client.reconnect_attempts}...") await mqtt_client.subscribe_device_status(device, on_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # Monitor connection status print("\n" + "=" * 70) @@ -126,7 +126,7 @@ def on_status(status): # Request status update if connected if mqtt_client.is_connected: - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print("\n" + "=" * 70) print(f"Monitoring complete. Received {status_count} status updates.") diff --git a/examples/advanced/reservation_schedule.py b/examples/advanced/reservation_schedule.py index f522d5e..57fa1cb 100644 --- a/examples/advanced/reservation_schedule.py +++ b/examples/advanced/reservation_schedule.py @@ -74,12 +74,12 @@ def on_reservation_update(topic: str, message: dict[str, Any]) -> None: await mqtt_client.subscribe(response_topic, on_reservation_update) print("Sending reservation program update...") - await mqtt_client.control.update_reservations( + await mqtt_client.update_reservations( device, [weekday_reservation], enabled=True ) print("Requesting current reservation program...") - await mqtt_client.control.request_reservations(device) + await mqtt_client.request_reservations(device) print("Waiting up to 15 seconds for reservation responses...") await asyncio.sleep(15) diff --git a/examples/advanced/token_restoration.py b/examples/advanced/token_restoration.py index 8c7ef2f..3c9871d 100644 --- a/examples/advanced/token_restoration.py +++ b/examples/advanced/token_restoration.py @@ -89,7 +89,7 @@ async def restore_tokens_example(): # Import after getting token_data to avoid circular import issues from nwp500.auth import AuthTokens - stored_tokens = AuthTokens.from_dict(token_data) + stored_tokens = AuthTokens.model_validate(token_data) logger.info(f"Stored tokens issued at: {stored_tokens.issued_at}") logger.info(f"Stored tokens expire at: {stored_tokens.expires_at}") diff --git a/examples/advanced/tou_openei.py b/examples/advanced/tou_openei.py index 4fa8530..5d12322 100755 --- a/examples/advanced/tou_openei.py +++ b/examples/advanced/tou_openei.py @@ -108,7 +108,7 @@ async def main() -> None: print("Enabling TOU mode…") mqtt = NavienMqttClient(auth) await mqtt.connect() - await mqtt.control.set_tou_enabled(device, enabled=True) + await mqtt.set_tou_enabled(device, enabled=True) await mqtt.disconnect() print("\nDone! TOU schedule configured and enabled.") diff --git a/examples/advanced/tou_schedule.py b/examples/advanced/tou_schedule.py index b1249dc..2d3579d 100644 --- a/examples/advanced/tou_schedule.py +++ b/examples/advanced/tou_schedule.py @@ -22,7 +22,7 @@ def capture_feature(feature) -> None: await mqtt_client.subscribe_device_feature(device, capture_feature) # Then request device info - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) # Wait for the response feature = await asyncio.wait_for(feature_future, timeout=15) @@ -112,7 +112,7 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None: await mqtt_client.subscribe(response_topic, on_tou_response) print("Uploading TOU schedule (enabling reservation)...") - await mqtt_client.control.configure_tou_schedule( + await mqtt_client.configure_tou_schedule( device=device, controller_serial_number=controller_serial, periods=[off_peak, peak], @@ -120,17 +120,17 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None: ) print("Requesting current TOU settings for confirmation...") - await mqtt_client.control.request_tou_settings(device, controller_serial) + await mqtt_client.request_tou_settings(device, controller_serial) print("Waiting up to 15 seconds for TOU responses...") await asyncio.sleep(15) print("Toggling TOU off for quick test...") - await mqtt_client.control.set_tou_enabled(device, enabled=False) + await mqtt_client.set_tou_enabled(device, enabled=False) await asyncio.sleep(3) print("Re-enabling TOU...") - await mqtt_client.control.set_tou_enabled(device, enabled=True) + await mqtt_client.set_tou_enabled(device, enabled=True) await asyncio.sleep(3) await mqtt_client.disconnect() diff --git a/examples/advanced/water_reservation.py b/examples/advanced/water_reservation.py index ff03b26..e105682 100644 --- a/examples/advanced/water_reservation.py +++ b/examples/advanced/water_reservation.py @@ -54,7 +54,7 @@ def on_current_status(status): ) await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Enable water program reservation mode @@ -75,7 +75,7 @@ def on_water_program_configured(status): await mqtt_client.subscribe_device_status( device, on_water_program_configured ) - await mqtt_client.control.configure_reservation_water_program(device) + await mqtt_client.configure_reservation_water_program(device) # Wait for confirmation for i in range(10): # Wait up to 10 seconds diff --git a/examples/beginner/04_set_temperature.py b/examples/beginner/04_set_temperature.py index 993441a..1624ade 100644 --- a/examples/beginner/04_set_temperature.py +++ b/examples/beginner/04_set_temperature.py @@ -54,7 +54,7 @@ def on_current_status(status): logger.info(f"Current DHW temperature: {status.dhw_temperature}{unit}") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Set new target temperature to 140 (in user's preferred unit) @@ -82,7 +82,7 @@ def on_temp_change_response(status): await mqtt_client.subscribe_device_status(device, on_temp_change_response) # Send temperature change command using display temperature value - await mqtt_client.control.set_dhw_temperature(device, target_temperature) + await mqtt_client.set_dhw_temperature(device, target_temperature) # Wait for confirmation for i in range(15): # Wait up to 15 seconds diff --git a/examples/intermediate/advanced_auth_patterns.py b/examples/intermediate/advanced_auth_patterns.py index b55c21d..d5d6b4f 100644 --- a/examples/intermediate/advanced_auth_patterns.py +++ b/examples/intermediate/advanced_auth_patterns.py @@ -101,7 +101,7 @@ def on_status(status): await mqtt_client.subscribe_device_status(device, on_status) # Request initial status - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) # Wait for a moment to receive updates await asyncio.sleep(3) diff --git a/examples/intermediate/command_queue.py b/examples/intermediate/command_queue.py index 5b03f8c..badd3bc 100644 --- a/examples/intermediate/command_queue.py +++ b/examples/intermediate/command_queue.py @@ -111,7 +111,7 @@ def on_message(topic, message): # Step 5: Test normal operation print("\n5. Testing normal operation (connected)...") print(" Sending status request...") - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print(" [SUCCESS] Command sent successfully") await asyncio.sleep(2) @@ -131,15 +131,15 @@ def on_message(topic, message): # These will be queued print(" Queuing status request...") - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print(f" Queue size: {mqtt_client.queued_commands_count}") print(" Queuing device info request...") - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) print(f" Queue size: {mqtt_client.queued_commands_count}") print(" Queuing temperature change...") - await mqtt_client.control.set_dhw_temperature(device, 130) + await mqtt_client.set_dhw_temperature(device, 130) print(f" Queue size: {mqtt_client.queued_commands_count}") print(f" [SUCCESS] Queued {mqtt_client.queued_commands_count} command(s)") @@ -164,7 +164,7 @@ def on_message(topic, message): # Try to exceed queue limit print(f" Sending {config.max_queued_commands + 5} commands...") for _i in range(config.max_queued_commands + 5): - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print( f" Queue size: {mqtt_client.queued_commands_count} (max: {config.max_queued_commands})" diff --git a/examples/intermediate/device_status_callback.py b/examples/intermediate/device_status_callback.py index 0c3b86b..3198267 100755 --- a/examples/intermediate/device_status_callback.py +++ b/examples/intermediate/device_status_callback.py @@ -209,10 +209,10 @@ def on_device_status(status: DeviceStatus): # Step 5: Request device status print("Step 5: Requesting device status...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print("[SUCCESS] Status request sent") print() diff --git a/examples/intermediate/error_handling.py b/examples/intermediate/error_handling.py index 4dfcd73..756da7a 100755 --- a/examples/intermediate/error_handling.py +++ b/examples/intermediate/error_handling.py @@ -108,7 +108,7 @@ async def example_mqtt_errors(): await mqtt.disconnect() try: - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) except MqttNotConnectedError as e: print(f"[OK] Caught MqttNotConnectedError: {e}") print(" Can reconnect and retry the operation") @@ -157,7 +157,7 @@ async def example_validation_errors(): # Try to set invalid vacation days try: - await mqtt.control.set_dhw_mode(device, mode_id=5, vacation_days=50) + await mqtt.set_dhw_mode(device, mode_id=5, vacation_days=50) except RangeValidationError as e: print(f"[OK] Caught RangeValidationError: {e}") print(f" Field: {e.field}") diff --git a/examples/intermediate/event_driven_control.py b/examples/intermediate/event_driven_control.py index 3bcc60b..92210a7 100644 --- a/examples/intermediate/event_driven_control.py +++ b/examples/intermediate/event_driven_control.py @@ -227,7 +227,7 @@ async def main(): # Step 5: Request initial status print("7. Requesting initial status...") - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print(" [SUCCESS] Request sent") print() diff --git a/examples/intermediate/improved_auth.py b/examples/intermediate/improved_auth.py index f59e5ad..3b479ed 100644 --- a/examples/intermediate/improved_auth.py +++ b/examples/intermediate/improved_auth.py @@ -51,7 +51,7 @@ def on_status(status): print(f" Power: {status.current_inst_power}W") await mqtt.subscribe_device_status(device, on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) # Keep alive for a few seconds to receive status print("\nMonitoring for 10 seconds...") diff --git a/examples/intermediate/mqtt_realtime_monitoring.py b/examples/intermediate/mqtt_realtime_monitoring.py index 21abe0f..592caeb 100755 --- a/examples/intermediate/mqtt_realtime_monitoring.py +++ b/examples/intermediate/mqtt_realtime_monitoring.py @@ -183,17 +183,17 @@ def on_device_feature(feature: DeviceFeature): # Signal app connection print("📤 Signaling app connection...") - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) await asyncio.sleep(1) # Request device info print("📤 Requesting device information...") - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) await asyncio.sleep(2) # Request device status print("📤 Requesting device status...") - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(2) # Wait for messages diff --git a/examples/intermediate/periodic_requests.py b/examples/intermediate/periodic_requests.py index cc807de..1730f3e 100755 --- a/examples/intermediate/periodic_requests.py +++ b/examples/intermediate/periodic_requests.py @@ -120,7 +120,7 @@ async def monitor_with_dots(seconds: int, interval: int = 5): # Send initial request immediately to get first response print("Sending initial status request...") - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) print("Monitoring for 60 seconds...") print("(First automatic request in ~20 seconds)") @@ -140,7 +140,7 @@ async def monitor_with_dots(seconds: int, interval: int = 5): # Send initial request immediately print("Sending initial device info request...") - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) print("Monitoring for 60 seconds...") print("(First automatic request in ~20 seconds)") @@ -169,9 +169,9 @@ async def monitor_with_dots(seconds: int, interval: int = 5): # Send initial requests for both types print("\nSending initial requests for both types...") - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) await asyncio.sleep(1) # Small delay between requests - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) print("\nMonitoring for 2 minutes...") print("(Status requests: ~20s, ~40s, ~60s, ~80s, ~100s, ~120s)") diff --git a/examples/intermediate/set_mode.py b/examples/intermediate/set_mode.py index ffae437..6534be8 100644 --- a/examples/intermediate/set_mode.py +++ b/examples/intermediate/set_mode.py @@ -50,7 +50,7 @@ def on_current_status(status): logger.info(f"Current mode: {status.operation_mode.name}") await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Change to Energy Saver mode @@ -71,7 +71,7 @@ def on_mode_change_response(status): await mqtt_client.subscribe_device_status(device, on_mode_change_response) # Send mode change command (3 = Energy Saver, per MQTT protocol) - await mqtt_client.control.set_dhw_mode(device, 3) + await mqtt_client.set_dhw_mode(device, 3) # Wait for confirmation for i in range(15): # Wait up to 15 seconds diff --git a/examples/intermediate/vacation_mode.py b/examples/intermediate/vacation_mode.py index 2bae187..a0a93be 100644 --- a/examples/intermediate/vacation_mode.py +++ b/examples/intermediate/vacation_mode.py @@ -57,7 +57,7 @@ def on_current_status(status): ) await mqtt_client.subscribe_device_status(device, on_current_status) - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) await asyncio.sleep(3) # Wait for current status # Set vacation mode @@ -73,7 +73,7 @@ def on_vacation_set(status): vacation_set = True await mqtt_client.subscribe_device_status(device, on_vacation_set) - await mqtt_client.control.set_vacation_days(device, vacation_days) + await mqtt_client.set_vacation_days(device, vacation_days) # Wait for confirmation for i in range(10): # Wait up to 10 seconds diff --git a/examples/testing/periodic_device_info.py b/examples/testing/periodic_device_info.py index 1ace6b0..495cedd 100755 --- a/examples/testing/periodic_device_info.py +++ b/examples/testing/periodic_device_info.py @@ -97,7 +97,7 @@ def on_device_feature(feature: DeviceFeature): # Send initial request to get immediate response print(" Sending initial request...") - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) # Wait for a few updates with the default period print(" Waiting 15 seconds for response...") @@ -112,7 +112,7 @@ def on_device_feature(feature: DeviceFeature): # Send initial request for immediate feedback print(" Sending initial request...") - await mqtt.control.request_device_info(device) + await mqtt.request_device_info(device) # Monitor for 2 minutes print("\n Monitoring for 2 minutes...") diff --git a/examples/testing/test_mqtt_messaging.py b/examples/testing/test_mqtt_messaging.py index 7bfa4d3..ec6da33 100644 --- a/examples/testing/test_mqtt_messaging.py +++ b/examples/testing/test_mqtt_messaging.py @@ -182,7 +182,7 @@ def mask_any(_): f"📤 [{datetime.now().strftime('%H:%M:%S')}] Signaling app connection..." ) try: - await mqtt_client.control.signal_app_connection(device) + await mqtt_client.signal_app_connection(device) print(" [SUCCESS] Sent") except Exception as e: print(f" [ERROR] Error: {e}") @@ -193,7 +193,7 @@ def mask_any(_): f"📤 [{datetime.now().strftime('%H:%M:%S')}] Requesting device info..." ) try: - await mqtt_client.control.request_device_info(device) + await mqtt_client.request_device_info(device) print(" [SUCCESS] Sent") except Exception as e: print(f" [ERROR] Error: {e}") @@ -204,7 +204,7 @@ def mask_any(_): f"📤 [{datetime.now().strftime('%H:%M:%S')}] Requesting device status..." ) try: - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) print(" [SUCCESS] Sent") except Exception as e: print(f" [ERROR] Error: {e}") diff --git a/pyproject.toml b/pyproject.toml index 3f68238..5f056ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ version_scheme = "no-guess-dev" [tool.ruff] # Ruff configuration for code formatting and linting line-length = 80 -target-version = "py313" +target-version = "py314" # Exclude directories exclude = [ @@ -102,7 +102,7 @@ line-ending = "auto" strict = true # Python version target -python_version = "3.13" +python_version = "3.14" # Module discovery files = ["src/nwp500", "tests"] @@ -160,7 +160,7 @@ ignore_missing_imports = true [tool.pyright] # Pyright configuration for strict type checking -pythonVersion = "3.13" +pythonVersion = "3.14" typeCheckingMode = "strict" include = ["src/nwp500", "tests"] exclude = [".venv", "build", "dist", ".tox"] diff --git a/setup.cfg b/setup.cfg index b84aef3..8e8fe14 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,7 +32,7 @@ classifiers = Development Status :: 4 - Beta Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 Programming Language :: Python :: 3 :: Only @@ -44,7 +44,7 @@ package_dir = =src # Require a min/specific Python version (comma-separated conditions) -python_requires = >=3.13 +python_requires = >=3.14 # Add here dependencies of your project (line-separated), e.g. requests>=2.2,<3.0. # Version specifiers like >=2.2,<3.0 avoid problems due to API changes in @@ -52,7 +52,7 @@ python_requires = >=3.13 # For more information, check out https://semver.org/. install_requires = aiohttp>=3.13.5 - awsiotsdk>=1.28.2 + awsiotsdk>=1.29.0 pydantic>=2.0.0 diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index ab81214..596c599 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -15,6 +15,7 @@ import json import logging +import warnings from datetime import UTC, datetime, timedelta from typing import Any, Self, cast @@ -52,11 +53,6 @@ class UserInfo(NavienBaseModel): user_status: str = "" user_seq: int = 0 - @classmethod - def from_dict(cls, data: dict[str, Any]) -> UserInfo: - """Create UserInfo from API response dictionary (compatibility).""" - return cls.model_validate(data) - @property def full_name(self) -> str: """Return the user's full name.""" @@ -141,32 +137,6 @@ def model_post_init(self, __context: Any) -> None: else: self._aws_expires_at = None - @classmethod - def from_dict(cls, data: dict[str, Any]) -> AuthTokens: - """Create AuthTokens from API response dictionary or stored data. - - Args: - data: Dictionary containing token data. Can be from API response - (using camelCase keys) or from stored data (using snake_case - keys from to_dict()). - - Returns: - AuthTokens instance - """ - # Pydantic with populate_by_name=True handles both snake_case (stored) - # and camelCase (API alias) automatically. - return cls.model_validate(data) - - def to_dict(self) -> dict[str, Any]: - """Convert AuthTokens to a dictionary for storage. - - Returns: - Dictionary with snake_case keys suitable for JSON serialization. - DateTime fields are serialized to ISO 8601 format strings - (e.g., "2025-11-19T08:51:00") for backward compatibility. - """ - return self.model_dump(mode="json") - @property def expires_at(self) -> datetime: """Get the cached expiration timestamp.""" @@ -222,23 +192,21 @@ class AuthenticationResponse(NavienBaseModel): code: int = 200 message: str = Field(default="SUCCESS", alias="msg") + @model_validator(mode="before") @classmethod - def from_dict(cls, response_data: dict[str, Any]) -> AuthenticationResponse: - """Create AuthenticationResponse from API response.""" - # Map nested API response to flat model structure - # API response: { "code": ..., "msg": ..., "data": { ... } } - data = response_data.get("data", {}) - - # Construct a dict that matches the model structure - model_data = { - "code": response_data.get("code", 200), - "msg": response_data.get("msg", "SUCCESS"), - "userInfo": data.get("userInfo", {}), - "tokens": data.get("token", {}), - "legal": data.get("legal", []), - } - - return cls.model_validate(model_data) + def wrap_api_response(cls, data: Any) -> Any: + """Handle nested 'data' wrapper in API responses.""" + if isinstance(data, dict) and "data" in data: + # Lift fields from 'data' into the top level for validation + # while preserving top-level code/msg + response_data = data.get("data", {}) + if isinstance(response_data, dict): + merged = {**data, **response_data} + # Handle 'token' vs 'tokens' inconsistency in API + if "token" in response_data and "tokens" not in response_data: + merged["tokens"] = response_data["token"] + return merged + return data __all__ = [ @@ -290,7 +258,7 @@ class NavienAuthClient: ... await mqtt_client.connect() Restore session from stored tokens: - >>> stored_tokens = AuthTokens.from_dict(saved_data) + >>> stored_tokens = AuthTokens.model_validate(saved_data) >>> async with NavienAuthClient( ... user_id="user@example.com", ... password="password", @@ -460,7 +428,7 @@ async def sign_in( ) # Parse successful response - auth_response = AuthenticationResponse.from_dict(response_data) + auth_response = AuthenticationResponse.model_validate(response_data) self._auth_response = auth_response self._user_email = user_id # Store the email for later use @@ -535,7 +503,7 @@ async def refresh_token( # Parse new tokens data = response_data.get("data", {}) - new_tokens = AuthTokens.from_dict(data) + new_tokens = AuthTokens.model_validate(data) # Preserve AWS credentials from old tokens if not in refresh # response @@ -812,4 +780,4 @@ async def refresh_access_token(refresh_token: str) -> AuthTokens: ) data = response_data.get("data", {}) - return AuthTokens.from_dict(data) + return AuthTokens.model_validate(data) diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 76a06ff..369491d 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -54,7 +54,7 @@ def _on_status(status: DeviceStatus) -> None: future.set_result(status) await mqtt.subscribe_device_status(device, _on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) try: status = await asyncio.wait_for(future, timeout=5.0) if status.temperature_type == TemperatureType.CELSIUS: diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index 69c3220..60a45a1 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -116,7 +116,7 @@ async def get_controller_serial_number( feature: Any = await _wait_for_response( mqtt.subscribe_device_feature, device, - lambda: mqtt.control.request_device_info(device), + lambda: mqtt.request_device_info(device), timeout=timeout, action_name="controller serial", ) @@ -192,7 +192,7 @@ async def handle_status_request( mqtt, device, mqtt.subscribe_device_status, - mqtt.control.request_device_status, + mqtt.request_device_status, "status", "device status", raw, @@ -208,7 +208,7 @@ async def handle_device_info_request( mqtt, device, mqtt.subscribe_device_feature, - mqtt.control.request_device_info, + mqtt.request_device_info, "feature", "device information", raw, @@ -238,7 +238,7 @@ async def handle_set_mode_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_dhw_mode(device, mode_id), + lambda: mqtt.set_dhw_mode(device, mode_id), "setting mode", f"Mode changed to {mode_name}", ) @@ -252,7 +252,7 @@ async def handle_set_dhw_temp_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_dhw_temperature(device, temperature), + lambda: mqtt.set_dhw_temperature(device, temperature), "setting temperature", f"Temperature set to {temperature}{unit_suffix}", ) @@ -266,7 +266,7 @@ async def handle_power_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_power(device, power_on), + lambda: mqtt.set_power(device, power_on), f"turning {state}", f"Device turned {state}", ) @@ -328,7 +328,7 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: device_type, mqtt.client_id, "rsv/rd" ) await mqtt.subscribe(response_topic, raw_callback) - await mqtt.control.update_reservations( + await mqtt.update_reservations( device, reservations, enabled=enabled ) try: @@ -425,7 +425,7 @@ async def handle_enable_anti_legionella_request( ) -> None: """Enable Anti-Legionella disinfection cycle.""" try: - await mqtt.control.enable_anti_legionella(device, period_days) + await mqtt.enable_anti_legionella(device, period_days) print(f"✓ Anti-Legionella enabled (cycle every {period_days} day(s))") except (RangeValidationError, ValidationError) as e: _logger.error(f"Failed to enable Anti-Legionella: {e}") @@ -449,14 +449,14 @@ def _on_status(status: DeviceStatus) -> None: try: await mqtt.subscribe_device_status(device, _on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) status = await asyncio.wait_for(future, timeout=10) # Get current enabled state use = getattr(status, "anti_legionella_use", None) if use: - await mqtt.control.enable_anti_legionella(device, period_days) + await mqtt.enable_anti_legionella(device, period_days) print(f"Anti-Legionella period set to {period_days} day(s)") else: print( @@ -478,7 +478,7 @@ async def handle_disable_anti_legionella_request( ) -> None: """Disable Anti-Legionella disinfection cycle.""" try: - await mqtt.control.disable_anti_legionella(device) + await mqtt.disable_anti_legionella(device) print("✓ Anti-Legionella disabled") except DeviceError as e: _logger.error(f"Device error: {e}") @@ -498,7 +498,7 @@ def _on_status(status: DeviceStatus) -> None: future.set_result(status) await mqtt.subscribe_device_status(device, _on_status) - await mqtt.control.request_device_status(device) + await mqtt.request_device_status(device) try: status = await asyncio.wait_for(future, timeout=10) period = getattr(status, "anti_legionella_period", None) @@ -631,7 +631,7 @@ async def handle_set_tou_enabled_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_tou_enabled(device, enabled), + lambda: mqtt.set_tou_enabled(device, enabled), f"{'enabling' if enabled else 'disabling'} TOU", f"TOU {'enabled' if enabled else 'disabled'}", ) @@ -856,7 +856,7 @@ async def handle_tou_apply_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_tou_enabled(device, True), + lambda: mqtt.set_tou_enabled(device, True), "enabling TOU", "TOU enabled", ) @@ -880,7 +880,7 @@ async def handle_get_energy_request( res: Any = await _wait_for_response( mqtt.subscribe_energy_usage, device, - lambda: mqtt.control.request_energy_usage(device, year, months), + lambda: mqtt.request_energy_usage(device, year, months), action_name="energy usage", timeout=15, ) @@ -904,7 +904,7 @@ async def handle_reset_air_filter_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.reset_air_filter(device), + lambda: mqtt.reset_air_filter(device), "resetting air filter", "Air filter timer reset", ) @@ -917,7 +917,7 @@ async def handle_set_vacation_days_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_vacation_days(device, days), + lambda: mqtt.set_vacation_days(device, days), "setting vacation days", f"Vacation days set to {days}", ) @@ -932,7 +932,7 @@ async def handle_set_recirculation_mode_request( status = await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.set_recirculation_mode(device, mode), + lambda: mqtt.set_recirculation_mode(device, mode), "setting recirculation mode", f"Recirculation mode set to {mode_name}", ) @@ -952,7 +952,7 @@ async def handle_trigger_recirculation_hot_button_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.trigger_recirculation_hot_button(device), + lambda: mqtt.trigger_recirculation_hot_button(device), "triggering hot button", "Hot button triggered", ) @@ -965,7 +965,7 @@ async def handle_enable_demand_response_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.enable_demand_response(device), + lambda: mqtt.enable_demand_response(device), "enabling DR", "Demand response enabled", ) @@ -978,7 +978,7 @@ async def handle_disable_demand_response_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.disable_demand_response(device), + lambda: mqtt.disable_demand_response(device), "disabling DR", "Demand response disabled", ) @@ -991,7 +991,7 @@ async def handle_configure_reservation_water_program_request( await _handle_command_with_status_feedback( mqtt, device, - lambda: mqtt.control.configure_reservation_water_program(device), + lambda: mqtt.configure_reservation_water_program(device), "configuring water program", "Water program configured", ) diff --git a/src/nwp500/cli/monitoring.py b/src/nwp500/cli/monitoring.py index 371a88f..1ca1271 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -41,7 +41,7 @@ def on_status_update(status: DeviceStatus) -> None: await mqtt.subscribe_device_status(device, on_status_update) await mqtt.start_periodic_requests(device, period_seconds=30) - await mqtt.control.request_device_status( + await mqtt.request_device_status( device ) # Get an initial status right away diff --git a/src/nwp500/cli/token_storage.py b/src/nwp500/cli/token_storage.py index 904e704..ddfdd6a 100644 --- a/src/nwp500/cli/token_storage.py +++ b/src/nwp500/cli/token_storage.py @@ -47,8 +47,8 @@ def load_tokens() -> tuple[AuthTokens | None, str | None]: _logger.error("No email found in token file") return None, None - # Use the built-in from_dict() method for deserialization - tokens = AuthTokens.from_dict(data) + # Use the built-in model_validate() method for deserialization + tokens = AuthTokens.model_validate(data) _logger.info(f"Tokens loaded from {TOKEN_FILE} for user {email}") return tokens, email except (OSError, json.JSONDecodeError, KeyError, ValueError) as e: diff --git a/src/nwp500/device_info_cache.py b/src/nwp500/device_info_cache.py index 5228e5b..f0e5547 100644 --- a/src/nwp500/device_info_cache.py +++ b/src/nwp500/device_info_cache.py @@ -7,7 +7,7 @@ import asyncio import logging from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, ReadOnly, TypedDict if TYPE_CHECKING: from .models import DeviceFeature @@ -20,18 +20,18 @@ class CachedDeviceInfo(TypedDict): """Cached device information metadata.""" - mac: str - cached_at: str - expires_at: str | None - is_expired: bool + mac: ReadOnly[str] + cached_at: ReadOnly[str] + expires_at: ReadOnly[str | None] + is_expired: ReadOnly[bool] class CacheInfoResult(TypedDict): """Result of get_cache_info() call.""" - device_count: int - update_interval_minutes: float - devices: list[CachedDeviceInfo] + device_count: ReadOnly[int] + update_interval_minutes: ReadOnly[float] + devices: ReadOnly[list[CachedDeviceInfo]] class MqttDeviceInfoCache: diff --git a/src/nwp500/exceptions.py b/src/nwp500/exceptions.py index eacccf9..6344dab 100644 --- a/src/nwp500/exceptions.py +++ b/src/nwp500/exceptions.py @@ -36,14 +36,14 @@ # Old code (v4.x) try: - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) except RuntimeError as e: if "Not connected" in str(e): # handle connection error # New code (v5.0+) try: - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) except MqttNotConnectedError: # handle connection error except MqttError: @@ -273,7 +273,7 @@ class MqttNotConnectedError(MqttError): mqtt_client = NavienMqttClient(auth_client) # Must connect first await mqtt_client.connect() - await mqtt_client.control.request_device_status(device) + await mqtt_client.request_device_status(device) """ pass diff --git a/src/nwp500/models/energy.py b/src/nwp500/models/energy.py index 658f0f9..ce44e1b 100644 --- a/src/nwp500/models/energy.py +++ b/src/nwp500/models/energy.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import Any from pydantic import Field @@ -77,9 +78,3 @@ def get_month_data(self, year: int, month: int) -> MonthlyEnergyData | None: for monthly_data in self.usage: if monthly_data.year == year and monthly_data.month == month: return monthly_data - return None - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> EnergyUsageResponse: - """Compatibility method.""" - return cls.model_validate(data) diff --git a/src/nwp500/models/feature.py b/src/nwp500/models/feature.py index fb3e797..28bd4b5 100644 --- a/src/nwp500/models/feature.py +++ b/src/nwp500/models/feature.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import Annotated, Any from pydantic import BeforeValidator, Field, computed_field @@ -403,9 +404,3 @@ def get_field_unit(self, field_name: str) -> str: unit: str = str(unit_val) if unit_val is not None else "" return f" {unit}" if unit else "" - return "" - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DeviceFeature: - """Compatibility method.""" - return cls.model_validate(data) diff --git a/src/nwp500/models/schedule.py b/src/nwp500/models/schedule.py index ad28357..0e7aab3 100644 --- a/src/nwp500/models/schedule.py +++ b/src/nwp500/models/schedule.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import Any, cast +import warnings +from typing import Any +, cast from pydantic import ConfigDict, Field, computed_field, model_validator @@ -125,9 +127,7 @@ def enabled(self) -> bool: return self.reservation_use == 2 @classmethod - def from_dict(cls, data: dict[str, Any]) -> ReservationSchedule: - """Construct from a raw MQTT response dict.""" - return cls.model_validate(data) + ) class WeeklyReservationEntry(NavienBaseModel): @@ -242,9 +242,7 @@ def enabled(self) -> bool: return self.reservation_use == 2 @classmethod - def from_dict(cls, data: dict[str, Any]) -> WeeklyReservationSchedule: - """Construct from a raw MQTT response dict.""" - return cls.model_validate(data) + ) class RecirculationScheduleEntry(NavienBaseModel): @@ -325,9 +323,7 @@ class RecirculationSchedule(NavienBaseModel): schedule: list[RecirculationScheduleEntry] = Field(default_factory=list) @classmethod - def from_dict(cls, data: dict[str, Any]) -> RecirculationSchedule: - """Construct from a raw MQTT response dict.""" - return cls.model_validate(data) + ) class OtaCommitPayload(NavienBaseModel): @@ -348,7 +344,3 @@ class OtaCommitPayload(NavienBaseModel): model_config = ConfigDict( alias_generator=None, - populate_by_name=True, - extra="ignore", - use_enum_values=False, - ) diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index 1cd410d..cb1d0a1 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import Annotated, Any from pydantic import BeforeValidator, Field, computed_field @@ -920,8 +921,3 @@ def get_field_unit(self, field_name: str) -> str: return f" {unit}" if unit else "" return "" - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> DeviceStatus: - """Compatibility method for existing code.""" - return cls.model_validate(data) diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 49a7f69..5a8ddc0 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -15,6 +15,7 @@ import json import logging import uuid +import warnings from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any, cast @@ -203,11 +204,17 @@ def __init__( # Command queue (independent, can be created immediately) self._command_queue = MqttCommandQueue(config=self.config) + # Device controller (independent of connection status, uses client.publish for queuing) + self._device_controller = MqttDeviceController( + client_id=self.config.client_id or "", + session_id=self._session_id, + publish_func=self.publish, + ) + # Components that depend on connection (initialized in connect()) self._connection_manager: MqttConnection | None = None self._reconnection_handler: MqttReconnectionHandler | None = None self._subscription_manager: MqttSubscriptionManager | None = None - self._device_controller: MqttDeviceController | None = None self._reconnect_task: asyncio.Task[None] | None = None self._periodic_manager: MqttPeriodicRequestManager | None = None @@ -584,13 +591,8 @@ async def connect(self) -> bool: device_info_cache=device_info_cache, ) - # Initialize device controller with cache - self._device_controller = MqttDeviceController( - client_id=client_id, - session_id=self._session_id, - publish_func=self._connection_manager.publish, - device_info_cache=device_info_cache, - ) + # Update device controller cache + self._device_controller.device_info_cache = device_info_cache # Set the auto-request callback on the controller # Wrap ensure_device_info_cached to match callback signature @@ -902,6 +904,16 @@ async def subscribe_device_status( "subscribe_device_status", device, callback ) + async def unsubscribe_device_status( + self, device: Device, callback: Callable[[DeviceStatus], None] + ) -> None: + """Unsubscribe a specific device status callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_device_status( + device, callback + ) + async def subscribe_device_feature( self, device: Device, callback: Callable[[DeviceFeature], None] ) -> int: @@ -930,6 +942,18 @@ async def subscribe_energy_usage( "subscribe_energy_usage", device, callback ) + async def unsubscribe_energy_usage( + self, + device: Device, + callback: Callable[[EnergyUsageResponse], None], + ) -> None: + """Unsubscribe a specific energy usage callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_energy_usage( + device, callback + ) + async def subscribe_reservation_response( self, device: Device, @@ -940,6 +964,18 @@ async def subscribe_reservation_response( "subscribe_reservation_response", device, callback ) + async def unsubscribe_reservation_response( + self, + device: Device, + callback: Callable[[ReservationSchedule], None], + ) -> None: + """Unsubscribe a specific reservation response callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_reservation_response( + device, callback + ) + async def subscribe_weekly_reservation_response( self, device: Device, @@ -950,6 +986,18 @@ async def subscribe_weekly_reservation_response( "subscribe_weekly_reservation_response", device, callback ) + async def unsubscribe_weekly_reservation_response( + self, + device: Device, + callback: Callable[[WeeklyReservationSchedule], None], + ) -> None: + """Unsubscribe a specific weekly reservation callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_weekly_reservation_response( + device, callback + ) + async def subscribe_recirculation_schedule_response( self, device: Device, @@ -960,43 +1008,55 @@ async def subscribe_recirculation_schedule_response( "subscribe_recirculation_schedule_response", device, callback ) + async def unsubscribe_recirculation_schedule_response( + self, + device: Device, + callback: Callable[[RecirculationSchedule], None], + ) -> None: + """Unsubscribe a specific recirculation schedule callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_recirculation_schedule_response( + device, callback + ) + # ------------------------------------------------------------------------- # Device control proxies (delegate to self.control) # ------------------------------------------------------------------------- async def request_device_status(self, device: Device) -> int: """Request general device status.""" - return await self.control.request_device_status(device) + return await self._device_controller.request_device_status(device) async def request_device_info(self, device: Device) -> int: """Request device information (features, firmware, etc.).""" - return await self.control.request_device_info(device) + return await self._device_controller.request_device_info(device) async def set_power(self, device: Device, power_on: bool) -> int: """Turn device on or off.""" - return await self.control.set_power(device, power_on) + return await self._device_controller.set_power(device, power_on) async def set_dhw_mode( self, device: Device, mode_id: int, vacation_days: int | None = None ) -> int: """Set DHW operation mode.""" - return await self.control.set_dhw_mode(device, mode_id, vacation_days) + return await self._device_controller.set_dhw_mode(device, mode_id, vacation_days) async def enable_anti_legionella( self, device: Device, period_days: int ) -> int: """Enable Anti-Legionella disinfection.""" - return await self.control.enable_anti_legionella(device, period_days) + return await self._device_controller.enable_anti_legionella(device, period_days) async def disable_anti_legionella(self, device: Device) -> int: """Disable the Anti-Legionella disinfection cycle.""" - return await self.control.disable_anti_legionella(device) + return await self._device_controller.disable_anti_legionella(device) async def set_dhw_temperature( self, device: Device, temperature: float ) -> int: """Set DHW target temperature in the user's preferred unit.""" - return await self.control.set_dhw_temperature(device, temperature) + return await self._device_controller.set_dhw_temperature(device, temperature) async def update_reservations( self, @@ -1006,13 +1066,13 @@ async def update_reservations( enabled: bool = True, ) -> int: """Update programmed reservations.""" - return await self.control.update_reservations( + return await self._device_controller.update_reservations( device, reservations, enabled=enabled ) async def request_reservations(self, device: Device) -> int: """Request the current reservation program from the device.""" - return await self.control.request_reservations(device) + return await self._device_controller.request_reservations(device) async def configure_tou_schedule( self, @@ -1023,7 +1083,7 @@ async def configure_tou_schedule( enabled: bool = True, ) -> int: """Configure the Time-of-Use rate schedule.""" - return await self.control.configure_tou_schedule( + return await self._device_controller.configure_tou_schedule( device, controller_serial_number, periods, enabled=enabled ) @@ -1031,103 +1091,103 @@ async def request_tou_settings( self, device: Device, controller_serial_number: str ) -> int: """Request the current TOU settings from the device.""" - return await self.control.request_tou_settings( + return await self._device_controller.request_tou_settings( device, controller_serial_number ) async def set_tou_enabled(self, device: Device, enabled: bool) -> int: """Enable or disable Time-of-Use optimization.""" - return await self.control.set_tou_enabled(device, enabled) + return await self._device_controller.set_tou_enabled(device, enabled) async def request_energy_usage( self, device: Device, year: int, months: list[int] ) -> int: """Request daily energy usage data for specified month(s).""" - return await self.control.request_energy_usage(device, year, months) + return await self._device_controller.request_energy_usage(device, year, months) async def signal_app_connection(self, device: Device) -> int: """Signal that the app has connected.""" - return await self.control.signal_app_connection(device) + return await self._device_controller.signal_app_connection(device) async def enable_demand_response(self, device: Device) -> int: """Enable utility demand response participation.""" - return await self.control.enable_demand_response(device) + return await self._device_controller.enable_demand_response(device) async def disable_demand_response(self, device: Device) -> int: """Disable utility demand response participation.""" - return await self.control.disable_demand_response(device) + return await self._device_controller.disable_demand_response(device) async def reset_air_filter(self, device: Device) -> int: """Reset air filter maintenance timer.""" - return await self.control.reset_air_filter(device) + return await self._device_controller.reset_air_filter(device) async def set_vacation_days(self, device: Device, days: int) -> int: """Set vacation/away mode duration (1-30 days).""" - return await self.control.set_vacation_days(device, days) + return await self._device_controller.set_vacation_days(device, days) async def update_weekly_reservation( self, device: Device, schedule: WeeklyReservationSchedule ) -> int: """Configure the weekly temperature reservation schedule.""" - return await self.control.update_weekly_reservation(device, schedule) + return await self._device_controller.update_weekly_reservation(device, schedule) async def configure_reservation_water_program(self, device: Device) -> int: """Enable/configure water program reservation mode.""" - return await self.control.configure_reservation_water_program(device) + return await self._device_controller.configure_reservation_water_program(device) async def configure_recirculation_schedule( self, device: Device, schedule: RecirculationSchedule ) -> int: """Configure the recirculation pump timed schedule.""" - return await self.control.configure_recirculation_schedule( + return await self._device_controller.configure_recirculation_schedule( device, schedule ) async def set_recirculation_mode(self, device: Device, mode: int) -> int: """Set recirculation pump operation mode (1-4).""" - return await self.control.set_recirculation_mode(device, mode) + return await self._device_controller.set_recirculation_mode(device, mode) async def trigger_recirculation_hot_button(self, device: Device) -> int: """Manually trigger the recirculation pump hot button.""" - return await self.control.trigger_recirculation_hot_button(device) + return await self._device_controller.trigger_recirculation_hot_button(device) async def check_firmware_update(self, device: Device) -> int: """Check for available over-the-air firmware updates.""" - return await self.control.check_firmware_update(device) + return await self._device_controller.check_firmware_update(device) async def commit_firmware_update( self, device: Device, payload: OtaCommitPayload ) -> int: """Commit a previously downloaded firmware update.""" - return await self.control.commit_firmware_update(device, payload) + return await self._device_controller.commit_firmware_update(device, payload) async def reconnect_wifi(self, device: Device) -> int: """Trigger a WiFi reconnection on the device.""" - return await self.control.reconnect_wifi(device) + return await self._device_controller.reconnect_wifi(device) async def reset_wifi(self, device: Device) -> int: """Reset WiFi settings to factory defaults.""" - return await self.control.reset_wifi(device) + return await self._device_controller.reset_wifi(device) async def set_freeze_protection_temperature( self, device: Device, temperature: float ) -> int: """Set the freeze protection activation temperature.""" - return await self.control.set_freeze_protection_temperature( + return await self._device_controller.set_freeze_protection_temperature( device, temperature ) async def run_smart_diagnostic(self, device: Device) -> int: """Trigger the smart diagnostic routine on the device.""" - return await self.control.run_smart_diagnostic(device) + return await self._device_controller.run_smart_diagnostic(device) async def enable_intelligent_scheduling(self, device: Device) -> int: """Enable intelligent/adaptive heating mode.""" - return await self.control.enable_intelligent_scheduling(device) + return await self._device_controller.enable_intelligent_scheduling(device) async def disable_intelligent_scheduling(self, device: Device) -> int: """Disable intelligent/adaptive heating mode.""" - return await self.control.disable_intelligent_scheduling(device) + return await self._device_controller.disable_intelligent_scheduling(device) async def ensure_device_info_cached( self, device: Device, timeout: float = 30.0 @@ -1173,7 +1233,7 @@ def on_feature(feature: DeviceFeature) -> None: await self.subscribe_device_feature(device, on_feature) try: _logger.info(f"Requesting device info from {redacted_mac}") - await self.control.request_device_info(device) + await self._device_controller.request_device_info(device) _logger.info(f"Waiting for device feature (timeout={timeout}s)") feature = await asyncio.wait_for(future, timeout=timeout) # Cache the feature immediately @@ -1190,22 +1250,24 @@ def on_feature(feature: DeviceFeature) -> None: await self.unsubscribe_device_feature(device, on_feature) @property - def control(self) -> MqttDeviceController: + def _control(self) -> MqttDeviceController: """ - Get the device controller for sending commands. - - The control property enforces that the client must be connected before - accessing any control methods. This is by design to ensure device - commands are only sent when MQTT connection is established and active. - Commands like request_device_info that populate the cache are not - accessible through this property and must be called separately if - needed before connection is fully established. + Get the internal device controller for sending commands. - Raises: - MqttNotConnectedError: If client is not connected + Note: + This property is now internal. Use the delegated methods on + NavienMqttClient directly for device control. """ - if not self._connected or not self._device_controller: - raise MqttNotConnectedError("Not connected to MQTT broker") + return self._device_controller + + @property + @warnings.deprecated( + "The .control attribute is deprecated and will be removed in v9.0.0. " + "Use the delegated methods on NavienMqttClient directly (e.g., " + "client.set_power() instead of client.control.set_power())." + ) + def control(self) -> MqttDeviceController: + """Deprecated access to device controller.""" return self._device_controller async def start_periodic_requests( diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 604b653..dbcdf82 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -104,6 +104,11 @@ def device_info_cache(self) -> "MqttDeviceInfoCache": """Get the device info cache.""" return self._device_info_cache + @device_info_cache.setter + def device_info_cache(self, cache: "MqttDeviceInfoCache") -> None: + """Set the device info cache.""" + self._device_info_cache = cache + async def _ensure_device_info_cached( self, device: Device, timeout: float = 5.0 ) -> None: diff --git a/src/nwp500/mqtt/state_tracker.py b/src/nwp500/mqtt/state_tracker.py index 3b56434..d9a357e 100644 --- a/src/nwp500/mqtt/state_tracker.py +++ b/src/nwp500/mqtt/state_tracker.py @@ -66,8 +66,8 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: prev = self._previous_status[device_mac] try: - # Temperature change - if status.dhw_temperature != prev.dhw_temperature: + # Temperature change (compare raw values) + if status.dhw_temperature_raw != prev.dhw_temperature_raw: await self._event_emitter.emit( "temperature_changed", TemperatureChangedEvent( @@ -84,7 +84,7 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: unit_suffix, ) - # Operation mode change + # Operation mode change (compare raw values) if status.operation_mode != prev.operation_mode: await self._event_emitter.emit( "mode_changed", @@ -99,8 +99,8 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: status.operation_mode, ) - # Power consumption change - if status.current_inst_power != prev.current_inst_power: + # Power consumption change (compare raw values) + if status.current_inst_power_raw != prev.current_inst_power_raw: await self._event_emitter.emit( "power_changed", PowerChangedEvent( @@ -114,9 +114,9 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: status.current_inst_power, ) - # Heating started / stopped - prev_heating = prev.current_inst_power > 0 - curr_heating = status.current_inst_power > 0 + # Heating started / stopped (compare raw values) + prev_heating = prev.current_inst_power_raw > 0 + curr_heating = status.current_inst_power_raw > 0 if curr_heating and not prev_heating: await self._event_emitter.emit( diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index f2fd91a..786d607 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -173,6 +173,20 @@ async def subscribe( if not self._connection: raise MqttNotConnectedError("Not connected to MQTT broker") + # Track handler first + if topic not in self._message_handlers: + self._message_handlers[topic] = [] + if callback not in self._message_handlers[topic]: + self._message_handlers[topic].append(callback) + + # Check if already subscribed to this topic at the broker level + if topic in self._subscriptions: + # Already subscribed. If requested QoS is higher than current, + # we should upgrade, but standard practice is to just return. + # Most brokers handle multiple overlapping subscriptions. + # Return a synthetic packet ID (0) as we didn't send a request. + return 0 + _logger.info(f"Subscribing to topic: {redact_topic(topic)}") try: @@ -201,30 +215,41 @@ async def subscribe( f"{subscribe_result['qos']}" ) - # Store subscription and handler + # Store subscription self._subscriptions[topic] = qos - if topic not in self._message_handlers: - self._message_handlers[topic] = [] - if callback not in self._message_handlers[topic]: - self._message_handlers[topic].append(callback) return int(packet_id) except (AwsCrtError, RuntimeError) as e: + # Clean up handler on failure if this was the first one + if topic in self._message_handlers and callback in self._message_handlers[topic]: + self._message_handlers[topic].remove(callback) _logger.error( f"Failed to subscribe to '{redact_topic(topic)}': {e}" ) raise - async def unsubscribe(self, topic: str) -> int: + async def unsubscribe( + self, + topic: str, + callback: Callable[[str, dict[str, Any]], None] | None = None, + ) -> int: """ Unsubscribe from an MQTT topic. + If a callback is provided, only that specific handler is removed. + The underlying MQTT unsubscribe from the broker is only performed + if no handlers remain for the topic. + + If no callback is provided, all handlers are removed and the broker + is unsubscribed immediately. + Args: topic: MQTT topic to unsubscribe from + callback: Optional specific handler to remove Returns: - Unsubscribe packet ID + Unsubscribe packet ID (or 0 if no broker call was made) Raises: RuntimeError: If not connected to MQTT broker @@ -233,12 +258,19 @@ async def unsubscribe(self, topic: str) -> int: if not self._connection: raise MqttNotConnectedError("Not connected to MQTT broker") - # Redact topic for logging to avoid leaking sensitive information - # (device IDs). We perform this check early to ensure we don't log raw - # topics. - # Note: CodeQL flags log calls using the topic variable (even redacted) - # as a security risk ("Clear-text logging of sensitive information"). - # To pass CI, we must use a generic message here. + if topic not in self._message_handlers: + return 0 + + if callback is not None: + # Remove specific handler + if callback in self._message_handlers[topic]: + self._message_handlers[topic].remove(callback) + + # If handlers still exist, don't unsubscribe from broker yet + if self._message_handlers[topic]: + return 0 + + # No callback provided or no handlers left: unsubscribe from broker _logger.info("Unsubscribing from topic (redacted)") try: @@ -393,6 +425,24 @@ def post_parse(status: DeviceStatus) -> None: ) return await self.subscribe_device(device=device, callback=handler) + async def unsubscribe_device_status( + self, device: Device, callback: Callable[[DeviceStatus], None] + ) -> None: + """Unsubscribe a specific device status callback.""" + device_id = device.device_info.mac_address + device_type = str(device.device_info.device_type) + topic = MqttTopicBuilder.command_topic(device_type, device_id, "#") + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + def _make_handler( self, model: Any, @@ -412,7 +462,7 @@ def handler(topic: str, message: dict[str, Any]) -> None: if not data: return - parsed = model.from_dict(data) + parsed = model.model_validate(data) if post_parse: post_parse(parsed) callback(parsed) @@ -465,19 +515,15 @@ async def unsubscribe_device_feature( if topic not in self._message_handlers: return - # Find and remove the specific handler - handlers = self._message_handlers[topic] - handlers_to_remove = [] - for h in handlers: + # Find the specific internal handler that wraps this callback + target_handler = None + for h in self._message_handlers[topic]: if getattr(h, "_original_callback", None) == callback: - handlers_to_remove.append(h) - - for h in handlers_to_remove: - handlers.remove(h) + target_handler = h + break - # If no handlers left, unsubscribe from MQTT - if not handlers: - await self.unsubscribe(topic) + if target_handler: + await self.unsubscribe(topic, target_handler) async def subscribe_energy_usage( self, @@ -493,6 +539,28 @@ async def subscribe_energy_usage( ) return await self.subscribe(topic, handler) + async def unsubscribe_energy_usage( + self, + device: Device, + callback: Callable[[EnergyUsageResponse], None], + ) -> None: + """Unsubscribe a specific energy usage callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "energy-usage-daily-query/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + async def subscribe_reservation_response( self, device: Device, @@ -520,6 +588,28 @@ async def subscribe_reservation_response( ) return await self.subscribe(topic, handler) + async def unsubscribe_reservation_response( + self, + device: Device, + callback: Callable[[ReservationSchedule], None], + ) -> None: + """Unsubscribe a specific reservation response callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "rsv/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + async def subscribe_weekly_reservation_response( self, device: Device, @@ -547,6 +637,28 @@ async def subscribe_weekly_reservation_response( ) return await self.subscribe(topic, handler) + async def unsubscribe_weekly_reservation_response( + self, + device: Device, + callback: Callable[[WeeklyReservationSchedule], None], + ) -> None: + """Unsubscribe a specific weekly reservation callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "rsv-weekly/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + async def subscribe_recirculation_schedule_response( self, device: Device, @@ -573,6 +685,28 @@ async def subscribe_recirculation_schedule_response( ) return await self.subscribe(topic, handler) + async def unsubscribe_recirculation_schedule_response( + self, + device: Device, + callback: Callable[[RecirculationSchedule], None], + ) -> None: + """Unsubscribe a specific recirculation schedule callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "recirc-rsv/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + def clear_subscriptions(self) -> None: """Clear all subscription tracking (called on disconnect).""" self._subscriptions.clear() diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py index d17fab2..f719892 100644 --- a/src/nwp500/reservations.py +++ b/src/nwp500/reservations.py @@ -70,17 +70,12 @@ def on_schedule(schedule: ReservationSchedule) -> None: except TimeoutError: return None finally: - from .topic_builder import MqttTopicBuilder - - response_topic = MqttTopicBuilder.response_topic( - device_type, mqtt.client_id, "rsv/rd" - ) try: - await mqtt.unsubscribe(response_topic) + await mqtt.unsubscribe_reservation_response(device, on_schedule) except Exception: _logger.warning( - "Failed to unsubscribe reservations response handler for %s", - response_topic, + "Failed to unsubscribe reservations response handler for device %s", + device.device_info.mac_address, exc_info=True, ) diff --git a/tests/test_auth.py b/tests/test_auth.py index bf4b58c..7ad9a8f 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -64,8 +64,8 @@ def test_user_info_full_name_with_empty_names(): assert user_info.full_name == "Doe" -def test_user_info_from_dict(): - """Test UserInfo.from_dict class method.""" +def test_user_info_model_validate_validate(): + """Test UserInfo.model_validate class method.""" data = { "userType": "premium", "userFirstName": "Jane", @@ -74,7 +74,7 @@ def test_user_info_from_dict(): "userSeq": 456, } - user_info = UserInfo.from_dict(data) + user_info = UserInfo.model_validate(data) assert user_info.user_type == "premium" assert user_info.user_first_name == "Jane" @@ -83,11 +83,11 @@ def test_user_info_from_dict(): assert user_info.user_seq == 456 -def test_user_info_from_dict_with_missing_fields(): - """Test UserInfo.from_dict with missing fields.""" +def test_user_info_model_validate_validate_with_missing_fields(): + """Test UserInfo.model_validate with missing fields.""" data = {} - user_info = UserInfo.from_dict(data) + user_info = UserInfo.model_validate(data) assert user_info.user_type == "" assert user_info.user_first_name == "" @@ -250,8 +250,8 @@ def test_auth_tokens_bearer_token(): assert tokens.bearer_token == "Bearer my_access_token" -def test_auth_tokens_from_dict(): - """Test AuthTokens.from_dict class method.""" +def test_auth_tokens_model_validate_validate(): + """Test AuthTokens.model_validate class method.""" data = { "idToken": "test_id", "accessToken": "test_access", @@ -263,7 +263,7 @@ def test_auth_tokens_from_dict(): "authorizationExpiresIn": 1800, } - tokens = AuthTokens.from_dict(data) + tokens = AuthTokens.model_validate(data) assert tokens.id_token == "test_id" assert tokens.access_token == "test_access" @@ -275,11 +275,11 @@ def test_auth_tokens_from_dict(): assert tokens.authorization_expires_in == 1800 -def test_auth_tokens_from_dict_minimal(): - """Test AuthTokens.from_dict with minimal data.""" +def test_auth_tokens_model_validate_validate_minimal(): + """Test AuthTokens.model_validate with minimal data.""" data = {} - tokens = AuthTokens.from_dict(data) + tokens = AuthTokens.model_validate(data) assert tokens.id_token == "" assert tokens.access_token == "" @@ -292,8 +292,8 @@ def test_auth_tokens_from_dict_minimal(): # Test AuthenticationResponse dataclass -def test_authentication_response_from_dict(): - """Test AuthenticationResponse.from_dict class method.""" +def test_authentication_response_model_validate_validate(): + """Test AuthenticationResponse.model_validate class method.""" data = { "code": 200, "msg": "SUCCESS", @@ -315,7 +315,7 @@ def test_authentication_response_from_dict(): }, } - response = AuthenticationResponse.from_dict(data) + response = AuthenticationResponse.model_validate(data) assert response.code == 200 assert response.message == "SUCCESS" @@ -869,8 +869,8 @@ def test_auth_tokens_to_dict(): assert result["issued_at"] == expected_issued_at -def test_auth_tokens_from_dict_with_issued_at(): - """Test AuthTokens.from_dict with issued_at timestamp.""" +def test_auth_tokens_model_validate_validate_with_issued_at(): + """Test AuthTokens.model_validate with issued_at timestamp.""" issued_at = datetime.now(UTC) - timedelta(seconds=1800) data = { "id_token": "test_id", @@ -884,7 +884,7 @@ def test_auth_tokens_from_dict_with_issued_at(): "issued_at": issued_at.isoformat(), } - tokens = AuthTokens.from_dict(data) + tokens = AuthTokens.model_validate(data) assert tokens.id_token == "test_id" assert tokens.access_token == "test_access" @@ -915,7 +915,7 @@ def test_auth_tokens_serialization_roundtrip(): # Serialize and deserialize serialized = original.to_dict() - restored = AuthTokens.from_dict(serialized) + restored = AuthTokens.model_validate(serialized) # Verify all fields match assert restored.id_token == original.id_token @@ -937,8 +937,8 @@ def test_auth_tokens_serialization_roundtrip(): assert restored.is_expired == original.is_expired -def test_auth_tokens_from_dict_with_empty_strings(): - """Test AuthTokens.from_dict handles empty strings in camelCase.""" +def test_auth_tokens_model_validate_validate_with_empty_strings(): + """Test AuthTokens.model_validate handles empty strings in camelCase.""" # Simulate API response with empty optional fields (camelCase) # Should fall back to snake_case alternatives data = { @@ -955,7 +955,7 @@ def test_auth_tokens_from_dict_with_empty_strings(): "secret_key": "fallback_secret", } - tokens = AuthTokens.from_dict(data) + tokens = AuthTokens.model_validate(data) assert tokens.id_token == "test_id" assert tokens.access_token == "fallback_access" # Should use snake_case diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index feb1f46..bbe9ab7 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -28,11 +28,11 @@ def mock_device(): def mock_mqtt(): mqtt = MagicMock() # Control attribute contains device control methods - mqtt.control = MagicMock() - mqtt.control.request_device_info = AsyncMock() - mqtt.control.request_device_status = AsyncMock() - mqtt.control.set_dhw_mode = AsyncMock() - mqtt.control.set_dhw_temperature = AsyncMock() + + mqtt.request_device_info = AsyncMock() + mqtt.request_device_status = AsyncMock() + mqtt.set_dhw_mode = AsyncMock() + mqtt.set_dhw_temperature = AsyncMock() # Async methods on mqtt itself mqtt.subscribe_device_feature = AsyncMock() @@ -59,7 +59,7 @@ async def side_effect_subscribe(device, callback): ) assert serial == "TEST_SERIAL_123" - mock_mqtt.control.request_device_info.assert_called_once_with(mock_device) + mock_mqtt.request_device_info.assert_called_once_with(mock_device) @pytest.mark.asyncio @@ -74,7 +74,7 @@ async def test_get_controller_serial_number_timeout(mock_mqtt, mock_device): ) assert serial is None - mock_mqtt.control.request_device_info.assert_called_once_with(mock_device) + mock_mqtt.request_device_info.assert_called_once_with(mock_device) @pytest.mark.asyncio @@ -91,7 +91,7 @@ async def side_effect_subscribe(device, callback): await handle_status_request(mock_mqtt, mock_device) - mock_mqtt.control.request_device_status.assert_called_once_with(mock_device) + mock_mqtt.request_device_status.assert_called_once_with(mock_device) captured = capsys.readouterr() # Check for human-readable format output assert "DEVICE STATUS" in captured.out @@ -118,7 +118,7 @@ async def side_effect_subscribe(device, callback): await handle_set_mode_request(mock_mqtt, mock_device, "heat-pump") # 1 = Heat Pump - mock_mqtt.control.set_dhw_mode.assert_called_once_with(mock_device, 1) + mock_mqtt.set_dhw_mode.assert_called_once_with(mock_device, 1) @pytest.mark.asyncio @@ -126,7 +126,7 @@ async def test_handle_set_mode_request_invalid_mode(mock_mqtt, mock_device): """Test setting an invalid mode.""" await handle_set_mode_request(mock_mqtt, mock_device, "invalid-mode") - mock_mqtt.control.set_dhw_mode.assert_not_called() + mock_mqtt.set_dhw_mode.assert_not_called() @pytest.mark.asyncio @@ -144,6 +144,6 @@ async def side_effect_subscribe(device, callback): await handle_set_dhw_temp_request(mock_mqtt, mock_device, 120.0) - mock_mqtt.control.set_dhw_temperature.assert_called_once_with( + mock_mqtt.set_dhw_temperature.assert_called_once_with( mock_device, 120.0 ) From 7d8a7358e1bb3e7c187dddb0d92adddf34ff71b3 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 16:15:05 -0700 Subject: [PATCH 13/29] chore: fix CI issues, linting, and type safety - Fix syntax errors and truncated code in src/nwp500/models/schedule.py - Add 'from __future__ import annotations' to all source files to fix runtime NameErrors - Resolve all ruff linting and formatting issues (E501, F401, etc.) - Fix pyright type-checking errors in AWS credential handling and model methods - Update tests/test_reservations.py to match high-level MQTT client API - Restore AuthTokens.to_dict() for compatibility --- examples/advanced/recirculation_control.py | 4 +- src/nwp500/__init__.py | 2 + src/nwp500/auth.py | 20 ++++++- src/nwp500/cli/__init__.py | 2 + src/nwp500/cli/__main__.py | 2 + src/nwp500/cli/commands.py | 2 + src/nwp500/cli/handlers.py | 6 +-- src/nwp500/cli/monitoring.py | 6 +-- src/nwp500/cli/output_formatters.py | 2 + src/nwp500/cli/rich_output.py | 2 + src/nwp500/cli/token_storage.py | 2 + src/nwp500/command_decorators.py | 2 + src/nwp500/config.py | 2 + src/nwp500/device_capabilities.py | 10 ++-- src/nwp500/device_info_cache.py | 8 +-- src/nwp500/encoding.py | 2 + src/nwp500/enums.py | 2 + src/nwp500/events.py | 2 + src/nwp500/exceptions.py | 2 + src/nwp500/factory.py | 2 + src/nwp500/models/energy.py | 3 -- src/nwp500/models/feature.py | 4 +- src/nwp500/models/schedule.py | 16 ++---- src/nwp500/models/status.py | 3 +- src/nwp500/mqtt/__init__.py | 2 + src/nwp500/mqtt/client.py | 61 +++++++++++++++------- src/nwp500/mqtt/connection.py | 20 ++++--- src/nwp500/mqtt/control.py | 6 ++- src/nwp500/mqtt/subscriptions.py | 4 +- src/nwp500/mqtt_events.py | 20 +++---- src/nwp500/reservations.py | 4 +- src/nwp500/topic_builder.py | 2 + src/nwp500/utils.py | 2 + tests/test_cli_commands.py | 6 +-- tests/test_reservations.py | 11 ++-- 35 files changed, 160 insertions(+), 86 deletions(-) diff --git a/examples/advanced/recirculation_control.py b/examples/advanced/recirculation_control.py index b4cffa6..e2f8a36 100644 --- a/examples/advanced/recirculation_control.py +++ b/examples/advanced/recirculation_control.py @@ -119,9 +119,7 @@ def on_button_only_set(status): button_only_set = True await mqtt_client.subscribe_device_status(device, on_button_only_set) - await mqtt_client.set_recirculation_mode( - device, 2 - ) # 2 = Button Only + await mqtt_client.set_recirculation_mode(device, 2) # 2 = Button Only # Wait for confirmation for i in range(10): # Wait up to 10 seconds diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 48a1400..a49a425 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -4,6 +4,8 @@ communication for NWP500 heat pump water heaters. """ +from __future__ import annotations + from importlib.metadata import ( PackageNotFoundError, version, diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index 596c599..b182c86 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -15,7 +15,6 @@ import json import logging -import warnings from datetime import UTC, datetime, timedelta from typing import Any, Self, cast @@ -182,6 +181,21 @@ def bearer_token(self) -> str: """Get the formatted Bearer token for Authorization header.""" return f"Bearer {self.access_token}" + def to_dict(self) -> dict[str, Any]: + """Convert tokens to a dictionary for serialization. + + This includes the calculated issued_at timestamp, which is needed + to maintain the correct expiration time when restoring tokens. + """ + data = self.model_dump() + # Ensure issued_at is serialized in a format that model_validate can + # parse + if isinstance(data.get("issued_at"), datetime): + data["issued_at"] = ( + data["issued_at"].strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z" + ) + return data + class AuthenticationResponse(NavienBaseModel): """Complete authentication response including user info and tokens.""" @@ -428,7 +442,9 @@ async def sign_in( ) # Parse successful response - auth_response = AuthenticationResponse.model_validate(response_data) + auth_response = AuthenticationResponse.model_validate( + response_data + ) self._auth_response = auth_response self._user_email = user_id # Store the email for later use diff --git a/src/nwp500/cli/__init__.py b/src/nwp500/cli/__init__.py index ca5d623..62ad37c 100644 --- a/src/nwp500/cli/__init__.py +++ b/src/nwp500/cli/__init__.py @@ -1,5 +1,7 @@ """CLI package for nwp500-python.""" +from __future__ import annotations + from .__main__ import run from .handlers import ( handle_device_info_request, diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index 369491d..3d20691 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -1,5 +1,7 @@ """Navien Water Heater Control CLI - Main Entry Point.""" +from __future__ import annotations + import asyncio import functools import logging diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py index 5e3f5b8..54febc2 100644 --- a/src/nwp500/cli/commands.py +++ b/src/nwp500/cli/commands.py @@ -1,5 +1,7 @@ """Command registry for NWP500 CLI.""" +from __future__ import annotations + from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index 60a45a1..2f327b2 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -1,5 +1,7 @@ """Command handlers for CLI operations.""" +from __future__ import annotations + import asyncio import json import logging @@ -328,9 +330,7 @@ def raw_callback(topic: str, message: dict[str, Any]) -> None: device_type, mqtt.client_id, "rsv/rd" ) await mqtt.subscribe(response_topic, raw_callback) - await mqtt.update_reservations( - device, reservations, enabled=enabled - ) + await mqtt.update_reservations(device, reservations, enabled=enabled) try: await asyncio.wait_for(future, timeout=10) except TimeoutError: diff --git a/src/nwp500/cli/monitoring.py b/src/nwp500/cli/monitoring.py index 1ca1271..51c9086 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -1,5 +1,7 @@ """Monitoring and periodic status polling.""" +from __future__ import annotations + import asyncio import logging @@ -41,9 +43,7 @@ def on_status_update(status: DeviceStatus) -> None: await mqtt.subscribe_device_status(device, on_status_update) await mqtt.start_periodic_requests(device, period_seconds=30) - await mqtt.request_device_status( - device - ) # Get an initial status right away + await mqtt.request_device_status(device) # Get an initial status right away # Keep the script running indefinitely await asyncio.Event().wait() diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index ee28df0..70c65c4 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -1,5 +1,7 @@ """Output formatting utilities for CLI (CSV, JSON).""" +from __future__ import annotations + import csv import json import logging diff --git a/src/nwp500/cli/rich_output.py b/src/nwp500/cli/rich_output.py index 426b420..c9099f3 100644 --- a/src/nwp500/cli/rich_output.py +++ b/src/nwp500/cli/rich_output.py @@ -1,5 +1,7 @@ """Rich-enhanced output formatting with graceful fallback.""" +from __future__ import annotations + import json import logging import os diff --git a/src/nwp500/cli/token_storage.py b/src/nwp500/cli/token_storage.py index ddfdd6a..4b5b763 100644 --- a/src/nwp500/cli/token_storage.py +++ b/src/nwp500/cli/token_storage.py @@ -1,5 +1,7 @@ """Token storage and management for CLI authentication.""" +from __future__ import annotations + import json import logging from pathlib import Path diff --git a/src/nwp500/command_decorators.py b/src/nwp500/command_decorators.py index 4395212..56ee072 100644 --- a/src/nwp500/command_decorators.py +++ b/src/nwp500/command_decorators.py @@ -4,6 +4,8 @@ before command execution, preventing unsupported commands from being sent. """ +from __future__ import annotations + import functools import inspect import logging diff --git a/src/nwp500/config.py b/src/nwp500/config.py index 97d825d..fa22ad7 100644 --- a/src/nwp500/config.py +++ b/src/nwp500/config.py @@ -1,5 +1,7 @@ """Configuration for the Navien API client.""" +from __future__ import annotations + API_BASE_URL = "https://nlus.naviensmartcontrol.com/api/v2.1" SIGN_IN_ENDPOINT = "/user/sign-in" REFRESH_ENDPOINT = "/auth/refresh" diff --git a/src/nwp500/device_capabilities.py b/src/nwp500/device_capabilities.py index 06f48c8..d907ddf 100644 --- a/src/nwp500/device_capabilities.py +++ b/src/nwp500/device_capabilities.py @@ -6,6 +6,8 @@ individual checker functions. """ +from __future__ import annotations + from collections.abc import Callable from typing import TYPE_CHECKING @@ -48,7 +50,7 @@ class MqttDeviceCapabilityChecker: } @classmethod - def supports(cls, feature: str, device_features: "DeviceFeature") -> bool: + def supports(cls, feature: str, device_features: DeviceFeature) -> bool: """Check if device supports control of a specific feature. Args: @@ -71,7 +73,7 @@ def supports(cls, feature: str, device_features: "DeviceFeature") -> bool: @classmethod def assert_supported( - cls, feature: str, device_features: "DeviceFeature" + cls, feature: str, device_features: DeviceFeature ) -> None: """Assert that device supports control of a feature. @@ -103,7 +105,7 @@ def register_capability( @classmethod def get_available_controls( - cls, device_features: "DeviceFeature" + cls, device_features: DeviceFeature ) -> dict[str, bool]: """Get all controllable features available on a device. @@ -119,7 +121,7 @@ def get_available_controls( } -def _check_dhw_temperature_control(features: "DeviceFeature") -> bool: +def _check_dhw_temperature_control(features: DeviceFeature) -> bool: """Check if device supports DHW temperature control. Returns True if temperature control is enabled (not UNKNOWN or DISABLE). diff --git a/src/nwp500/device_info_cache.py b/src/nwp500/device_info_cache.py index f0e5547..f131f2b 100644 --- a/src/nwp500/device_info_cache.py +++ b/src/nwp500/device_info_cache.py @@ -4,6 +4,8 @@ with automatic periodic updates to keep data synchronized with the device. """ +from __future__ import annotations + import asyncio import logging from datetime import UTC, datetime, timedelta @@ -57,7 +59,7 @@ def __init__(self, update_interval_minutes: int = 30) -> None: self._cache: dict[str, tuple[DeviceFeature, datetime]] = {} self._lock = asyncio.Lock() - async def get(self, device_mac: str) -> "DeviceFeature | None": + async def get(self, device_mac: str) -> DeviceFeature | None: """Get cached device features if available and not expired. Args: @@ -79,7 +81,7 @@ async def get(self, device_mac: str) -> "DeviceFeature | None": return features - async def set(self, device_mac: str, features: "DeviceFeature") -> None: + async def set(self, device_mac: str, features: DeviceFeature) -> None: """Cache device features with current timestamp. Args: @@ -128,7 +130,7 @@ def is_expired(self, timestamp: datetime) -> bool: age = datetime.now(UTC) - timestamp return age > self.update_interval - async def get_all_cached(self) -> dict[str, "DeviceFeature"]: + async def get_all_cached(self) -> dict[str, DeviceFeature]: """Get all currently cached device features. Returns: diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 063708c..5f783dd 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -6,6 +6,8 @@ These utilities are used by both the API client and MQTT client. """ +from __future__ import annotations + from collections.abc import Iterable from numbers import Real diff --git a/src/nwp500/enums.py b/src/nwp500/enums.py index ae72667..7899f07 100644 --- a/src/nwp500/enums.py +++ b/src/nwp500/enums.py @@ -7,6 +7,8 @@ See docs/protocol/quick_reference.rst for comprehensive protocol details. """ +from __future__ import annotations + from enum import IntEnum, StrEnum # ============================================================================ diff --git a/src/nwp500/events.py b/src/nwp500/events.py index 7a2a0f8..5101669 100644 --- a/src/nwp500/events.py +++ b/src/nwp500/events.py @@ -6,6 +6,8 @@ detection. """ +from __future__ import annotations + import asyncio import inspect import logging diff --git a/src/nwp500/exceptions.py b/src/nwp500/exceptions.py index 6344dab..39e0ae6 100644 --- a/src/nwp500/exceptions.py +++ b/src/nwp500/exceptions.py @@ -65,6 +65,8 @@ # handle other validation errors """ +from __future__ import annotations + from typing import Any __author__ = "Emmanuel Levijarvi" diff --git a/src/nwp500/factory.py b/src/nwp500/factory.py index 378b2bd..4852626 100644 --- a/src/nwp500/factory.py +++ b/src/nwp500/factory.py @@ -18,6 +18,8 @@ ... devices = await api.list_devices() """ +from __future__ import annotations + import asyncio from .api_client import NavienAPIClient diff --git a/src/nwp500/models/energy.py b/src/nwp500/models/energy.py index ce44e1b..702499d 100644 --- a/src/nwp500/models/energy.py +++ b/src/nwp500/models/energy.py @@ -1,8 +1,5 @@ from __future__ import annotations -import warnings -from typing import Any - from pydantic import Field from .._base import NavienBaseModel diff --git a/src/nwp500/models/feature.py b/src/nwp500/models/feature.py index 28bd4b5..c83a8b2 100644 --- a/src/nwp500/models/feature.py +++ b/src/nwp500/models/feature.py @@ -1,7 +1,6 @@ from __future__ import annotations -import warnings -from typing import Annotated, Any +from typing import Annotated from pydantic import BeforeValidator, Field, computed_field @@ -404,3 +403,4 @@ def get_field_unit(self, field_name: str) -> str: unit: str = str(unit_val) if unit_val is not None else "" return f" {unit}" if unit else "" + return "" diff --git a/src/nwp500/models/schedule.py b/src/nwp500/models/schedule.py index 0e7aab3..7666d7f 100644 --- a/src/nwp500/models/schedule.py +++ b/src/nwp500/models/schedule.py @@ -1,8 +1,6 @@ from __future__ import annotations -import warnings -from typing import Any -, cast +from typing import Any, cast from pydantic import ConfigDict, Field, computed_field, model_validator @@ -126,9 +124,6 @@ def enabled(self) -> bool: """ return self.reservation_use == 2 - @classmethod - ) - class WeeklyReservationEntry(NavienBaseModel): """A single entry in a weekly temperature reservation schedule. @@ -241,9 +236,6 @@ def enabled(self) -> bool: """ return self.reservation_use == 2 - @classmethod - ) - class RecirculationScheduleEntry(NavienBaseModel): """A single entry in a recirculation pump schedule. @@ -322,9 +314,6 @@ class RecirculationSchedule(NavienBaseModel): schedule: list[RecirculationScheduleEntry] = Field(default_factory=list) - @classmethod - ) - class OtaCommitPayload(NavienBaseModel): """Payload for committing a firmware component update. @@ -344,3 +333,6 @@ class OtaCommitPayload(NavienBaseModel): model_config = ConfigDict( alias_generator=None, + populate_by_name=True, + extra="ignore", + ) diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index cb1d0a1..efde5aa 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -1,7 +1,6 @@ from __future__ import annotations -import warnings -from typing import Annotated, Any +from typing import Annotated from pydantic import BeforeValidator, Field, computed_field diff --git a/src/nwp500/mqtt/__init__.py b/src/nwp500/mqtt/__init__.py index 2a0d205..2a82fd3 100644 --- a/src/nwp500/mqtt/__init__.py +++ b/src/nwp500/mqtt/__init__.py @@ -11,6 +11,8 @@ - MqttMetrics, ConnectionDropEvent, ConnectionEvent: Diagnostic types """ +from __future__ import annotations + from .client import NavienMqttClient from .diagnostics import ( ConnectionDropEvent, diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 5a8ddc0..25a57e3 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -204,7 +204,8 @@ def __init__( # Command queue (independent, can be created immediately) self._command_queue = MqttCommandQueue(config=self.config) - # Device controller (independent of connection status, uses client.publish for queuing) + # Device controller (independent of connection status, + # uses client.publish for queuing) self._device_controller = MqttDeviceController( client_id=self.config.client_id or "", session_id=self._session_id, @@ -695,8 +696,12 @@ def _create_credentials_provider(self) -> Any: # Get current tokens from auth client auth_tokens = self._auth_client.current_tokens - if not auth_tokens: - raise MqttCredentialsError("No tokens available from auth client") + if ( + not auth_tokens + or not auth_tokens.access_key_id + or not auth_tokens.secret_key + ): + raise MqttCredentialsError("AWS credentials not available") return AwsCredentialsProvider.new_static( access_key_id=auth_tokens.access_key_id, @@ -994,9 +999,8 @@ async def unsubscribe_weekly_reservation_response( """Unsubscribe a specific weekly reservation callback.""" if not self._connected or not self._subscription_manager: return - await self._subscription_manager.unsubscribe_weekly_reservation_response( - device, callback - ) + manager = self._subscription_manager + await manager.unsubscribe_weekly_reservation_response(device, callback) async def subscribe_recirculation_schedule_response( self, @@ -1016,7 +1020,8 @@ async def unsubscribe_recirculation_schedule_response( """Unsubscribe a specific recirculation schedule callback.""" if not self._connected or not self._subscription_manager: return - await self._subscription_manager.unsubscribe_recirculation_schedule_response( + manager = self._subscription_manager + await manager.unsubscribe_recirculation_schedule_response( device, callback ) @@ -1040,13 +1045,17 @@ async def set_dhw_mode( self, device: Device, mode_id: int, vacation_days: int | None = None ) -> int: """Set DHW operation mode.""" - return await self._device_controller.set_dhw_mode(device, mode_id, vacation_days) + return await self._device_controller.set_dhw_mode( + device, mode_id, vacation_days + ) async def enable_anti_legionella( self, device: Device, period_days: int ) -> int: """Enable Anti-Legionella disinfection.""" - return await self._device_controller.enable_anti_legionella(device, period_days) + return await self._device_controller.enable_anti_legionella( + device, period_days + ) async def disable_anti_legionella(self, device: Device) -> int: """Disable the Anti-Legionella disinfection cycle.""" @@ -1056,7 +1065,9 @@ async def set_dhw_temperature( self, device: Device, temperature: float ) -> int: """Set DHW target temperature in the user's preferred unit.""" - return await self._device_controller.set_dhw_temperature(device, temperature) + return await self._device_controller.set_dhw_temperature( + device, temperature + ) async def update_reservations( self, @@ -1103,7 +1114,9 @@ async def request_energy_usage( self, device: Device, year: int, months: list[int] ) -> int: """Request daily energy usage data for specified month(s).""" - return await self._device_controller.request_energy_usage(device, year, months) + return await self._device_controller.request_energy_usage( + device, year, months + ) async def signal_app_connection(self, device: Device) -> int: """Signal that the app has connected.""" @@ -1129,11 +1142,13 @@ async def update_weekly_reservation( self, device: Device, schedule: WeeklyReservationSchedule ) -> int: """Configure the weekly temperature reservation schedule.""" - return await self._device_controller.update_weekly_reservation(device, schedule) + return await self._device_controller.update_weekly_reservation( + device, schedule + ) async def configure_reservation_water_program(self, device: Device) -> int: """Enable/configure water program reservation mode.""" - return await self._device_controller.configure_reservation_water_program(device) + return await self._control.configure_reservation_water_program(device) async def configure_recirculation_schedule( self, device: Device, schedule: RecirculationSchedule @@ -1145,11 +1160,15 @@ async def configure_recirculation_schedule( async def set_recirculation_mode(self, device: Device, mode: int) -> int: """Set recirculation pump operation mode (1-4).""" - return await self._device_controller.set_recirculation_mode(device, mode) + return await self._device_controller.set_recirculation_mode( + device, mode + ) async def trigger_recirculation_hot_button(self, device: Device) -> int: """Manually trigger the recirculation pump hot button.""" - return await self._device_controller.trigger_recirculation_hot_button(device) + return await self._device_controller.trigger_recirculation_hot_button( + device + ) async def check_firmware_update(self, device: Device) -> int: """Check for available over-the-air firmware updates.""" @@ -1159,7 +1178,9 @@ async def commit_firmware_update( self, device: Device, payload: OtaCommitPayload ) -> int: """Commit a previously downloaded firmware update.""" - return await self._device_controller.commit_firmware_update(device, payload) + return await self._device_controller.commit_firmware_update( + device, payload + ) async def reconnect_wifi(self, device: Device) -> int: """Trigger a WiFi reconnection on the device.""" @@ -1183,11 +1204,15 @@ async def run_smart_diagnostic(self, device: Device) -> int: async def enable_intelligent_scheduling(self, device: Device) -> int: """Enable intelligent/adaptive heating mode.""" - return await self._device_controller.enable_intelligent_scheduling(device) + return await self._device_controller.enable_intelligent_scheduling( + device + ) async def disable_intelligent_scheduling(self, device: Device) -> int: """Disable intelligent/adaptive heating mode.""" - return await self._device_controller.disable_intelligent_scheduling(device) + return await self._device_controller.disable_intelligent_scheduling( + device + ) async def ensure_device_info_cached( self, device: Device, timeout: float = 30.0 diff --git a/src/nwp500/mqtt/connection.py b/src/nwp500/mqtt/connection.py index 1a6452f..4ca3257 100644 --- a/src/nwp500/mqtt/connection.py +++ b/src/nwp500/mqtt/connection.py @@ -6,6 +6,8 @@ including credential management and connection state tracking. """ +from __future__ import annotations + import asyncio import json import logging @@ -45,8 +47,8 @@ class MqttConnection: def __init__( self, - config: "MqttConnectionConfig", - auth_client: "NavienAuthClient", + config: MqttConnectionConfig, + auth_client: NavienAuthClient, on_connection_interrupted: ( Callable[[mqtt.Connection, AwsCrtError], None] | None ) = None, @@ -189,8 +191,12 @@ def _create_credentials_provider(self) -> Any: # Get current tokens from auth client auth_tokens = self._auth_client.current_tokens - if not auth_tokens: - raise MqttCredentialsError("No tokens available from auth client") + if ( + not auth_tokens + or not auth_tokens.access_key_id + or not auth_tokens.secret_key + ): + raise MqttCredentialsError("AWS credentials not available") return AwsCredentialsProvider.new_static( access_key_id=auth_tokens.access_key_id, @@ -269,7 +275,7 @@ async def subscribe( topic=topic, qos=qos, callback=callback ) subscribe_future = cast(asyncio.Future[Any], subscribe_future_raw) - packet_id = cast(int, packet_id_raw) + packet_id = packet_id_raw try: await asyncio.shield(asyncio.wrap_future(subscribe_future)) @@ -311,7 +317,7 @@ async def unsubscribe(self, topic: str) -> int: topic=topic ) unsubscribe_future = cast(asyncio.Future[Any], unsubscribe_future_raw) - packet_id = cast(int, packet_id_raw) + packet_id = packet_id_raw try: await asyncio.shield(asyncio.wrap_future(unsubscribe_future)) @@ -366,7 +372,7 @@ async def publish( topic=topic, payload=payload_bytes, qos=qos ) publish_future = cast(asyncio.Future[Any], publish_future_raw) - packet_id = cast(int, packet_id_raw) + packet_id = packet_id_raw # Shield the operation to prevent cancellation from propagating to # the underlying concurrent.futures.Future. This avoids diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index dbcdf82..bab96c3 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -17,6 +17,8 @@ - Recirculation pump control and scheduling """ +from __future__ import annotations + import logging from collections.abc import Awaitable, Callable, Sequence from datetime import UTC, datetime @@ -100,12 +102,12 @@ def set_ensure_device_info_callback( self._ensure_device_info_callback = callback @property - def device_info_cache(self) -> "MqttDeviceInfoCache": + def device_info_cache(self) -> MqttDeviceInfoCache: """Get the device info cache.""" return self._device_info_cache @device_info_cache.setter - def device_info_cache(self, cache: "MqttDeviceInfoCache") -> None: + def device_info_cache(self, cache: MqttDeviceInfoCache) -> None: """Set the device info cache.""" self._device_info_cache = cache diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index 786d607..81b16a7 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -222,8 +222,8 @@ async def subscribe( except (AwsCrtError, RuntimeError) as e: # Clean up handler on failure if this was the first one - if topic in self._message_handlers and callback in self._message_handlers[topic]: - self._message_handlers[topic].remove(callback) + if (h := self._message_handlers.get(topic)) and callback in h: + h.remove(callback) _logger.error( f"Failed to subscribe to '{redact_topic(topic)}': {e}" ) diff --git a/src/nwp500/mqtt_events.py b/src/nwp500/mqtt_events.py index 4a62d7a..7b79272 100644 --- a/src/nwp500/mqtt_events.py +++ b/src/nwp500/mqtt_events.py @@ -28,6 +28,8 @@ def on_temperature_changed(event): print(event_name) """ +from __future__ import annotations + from dataclasses import dataclass from typing import TYPE_CHECKING, cast @@ -68,7 +70,7 @@ class StatusReceivedEvent: status: The current device status snapshot """ - status: "DeviceStatus" + status: DeviceStatus @dataclass(frozen=True) @@ -95,8 +97,8 @@ class ModeChangedEvent: new_mode: New operation mode """ - old_mode: "CurrentOperationMode" - new_mode: "CurrentOperationMode" + old_mode: CurrentOperationMode + new_mode: CurrentOperationMode @dataclass(frozen=True) @@ -120,7 +122,7 @@ class HeatingStartedEvent: status: Device status when heating started """ - status: "DeviceStatus" + status: DeviceStatus @dataclass(frozen=True) @@ -131,7 +133,7 @@ class HeatingStoppedEvent: status: Device status when heating stopped """ - status: "DeviceStatus" + status: DeviceStatus @dataclass(frozen=True) @@ -143,8 +145,8 @@ class ErrorDetectedEvent: status: Device status when error was detected """ - error_code: "ErrorCode" - status: "DeviceStatus" + error_code: ErrorCode + status: DeviceStatus @dataclass(frozen=True) @@ -155,7 +157,7 @@ class ErrorClearedEvent: error_code: The error code that was cleared """ - error_code: "ErrorCode" + error_code: ErrorCode @dataclass(frozen=True) @@ -166,7 +168,7 @@ class FeatureReceivedEvent: feature: The device feature information """ - feature: "DeviceFeature" + feature: DeviceFeature class MqttClientEvents: diff --git a/src/nwp500/reservations.py b/src/nwp500/reservations.py index f719892..5dd6172 100644 --- a/src/nwp500/reservations.py +++ b/src/nwp500/reservations.py @@ -62,7 +62,6 @@ def on_schedule(schedule: ReservationSchedule) -> None: if not future.done(): future.set_result(schedule) - device_type = str(device.device_info.device_type) await mqtt.subscribe_reservation_response(device, on_schedule) await mqtt.request_reservations(device) try: @@ -74,7 +73,8 @@ def on_schedule(schedule: ReservationSchedule) -> None: await mqtt.unsubscribe_reservation_response(device, on_schedule) except Exception: _logger.warning( - "Failed to unsubscribe reservations response handler for device %s", + "Failed to unsubscribe reservations response handler for " + "device %s", device.device_info.mac_address, exc_info=True, ) diff --git a/src/nwp500/topic_builder.py b/src/nwp500/topic_builder.py index b9a5677..41d9259 100644 --- a/src/nwp500/topic_builder.py +++ b/src/nwp500/topic_builder.py @@ -12,6 +12,8 @@ Event: evt/{device_type}/navilink-{mac}/{suffix} """ +from __future__ import annotations + class MqttTopicBuilder: """Helper to construct standard MQTT topics for Navien devices.""" diff --git a/src/nwp500/utils.py b/src/nwp500/utils.py index 5f1be09..964db2e 100644 --- a/src/nwp500/utils.py +++ b/src/nwp500/utils.py @@ -5,6 +5,8 @@ including performance monitoring decorators and helper functions. """ +from __future__ import annotations + import functools import inspect import logging diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index bbe9ab7..820b692 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -28,7 +28,7 @@ def mock_device(): def mock_mqtt(): mqtt = MagicMock() # Control attribute contains device control methods - + mqtt.request_device_info = AsyncMock() mqtt.request_device_status = AsyncMock() mqtt.set_dhw_mode = AsyncMock() @@ -144,6 +144,4 @@ async def side_effect_subscribe(device, callback): await handle_set_dhw_temp_request(mock_mqtt, mock_device, 120.0) - mock_mqtt.set_dhw_temperature.assert_called_once_with( - mock_device, 120.0 - ) + mock_mqtt.set_dhw_temperature.assert_called_once_with(mock_device, 120.0) diff --git a/tests/test_reservations.py b/tests/test_reservations.py index d380de7..de15501 100644 --- a/tests/test_reservations.py +++ b/tests/test_reservations.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest @@ -33,6 +33,7 @@ def mock_mqtt(mock_device: MagicMock) -> MagicMock: mqtt.client_id = "test-client" mqtt.subscribe = AsyncMock() mqtt.subscribe_reservation_response = AsyncMock() + mqtt.unsubscribe_reservation_response = AsyncMock() mqtt.unsubscribe = AsyncMock() mqtt.request_reservations = AsyncMock() mqtt.update_reservations = AsyncMock() @@ -98,8 +99,8 @@ async def fake_request(device: Any) -> None: result = await fetch_reservations(mock_mqtt, mock_device) assert result is schedule - mock_mqtt.unsubscribe.assert_called_once_with( - "cmd/NWP500/test-client/res/rsv/rd" + mock_mqtt.unsubscribe_reservation_response.assert_called_once_with( + mock_device, ANY ) @@ -114,8 +115,8 @@ async def test_fetch_reservations_timeout( result = await fetch_reservations(mock_mqtt, mock_device, timeout=0.01) assert result is None - mock_mqtt.unsubscribe.assert_called_once_with( - "cmd/NWP500/test-client/res/rsv/rd" + mock_mqtt.unsubscribe_reservation_response.assert_called_once_with( + mock_device, ANY ) From d99cf5e3334cdb3b4d301811674b1eaa67810964 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 16:26:59 -0700 Subject: [PATCH 14/29] fix(mqtt): use correct power attribute in state tracker --- src/nwp500/mqtt/state_tracker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nwp500/mqtt/state_tracker.py b/src/nwp500/mqtt/state_tracker.py index d9a357e..fb707ac 100644 --- a/src/nwp500/mqtt/state_tracker.py +++ b/src/nwp500/mqtt/state_tracker.py @@ -100,7 +100,7 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: ) # Power consumption change (compare raw values) - if status.current_inst_power_raw != prev.current_inst_power_raw: + if status.current_inst_power != prev.current_inst_power: await self._event_emitter.emit( "power_changed", PowerChangedEvent( @@ -115,8 +115,8 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: ) # Heating started / stopped (compare raw values) - prev_heating = prev.current_inst_power_raw > 0 - curr_heating = status.current_inst_power_raw > 0 + prev_heating = prev.current_inst_power > 0 + curr_heating = status.current_inst_power > 0 if curr_heating and not prev_heating: await self._event_emitter.emit( From bbf362366a6bd39b04945e4f863d887159368133 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 18:24:35 -0700 Subject: [PATCH 15/29] fix(mqtt): support both 'st/did' and 'status/feature' keys in device responses --- src/nwp500/mqtt/subscriptions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index 81b16a7..3213f32 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -455,9 +455,12 @@ def _make_handler( def handler(topic: str, message: dict[str, Any]) -> None: try: res = message.get("response", {}) - # Try nested response field, then fallback to top-level + # Try multiple possible keys for Navien protocol compatibility + alt_key = "st" if key == "status" else "did" if key == "feature" else None data = (res.get(key) if key else res) or ( message.get(key) if key else None + ) or (res.get(alt_key) if alt_key else None) or ( + message.get(alt_key) if alt_key else None ) if not data: return From 405a8e9cf965b3c1aa2bc0735975913d78e409ae Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 18:41:29 -0700 Subject: [PATCH 16/29] refactor(mqtt): extract get_response_data helper with correct key precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add get_response_data() helper to mqtt/utils.py that checks nested response dict before top-level, and primary key before alt key: res[key] → res[alt_key] → message[key] → message[alt_key] - Use helper in _make_handler() fixing the fallback order (previously message[key] was checked before res[alt_key]) - Use same helper in CLI raw_cb path so 'st'/'did' alt keys are supported there too, consistent with the typed handler path - Fix missing return None in EnergyUsageResponse.get_month_data - Fix mypy no-any-return in MqttConnection.unsubscribe/publish --- src/nwp500/cli/handlers.py | 6 ++--- src/nwp500/models/energy.py | 1 + src/nwp500/mqtt/connection.py | 4 ++-- src/nwp500/mqtt/subscriptions.py | 11 ++------- src/nwp500/mqtt/utils.py | 39 ++++++++++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/nwp500/cli/handlers.py b/src/nwp500/cli/handlers.py index 2f327b2..c31f65f 100644 --- a/src/nwp500/cli/handlers.py +++ b/src/nwp500/cli/handlers.py @@ -24,7 +24,7 @@ ValidationError, ) from nwp500.models import ReservationSchedule -from nwp500.mqtt.utils import redact_serial +from nwp500.mqtt.utils import get_response_data, redact_serial from nwp500.reservations import ( add_reservation, delete_reservation, @@ -172,9 +172,7 @@ async def _handle_info_request( def raw_cb(topic: str, message: dict[str, Any]) -> None: if not future.done(): - res = message.get("response", {}).get( - data_key - ) or message.get(data_key) + res = get_response_data(message, data_key) if res: print_json(res) future.set_result(None) diff --git a/src/nwp500/models/energy.py b/src/nwp500/models/energy.py index 702499d..d2410bf 100644 --- a/src/nwp500/models/energy.py +++ b/src/nwp500/models/energy.py @@ -75,3 +75,4 @@ def get_month_data(self, year: int, month: int) -> MonthlyEnergyData | None: for monthly_data in self.usage: if monthly_data.year == year and monthly_data.month == month: return monthly_data + return None diff --git a/src/nwp500/mqtt/connection.py b/src/nwp500/mqtt/connection.py index 4ca3257..0e6dee7 100644 --- a/src/nwp500/mqtt/connection.py +++ b/src/nwp500/mqtt/connection.py @@ -317,7 +317,7 @@ async def unsubscribe(self, topic: str) -> int: topic=topic ) unsubscribe_future = cast(asyncio.Future[Any], unsubscribe_future_raw) - packet_id = packet_id_raw + packet_id = int(packet_id_raw) try: await asyncio.shield(asyncio.wrap_future(unsubscribe_future)) @@ -372,7 +372,7 @@ async def publish( topic=topic, payload=payload_bytes, qos=qos ) publish_future = cast(asyncio.Future[Any], publish_future_raw) - packet_id = packet_id_raw + packet_id = int(packet_id_raw) # Shield the operation to prevent cancellation from propagating to # the underlying concurrent.futures.Future. This avoids diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index 3213f32..7adb593 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -35,7 +35,7 @@ from ..mqtt_events import FeatureReceivedEvent, StatusReceivedEvent from ..topic_builder import MqttTopicBuilder from .state_tracker import DeviceStateTracker -from .utils import redact_topic, topic_matches_pattern +from .utils import get_response_data, redact_topic, topic_matches_pattern if TYPE_CHECKING: from ..device_info_cache import MqttDeviceInfoCache @@ -454,14 +454,7 @@ def _make_handler( def handler(topic: str, message: dict[str, Any]) -> None: try: - res = message.get("response", {}) - # Try multiple possible keys for Navien protocol compatibility - alt_key = "st" if key == "status" else "did" if key == "feature" else None - data = (res.get(key) if key else res) or ( - message.get(key) if key else None - ) or (res.get(alt_key) if alt_key else None) or ( - message.get(alt_key) if alt_key else None - ) + data = get_response_data(message, key) if not data: return diff --git a/src/nwp500/mqtt/utils.py b/src/nwp500/mqtt/utils.py index d9a8ec6..3b8ea02 100644 --- a/src/nwp500/mqtt/utils.py +++ b/src/nwp500/mqtt/utils.py @@ -269,6 +269,45 @@ class PeriodicRequestType(Enum): DEVICE_STATUS = "device_status" +_ALT_KEYS: dict[str, str] = { + "status": "st", + "feature": "did", +} + + +def get_response_data(message: dict[str, Any], key: str | None) -> Any: + """Extract data from an MQTT message, supporting key variants. + + Checks both the nested ``response`` dict and the top-level message, + using both the primary key and its alternate short-form name (e.g. + ``"status"`` / ``"st"``, ``"feature"`` / ``"did"``). Lookup order + preserves a strict *nested-first* precedence: + + 1. ``response[key]`` + 2. ``response[alt_key]`` + 3. ``message[key]`` + 4. ``message[alt_key]`` + + Args: + message: Raw MQTT message dict. + key: Primary key to look up, or ``None`` to return the full + ``response`` dict. + + Returns: + The first non-falsy value found, or ``None``. + """ + res: dict[str, Any] = message.get("response", {}) + if key is None: + return res + alt_key = _ALT_KEYS.get(key) + return ( + res.get(key) + or (res.get(alt_key) if alt_key else None) + or message.get(key) + or (message.get(alt_key) if alt_key else None) + ) + + def topic_matches_pattern(topic: str, pattern: str) -> bool: """ Check if a topic matches a subscription pattern with wildcards. From 66ddcce5adee8fa911d88730c32e9b83bb895d93 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 18:48:56 -0700 Subject: [PATCH 17/29] fix(mqtt): use sentinel in get_response_data to handle falsy values Replace or-chain with explicit key-presence checks using a sentinel object, so falsy values (0, False, {}) are returned correctly and do not fall through to a lower-precedence candidate. Also add unit tests for the raw=True CLI path with 'st'/'did' alt keys and the standard 'status'/'feature' keys. --- src/nwp500/mqtt/utils.py | 30 ++++++++++++++------ tests/test_cli_commands.py | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/nwp500/mqtt/utils.py b/src/nwp500/mqtt/utils.py index 3b8ea02..9d677bf 100644 --- a/src/nwp500/mqtt/utils.py +++ b/src/nwp500/mqtt/utils.py @@ -274,6 +274,8 @@ class PeriodicRequestType(Enum): "feature": "did", } +_SENTINEL = object() + def get_response_data(message: dict[str, Any], key: str | None) -> Any: """Extract data from an MQTT message, supporting key variants. @@ -288,24 +290,34 @@ def get_response_data(message: dict[str, Any], key: str | None) -> Any: 3. ``message[key]`` 4. ``message[alt_key]`` + Key presence is checked explicitly (not by truthiness), so falsy + values like ``0``, ``False``, or ``{}`` are returned correctly and + do not fall through to a lower-precedence candidate. + Args: message: Raw MQTT message dict. - key: Primary key to look up, or ``None`` to return the full - ``response`` dict. + key: Primary key to look up. When ``None``, the nested + ``response`` dict is returned directly. Returns: - The first non-falsy value found, or ``None``. + The value of the first *present* key in priority order, + or ``None`` if no candidate key is found. """ res: dict[str, Any] = message.get("response", {}) if key is None: return res alt_key = _ALT_KEYS.get(key) - return ( - res.get(key) - or (res.get(alt_key) if alt_key else None) - or message.get(key) - or (message.get(alt_key) if alt_key else None) - ) + for source, k in ( + (res, key), + (res, alt_key), + (message, key), + (message, alt_key), + ): + if k is not None: + value = source.get(k, _SENTINEL) + if value is not _SENTINEL: + return value + return None def topic_matches_pattern(topic: str, pattern: str) -> bool: diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 820b692..e3887c2 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -7,6 +7,7 @@ try: from nwp500.cli.handlers import ( get_controller_serial_number, + handle_device_info_request, handle_set_dhw_temp_request, handle_set_mode_request, handle_status_request, @@ -35,6 +36,7 @@ def mock_mqtt(): mqtt.set_dhw_temperature = AsyncMock() # Async methods on mqtt itself + mqtt.subscribe_device = AsyncMock() mqtt.subscribe_device_feature = AsyncMock() mqtt.subscribe_device_status = AsyncMock() return mqtt @@ -145,3 +147,59 @@ async def side_effect_subscribe(device, callback): await handle_set_dhw_temp_request(mock_mqtt, mock_device, 120.0) mock_mqtt.set_dhw_temperature.assert_called_once_with(mock_device, 120.0) + + +@pytest.mark.asyncio +async def test_handle_status_request_raw_with_st_key( + mock_mqtt, mock_device, capsys +): + """Raw status request handles the 'st' alt key from Navien devices.""" + status_data = {"operationMode": 1, "hotWaterTemperature": 500} + + async def subscribe_and_invoke(device, callback): + callback("cmd/52/device/st", {"response": {"st": status_data}}) + + mock_mqtt.subscribe_device = AsyncMock(side_effect=subscribe_and_invoke) + + await handle_status_request(mock_mqtt, mock_device, raw=True) + + captured = capsys.readouterr() + assert "operationMode" in captured.out + assert "hotWaterTemperature" in captured.out + + +@pytest.mark.asyncio +async def test_handle_device_info_request_raw_with_did_key( + mock_mqtt, mock_device, capsys +): + """Raw device info request handles the 'did' alt key from Navien devices.""" + feature_data = {"serialNumber": "ABC123", "modelName": "NWP500"} + + async def subscribe_and_invoke(device, callback): + callback("cmd/52/device/st/did", {"response": {"did": feature_data}}) + + mock_mqtt.subscribe_device = AsyncMock(side_effect=subscribe_and_invoke) + + await handle_device_info_request(mock_mqtt, mock_device, raw=True) + + captured = capsys.readouterr() + assert "serialNumber" in captured.out + assert "modelName" in captured.out + + +@pytest.mark.asyncio +async def test_handle_status_request_raw_with_standard_key( + mock_mqtt, mock_device, capsys +): + """Raw status request handles the standard 'status' key.""" + status_data = {"operationMode": 2, "hotWaterTemperature": 600} + + async def subscribe_and_invoke(device, callback): + callback("cmd/52/device/st", {"response": {"status": status_data}}) + + mock_mqtt.subscribe_device = AsyncMock(side_effect=subscribe_and_invoke) + + await handle_status_request(mock_mqtt, mock_device, raw=True) + + captured = capsys.readouterr() + assert "operationMode" in captured.out From 43e2dfd5bebf5d5327fdf4a21067dbc0e3669939 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 19:44:24 -0700 Subject: [PATCH 18/29] feat: add subscribe_tou_response() typed subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds typed TOU schedule subscription to complete API symmetry with the other scheduling typed subscriptions: - subscribe_reservation_response() → ReservationSchedule - subscribe_weekly_reservation_response() → WeeklyReservationSchedule - subscribe_recirculation_schedule_response() → RecirculationSchedule - subscribe_tou_response() (new) → TOUInfo The TOUInfo model and request_tou_settings() control method already existed; only the typed subscription callback was missing. Changes: - MqttSubscriptionManager.subscribe_tou_response() - MqttSubscriptionManager.unsubscribe_tou_response() - NavienMqttClient.subscribe_tou_response() (proxy) - NavienMqttClient.unsubscribe_tou_response() (proxy) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/nwp500/mqtt/client.py | 36 +++++++++++++++++++++++ src/nwp500/mqtt/subscriptions.py | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 25a57e3..0eea580 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -58,6 +58,7 @@ OtaCommitPayload, RecirculationSchedule, ReservationSchedule, + TOUInfo, WeeklyReservationSchedule, ) @@ -1025,6 +1026,41 @@ async def unsubscribe_recirculation_schedule_response( device, callback ) + async def subscribe_tou_response( + self, + device: Device, + callback: Callable[[TOUInfo], None], + ) -> int: + """Subscribe to Time-of-Use schedule read responses with automatic parsing. + + Subscribes to the ``tou/rd`` response topic for the given device. + The callback receives a fully-parsed :class:`~nwp500.models.TOUInfo` + whenever the device responds to a TOU read request (triggered by + :meth:`request_tou_settings`). + + Args: + device: Device whose TOU responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + return await self._delegate_subscription( + "subscribe_tou_response", device, callback + ) + + async def unsubscribe_tou_response( + self, + device: Device, + callback: Callable[[TOUInfo], None], + ) -> None: + """Unsubscribe a specific TOU response callback.""" + if not self._connected or not self._subscription_manager: + return + await self._subscription_manager.unsubscribe_tou_response( + device, callback + ) + # ------------------------------------------------------------------------- # Device control proxies (delegate to self.control) # ------------------------------------------------------------------------- diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index 7adb593..ac92e20 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -30,6 +30,7 @@ EnergyUsageResponse, RecirculationSchedule, ReservationSchedule, + TOUInfo, WeeklyReservationSchedule, ) from ..mqtt_events import FeatureReceivedEvent, StatusReceivedEvent @@ -703,6 +704,55 @@ async def unsubscribe_recirculation_schedule_response( if target_handler: await self.unsubscribe(topic, target_handler) + async def subscribe_tou_response( + self, + device: Device, + callback: Callable[[TOUInfo], None], + ) -> int: + """Subscribe to Time-of-Use schedule read responses with automatic parsing. + + Subscribes to the ``tou/rd`` response topic for the given device. + The callback receives a fully-parsed :class:`~nwp500.models.TOUInfo` + whenever the device responds to a TOU read request (triggered by + :meth:`~nwp500.NavienMqttClient.request_tou_settings`). + + Args: + device: Device whose TOU responses to receive. + callback: Called with the parsed schedule on each response. + + Returns: + Publish packet ID from the MQTT subscribe call. + """ + handler = self._make_handler(TOUInfo, callback) + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "tou/rd", + ) + return await self.subscribe(topic, handler) + + async def unsubscribe_tou_response( + self, + device: Device, + callback: Callable[[TOUInfo], None], + ) -> None: + """Unsubscribe a specific TOU response callback.""" + topic = MqttTopicBuilder.response_topic( + str(device.device_info.device_type), + self._client_id, + "tou/rd", + ) + + target_handler = None + if topic in self._message_handlers: + for h in self._message_handlers[topic]: + if getattr(h, "_original_callback", None) == callback: + target_handler = h + break + + if target_handler: + await self.unsubscribe(topic, target_handler) + def clear_subscriptions(self) -> None: """Clear all subscription tracking (called on disconnect).""" self._subscriptions.clear() From 6d1aee6af3fd26d91d0d988e9ea8ccb31ecc82ae Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 20:18:28 -0700 Subject: [PATCH 19/29] fix: use TOUReservationSchedule for tou/rd MQTT responses Replace incorrect TOUInfo model (API-level) with new TOUReservationSchedule that matches the actual tou/rd MQTT payload structure (reservationUse + reservation period list). Add TOUPeriod model for individual TOU pricing periods with fields: season, week, startHour/Minute, endHour/Minute, priceMin, priceMax, decimalPoint, plus computed properties for formatted times and decoded prices. Export TOUPeriod and TOUReservationSchedule from models and top-level package. --- src/nwp500/__init__.py | 4 ++ src/nwp500/models/__init__.py | 10 ++- src/nwp500/models/tou.py | 108 ++++++++++++++++++++++++++++++- src/nwp500/mqtt/client.py | 16 +++-- src/nwp500/mqtt/subscriptions.py | 19 +++--- 5 files changed, 140 insertions(+), 17 deletions(-) diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index a49a425..5c0d880 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -119,6 +119,8 @@ ReservationEntry, ReservationSchedule, TOUInfo, + TOUPeriod, + TOUReservationSchedule, TOUSchedule, WeeklyReservationEntry, WeeklyReservationSchedule, @@ -182,6 +184,8 @@ "OtaCommitPayload", "TOUSchedule", "TOUInfo", + "TOUPeriod", + "TOUReservationSchedule", "MqttRequest", "MqttCommand", "EnergyUsageTotal", diff --git a/src/nwp500/models/__init__.py b/src/nwp500/models/__init__.py index 52cb5e0..e23c9ca 100644 --- a/src/nwp500/models/__init__.py +++ b/src/nwp500/models/__init__.py @@ -47,7 +47,13 @@ TouOverride, TouStatus, ) -from .tou import ConvertedTOUPlan, TOUInfo, TOUSchedule +from .tou import ( + ConvertedTOUPlan, + TOUInfo, + TOUPeriod, + TOUReservationSchedule, + TOUSchedule, +) __all__ = [ "NavienBaseModel", @@ -69,6 +75,8 @@ "TOUSchedule", "ConvertedTOUPlan", "TOUInfo", + "TOUPeriod", + "TOUReservationSchedule", "ReservationEntry", "ReservationSchedule", "WeeklyReservationEntry", diff --git a/src/nwp500/models/tou.py b/src/nwp500/models/tou.py index c589369..bca7e79 100644 --- a/src/nwp500/models/tou.py +++ b/src/nwp500/models/tou.py @@ -2,7 +2,7 @@ from typing import Any, cast -from pydantic import Field, model_validator +from pydantic import ConfigDict, Field, computed_field, model_validator from .._base import NavienBaseModel @@ -54,3 +54,109 @@ def _extract_nested_tou_info(cls, data: Any) -> Any: d.update(tou_data) return d return data + + +class TOUPeriod(NavienBaseModel): + """A single TOU pricing period from an MQTT ``tou/rd`` response. + + Each period defines a time window, active season/week bitfields, + and the pricing range for that window. + + Fields use camelCase aliases to match the raw MQTT payload: + - season: bitfield of active months (bit N-1 set for month N) + - week: bitfield of active weekdays (Sun=bit7, …, Sat=bit1) + - startHour / startMinute: start of the time window (0-23 / 0-59) + - endHour / endMinute: end of the time window (0-23 / 0-59) + - priceMin / priceMax: encoded integer prices (divide by + 10^decimalPoint) + - decimalPoint: number of decimal places for price values + """ + + season: int = 0 + week: int = 0 + start_hour: int = Field(default=0, alias="startHour") + start_minute: int = Field(default=0, alias="startMinute") + end_hour: int = Field(default=0, alias="endHour") + end_minute: int = Field(default=0, alias="endMinute") + price_min: int = Field(default=0, alias="priceMin") + price_max: int = Field(default=0, alias="priceMax") + decimal_point: int = Field(default=5, alias="decimalPoint") + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @computed_field # type: ignore[prop-decorator] + @property + def start_time(self) -> str: + """Formatted start time (HH:MM).""" + return f"{self.start_hour:02d}:{self.start_minute:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def end_time(self) -> str: + """Formatted end time (HH:MM).""" + return f"{self.end_hour:02d}:{self.end_minute:02d}" + + @computed_field # type: ignore[prop-decorator] + @property + def decoded_price_min(self) -> float: + """Minimum price decoded to a float (price_min / 10^decimal_point).""" + divisor: float = 10.0**self.decimal_point + return float(self.price_min) / divisor + + @computed_field # type: ignore[prop-decorator] + @property + def decoded_price_max(self) -> float: + """Maximum price decoded to a float (price_max / 10^decimal_point).""" + divisor: float = 10.0**self.decimal_point + return float(self.price_max) / divisor + + +class TOUReservationSchedule(NavienBaseModel): + """TOU schedule as returned by the MQTT ``tou/rd`` response topic. + + This model matches the raw MQTT payload for both + :meth:`~nwp500.NavienMqttClient.request_tou_settings` read responses + and :meth:`~nwp500.NavienMqttClient.configure_tou_schedule` write + confirmations — both use ``CommandCode.TOU_RESERVATION`` and the + ``tou/rd`` response topic. + + The payload structure is:: + + { + "reservationUse": 2, # 1=disabled, 2=enabled + "reservation": [ # list of TOU period dicts + { + "season": 4095, "week": 254, + "startHour": 0, "startMinute": 0, + "endHour": 23, "endMinute": 59, + "priceMin": 10, "priceMax": 25, + "decimalPoint": 2 + }, + ... + ] + } + """ + + reservation_use: int = Field(default=0, alias="reservationUse") + reservation: list[TOUPeriod] = Field(default_factory=list) + + model_config = ConfigDict( + alias_generator=None, + populate_by_name=True, + extra="ignore", + use_enum_values=False, + ) + + @computed_field # type: ignore[prop-decorator] + @property + def enabled(self) -> bool: + """Whether TOU scheduling is globally enabled. + + Device bool convention: 2=on, 1=off. + """ + return self.reservation_use == 2 diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 0eea580..5b405e6 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -58,7 +58,7 @@ OtaCommitPayload, RecirculationSchedule, ReservationSchedule, - TOUInfo, + TOUReservationSchedule, WeeklyReservationSchedule, ) @@ -1029,14 +1029,16 @@ async def unsubscribe_recirculation_schedule_response( async def subscribe_tou_response( self, device: Device, - callback: Callable[[TOUInfo], None], + callback: Callable[[TOUReservationSchedule], None], ) -> int: - """Subscribe to Time-of-Use schedule read responses with automatic parsing. + """Subscribe to Time-of-Use schedule read responses with automatic + parsing. Subscribes to the ``tou/rd`` response topic for the given device. - The callback receives a fully-parsed :class:`~nwp500.models.TOUInfo` - whenever the device responds to a TOU read request (triggered by - :meth:`request_tou_settings`). + The callback receives a fully-parsed + :class:`~nwp500.models.TOUReservationSchedule` whenever the device + responds to a TOU read or configure request (triggered by + :meth:`request_tou_settings` or :meth:`configure_tou_schedule`). Args: device: Device whose TOU responses to receive. @@ -1052,7 +1054,7 @@ async def subscribe_tou_response( async def unsubscribe_tou_response( self, device: Device, - callback: Callable[[TOUInfo], None], + callback: Callable[[TOUReservationSchedule], None], ) -> None: """Unsubscribe a specific TOU response callback.""" if not self._connected or not self._subscription_manager: diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index ac92e20..e9fe0db 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -30,7 +30,7 @@ EnergyUsageResponse, RecirculationSchedule, ReservationSchedule, - TOUInfo, + TOUReservationSchedule, WeeklyReservationSchedule, ) from ..mqtt_events import FeatureReceivedEvent, StatusReceivedEvent @@ -707,14 +707,17 @@ async def unsubscribe_recirculation_schedule_response( async def subscribe_tou_response( self, device: Device, - callback: Callable[[TOUInfo], None], + callback: Callable[[TOUReservationSchedule], None], ) -> int: - """Subscribe to Time-of-Use schedule read responses with automatic parsing. + """Subscribe to Time-of-Use schedule read responses with automatic + parsing. Subscribes to the ``tou/rd`` response topic for the given device. - The callback receives a fully-parsed :class:`~nwp500.models.TOUInfo` - whenever the device responds to a TOU read request (triggered by - :meth:`~nwp500.NavienMqttClient.request_tou_settings`). + The callback receives a fully-parsed + :class:`~nwp500.models.TOUReservationSchedule` whenever the device + responds to a TOU read or configure request (triggered by + :meth:`~nwp500.NavienMqttClient.request_tou_settings` or + :meth:`~nwp500.NavienMqttClient.configure_tou_schedule`). Args: device: Device whose TOU responses to receive. @@ -723,7 +726,7 @@ async def subscribe_tou_response( Returns: Publish packet ID from the MQTT subscribe call. """ - handler = self._make_handler(TOUInfo, callback) + handler = self._make_handler(TOUReservationSchedule, callback) topic = MqttTopicBuilder.response_topic( str(device.device_info.device_type), self._client_id, @@ -734,7 +737,7 @@ async def subscribe_tou_response( async def unsubscribe_tou_response( self, device: Device, - callback: Callable[[TOUInfo], None], + callback: Callable[[TOUReservationSchedule], None], ) -> None: """Unsubscribe a specific TOU response callback.""" topic = MqttTopicBuilder.response_topic( From c70fa1e46d106cf06d47b569b62b0aae0c562849 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 20:35:40 -0700 Subject: [PATCH 20/29] fix: address review feedback on TOUPeriod and TOU subscription docs - Rename start_minute/end_minute -> start_min/end_min in TOUPeriod to match existing scheduling model conventions (startMin/endMin aliases kept for MQTT payload compatibility) - Fix reservationUse docstring: 0=disabled, 2=enabled (not 1=disabled) - Add subscribe_tou_response()/unsubscribe_tou_response() to docs/python_api/mqtt_client.rst --- docs/python_api/mqtt_client.rst | 25 +++++++++++++++++++++++++ src/nwp500/models/tou.py | 12 ++++++------ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index 2eeb447..239bc0e 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -589,6 +589,31 @@ request_tou_settings() Request the current TOU schedule. +subscribe_tou_response() +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: subscribe_tou_response(device, callback) + + Subscribe to parsed TOU schedule responses. + + The callback is invoked with a :class:`~nwp500.models.TOUReservationSchedule` + whenever the device responds to a :meth:`request_tou_settings` read or a + :meth:`configure_tou_schedule` write (both use the ``tou/rd`` response + topic). + + :param callback: Called with the parsed TOU schedule on each response. + :type callback: Callable[[TOUReservationSchedule], None] + +unsubscribe_tou_response() +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:method:: unsubscribe_tou_response(device, callback) + + Unsubscribe a previously registered TOU response callback. + + :param callback: The same callable passed to :meth:`subscribe_tou_response`. + :type callback: Callable[[TOUReservationSchedule], None] + set_tou_enabled() ^^^^^^^^^^^^^^^^^ diff --git a/src/nwp500/models/tou.py b/src/nwp500/models/tou.py index bca7e79..5f85885 100644 --- a/src/nwp500/models/tou.py +++ b/src/nwp500/models/tou.py @@ -75,9 +75,9 @@ class TOUPeriod(NavienBaseModel): season: int = 0 week: int = 0 start_hour: int = Field(default=0, alias="startHour") - start_minute: int = Field(default=0, alias="startMinute") + start_min: int = Field(default=0, alias="startMinute") end_hour: int = Field(default=0, alias="endHour") - end_minute: int = Field(default=0, alias="endMinute") + end_min: int = Field(default=0, alias="endMinute") price_min: int = Field(default=0, alias="priceMin") price_max: int = Field(default=0, alias="priceMax") decimal_point: int = Field(default=5, alias="decimalPoint") @@ -93,13 +93,13 @@ class TOUPeriod(NavienBaseModel): @property def start_time(self) -> str: """Formatted start time (HH:MM).""" - return f"{self.start_hour:02d}:{self.start_minute:02d}" + return f"{self.start_hour:02d}:{self.start_min:02d}" @computed_field # type: ignore[prop-decorator] @property def end_time(self) -> str: """Formatted end time (HH:MM).""" - return f"{self.end_hour:02d}:{self.end_minute:02d}" + return f"{self.end_hour:02d}:{self.end_min:02d}" @computed_field # type: ignore[prop-decorator] @property @@ -128,7 +128,7 @@ class TOUReservationSchedule(NavienBaseModel): The payload structure is:: { - "reservationUse": 2, # 1=disabled, 2=enabled + "reservationUse": 2, # 0=disabled, 2=enabled "reservation": [ # list of TOU period dicts { "season": 4095, "week": 254, @@ -157,6 +157,6 @@ class TOUReservationSchedule(NavienBaseModel): def enabled(self) -> bool: """Whether TOU scheduling is globally enabled. - Device bool convention: 2=on, 1=off. + Protocol convention: 0=disabled, 2=enabled. """ return self.reservation_use == 2 From b6363d8071c3050cf0a826ad985299836183eb96 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 22:48:53 -0700 Subject: [PATCH 21/29] feat: add device identity to MQTT events and status models Inject MAC address into DeviceStatus and DeviceFeature models and propagate device_mac through all device-specific MQTT events. This ensures that consumers can distinguish between multiple devices in event-driven and callback-based monitoring flows without manual routing or closures. - Add mac_address field to DeviceStatus and DeviceFeature models - Add device_mac to all MQTT event dataclasses - Update MqttSubscriptionManager to populate model identity on receipt - Update DeviceStateTracker to include identity in emitted change events - Add comprehensive multi-device verification tests --- src/nwp500/models/feature.py | 5 + src/nwp500/models/status.py | 5 + src/nwp500/mqtt/state_tracker.py | 12 +- src/nwp500/mqtt/subscriptions.py | 17 ++- src/nwp500/mqtt_events.py | 20 ++- tests/test_multi_device.py | 239 +++++++++++++++++++++++++++++++ 6 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 tests/test_multi_device.py diff --git a/src/nwp500/models/feature.py b/src/nwp500/models/feature.py index c83a8b2..0dc5609 100644 --- a/src/nwp500/models/feature.py +++ b/src/nwp500/models/feature.py @@ -36,6 +36,11 @@ class DeviceFeature(NavienBaseModel): ), ) + mac_address: str | None = Field( + default=None, + description="MAC address of the origin device", + ) + country_code: int = Field( description=( "Country/region code where device is certified for operation. " diff --git a/src/nwp500/models/status.py b/src/nwp500/models/status.py index efde5aa..4315c2b 100644 --- a/src/nwp500/models/status.py +++ b/src/nwp500/models/status.py @@ -50,6 +50,11 @@ class DeviceStatus(NavienBaseModel): ), ) + mac_address: str | None = Field( + default=None, + description="MAC address of the origin device", + ) + # Basic status fields command: int = Field( description="The command that triggered this status update" diff --git a/src/nwp500/mqtt/state_tracker.py b/src/nwp500/mqtt/state_tracker.py index fb707ac..ba91e4a 100644 --- a/src/nwp500/mqtt/state_tracker.py +++ b/src/nwp500/mqtt/state_tracker.py @@ -71,6 +71,7 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: await self._event_emitter.emit( "temperature_changed", TemperatureChangedEvent( + device_mac=device_mac, old_temperature=prev.dhw_temperature, new_temperature=status.dhw_temperature, ), @@ -89,6 +90,7 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: await self._event_emitter.emit( "mode_changed", ModeChangedEvent( + device_mac=device_mac, old_mode=prev.operation_mode, new_mode=status.operation_mode, ), @@ -104,6 +106,7 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: await self._event_emitter.emit( "power_changed", PowerChangedEvent( + device_mac=device_mac, old_power=prev.current_inst_power, new_power=status.current_inst_power, ), @@ -121,14 +124,14 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: if curr_heating and not prev_heating: await self._event_emitter.emit( "heating_started", - HeatingStartedEvent(status=status), + HeatingStartedEvent(device_mac=device_mac, status=status), ) _logger.debug("Heating started") if not curr_heating and prev_heating: await self._event_emitter.emit( "heating_stopped", - HeatingStoppedEvent(status=status), + HeatingStoppedEvent(device_mac=device_mac, status=status), ) _logger.debug("Heating stopped") @@ -137,6 +140,7 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: await self._event_emitter.emit( "error_detected", ErrorDetectedEvent( + device_mac=device_mac, error_code=status.error_code, status=status, ), @@ -146,7 +150,9 @@ async def process(self, device_mac: str, status: DeviceStatus) -> None: if not status.error_code and prev.error_code: await self._event_emitter.emit( "error_cleared", - ErrorClearedEvent(error_code=prev.error_code), + ErrorClearedEvent( + device_mac=device_mac, error_code=prev.error_code + ), ) _logger.info("Error cleared: %s", prev.error_code) diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index e9fe0db..ce77b53 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -414,7 +414,7 @@ def post_parse(status: DeviceStatus) -> None: self._schedule_coroutine( self._event_emitter.emit( "status_received", - StatusReceivedEvent(status=status), + StatusReceivedEvent(device_mac=device_mac, status=status), ) ) self._schedule_coroutine( @@ -422,7 +422,7 @@ def post_parse(status: DeviceStatus) -> None: ) handler = self._make_handler( - DeviceStatus, callback, "status", post_parse + DeviceStatus, callback, "status", post_parse, device_mac=device_mac ) return await self.subscribe_device(device=device, callback=handler) @@ -450,6 +450,7 @@ def _make_handler( callback: Callable[[Any], None], key: str | None = None, post_parse: Callable[[Any], None] | None = None, + device_mac: str | None = None, ) -> Callable[[str, dict[str, Any]], None]: """Generic factory for MQTT message handlers.""" @@ -460,6 +461,9 @@ def handler(topic: str, message: dict[str, Any]) -> None: return parsed = model.model_validate(data) + if device_mac and hasattr(parsed, "mac_address"): + parsed.mac_address = device_mac + if post_parse: post_parse(parsed) callback(parsed) @@ -481,23 +485,22 @@ async def subscribe_device_feature( self, device: Device, callback: Callable[[DeviceFeature], None] ) -> int: """Subscribe to device feature/info messages with automatic parsing.""" + device_mac = device.device_info.mac_address def post_parse(feature: DeviceFeature) -> None: if self._device_info_cache: self._schedule_coroutine( - self._device_info_cache.set( - device.device_info.mac_address, feature - ) + self._device_info_cache.set(device_mac, feature) ) self._schedule_coroutine( self._event_emitter.emit( "feature_received", - FeatureReceivedEvent(feature=feature), + FeatureReceivedEvent(device_mac=device_mac, feature=feature), ) ) handler = self._make_handler( - DeviceFeature, callback, "feature", post_parse + DeviceFeature, callback, "feature", post_parse, device_mac=device_mac ) return await self.subscribe_device(device=device, callback=handler) diff --git a/src/nwp500/mqtt_events.py b/src/nwp500/mqtt_events.py index 7b79272..f4da01f 100644 --- a/src/nwp500/mqtt_events.py +++ b/src/nwp500/mqtt_events.py @@ -67,9 +67,11 @@ class StatusReceivedEvent: """Emitted when a device status message is received. Attributes: + device_mac: MAC address of the origin device status: The current device status snapshot """ + device_mac: str status: DeviceStatus @@ -78,12 +80,14 @@ class TemperatureChangedEvent: """Emitted when the DHW temperature changes. Attributes: + device_mac: MAC address of the origin device 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) """ + device_mac: str old_temperature: float new_temperature: float @@ -93,10 +97,12 @@ class ModeChangedEvent: """Emitted when the device operation mode changes. Attributes: + device_mac: MAC address of the origin device old_mode: Previous operation mode new_mode: New operation mode """ + device_mac: str old_mode: CurrentOperationMode new_mode: CurrentOperationMode @@ -106,10 +112,12 @@ class PowerChangedEvent: """Emitted when instantaneous power consumption changes. Attributes: + device_mac: MAC address of the origin device old_power: Previous power consumption in watts new_power: New power consumption in watts """ + device_mac: str old_power: float new_power: float @@ -119,9 +127,11 @@ class HeatingStartedEvent: """Emitted when device transitions from idle to heating. Attributes: + device_mac: MAC address of the origin device status: Device status when heating started """ + device_mac: str status: DeviceStatus @@ -130,9 +140,11 @@ class HeatingStoppedEvent: """Emitted when device transitions from heating to idle. Attributes: + device_mac: MAC address of the origin device status: Device status when heating stopped """ + device_mac: str status: DeviceStatus @@ -141,10 +153,12 @@ class ErrorDetectedEvent: """Emitted when a device error is first detected. Attributes: + device_mac: MAC address of the origin device error_code: The error code that occurred status: Device status when error was detected """ + device_mac: str error_code: ErrorCode status: DeviceStatus @@ -154,9 +168,11 @@ class ErrorClearedEvent: """Emitted when a device error is resolved. Attributes: + device_mac: MAC address of the origin device error_code: The error code that was cleared """ + device_mac: str error_code: ErrorCode @@ -165,9 +181,11 @@ class FeatureReceivedEvent: """Emitted when device feature information is received. Attributes: + device_mac: MAC address of the origin device feature: The device feature information """ + device_mac: str feature: DeviceFeature @@ -256,7 +274,7 @@ class MqttClientEvents: event (PowerChangedEvent): Event object with old_power and new_power fields. - See: :class:`PowerChangedEvent` + See: :class:`PowerChangedEvent" """ # Heating events diff --git a/tests/test_multi_device.py b/tests/test_multi_device.py new file mode 100644 index 0000000..c64c98d --- /dev/null +++ b/tests/test_multi_device.py @@ -0,0 +1,239 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock +from nwp500.models import DeviceStatus, DeviceFeature +from nwp500.mqtt_events import ( + StatusReceivedEvent, + TemperatureChangedEvent, + FeatureReceivedEvent, +) +from nwp500.mqtt.state_tracker import DeviceStateTracker +from nwp500.events import EventEmitter +from nwp500.enums import CurrentOperationMode, DhwOperationSetting +from nwp500.mqtt.subscriptions import MqttSubscriptionManager + +def test_models_have_mac_address(): + """Test that DeviceStatus and DeviceFeature have mac_address field.""" + # Use model_construct to avoid providing all required fields + status = DeviceStatus.model_construct(command=0, mac_address="00:11:22:33:44:55") + assert status.mac_address == "00:11:22:33:44:55" + + feature = DeviceFeature.model_construct( + controller_serial_number="ABC123", + mac_address="00:11:22:33:44:55" + ) + assert feature.mac_address == "00:11:22:33:44:55" + +def test_events_have_device_mac(): + """Test that events carry device_mac.""" + status = DeviceStatus.model_construct(command=0) + event = StatusReceivedEvent(device_mac="00:11:22:33:44:55", status=status) + assert event.device_mac == "00:11:22:33:44:55" + + temp_event = TemperatureChangedEvent( + device_mac="00:11:22:33:44:55", + old_temperature=120.0, + new_temperature=122.0 + ) + assert temp_event.device_mac == "00:11:22:33:44:55" + +@pytest.mark.asyncio +async def test_state_tracker_emits_with_mac(): + """Test that DeviceStateTracker includes mac_address in events.""" + emitter = MagicMock(spec=EventEmitter) + emitter.emit = AsyncMock(return_value=1) + tracker = DeviceStateTracker(emitter) + + mac1 = "00:11:22:33:44:55" + mac2 = "AA:BB:CC:DD:EE:FF" + + # We need to provide enough fields for computed properties if they are used + # DeviceStatus uses dhwTemperature computed property which uses dhw_temperature_raw + status1_v1 = DeviceStatus.model_construct( + dhw_temperature_raw=100, + operation_mode=CurrentOperationMode.STANDBY, + current_inst_power=0.0, + error_code=0 + ) + status1_v2 = DeviceStatus.model_construct( + dhw_temperature_raw=104, + operation_mode=CurrentOperationMode.STANDBY, + current_inst_power=0.0, + error_code=0 + ) + + # First update sets initial state + await tracker.process(mac1, status1_v1) + assert emitter.emit.call_count == 0 + + # Second update triggers event + await tracker.process(mac1, status1_v2) + assert emitter.emit.call_count == 1 + + args, kwargs = emitter.emit.call_args + assert args[0] == "temperature_changed" + event = args[1] + assert isinstance(event, TemperatureChangedEvent) + assert event.device_mac == mac1 + + # Update for different device + status2_v1 = DeviceStatus.model_construct( + dhw_temperature_raw=110, + operation_mode=CurrentOperationMode.STANDBY, + current_inst_power=0.0, + error_code=0 + ) + status2_v2 = DeviceStatus.model_construct( + dhw_temperature_raw=114, + operation_mode=CurrentOperationMode.STANDBY, + current_inst_power=0.0, + error_code=0 + ) + + await tracker.process(mac2, status2_v1) + await tracker.process(mac2, status2_v2) + + # Should have emitted another event for mac2 + assert emitter.emit.call_count == 2 + args, kwargs = emitter.emit.call_args + event = args[1] + assert event.device_mac == mac2 + +def test_make_handler_injects_mac(): + """Test that MqttSubscriptionManager._make_handler injects mac_address.""" + # Mock dependencies for MqttSubscriptionManager + connection = MagicMock() + event_emitter = MagicMock() + schedule_coroutine = MagicMock() + + manager = MqttSubscriptionManager( + connection=connection, + client_id="test_client", + event_emitter=event_emitter, + schedule_coroutine=schedule_coroutine + ) + + mac = "00:11:22:33:44:55" + callback_called = [] + + def my_callback(parsed): + callback_called.append(parsed) + + handler = manager._make_handler( + model=DeviceStatus, + callback=my_callback, + key="status", + device_mac=mac + ) + + # Simulate receiving a message + message = { + "status": { + "command": 0, + "specialFunctionStatus": 0, + "errorCode": 0, + "subErrorCode": 0, + "smartDiagnostic": 0, + "faultStatus1": 0, + "faultStatus2": 0, + "wifiRssi": 0, + "dhwChargePer": 0.0, + "drEventStatus": 0, + "vacationDaySetting": 0, + "vacationDayElapsed": 0, + "antiLegionellaPeriod": 0, + "programReservationType": 0, + "tempFormulaType": 0, + "currentStatenum": 0, + "targetFanRpm": 0, + "currentFanRpm": 0, + "fanPwm": 0, + "mixingRate": 0.0, + "eevStep": 0, + "airFilterAlarmPeriod": 0, + "airFilterAlarmElapsed": 0, + "cumulatedOpTimeEvaFan": 0, + "cumulatedDhwFlowRate": 0.0, + "touStatus": 0, + "drOverrideStatus": 0, + "touOverrideStatus": 0, + "totalEnergyCapacity": 0.0, + "availableEnergyCapacity": 0.0, + "recircOperationMode": 0, + "recircPumpOperationStatus": 0, + "recircHotBtnReady": 0, + "recircOperationReason": 0, + "recircErrorStatus": 0, + "currentInstPower": 0.0, + "didReload": 0, + "operationBusy": 0, + "freezeProtectionUse": 0, + "dhwUse": 0, + "dhwUseSustained": 0, + "programReservationUse": 0, + "ecoUse": 0, + "compUse": 0, + "eevUse": 0, + "evaFanUse": 0, + "shutOffValveUse": 0, + "conOvrSensorUse": 0, + "wtrOvrSensorUse": 0, + "antiLegionellaUse": 0, + "antiLegionellaOperationBusy": 0, + "errorBuzzerUse": 0, + "currentHeatUse": 0, + "heatUpperUse": 0, + "heatLowerUse": 0, + "scaldUse": 0, + "airFilterAlarmUse": 0, + "recircOperationBusy": 0, + "recircReservationUse": 0, + "dhwTemperature": 100, + "dhwTemperatureSetting": 100, + "dhwTargetTemperatureSetting": 100, + "freezeProtectionTemperature": 100, + "dhwTemperature2": 100, + "hpUpperOnTempSetting": 100, + "hpUpperOffTempSetting": 100, + "hpLowerOnTempSetting": 100, + "hpLowerOffTempSetting": 100, + "heUpperOnTempSetting": 100, + "heUpperOffTempSetting": 100, + "heLowerOnTempSetting": 100, + "heLowerOffTempSetting": 100, + "heatMinOpTemperature": 100, + "recircTempSetting": 100, + "recircTemperature": 100, + "recircFaucetTemperature": 100, + "currentInletTemperature": 100, + "currentDhwFlowRate": 100, + "hpUpperOnDiffTempSetting": 100, + "hpUpperOffDiffTempSetting": 100, + "hpLowerOnDiffTempSetting": 100, + "hpLowerOffDiffTempSetting": 100, + "heUpperOnDiffTempSetting": 100, + "heUpperOffDiffTempSetting": 100, + "heLowerOnTDiffempSetting": 100, + "heLowerOffDiffTempSetting": 100, + "recircDhwFlowRate": 100, + "tankUpperTemperature": 100, + "tankLowerTemperature": 100, + "dischargeTemperature": 100, + "suctionTemperature": 100, + "evaporatorTemperature": 100, + "ambientTemperature": 100, + "targetSuperHeat": 100, + "currentSuperHeat": 100, + "operationMode": 0, + "dhwOperationSetting": 3, + "temperatureType": 2, + "freezeProtectionTempMin": 43.0, + "freezeProtectionTempMax": 65.0, + } + } + + handler("test/topic", message) + + assert len(callback_called) == 1 + parsed = callback_called[0] + assert isinstance(parsed, DeviceStatus) + assert parsed.mac_address == mac From c3a9cacbe8c8a92cf100b9d88c4593a3c37175c4 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Thu, 7 May 2026 22:50:06 -0700 Subject: [PATCH 22/29] docs: update changelog for multi-device support enhancements --- CHANGELOG.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 71ef2ff..87bce2b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,19 @@ Changelog ========= +Version 8.1.0 (2026-05-07) +========================== + +Features +-------- +- **Multi-device support enhancements**: Improved support for accounts with multiple + Navilink devices by injecting device identity into models and events. + - Added ``mac_address`` field to ``DeviceStatus`` and ``DeviceFeature`` models. + - Added ``device_mac`` attribute to all device-specific MQTT events (temperature + changes, mode changes, power updates, errors, etc.). + - Updated ``DeviceStateTracker`` and ``MqttSubscriptionManager`` to propagate + device identity correctly. + Version 8.0.0 (2026-05-06) =========================== From cd6e0c7a4a2c1b6704fa3e4bbb7c81b9358cf36b Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Fri, 8 May 2026 08:51:44 -0700 Subject: [PATCH 23/29] fix: resolve linting violations and formatting in multi-device support Addressing E501 (line too long), F401 (unused imports), and I001 (import sorting) in subscriptions.py and test_multi_device.py to fix CI failures. --- src/nwp500/mqtt/subscriptions.py | 10 ++++-- tests/test_multi_device.py | 57 +++++++++++++++++--------------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/src/nwp500/mqtt/subscriptions.py b/src/nwp500/mqtt/subscriptions.py index ce77b53..52cf1d8 100644 --- a/src/nwp500/mqtt/subscriptions.py +++ b/src/nwp500/mqtt/subscriptions.py @@ -495,12 +495,18 @@ def post_parse(feature: DeviceFeature) -> None: self._schedule_coroutine( self._event_emitter.emit( "feature_received", - FeatureReceivedEvent(device_mac=device_mac, feature=feature), + FeatureReceivedEvent( + device_mac=device_mac, feature=feature + ), ) ) handler = self._make_handler( - DeviceFeature, callback, "feature", post_parse, device_mac=device_mac + DeviceFeature, + callback, + "feature", + post_parse, + device_mac=device_mac, ) return await self.subscribe_device(device=device, callback=handler) diff --git a/tests/test_multi_device.py b/tests/test_multi_device.py index c64c98d..ab34104 100644 --- a/tests/test_multi_device.py +++ b/tests/test_multi_device.py @@ -1,22 +1,26 @@ +from unittest.mock import AsyncMock, MagicMock + import pytest -from unittest.mock import MagicMock, AsyncMock -from nwp500.models import DeviceStatus, DeviceFeature + +from nwp500.enums import CurrentOperationMode +from nwp500.events import EventEmitter +from nwp500.models import DeviceFeature, DeviceStatus +from nwp500.mqtt.state_tracker import DeviceStateTracker +from nwp500.mqtt.subscriptions import MqttSubscriptionManager from nwp500.mqtt_events import ( StatusReceivedEvent, TemperatureChangedEvent, - FeatureReceivedEvent, ) -from nwp500.mqtt.state_tracker import DeviceStateTracker -from nwp500.events import EventEmitter -from nwp500.enums import CurrentOperationMode, DhwOperationSetting -from nwp500.mqtt.subscriptions import MqttSubscriptionManager + def test_models_have_mac_address(): """Test that DeviceStatus and DeviceFeature have mac_address field.""" # Use model_construct to avoid providing all required fields - status = DeviceStatus.model_construct(command=0, mac_address="00:11:22:33:44:55") + status = DeviceStatus.model_construct( + command=0, mac_address="00:11:22:33:44:55" + ) assert status.mac_address == "00:11:22:33:44:55" - + feature = DeviceFeature.model_construct( controller_serial_number="ABC123", mac_address="00:11:22:33:44:55" @@ -28,7 +32,7 @@ def test_events_have_device_mac(): status = DeviceStatus.model_construct(command=0) event = StatusReceivedEvent(device_mac="00:11:22:33:44:55", status=status) assert event.device_mac == "00:11:22:33:44:55" - + temp_event = TemperatureChangedEvent( device_mac="00:11:22:33:44:55", old_temperature=120.0, @@ -42,12 +46,13 @@ async def test_state_tracker_emits_with_mac(): emitter = MagicMock(spec=EventEmitter) emitter.emit = AsyncMock(return_value=1) tracker = DeviceStateTracker(emitter) - + mac1 = "00:11:22:33:44:55" mac2 = "AA:BB:CC:DD:EE:FF" - + # We need to provide enough fields for computed properties if they are used - # DeviceStatus uses dhwTemperature computed property which uses dhw_temperature_raw + # DeviceStatus uses dhwTemperature computed property + # which uses dhw_temperature_raw status1_v1 = DeviceStatus.model_construct( dhw_temperature_raw=100, operation_mode=CurrentOperationMode.STANDBY, @@ -60,21 +65,21 @@ async def test_state_tracker_emits_with_mac(): current_inst_power=0.0, error_code=0 ) - + # First update sets initial state await tracker.process(mac1, status1_v1) assert emitter.emit.call_count == 0 - + # Second update triggers event await tracker.process(mac1, status1_v2) assert emitter.emit.call_count == 1 - + args, kwargs = emitter.emit.call_args assert args[0] == "temperature_changed" event = args[1] assert isinstance(event, TemperatureChangedEvent) assert event.device_mac == mac1 - + # Update for different device status2_v1 = DeviceStatus.model_construct( dhw_temperature_raw=110, @@ -88,10 +93,10 @@ async def test_state_tracker_emits_with_mac(): current_inst_power=0.0, error_code=0 ) - + await tracker.process(mac2, status2_v1) await tracker.process(mac2, status2_v2) - + # Should have emitted another event for mac2 assert emitter.emit.call_count == 2 args, kwargs = emitter.emit.call_args @@ -104,27 +109,27 @@ def test_make_handler_injects_mac(): connection = MagicMock() event_emitter = MagicMock() schedule_coroutine = MagicMock() - + manager = MqttSubscriptionManager( connection=connection, client_id="test_client", event_emitter=event_emitter, schedule_coroutine=schedule_coroutine ) - + mac = "00:11:22:33:44:55" callback_called = [] - + def my_callback(parsed): callback_called.append(parsed) - + handler = manager._make_handler( model=DeviceStatus, callback=my_callback, key="status", device_mac=mac ) - + # Simulate receiving a message message = { "status": { @@ -230,9 +235,9 @@ def my_callback(parsed): "freezeProtectionTempMax": 65.0, } } - + handler("test/topic", message) - + assert len(callback_called) == 1 parsed = callback_called[0] assert isinstance(parsed, DeviceStatus) From 48db4155680f73a31685301af13fc7fd93a9a9f1 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Fri, 8 May 2026 09:14:21 -0700 Subject: [PATCH 24/29] style: fix formatting in multi-device tests to match ruff 0.15.12 --- tests/test_multi_device.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/test_multi_device.py b/tests/test_multi_device.py index ab34104..f48101c 100644 --- a/tests/test_multi_device.py +++ b/tests/test_multi_device.py @@ -22,11 +22,11 @@ def test_models_have_mac_address(): assert status.mac_address == "00:11:22:33:44:55" feature = DeviceFeature.model_construct( - controller_serial_number="ABC123", - mac_address="00:11:22:33:44:55" + controller_serial_number="ABC123", mac_address="00:11:22:33:44:55" ) assert feature.mac_address == "00:11:22:33:44:55" + def test_events_have_device_mac(): """Test that events carry device_mac.""" status = DeviceStatus.model_construct(command=0) @@ -36,10 +36,11 @@ def test_events_have_device_mac(): temp_event = TemperatureChangedEvent( device_mac="00:11:22:33:44:55", old_temperature=120.0, - new_temperature=122.0 + new_temperature=122.0, ) assert temp_event.device_mac == "00:11:22:33:44:55" + @pytest.mark.asyncio async def test_state_tracker_emits_with_mac(): """Test that DeviceStateTracker includes mac_address in events.""" @@ -57,13 +58,13 @@ async def test_state_tracker_emits_with_mac(): dhw_temperature_raw=100, operation_mode=CurrentOperationMode.STANDBY, current_inst_power=0.0, - error_code=0 + error_code=0, ) status1_v2 = DeviceStatus.model_construct( dhw_temperature_raw=104, operation_mode=CurrentOperationMode.STANDBY, current_inst_power=0.0, - error_code=0 + error_code=0, ) # First update sets initial state @@ -85,13 +86,13 @@ async def test_state_tracker_emits_with_mac(): dhw_temperature_raw=110, operation_mode=CurrentOperationMode.STANDBY, current_inst_power=0.0, - error_code=0 + error_code=0, ) status2_v2 = DeviceStatus.model_construct( dhw_temperature_raw=114, operation_mode=CurrentOperationMode.STANDBY, current_inst_power=0.0, - error_code=0 + error_code=0, ) await tracker.process(mac2, status2_v1) @@ -103,6 +104,7 @@ async def test_state_tracker_emits_with_mac(): event = args[1] assert event.device_mac == mac2 + def test_make_handler_injects_mac(): """Test that MqttSubscriptionManager._make_handler injects mac_address.""" # Mock dependencies for MqttSubscriptionManager @@ -114,7 +116,7 @@ def test_make_handler_injects_mac(): connection=connection, client_id="test_client", event_emitter=event_emitter, - schedule_coroutine=schedule_coroutine + schedule_coroutine=schedule_coroutine, ) mac = "00:11:22:33:44:55" @@ -124,10 +126,7 @@ def my_callback(parsed): callback_called.append(parsed) handler = manager._make_handler( - model=DeviceStatus, - callback=my_callback, - key="status", - device_mac=mac + model=DeviceStatus, callback=my_callback, key="status", device_mac=mac ) # Simulate receiving a message From 355e5fc66ae5f1fbab3ef065d8f9168ea24408f8 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Fri, 8 May 2026 09:19:04 -0700 Subject: [PATCH 25/29] docs: fix CI failures in documentation build - Add missing click and rich dependencies to docs/requirements.txt - Fix typo in POWER_CHANGED docstring in mqtt_events.py - Fix nested list formatting in CHANGELOG.rst - Fix malformed ASCII table in energy_monitoring.rst --- CHANGELOG.rst | 3 +++ docs/guides/energy_monitoring.rst | 30 +++++++++++++++--------------- docs/requirements.txt | 2 ++ src/nwp500/mqtt_events.py | 2 +- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 87bce2b..5306206 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ Features -------- - **Multi-device support enhancements**: Improved support for accounts with multiple Navilink devices by injecting device identity into models and events. + - Added ``mac_address`` field to ``DeviceStatus`` and ``DeviceFeature`` models. - Added ``device_mac`` attribute to all device-specific MQTT events (temperature changes, mode changes, power updates, errors, etc.). @@ -66,12 +67,14 @@ integrations like Home Assistant: conversions or relied on the library's eager conversion, note that ``DeviceStatus`` fields like ``dhw_temperature`` are now properties. They return values based on the global unit system context (``us_customary`` by default). + * **Home Assistant Tip**: To ensure your state tracking is immune to unit system toggles within the library, use the new ``*_raw`` fields (e.g., ``status.dhw_temperature_raw``) for comparison logic, and use the properties only for display or when a converted value is explicitly needed. 4. **Remove ``from_dict()`` Calls**: The ``from_dict()`` method has been removed from all models. Use ``model_validate()`` instead. + * **Note**: ``AuthenticationResponse.model_validate()`` now automatically handles the ``"data": { ... }`` wrapper found in raw API responses. 5. **Subpackage Imports**: While top-level imports from ``nwp500.models`` are diff --git a/docs/guides/energy_monitoring.rst b/docs/guides/energy_monitoring.rst index b5c1e5f..7eef703 100644 --- a/docs/guides/energy_monitoring.rst +++ b/docs/guides/energy_monitoring.rst @@ -259,21 +259,21 @@ Energy Data Fields Reference Power Consumption ~~~~~~~~~~~~~~~~~ -+----------------------+------------+--------------+---------------------------+ -| Field | Type | Units | Description | -+======================+============+==============+===========================+ -| ``current_inst_power`` | float | W | Total instantaneous power | -| | | | consumption | -+------------------------+-----------+--------------+---------------------------+ -| ``comp_use`` | bool | - | Heat pump compressor | -| | | | active | -+------------------------+-----------+--------------+---------------------------+ -| ``heat_upper_use`` | bool | - | Upper electric heater | -| | | | active | -+------------------------+-----------+--------------+---------------------------+ -| ``heat_lower_use`` | bool | - | Lower electric heater | -| | | | active | -+------------------------+-----------+--------------+---------------------------+ ++------------------------+------------+--------------+---------------------------+ +| Field | Type | Units | Description | ++========================+============+==============+===========================+ +| ``current_inst_power`` | float | W | Total instantaneous power | +| | | | consumption | ++------------------------+------------+--------------+---------------------------+ +| ``comp_use`` | bool | - | Heat pump compressor | +| | | | active | ++------------------------+------------+--------------+---------------------------+ +| ``heat_upper_use`` | bool | - | Upper electric heater | +| | | | active | ++------------------------+------------+--------------+---------------------------+ +| ``heat_lower_use`` | bool | - | Lower electric heater | +| | | | active | ++------------------------+------------+--------------+---------------------------+ Cumulative Usage ~~~~~~~~~~~~~~~~ diff --git a/docs/requirements.txt b/docs/requirements.txt index d95cbca..ad30930 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,3 +4,5 @@ sphinx>=3.2.1 # sphinx_rtd_theme sphinxcontrib-openapi>=0.8.0 +click>=8.3.0 +rich>=14.3.0 diff --git a/src/nwp500/mqtt_events.py b/src/nwp500/mqtt_events.py index f4da01f..22aeac6 100644 --- a/src/nwp500/mqtt_events.py +++ b/src/nwp500/mqtt_events.py @@ -274,7 +274,7 @@ class MqttClientEvents: event (PowerChangedEvent): Event object with old_power and new_power fields. - See: :class:`PowerChangedEvent" + See: :class:`PowerChangedEvent` """ # Heating events From 82a8ba290617119703cec7e71e1482319d95499c Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Fri, 8 May 2026 10:03:13 -0700 Subject: [PATCH 26/29] =?UTF-8?q?docs:=20reorganize=20documentation=20into?= =?UTF-8?q?=20Di=C3=A1taxis=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorganized docs/ into tutorials, how-to, reference, and explanation quadrants. - Updated README.rst and docs/index.rst to reflect the new structure. - Systematic repair of internal :doc: and :ref: links. - Added a new Architecture explanation document. - Updated conf.py for the new API documentation location. --- README.rst | 256 ++---------------- docs/api/nwp500.rst | 183 ------------- docs/conf.py | 2 +- .../advanced-features.rst} | 10 +- docs/explanation/architecture.rst | 81 ++++++ docs/explanation/index.rst | 11 + .../authenticate.rst} | 0 .../auto-recovery.rst} | 0 .../diagnose-mqtt.rst} | 4 +- .../home-assistant.rst} | 6 +- docs/how-to/index.rst | 20 ++ .../maintenance.rst} | 6 +- .../manage-units.rst} | 8 +- .../monitor-status.rst} | 10 +- .../optimize-tou.rst} | 10 +- .../queue-commands.rst} | 6 +- .../schedule-operation.rst} | 12 +- .../track-energy.rst} | 6 +- docs/index.rst | 86 ++---- docs/{ => project}/authors.rst | 0 docs/{ => project}/changelog.rst | 0 .../{development => project}/contributing.rst | 0 docs/{development => project}/history.rst | 19 +- docs/{ => project}/license.rst | 0 docs/{ => reference}/configuration.rst | 8 +- docs/{ => reference}/enumerations.rst | 2 +- docs/reference/index.rst | 43 +++ docs/{ => reference}/installation.rst | 6 +- .../protocol/data_conversions.rst | 6 +- .../protocol/device_features.rst | 0 .../protocol/device_status.rst | 2 +- docs/{ => reference}/protocol/error_codes.rst | 0 .../protocol/mqtt_protocol.rst | 0 .../protocol/quick_reference.rst | 0 docs/{ => reference}/protocol/rest_api.rst | 0 .../{ => reference}/python_api/api_client.rst | 0 .../python_api/auth_client.rst | 0 docs/{ => reference}/python_api/cli.rst | 2 +- docs/{ => reference}/python_api/events.rst | 2 +- .../{ => reference}/python_api/exceptions.rst | 0 docs/{ => reference}/python_api/models.rst | 0 .../python_api/mqtt_client.rst | 10 +- .../getting-started.rst} | 12 +- 43 files changed, 280 insertions(+), 549 deletions(-) delete mode 100644 docs/api/nwp500.rst rename docs/{guides/advanced_features_explained.rst => explanation/advanced-features.rst} (97%) create mode 100644 docs/explanation/architecture.rst create mode 100644 docs/explanation/index.rst rename docs/{guides/authentication.rst => how-to/authenticate.rst} (100%) rename docs/{guides/auto_recovery.rst => how-to/auto-recovery.rst} (100%) rename docs/{guides/mqtt_diagnostics.rst => how-to/diagnose-mqtt.rst} (99%) rename docs/{guides/home_assistant_integration.rst => how-to/home-assistant.rst} (98%) create mode 100644 docs/how-to/index.rst rename docs/{guides/device_maintenance.rst => how-to/maintenance.rst} (93%) rename docs/{guides/unit_conversion.rst => how-to/manage-units.rst} (98%) rename docs/{guides/event_system.rst => how-to/monitor-status.rst} (97%) rename docs/{guides/time_of_use.rst => how-to/optimize-tou.rst} (98%) rename docs/{guides/command_queue.rst => how-to/queue-commands.rst} (97%) rename docs/{guides/scheduling.rst => how-to/schedule-operation.rst} (98%) rename docs/{guides/energy_monitoring.rst => how-to/track-energy.rst} (98%) rename docs/{ => project}/authors.rst (100%) rename docs/{ => project}/changelog.rst (100%) rename docs/{development => project}/contributing.rst (100%) rename docs/{development => project}/history.rst (95%) rename docs/{ => project}/license.rst (100%) rename docs/{ => reference}/configuration.rst (96%) rename docs/{ => reference}/enumerations.rst (99%) create mode 100644 docs/reference/index.rst rename docs/{ => reference}/installation.rst (96%) rename docs/{ => reference}/protocol/data_conversions.rst (99%) rename docs/{ => reference}/protocol/device_features.rst (100%) rename docs/{ => reference}/protocol/device_status.rst (99%) rename docs/{ => reference}/protocol/error_codes.rst (100%) rename docs/{ => reference}/protocol/mqtt_protocol.rst (100%) rename docs/{ => reference}/protocol/quick_reference.rst (100%) rename docs/{ => reference}/protocol/rest_api.rst (100%) rename docs/{ => reference}/python_api/api_client.rst (100%) rename docs/{ => reference}/python_api/auth_client.rst (100%) rename docs/{ => reference}/python_api/cli.rst (99%) rename docs/{ => reference}/python_api/events.rst (99%) rename docs/{ => reference}/python_api/exceptions.rst (100%) rename docs/{ => reference}/python_api/models.rst (100%) rename docs/{ => reference}/python_api/mqtt_client.rst (98%) rename docs/{quickstart.rst => tutorials/getting-started.rst} (94%) diff --git a/README.rst b/README.rst index 8f18838..51e6f99 100644 --- a/README.rst +++ b/README.rst @@ -7,260 +7,62 @@ nwp500-python Python library for Navien NWP500 Heat Pump Water Heater ======================================================== -A Python library for monitoring and controlling the Navien NWP500 Heat Pump Water Heater through the Navilink cloud service. +A complete Python library for monitoring and controlling the Navien NWP500 Heat Pump Water Heater through the Navilink cloud service. -**Documentation:** https://nwp500-python.readthedocs.io/ - -**Source Code:** https://github.com/eman/nwp500-python +* **Documentation:** https://nwp500-python.readthedocs.io/ +* **Source:** https://github.com/eman/nwp500-python Features ======== -* Monitor status (temperature, power, charge %) -* Set target water temperature -* Change operation mode -* Optional scheduling (reservations) -* Optional time-of-use settings -* Periodic high-temp cycle info -* Access detailed status fields - -* Async friendly - -Quick Start -=========== - -Installation ------------- - -Basic Installation (Library Only) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For using the library as a Python package: +* **Complete Interface:** Full support for both REST API and real-time MQTT (AWS IoT). +* **Monitoring:** Real-time tracking of temperature, power usage, tank charge, and component status. +* **Control:** Remote control of target temperatures, operation modes, and vacation settings. +* **Advanced Features:** Native support for reservations, time-of-use (TOU) optimization, and anti-legionella cycles. +* **Type-Safe:** Built with Pydantic for robust data validation and unit handling. +* **Async/Await:** Modern asyncio-based implementation for high-performance integration. + +Getting Started +=============== .. code-block:: bash pip install nwp500-python -Installation with CLI Support -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To use the CLI with rich formatting and colors: - -.. code-block:: bash - - pip install nwp500-python[cli] - -Basic Usage ------------ +Quick Example +------------- .. code-block:: python from nwp500 import NavienAuthClient, NavienAPIClient - # Authentication happens automatically when entering the context - async with NavienAuthClient("your_email@example.com", "your_password") as auth_client: - # Create API client - api_client = NavienAPIClient(auth_client=auth_client) - - # Get device data - devices = await api_client.list_devices() - device = devices[0] if devices else None + async with NavienAuthClient("email@example.com", "password") as auth: + api = NavienAPIClient(auth) + devices = await api.list_devices() - if device: - # Access status information - status = device.status - print(f"Water Temperature: {status.dhw_temperature}") - print(f"Tank Charge: {status.dhw_charge_per}%") - print(f"Power Consumption: {status.current_inst_power}W") - - # Set temperature - await api_client.set_device_temperature(device, 130) - - # Change operation mode - await api_client.set_device_mode(device, "heat_pump") - -For more detailed authentication information, see the `Authentication & Session Management `_ guide. - -MQTT Real-Time Monitoring --------------------------- - -Monitor your device in real-time using MQTT: - -.. code-block:: python - - from nwp500 import NavienAuthClient, NavienMqttClient - - async with NavienAuthClient("your_email@example.com", "your_password") as auth_client: - # Create MQTT client - mqtt_client = NavienMqttClient(auth_client=auth_client) - await mqtt_client.connect() - - # Subscribe to device status updates - def on_status(status): - print(f"Temperature: {status.dhw_temperature}°F") - print(f"Mode: {status.operation_mode}") - - device = (await api_client.list_devices())[0] - await mqtt_client.subscribe_device_status(device, on_status) - - # Keep the connection alive - await mqtt_client.wait() - - -Command Line Interface -====================== - -Monitor and control your Navien water heater from the terminal. - -**Installation Requirement:** - -.. code-block:: bash - - pip install nwp500-python[cli] - -Quick Reference ---------------- - -.. code-block:: bash - - # Set credentials via environment variables - export NAVIEN_EMAIL="your_email@example.com" - export NAVIEN_PASSWORD="your_password" - - # Get current device status - python3 -m nwp500.cli status - - # Get device information and firmware (via MQTT - DeviceFeature) - python3 -m nwp500.cli info - - # Get basic device info from REST API (DeviceInfo) - python3 -m nwp500.cli device-info - - # Get controller serial number - python3 -m nwp500.cli serial - - # Turn device on/off - python3 -m nwp500.cli power on - python3 -m nwp500.cli power off - - # Set operation mode - python3 -m nwp500.cli mode heat-pump - python3 -m nwp500.cli mode energy-saver - python3 -m nwp500.cli mode high-demand - python3 -m nwp500.cli mode electric - python3 -m nwp500.cli mode vacation - python3 -m nwp500.cli mode standby - - # Set target temperature - python3 -m nwp500.cli temp 140 - - # Set vacation days - python3 -m nwp500.cli vacation 7 - - # Trigger instant hot water - python3 -m nwp500.cli hot-button - - # Set recirculation pump mode (1-4) - python3 -m nwp500.cli recirc 2 - - # Reset air filter timer - python3 -m nwp500.cli reset-filter - - # Enable water program mode - python3 -m nwp500.cli water-program - - # View and update schedules - python3 -m nwp500.cli reservations get - python3 -m nwp500.cli reservations set '[{"hour": 6, "min": 0, ...}]' - - # Time-of-use settings - python3 -m nwp500.cli tou get - python3 -m nwp500.cli tou set on - - # Energy usage data - python3 -m nwp500.cli energy --year 2024 --months 10,11,12 - - # Demand response - python3 -m nwp500.cli dr enable - python3 -m nwp500.cli dr disable - - # Real-time monitoring (logs to CSV) - python3 -m nwp500.cli monitor - python3 -m nwp500.cli monitor -o my_data.csv - -**Global Options:** - -* ``--email EMAIL``: Navien account email (or use ``NAVIEN_EMAIL`` env var) -* ``--password PASSWORD``: Navien account password (or use ``NAVIEN_PASSWORD`` env var) -* ``-v, --verbose``: Enable debug logging -* ``--version``: Show version and exit - -**Available Commands:** - -* ``status``: Show current device status (temperature, mode, power) -* ``info``: Show device information (firmware, capabilities) -* ``serial``: Get controller serial number -* ``power on|off``: Turn device on or off -* ``mode MODE``: Set operation mode (heat-pump, electric, energy-saver, high-demand, vacation, standby) -* ``temp TEMPERATURE``: Set target water temperature in °F -* ``vacation DAYS``: Enable vacation mode for N days -* ``recirc MODE``: Set recirculation pump (1=always, 2=button, 3=schedule, 4=temperature) -* ``hot-button``: Trigger instant hot water -* ``reset-filter``: Reset air filter maintenance timer -* ``water-program``: Enable water program reservation mode -* ``reservations get|set``: View or update schedule -* ``tou get|set STATE``: View or configure time-of-use settings -* ``energy``: Query historical energy usage (requires ``--year`` and ``--months``) -* ``dr enable|disable``: Enable or disable demand response -* ``monitor``: Monitor device status in real-time (logs to CSV with ``-o`` option) - -Device Status Fields -==================== - -See the `full documentation `_ for all available status fields. + if devices: + device = devices[0] + print(f"Temperature: {device.status.dhw_temperature}°F") + await api.set_device_temperature(device, 130) Documentation ============= -Full docs: https://nwp500-python.readthedocs.io/ - -Home Assistant Integration -========================== +The documentation follows the `Diátaxis `_ framework: -Use this library with Home Assistant: `ha_nwp500 `_ +* `Tutorials `_: Start here if you're new to the library. +* `How-to Guides `_: Practical step-by-step recipes for specific tasks. +* `Reference `_: Technical descriptions of the API, models, and protocol. +* `Explanation `_: Understanding-oriented deep dives into the library's design and advanced features. -Data Models -=========== - -The library includes type-safe data models with automatic unit conversions: - -* **DeviceStatus**: Complete device status with 70+ fields -* **DeviceFeature**: Device capabilities, firmware versions, and configuration limits -* **OperationMode**: Enumeration of available operation modes -* **TemperatureUnit**: Celsius/Fahrenheit handling - -Requirements +Contributing ============ -* Python 3.14+ -* aiohttp >= 3.13.5 -* pydantic >= 2.0.0 -* awsiotsdk >= 1.29.0 +We welcome contributions! Please see our `Contributing Guide `_ for more details. License ======= -This project is licensed under the MIT License. - -Author -====== - -Emmanuel Levijarvi - -Acknowledgments -=============== - -This project has been set up using PyScaffold 4.6. For details and usage -information on PyScaffold see https://pyscaffold.org/. +This project is licensed under the MIT License. See the `LICENSE.txt `_ file for details. .. |PyPI-v| image:: https://img.shields.io/pypi/v/nwp500-python.svg :target: https://pypi.org/project/nwp500-python/ diff --git a/docs/api/nwp500.rst b/docs/api/nwp500.rst deleted file mode 100644 index 2c63a67..0000000 --- a/docs/api/nwp500.rst +++ /dev/null @@ -1,183 +0,0 @@ -nwp500 package -============== - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - nwp500.cli - nwp500.models - nwp500.mqtt - -Submodules ----------- - -nwp500.api\_client module -------------------------- - -.. automodule:: nwp500.api_client - :members: - :show-inheritance: - :undoc-members: - -nwp500.auth module ------------------- - -.. automodule:: nwp500.auth - :members: - :show-inheritance: - :undoc-members: - -nwp500.command\_decorators module ---------------------------------- - -.. automodule:: nwp500.command_decorators - :members: - :show-inheritance: - :undoc-members: - -nwp500.config module --------------------- - -.. automodule:: nwp500.config - :members: - :show-inheritance: - :undoc-members: - -nwp500.converters module ------------------------- - -.. automodule:: nwp500.converters - :members: - :show-inheritance: - :undoc-members: - -nwp500.device\_capabilities module ----------------------------------- - -.. automodule:: nwp500.device_capabilities - :members: - :show-inheritance: - :undoc-members: - -nwp500.device\_info\_cache module ---------------------------------- - -.. automodule:: nwp500.device_info_cache - :members: - :show-inheritance: - :undoc-members: - -nwp500.encoding module ----------------------- - -.. automodule:: nwp500.encoding - :members: - :show-inheritance: - :undoc-members: - -nwp500.enums module -------------------- - -.. automodule:: nwp500.enums - :members: - :show-inheritance: - :undoc-members: - -nwp500.events module --------------------- - -.. automodule:: nwp500.events - :members: - :show-inheritance: - :undoc-members: - -nwp500.exceptions module ------------------------- - -.. automodule:: nwp500.exceptions - :members: - :show-inheritance: - :undoc-members: - -nwp500.factory module ---------------------- - -.. automodule:: nwp500.factory - :members: - :show-inheritance: - :undoc-members: - -nwp500.field\_factory module ----------------------------- - -.. automodule:: nwp500.field_factory - :members: - :show-inheritance: - :undoc-members: - -nwp500.mqtt\_events module --------------------------- - -.. automodule:: nwp500.mqtt_events - :members: - :show-inheritance: - :undoc-members: - -nwp500.openei module --------------------- - -.. automodule:: nwp500.openei - :members: - :show-inheritance: - :undoc-members: - -nwp500.reservations module --------------------------- - -.. automodule:: nwp500.reservations - :members: - :show-inheritance: - :undoc-members: - -nwp500.temperature module -------------------------- - -.. automodule:: nwp500.temperature - :members: - :show-inheritance: - :undoc-members: - -nwp500.topic\_builder module ----------------------------- - -.. automodule:: nwp500.topic_builder - :members: - :show-inheritance: - :undoc-members: - -nwp500.unit\_system module --------------------------- - -.. automodule:: nwp500.unit_system - :members: - :show-inheritance: - :undoc-members: - -nwp500.utils module -------------------- - -.. automodule:: nwp500.utils - :members: - :show-inheritance: - :undoc-members: - -Module contents ---------------- - -.. automodule:: nwp500 - :members: - :show-inheritance: - :undoc-members: diff --git a/docs/conf.py b/docs/conf.py index ba6d57b..059a79b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,7 +33,7 @@ except ImportError: from sphinx import apidoc -output_dir = os.path.join(__location__, "api") +output_dir = os.path.join(__location__, "reference", "api") module_dir = os.path.join(__location__, "../src/nwp500") try: shutil.rmtree(output_dir) diff --git a/docs/guides/advanced_features_explained.rst b/docs/explanation/advanced-features.rst similarity index 97% rename from docs/guides/advanced_features_explained.rst rename to docs/explanation/advanced-features.rst index 5dd663c..8c6bfcb 100644 --- a/docs/guides/advanced_features_explained.rst +++ b/docs/explanation/advanced-features.rst @@ -464,7 +464,7 @@ The NWP500 uses **half-degrees Celsius** encoding for temperature fields. **Related Documentation**: -See :doc:`../protocol/data_conversions` for complete field conversion reference and formula applications. +See :doc:`../reference/protocol/data_conversions` for complete field conversion reference and formula applications. Summary and Recommendations ============================ @@ -491,7 +491,7 @@ Summary and Recommendations See Also -------- -* :doc:`../protocol/data_conversions` - Temperature field conversions (HalfCelsiusToF, DeciCelsiusToF) -* :doc:`../protocol/device_status` - Complete device status field reference -* :doc:`scheduling` - Scheduling and automation guide -* :doc:`../python_api/models` - DeviceStatus model field definitions +* :doc:`../reference/protocol/data_conversions` - Temperature field conversions (HalfCelsiusToF, DeciCelsiusToF) +* :doc:`../reference/protocol/device_status` - Complete device status field reference +* :doc:`../how-to/schedule-operation` - Scheduling and automation guide +* :doc:`../reference/python_api/models` - DeviceStatus model field definitions diff --git a/docs/explanation/architecture.rst b/docs/explanation/architecture.rst new file mode 100644 index 0000000..b5942e8 --- /dev/null +++ b/docs/explanation/architecture.rst @@ -0,0 +1,81 @@ +============ +Architecture +============ + +This document explains the high-level architecture of the ``nwp500-python`` library and how it interacts with the Navien Smart Control cloud platform. + +System Overview +=============== + +The library acts as a bridge between your Python application and the Navien NWP500 Heat Pump Water Heater. Communication happens through two primary channels: + +1. **REST API**: Used for authentication, account management, and listing devices. +2. **MQTT (AWS IoT)**: Used for real-time monitoring and control of the device. + +Component Diagram +================= + +.. code-block:: text + + +-------------------+ +------------------------+ + | | | | + | Your Application | | Navien Smart Control | + | | | (Cloud) | + +---------+---------+ +-----------+------------+ + | | + | +-------------+ | + +------>| Auth Client |<------+ (Sign-in/Tokens) + | +-------------+ | + | | + | +-------------+ | + +------>| API Client |<------+ (REST API) + | +-------------+ | + | | + | +-------------+ | + +------>| MQTT Client |<------+ (AWS IoT Core) + +-------------+ + +Core Components +=============== + +Authentication Client +--------------------- + +The ``NavienAuthClient`` is responsible for managing credentials and tokens. It performs the initial sign-in to obtain: +* A JWT access token for REST API requests. +* AWS IoT credentials (identity ID, session token, etc.) for MQTT connection. + +REST API Client +--------------- + +The ``NavienAPIClient`` provides methods for "heavy" or infrequent operations, such as: +* Retrieving the list of devices. +* Getting detailed device information. +* Accessing historical energy data. + +MQTT Client +----------- + +The ``NavienMqttClient`` is the heart of real-time interaction. It maintains a persistent connection to AWS IoT Core and handles: +* Subscribing to device status updates. +* Publishing control commands (e.g., setting temperature). +* Parsing incoming hex-encoded payloads into structured data models. + +Data Models +=========== + +The library uses **Pydantic** for all data models. This ensures: +* **Type Safety**: All fields have explicit types. +* **Validation**: Incoming data is validated against expected formats. +* **Unit Handling**: Temperatures and other units are automatically converted to appropriate scales (e.g., Fahrenheit). + +Event System +============ + +The library implements an asynchronous event system. You can subscribe to various events (e.g., status updates, connection changes) and provide callback functions that will be executed when those events occur. + +See Also +======== + +* :doc:`advanced-features` - Deep dive into TOU, reservations, and more. +* :doc:`../reference/protocol/mqtt_protocol` - Low-level details of the MQTT messaging. diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst new file mode 100644 index 0000000..f1fe4c9 --- /dev/null +++ b/docs/explanation/index.rst @@ -0,0 +1,11 @@ +=========== +Explanation +=========== + +Understanding-oriented deep dives into the library's design and advanced features. + +.. toctree:: + :maxdepth: 1 + + advanced-features + architecture diff --git a/docs/guides/authentication.rst b/docs/how-to/authenticate.rst similarity index 100% rename from docs/guides/authentication.rst rename to docs/how-to/authenticate.rst diff --git a/docs/guides/auto_recovery.rst b/docs/how-to/auto-recovery.rst similarity index 100% rename from docs/guides/auto_recovery.rst rename to docs/how-to/auto-recovery.rst diff --git a/docs/guides/mqtt_diagnostics.rst b/docs/how-to/diagnose-mqtt.rst similarity index 99% rename from docs/guides/mqtt_diagnostics.rst rename to docs/how-to/diagnose-mqtt.rst index a76d2d1..dca3fa7 100644 --- a/docs/guides/mqtt_diagnostics.rst +++ b/docs/how-to/diagnose-mqtt.rst @@ -1116,8 +1116,8 @@ Investigation Checklist See Also ======== -- :doc:`../protocol/device_status` - Device status field reference -- :doc:`../python_api/mqtt_client` - MQTT client API documentation +- :doc:`../reference/protocol/device_status` - Device status field reference +- :doc:`../reference/python_api/mqtt_client` - MQTT client API documentation External Resources diff --git a/docs/guides/home_assistant_integration.rst b/docs/how-to/home-assistant.rst similarity index 98% rename from docs/guides/home_assistant_integration.rst rename to docs/how-to/home-assistant.rst index 22e608b..ef66a20 100644 --- a/docs/guides/home_assistant_integration.rst +++ b/docs/how-to/home-assistant.rst @@ -365,6 +365,6 @@ Best Practices See Also ======== -- :doc:`../python_api/models` - Complete model reference -- :doc:`../protocol/data_conversions` - Detailed conversion formulas -- :doc:`../enumerations` - TemperatureType and other enumerations +- :doc:`../reference/python_api/models` - Complete model reference +- :doc:`../reference/protocol/data_conversions` - Detailed conversion formulas +- :doc:`../reference/enumerations` - TemperatureType and other enumerations diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst new file mode 100644 index 0000000..60ac6d9 --- /dev/null +++ b/docs/how-to/index.rst @@ -0,0 +1,20 @@ +============= +How-to Guides +============= + +Practical step-by-step recipes for specific tasks. + +.. toctree:: + :maxdepth: 1 + + authenticate + monitor-status + track-energy + schedule-operation + manage-units + queue-commands + auto-recovery + optimize-tou + diagnose-mqtt + maintenance + home-assistant diff --git a/docs/guides/device_maintenance.rst b/docs/how-to/maintenance.rst similarity index 93% rename from docs/guides/device_maintenance.rst rename to docs/how-to/maintenance.rst index f37f07f..bfe04e1 100644 --- a/docs/guides/device_maintenance.rst +++ b/docs/how-to/maintenance.rst @@ -114,6 +114,6 @@ The result is reflected in the next Related Documentation ===================== -* :doc:`../python_api/mqtt_client` - Full MQTT client API reference -* :doc:`scheduling` - Reservations, recirculation schedules, and intelligent scheduling -* :doc:`mqtt_diagnostics` - Connection troubleshooting and diagnostics +* :doc:`../reference/python_api/mqtt_client` - Full MQTT client API reference +* :doc:`schedule-operation` - Reservations, recirculation schedules, and intelligent scheduling +* :doc:`diagnose-mqtt` - Connection troubleshooting and diagnostics diff --git a/docs/guides/unit_conversion.rst b/docs/how-to/manage-units.rst similarity index 98% rename from docs/guides/unit_conversion.rst rename to docs/how-to/manage-units.rst index 245e600..eec13f6 100644 --- a/docs/guides/unit_conversion.rst +++ b/docs/how-to/manage-units.rst @@ -657,7 +657,7 @@ This feature represents a breaking change from previous versions where all value See Also ======== -- :doc:`../python_api/models` - Complete model reference -- :doc:`../enumerations` - TemperatureType and TemperatureFormulaType enumerations -- :doc:`../protocol/data_conversions` - Raw protocol data formats -- :doc:`home_assistant_integration` - Home Assistant integration guide +- :doc:`../reference/python_api/models` - Complete model reference +- :doc:`../reference/enumerations` - TemperatureType and TemperatureFormulaType enumerations +- :doc:`../reference/protocol/data_conversions` - Raw protocol data formats +- :doc:`home-assistant` - Home Assistant integration guide diff --git a/docs/guides/event_system.rst b/docs/how-to/monitor-status.rst similarity index 97% rename from docs/guides/event_system.rst rename to docs/how-to/monitor-status.rst index c9bef44..7fb2160 100644 --- a/docs/guides/event_system.rst +++ b/docs/how-to/monitor-status.rst @@ -38,7 +38,7 @@ derived state transitions (temperature delta, mode change, etc.): mqtt.on(MqttClientEvents.STATUS_RECEIVED, on_status_event) -See :doc:`../python_api/events` for the full event dataclass reference. +See :doc:`../reference/python_api/events` for the full event dataclass reference. Available Events ---------------- @@ -119,7 +119,7 @@ Use ``MqttClientEvents`` constants to avoid typos and get IDE autocomplete: for event_name in MqttClientEvents.get_all_events(): print(f" - {event_name}") -See :doc:`../python_api/events` for the event dataclass reference. +See :doc:`../reference/python_api/events` for the event dataclass reference. Advanced Patterns ================= @@ -553,6 +553,6 @@ Buffer updates and flush periodically to reduce I/O overhead: Related Documentation ===================== -* :doc:`../python_api/events` - Event API reference -* :doc:`../python_api/mqtt_client` - MQTT client -* :doc:`../python_api/models` - Data models +* :doc:`../reference/python_api/events` - Event API reference +* :doc:`../reference/python_api/mqtt_client` - MQTT client +* :doc:`../reference/python_api/models` - Data models diff --git a/docs/guides/time_of_use.rst b/docs/how-to/optimize-tou.rst similarity index 98% rename from docs/guides/time_of_use.rst rename to docs/how-to/optimize-tou.rst index ea63915..bc23a50 100644 --- a/docs/guides/time_of_use.rst +++ b/docs/how-to/optimize-tou.rst @@ -910,7 +910,7 @@ The device status includes TOU-related fields: * ``touStatus``: ``1`` if TOU scheduling is enabled/active, ``0`` if disabled/inactive * ``touOverrideStatus``: ``2`` (ON) = TOU schedule is operating normally, ``1`` (OFF) = user has overridden TOU to force immediate heating -See :doc:`../protocol/device_status` for more details. +See :doc:`../reference/protocol/device_status` for more details. Best Practices -------------- @@ -955,10 +955,10 @@ Limitations Further Reading --------------- -* :doc:`../python_api/api_client` - API client documentation and ``get_tou_info()`` method -* :doc:`../python_api/mqtt_client` - MQTT client and TOU configuration methods -* :doc:`../protocol/mqtt_protocol` - MQTT message formats including TOU commands -* :doc:`../protocol/device_status` - Device status fields including ``touStatus`` +* :doc:`../reference/python_api/api_client` - API client documentation and ``get_tou_info()`` method +* :doc:`../reference/python_api/mqtt_client` - MQTT client and TOU configuration methods +* :doc:`../reference/protocol/mqtt_protocol` - MQTT message formats including TOU commands +* :doc:`../reference/protocol/device_status` - Device status fields including ``touStatus`` * `OpenEI Utility Rates API `__ - Official OpenEI API documentation * `OpenEI IURDB `__ - Interactive Utility Rate Database diff --git a/docs/guides/command_queue.rst b/docs/how-to/queue-commands.rst similarity index 97% rename from docs/guides/command_queue.rst rename to docs/how-to/queue-commands.rst index 631f00f..2e07312 100644 --- a/docs/guides/command_queue.rst +++ b/docs/how-to/queue-commands.rst @@ -297,9 +297,9 @@ Technical Notes See Also ======== -- :doc:`../python_api/mqtt_client` - MQTT client documentation -- :doc:`../python_api/events` - Event emitter documentation -- :doc:`../python_api/auth_client` - Authentication and tokens +- :doc:`../reference/python_api/mqtt_client` - MQTT client documentation +- :doc:`../reference/python_api/events` - Event emitter documentation +- :doc:`../reference/python_api/auth_client` - Authentication and tokens Example Code ============ diff --git a/docs/guides/scheduling.rst b/docs/how-to/schedule-operation.rst similarity index 98% rename from docs/guides/scheduling.rst rename to docs/how-to/schedule-operation.rst index c599e67..1287578 100644 --- a/docs/guides/scheduling.rst +++ b/docs/how-to/schedule-operation.rst @@ -689,7 +689,7 @@ Time of Use (TOU) TOU scheduling allows price-aware heating optimization based on your utility's electricity rate structure. For the full TOU guide including -OpenEI integration, see :doc:`time_of_use`. +OpenEI integration, see :doc:`optimize-tou`. TOU Period Structure -------------------- @@ -892,9 +892,9 @@ Anti-legionella is especially important when: See Also ======== -* :doc:`time_of_use` — Full TOU guide with OpenEI integration -* :doc:`../python_api/mqtt_client` — MQTT client API reference -* :doc:`device_maintenance` — Maintenance and OTA operations -* :doc:`../protocol/data_conversions` — Temperature and power field +* :doc:`optimize-tou` — Full TOU guide with OpenEI integration +* :doc:`../reference/python_api/mqtt_client` — MQTT client API reference +* :doc:`maintenance` — Maintenance and OTA operations +* :doc:`../reference/protocol/data_conversions` — Temperature and power field conversions -* :doc:`auto_recovery` — Handling temporary connectivity issues +* :doc:`auto-recovery` — Handling temporary connectivity issues diff --git a/docs/guides/energy_monitoring.rst b/docs/how-to/track-energy.rst similarity index 98% rename from docs/guides/energy_monitoring.rst rename to docs/how-to/track-energy.rst index 7eef703..facd2e4 100644 --- a/docs/guides/energy_monitoring.rst +++ b/docs/how-to/track-energy.rst @@ -328,6 +328,6 @@ Notes See Also -------- -- :doc:`../protocol/device_status` - Complete list of all status fields -- :doc:`../python_api/mqtt_client` - How to connect and subscribe to device updates -- :doc:`../protocol/mqtt_protocol` - Message format reference +- :doc:`../reference/protocol/device_status` - Complete list of all status fields +- :doc:`../reference/python_api/mqtt_client` - How to connect and subscribe to device updates +- :doc:`../reference/protocol/mqtt_protocol` - Message format reference diff --git a/docs/index.rst b/docs/index.rst index 60cbafd..0cb6734 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,93 +19,45 @@ This library provides a complete Python interface to Navien NWP500 heat pump water heaters through the Navien Smart Control cloud platform. It supports both REST API and real-time MQTT communication. -**Features:** - -* **REST API Client** - Complete implementation of Navien Smart Control - API -* **MQTT Client** - Real-time device communication via AWS IoT Core -* **Authentication** - JWT-based auth with automatic token refresh -* **Type Safety** - Type-annotated models for all device data -* **Event System** - Subscribe to device state changes with callbacks -* **Energy Monitoring** - Track power consumption and usage statistics -* **Time-of-Use (TOU)** - Optimize for variable electricity pricing -* **Async/Await** - Built on asyncio throughout - -Quick Start -=========== - -Install with ``pip install nwp500-python``, then see the :doc:`quickstart` guide. +Documentation +============= -Documentation Index -=================== +The documentation is organized into four sections, following the +`Diátaxis `_ framework: .. toctree:: - :maxdepth: 1 - :caption: Getting Started + :maxdepth: 2 + :caption: Tutorials - quickstart - installation - configuration + tutorials/getting-started .. toctree:: :maxdepth: 2 - :caption: Python API Reference + :caption: How-to Guides - python_api/auth_client - python_api/api_client - python_api/mqtt_client - python_api/models - enumerations - python_api/events - python_api/exceptions - python_api/cli + how-to/index .. toctree:: :maxdepth: 2 - :caption: Complete Module Reference + :caption: Reference - api/modules - - - -.. toctree:: - :maxdepth: 1 - :caption: User Guides - - guides/authentication - guides/event_system - guides/command_queue - guides/auto_recovery - guides/scheduling - guides/device_maintenance - guides/energy_monitoring - guides/time_of_use - guides/unit_conversion - guides/home_assistant_integration - guides/mqtt_diagnostics - guides/advanced_features_explained + reference/index .. toctree:: :maxdepth: 2 - :caption: Advanced: Protocol Reference + :caption: Explanation - protocol/quick_reference - protocol/rest_api - protocol/mqtt_protocol - protocol/device_status - protocol/data_conversions - protocol/device_features - protocol/error_codes + explanation/index .. toctree:: :maxdepth: 1 - :caption: Development + :caption: Project Information - development/contributing - development/history - changelog - license - authors + project/contributing + project/history + project/changelog + project/license + project/authors Indices and tables ================== diff --git a/docs/authors.rst b/docs/project/authors.rst similarity index 100% rename from docs/authors.rst rename to docs/project/authors.rst diff --git a/docs/changelog.rst b/docs/project/changelog.rst similarity index 100% rename from docs/changelog.rst rename to docs/project/changelog.rst diff --git a/docs/development/contributing.rst b/docs/project/contributing.rst similarity index 100% rename from docs/development/contributing.rst rename to docs/project/contributing.rst diff --git a/docs/development/history.rst b/docs/project/history.rst similarity index 95% rename from docs/development/history.rst rename to docs/project/history.rst index 1986dbd..5255886 100644 --- a/docs/development/history.rst +++ b/docs/project/history.rst @@ -85,8 +85,8 @@ compatibility - Automatic credential handling from authentication API - Session ID generation for connection tracking **Key Files:** - ``src/nwp500/mqtt_client.py`` - MQTT client -implementation - :doc:`../python_api/mqtt_client` - Complete documentation - -:doc:`../protocol/mqtt_protocol` - Message format reference +implementation - :doc:`../reference/python_api/mqtt_client` - Complete documentation - +:doc:`../reference/protocol/mqtt_protocol` - Message format reference Device Status & Feature Callbacks (October 7, 2025) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -265,11 +265,12 @@ Complete event-driven architecture for device state changes: - ``src/nwp500/events.py`` - EventEmitter implementation (370 lines) - ``src/nwp500/mqtt_client.py`` - MQTT integration with event emitter - ``examples/event_emitter_demo.py`` - Comprehensive demonstration -- ``tests/test_events.py`` - Unit tests (19 tests) -- :doc:`../python_api/events` - Feature documentation +- tests/test_events.py - Unit tests (19 tests) +- :doc:`../reference/python_api/events` - Feature documentation **Thread Safety Implementation:** + MQTT callbacks run in separate threads (e.g., 'Dummy-1') created by AWS IoT SDK. To safely emit events: 1. Event loop captured during ``connect()`` via ``asyncio.get_running_loop()`` @@ -323,8 +324,8 @@ References ---------- - `OpenAPI Specification `__ - API specification -- :doc:`../protocol/mqtt_protocol` - MQTT message reference -- :doc:`../protocol/device_status` - Device status fields -- :doc:`../python_api/auth_client` - Authentication guide -- :doc:`../python_api/api_client` - API client guide -- :doc:`../python_api/mqtt_client` - MQTT client guide +- :doc:`../reference/protocol/mqtt_protocol` - MQTT message reference +- :doc:`../reference/protocol/device_status` - Device status fields +- :doc:`../reference/python_api/auth_client` - Authentication guide +- :doc:`../reference/python_api/api_client` - API client guide +- :doc:`../reference/python_api/mqtt_client` - MQTT client guide diff --git a/docs/license.rst b/docs/project/license.rst similarity index 100% rename from docs/license.rst rename to docs/project/license.rst diff --git a/docs/configuration.rst b/docs/reference/configuration.rst similarity index 96% rename from docs/configuration.rst rename to docs/reference/configuration.rst index c5228c3..d5f8c7e 100644 --- a/docs/configuration.rst +++ b/docs/reference/configuration.rst @@ -144,8 +144,8 @@ The MQTT client supports various configuration options through For detailed configuration guides, see: -* :doc:`guides/auto_recovery` - Connection recovery settings -* :doc:`guides/command_queue` - Offline command queuing +* :doc:`../how-to/auto-recovery` - Connection recovery settings +* :doc:`../how-to/queue-commands` - Offline command queuing Basic Example ------------- @@ -281,7 +281,7 @@ Example: Production Configuration Next Steps ========== -* :doc:`quickstart` - Build your first application +* :doc:`../tutorials/getting-started` - Build your first application * :doc:`python_api/auth_client` - Authentication details * :doc:`python_api/mqtt_client` - MQTT client configuration -* :doc:`guides/auto_recovery` - Automatic reconnection guide +* :doc:`../how-to/auto-recovery` - Automatic reconnection guide diff --git a/docs/enumerations.rst b/docs/reference/enumerations.rst similarity index 99% rename from docs/enumerations.rst rename to docs/reference/enumerations.rst index cd4afc2..88690a4 100644 --- a/docs/enumerations.rst +++ b/docs/reference/enumerations.rst @@ -305,5 +305,5 @@ Related Documentation For detailed protocol documentation, see: - :doc:`protocol/device_status` - Status field definitions -- :doc:`guides/time_of_use` - TOU scheduling and rate types +- :doc:`../how-to/optimize-tou` - TOU scheduling and rate types - :doc:`protocol/quick_reference` - Quick reference and control commands diff --git a/docs/reference/index.rst b/docs/reference/index.rst new file mode 100644 index 0000000..e4cb0b1 --- /dev/null +++ b/docs/reference/index.rst @@ -0,0 +1,43 @@ +========= +Reference +========= + +Technical descriptions of the API, models, and protocol. + +Python API +---------- + +.. toctree:: + :maxdepth: 1 + + python_api/auth_client + python_api/api_client + python_api/mqtt_client + python_api/models + python_api/events + python_api/exceptions + python_api/cli + api/modules + +Protocol Reference +------------------ + +.. toctree:: + :maxdepth: 1 + + protocol/rest_api + protocol/mqtt_protocol + protocol/device_status + protocol/data_conversions + protocol/device_features + protocol/error_codes + +General Reference +----------------- + +.. toctree:: + :maxdepth: 1 + + enumerations + installation + configuration diff --git a/docs/installation.rst b/docs/reference/installation.rst similarity index 96% rename from docs/installation.rst rename to docs/reference/installation.rst index 162101c..a0ba2f0 100644 --- a/docs/installation.rst +++ b/docs/reference/installation.rst @@ -18,7 +18,11 @@ The easiest way to install nwp500-python: pip install nwp500-python -This will install the library and all required dependencies. +For rich formatting and colors when using the CLI: + +.. code-block:: bash + + pip install nwp500-python[cli] Installing from Source ====================== diff --git a/docs/protocol/data_conversions.rst b/docs/reference/protocol/data_conversions.rst similarity index 99% rename from docs/protocol/data_conversions.rst rename to docs/reference/protocol/data_conversions.rst index f53053d..5e4f631 100644 --- a/docs/protocol/data_conversions.rst +++ b/docs/reference/protocol/data_conversions.rst @@ -660,6 +660,6 @@ See Also * :doc:`device_status` - Complete status message structure and field definitions * :doc:`error_codes` - Error code reference for fault diagnosis -* :doc:`../guides/energy_monitoring` - Using energy data for optimization -* :doc:`../guides/time_of_use` - TOU scheduling and rate optimization -* :doc:`../guides/advanced_features_explained` - Weather-responsive heating, demand response, and tank stratification +* :doc:`../../how-to/track-energy` - Using energy data for optimization +* :doc:`../../how-to/optimize-tou` - TOU scheduling and rate optimization +* :doc:`../../explanation/advanced-features` - Weather-responsive heating, demand response, and tank stratification diff --git a/docs/protocol/device_features.rst b/docs/reference/protocol/device_features.rst similarity index 100% rename from docs/protocol/device_features.rst rename to docs/reference/protocol/device_features.rst diff --git a/docs/protocol/device_status.rst b/docs/reference/protocol/device_status.rst similarity index 99% rename from docs/protocol/device_status.rst rename to docs/reference/protocol/device_status.rst index 2b9789f..b03d25a 100644 --- a/docs/protocol/device_status.rst +++ b/docs/reference/protocol/device_status.rst @@ -764,5 +764,5 @@ See Also -------- * :doc:`error_codes` - Complete error code reference with diagnostics -* :doc:`../guides/energy_monitoring` - Energy consumption tracking +* :doc:`../../how-to/track-energy` - Energy consumption tracking * :doc:`mqtt_protocol` - Status message format details diff --git a/docs/protocol/error_codes.rst b/docs/reference/protocol/error_codes.rst similarity index 100% rename from docs/protocol/error_codes.rst rename to docs/reference/protocol/error_codes.rst diff --git a/docs/protocol/mqtt_protocol.rst b/docs/reference/protocol/mqtt_protocol.rst similarity index 100% rename from docs/protocol/mqtt_protocol.rst rename to docs/reference/protocol/mqtt_protocol.rst diff --git a/docs/protocol/quick_reference.rst b/docs/reference/protocol/quick_reference.rst similarity index 100% rename from docs/protocol/quick_reference.rst rename to docs/reference/protocol/quick_reference.rst diff --git a/docs/protocol/rest_api.rst b/docs/reference/protocol/rest_api.rst similarity index 100% rename from docs/protocol/rest_api.rst rename to docs/reference/protocol/rest_api.rst diff --git a/docs/python_api/api_client.rst b/docs/reference/python_api/api_client.rst similarity index 100% rename from docs/python_api/api_client.rst rename to docs/reference/python_api/api_client.rst diff --git a/docs/python_api/auth_client.rst b/docs/reference/python_api/auth_client.rst similarity index 100% rename from docs/python_api/auth_client.rst rename to docs/reference/python_api/auth_client.rst diff --git a/docs/python_api/cli.rst b/docs/reference/python_api/cli.rst similarity index 99% rename from docs/python_api/cli.rst rename to docs/reference/python_api/cli.rst index 9d3e853..9464aa8 100644 --- a/docs/python_api/cli.rst +++ b/docs/reference/python_api/cli.rst @@ -815,4 +815,4 @@ Related Documentation * :doc:`auth_client` - Python authentication API * :doc:`api_client` - Python REST API * :doc:`mqtt_client` - Python MQTT API -* :doc:`../guides/auto_recovery` - Connection recovery and resilience +* :doc:`../../how-to/auto-recovery` - Connection recovery and resilience diff --git a/docs/python_api/events.rst b/docs/reference/python_api/events.rst similarity index 99% rename from docs/python_api/events.rst rename to docs/reference/python_api/events.rst index d9e7713..ca2cabe 100644 --- a/docs/python_api/events.rst +++ b/docs/reference/python_api/events.rst @@ -284,4 +284,4 @@ Related Documentation * :doc:`mqtt_client` - MQTT client API reference * :doc:`models` - Models used by subscription callbacks -* :doc:`../guides/event_system` - Event-driven programming guide +* :doc:`../../how-to/monitor-status` - Event-driven programming guide diff --git a/docs/python_api/exceptions.rst b/docs/reference/python_api/exceptions.rst similarity index 100% rename from docs/python_api/exceptions.rst rename to docs/reference/python_api/exceptions.rst diff --git a/docs/python_api/models.rst b/docs/reference/python_api/models.rst similarity index 100% rename from docs/python_api/models.rst rename to docs/reference/python_api/models.rst diff --git a/docs/python_api/mqtt_client.rst b/docs/reference/python_api/mqtt_client.rst similarity index 98% rename from docs/python_api/mqtt_client.rst rename to docs/reference/python_api/mqtt_client.rst index 239bc0e..7eb4ec4 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/reference/python_api/mqtt_client.rst @@ -1116,8 +1116,8 @@ Related Documentation * :doc:`events` - Event system * :doc:`exceptions` - Exception handling * :doc:`../protocol/mqtt_protocol` - MQTT protocol details -* :doc:`../guides/energy_monitoring` - Energy monitoring guide -* :doc:`../guides/scheduling` - Scheduling, recirculation, and intelligent modes -* :doc:`../guides/device_maintenance` - OTA, WiFi, freeze protection, and diagnostics -* :doc:`../guides/command_queue` - Command queueing guide -* :doc:`../guides/auto_recovery` - Auto-reconnection guide +* :doc:`../../how-to/track-energy` - Energy monitoring guide +* :doc:`../../how-to/schedule-operation` - Scheduling, recirculation, and intelligent modes +* :doc:`../../how-to/maintenance` - OTA, WiFi, freeze protection, and diagnostics +* :doc:`../../how-to/queue-commands` - Command queueing guide +* :doc:`../../how-to/auto-recovery` - Auto-reconnection guide diff --git a/docs/quickstart.rst b/docs/tutorials/getting-started.rst similarity index 94% rename from docs/quickstart.rst rename to docs/tutorials/getting-started.rst index 2cbe723..b33a437 100644 --- a/docs/quickstart.rst +++ b/docs/tutorials/getting-started.rst @@ -253,11 +253,11 @@ Next Steps Now that you have the basics, explore these topics: -* :doc:`python_api/auth_client` - Deep dive into authentication -* :doc:`python_api/mqtt_client` - Complete MQTT client documentation -* :doc:`guides/energy_monitoring` - Track energy usage -* :doc:`guides/time_of_use` - Optimize for TOU pricing -* :doc:`guides/event_system` - Use the event-driven architecture +* :doc:`../reference/python_api/auth_client` - Deep dive into authentication +* :doc:`../reference/python_api/mqtt_client` - Complete MQTT client documentation +* :doc:`../how-to/track-energy` - Track energy usage +* :doc:`../how-to/optimize-tou` - Optimize for TOU pricing +* :doc:`../how-to/monitor-status` - Use the event-driven architecture Common Issues ============= @@ -277,5 +277,5 @@ Common Issues **Import Errors** Check that the library is installed: ``pip install nwp500-python`` -For more help, see the :doc:`development/contributing` guide or file an +For more help, see the :doc:`../project/contributing` guide or file an issue on GitHub. From 5229bacea62556b642c9f9b4af562d11c57894c0 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Fri, 8 May 2026 10:09:09 -0700 Subject: [PATCH 27/29] remove unnecessary comment --- docs/index.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 0cb6734..0f28550 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,9 +22,6 @@ supports both REST API and real-time MQTT communication. Documentation ============= -The documentation is organized into four sections, following the -`Diátaxis `_ framework: - .. toctree:: :maxdepth: 2 :caption: Tutorials From ac440a46c2e15343b77e984c024076e58b508c06 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 12 May 2026 07:28:35 -0700 Subject: [PATCH 28/29] Fix on_connection_resumed callback missing connection parameter The _on_connection_resumed_internal callback was missing the 'connection' parameter required by the AWS IoT SDK callback signature. The AWS SDK calls on_connection_resumed(connection, return_code, session_present, **kwargs), but the handler only accepted (return_code, session_present). This caused the callback to fail silently (exception swallowed by the AWS CRT C layer) when the SDK auto-reconnected after AWS_ERROR_MQTT_UNEXPECTED_HANGUP. As a result: - self._connected was never set back to True - connection_resumed event was never emitted - The reconnection handler loop kept running indefinitely - Sensors became permanently unavailable until manual restart Fix: add 'connection' as the first parameter and '**kwargs' for forward-compatibility, matching the interrupted callback's signature. Also update the type annotation in MqttConnection to match. Fixes #85 --- src/nwp500/mqtt/client.py | 15 +++++++++++++-- src/nwp500/mqtt/connection.py | 4 +++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 5b405e6..3a3a6f1 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -303,9 +303,20 @@ def _on_connection_interrupted_internal( ) def _on_connection_resumed_internal( - self, return_code: Any, session_present: Any + self, + connection: mqtt.Connection, + return_code: Any, + session_present: Any, + **kwargs: Any, ) -> None: - """Internal handler for connection resumption.""" + """Internal handler for connection resumption. + + Args: + connection: MQTT connection that was resumed + return_code: MQTT return code from the resumed connection + session_present: Whether the previous session was present + **kwargs: Forward-compatibility kwargs from AWS SDK + """ _logger.info( f"Connection resumed: return_code={return_code}, " f"session_present={session_present}" diff --git a/src/nwp500/mqtt/connection.py b/src/nwp500/mqtt/connection.py index 0e6dee7..4493ddb 100644 --- a/src/nwp500/mqtt/connection.py +++ b/src/nwp500/mqtt/connection.py @@ -52,7 +52,9 @@ def __init__( on_connection_interrupted: ( Callable[[mqtt.Connection, AwsCrtError], None] | None ) = None, - on_connection_resumed: Callable[[Any, Any | None], None] | None = None, + on_connection_resumed: ( + Callable[[mqtt.Connection, Any, Any | None], None] | None + ) = None, ): """ Initialize connection manager. From 37e8e5fe801d6918f1a619e48b0ae531a95b1ee0 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 12 May 2026 07:31:06 -0700 Subject: [PATCH 29/29] Update changelog for v8.1.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5306206..5802079 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,23 @@ Changelog ========= +Version 8.1.1 (2026-05-12) +========================== + +Bug Fixes +--------- +- **Fix MQTT reconnection after unexpected AWS hangup**: The + ``on_connection_resumed`` callback was missing the ``connection`` parameter + required by the AWS IoT SDK callback signature. The SDK calls + ``on_connection_resumed(connection, return_code, session_present, **kwargs)``, + but the handler only accepted ``(return_code, session_present)``. The + mismatched signature caused the callback to fail silently (exception swallowed + by the AWS CRT C layer), so ``self._connected`` was never restored to ``True`` + after an ``AWS_ERROR_MQTT_UNEXPECTED_HANGUP``. As a result, the + ``connection_resumed`` event was never emitted, the reconnection loop ran + indefinitely, and device sensors became permanently unavailable until a manual + restart. (`#85 `_) + Version 8.1.0 (2026-05-07) ==========================