From 84300efef8ee5e08d819d1069bd11cf887aeb4e9 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 18 Nov 2025 20:03:16 -0800 Subject: [PATCH 01/29] Refactor models to use Pydantic Replaces dataclasses with Pydantic models for better validation and type safety. Updates auth, api_client, and mqtt_client to use new models. --- setup.cfg | 1 + src/nwp500/__init__.py | 6 +- src/nwp500/api_client.py | 8 +- src/nwp500/auth.py | 196 ++--- src/nwp500/models.py | 1187 ++++++++---------------------- src/nwp500/mqtt_client.py | 4 +- src/nwp500/mqtt_subscriptions.py | 58 +- 7 files changed, 407 insertions(+), 1053 deletions(-) diff --git a/setup.cfg b/setup.cfg index f323453..2f0e1a3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,6 +57,7 @@ python_requires = >=3.9 install_requires = aiohttp>=3.8.0 awsiotsdk>=1.26.0 + pydantic>=2.0.0 [options.packages.find] diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index d68f026..ef3292f 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -72,12 +72,10 @@ DeviceInfo, DeviceStatus, DhwOperationSetting, - EnergyUsageData, EnergyUsageResponse, EnergyUsageTotal, FirmwareInfo, Location, - MonthlyEnergyData, MqttCommand, MqttRequest, TemperatureUnit, @@ -106,8 +104,8 @@ "TemperatureUnit", "MqttRequest", "MqttCommand", - "EnergyUsageData", - "MonthlyEnergyData", + "MqttRequest", + "MqttCommand", "EnergyUsageTotal", "EnergyUsageResponse", # Authentication diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index c917bd0..9ad8e08 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -220,7 +220,7 @@ async def list_devices( ) devices_data = response.get("data", []) - devices = [Device.from_dict(d) for d in devices_data] + devices = [Device.model_validate(d) for d in devices_data] _logger.info(f"Retrieved {len(devices)} device(s)") return devices @@ -256,7 +256,7 @@ async def get_device_info( ) data = response.get("data", {}) - device = Device.from_dict(data) + device = Device.model_validate(data) _logger.info( f"Retrieved info for device: {device.device_info.device_name}" @@ -295,7 +295,7 @@ async def get_firmware_info( data = response.get("data", {}) firmwares_data = data.get("firmwares", []) - firmwares = [FirmwareInfo.from_dict(f) for f in firmwares_data] + firmwares = [FirmwareInfo.model_validate(f) for f in firmwares_data] _logger.info(f"Retrieved firmware info: {len(firmwares)} firmware(s)") return firmwares @@ -339,7 +339,7 @@ async def get_tou_info( ) data = response.get("data", {}) - tou_info = TOUInfo.from_dict(data) + tou_info = TOUInfo.model_validate(data) _logger.info("Retrieved TOU info for device") return tou_info diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index fadd188..595f7a3 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -13,11 +13,12 @@ import json import logging -from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any, Optional import aiohttp +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator +from pydantic.alias_generators import to_camel from . import __version__ from .config import API_BASE_URL, REFRESH_ENDPOINT, SIGN_IN_ENDPOINT @@ -34,26 +35,28 @@ _logger = logging.getLogger(__name__) -@dataclass -class UserInfo: +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.""" - user_type: str - user_first_name: str - user_last_name: str - user_status: str - user_seq: int + user_type: str = "" + user_first_name: str = "" + user_last_name: str = "" + user_status: str = "" + user_seq: int = 0 @classmethod def from_dict(cls, data: dict[str, Any]) -> "UserInfo": - """Create UserInfo from API response dictionary.""" - return cls( - user_type=data.get("userType", ""), - user_first_name=data.get("userFirstName", ""), - user_last_name=data.get("userLastName", ""), - user_status=data.get("userStatus", ""), - user_seq=data.get("userSeq", 0), - ) + """Create UserInfo from API response dictionary (compatibility).""" + return cls.model_validate(data) @property def full_name(self) -> str: @@ -61,29 +64,43 @@ def full_name(self) -> str: return f"{self.user_first_name} {self.user_last_name}".strip() -@dataclass -class AuthTokens: +class AuthTokens(NavienBaseModel): """Authentication tokens and AWS credentials returned from the API.""" - id_token: str - access_token: str - refresh_token: str - authentication_expires_in: int + id_token: str = "" + access_token: str = "" + refresh_token: str = "" + authentication_expires_in: int = 3600 access_key_id: Optional[str] = None secret_key: Optional[str] = None session_token: Optional[str] = None authorization_expires_in: Optional[int] = None # Calculated fields - issued_at: datetime = field(default_factory=datetime.now) - _expires_at: datetime = field( - default=datetime.now(), init=False, repr=False - ) - _aws_expires_at: Optional[datetime] = field( - default=None, init=False, repr=False - ) + issued_at: datetime = Field(default_factory=datetime.now) + + _expires_at: datetime = PrivateAttr() + _aws_expires_at: Optional[datetime] = PrivateAttr(default=None) - def __post_init__(self) -> None: + @model_validator(mode='before') + @classmethod + def handle_empty_aliases(cls, data: Any) -> Any: + """Handle cases where camelCase alias is empty but snake_case fallback exists.""" + if isinstance(data, dict): + # Fields to check for fallback + fields_to_check = [ + ('accessToken', 'access_token'), + ('accessKeyId', 'access_key_id'), + ('secretKey', 'secret_key'), + ] + + for camel, snake in fields_to_check: + # If camel exists but is empty/None, and snake exists, use snake + if camel in data and not data[camel] and snake in data: + data[camel] = data[snake] + return data + + def model_post_init(self, __context: Any) -> None: """Cache the expiration timestamp after initialization.""" # Pre-calculate and cache the expiration time self._expires_at = self.issued_at + timedelta( @@ -94,6 +111,8 @@ def __post_init__(self) -> None: self._aws_expires_at = self.issued_at + timedelta( seconds=self.authorization_expires_in ) + else: + self._aws_expires_at = None @classmethod def from_dict(cls, data: dict[str, Any]) -> "AuthTokens": @@ -106,84 +125,18 @@ def from_dict(cls, data: dict[str, Any]) -> "AuthTokens": Returns: AuthTokens instance - - Example: - # From API response - >>> tokens = AuthTokens.from_dict({ - ... "idToken": "...", - ... "accessToken": "...", - ... "refreshToken": "...", - ... "authenticationExpiresIn": 3600 - ... }) - - # From stored data (after to_dict()) - >>> stored = tokens.to_dict() - >>> restored = AuthTokens.from_dict(stored) """ - - # Helper to get value from either camelCase or snake_case key - def get_value( - camel_key: str, snake_key: str, default: Any = None - ) -> Any: - """Get value, checking camelCase first, then snake_case.""" - value = data.get(camel_key) - if value is not None and value != "": - return value - value = data.get(snake_key) - if value is not None and value != "": - return value - return default - - # Support both camelCase (API) and snake_case (stored) keys - return cls( - id_token=get_value("idToken", "id_token", ""), - access_token=get_value("accessToken", "access_token", ""), - refresh_token=get_value("refreshToken", "refresh_token", ""), - authentication_expires_in=get_value( - "authenticationExpiresIn", "authentication_expires_in", 3600 - ), - access_key_id=get_value("accessKeyId", "access_key_id"), - secret_key=get_value("secretKey", "secret_key"), - session_token=get_value("sessionToken", "session_token"), - authorization_expires_in=get_value( - "authorizationExpiresIn", "authorization_expires_in" - ), - issued_at=datetime.fromisoformat(data["issued_at"]) - if "issued_at" in data - else datetime.now(), - ) + # 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 a dictionary with all token data including the issued_at - timestamp, which is essential for correctly calculating expiration - times when restoring tokens. - Returns: Dictionary with snake_case keys suitable for JSON serialization - - Example: - >>> tokens = auth_client.current_tokens - >>> stored_data = tokens.to_dict() - >>> # Save to file/database - >>> import json - >>> json.dump(stored_data, file) - >>> - >>> # Later, restore tokens - >>> restored_tokens = AuthTokens.from_dict(json.load(file)) """ - return { - "id_token": self.id_token, - "access_token": self.access_token, - "refresh_token": self.refresh_token, - "authentication_expires_in": self.authentication_expires_in, - "access_key_id": self.access_key_id, - "secret_key": self.secret_key, - "session_token": self.session_token, - "authorization_expires_in": self.authorization_expires_in, - "issued_at": self.issued_at.isoformat(), - } + return self.model_dump(mode='json') @property def expires_at(self) -> datetime: @@ -229,36 +182,35 @@ def bearer_token(self) -> str: return f"Bearer {self.access_token}" -@dataclass -class AuthenticationResponse: +class AuthenticationResponse(NavienBaseModel): """Complete authentication response including user info and tokens.""" user_info: UserInfo tokens: AuthTokens - legal: list[dict[str, Any]] = field(default_factory=list) + legal: list[dict[str, Any]] = Field(default_factory=list) code: int = 200 - message: str = "SUCCESS" + message: str = Field(default="SUCCESS", alias="msg") @classmethod def from_dict( cls, response_data: dict[str, Any] ) -> "AuthenticationResponse": """Create AuthenticationResponse from API response.""" - code = response_data.get("code", 200) - message = response_data.get("msg", "SUCCESS") + # Map the nested structure of the API response to the flat model structure + # API response: { "code": ..., "msg": ..., "data": { "userInfo": ..., "token": ..., "legal": ... } } + data = response_data.get("data", {}) - - user_info = UserInfo.from_dict(data.get("userInfo", {})) - tokens = AuthTokens.from_dict(data.get("token", {})) - legal = data.get("legal", []) - - return cls( - user_info=user_info, - tokens=tokens, - legal=legal, - code=code, - message=message, - ) + + # 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) __all__ = [ @@ -353,13 +305,7 @@ def __init__( # Create a minimal AuthenticationResponse with stored tokens # UserInfo will be populated on first API call if needed self._auth_response = AuthenticationResponse( - user_info=UserInfo( - user_type="", - user_first_name="", - user_last_name="", - user_status="", - user_seq=0, - ), + user_info=UserInfo(), tokens=stored_tokens, ) self._user_email = user_id diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 5d2af30..56e5f1c 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -7,9 +7,11 @@ """ import logging -from dataclasses import dataclass, field from enum import Enum -from typing import Any, Optional, Union +from typing import Annotated, Any, Optional, Union + +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, AliasGenerator +from pydantic.alias_generators import to_camel from . import constants @@ -17,246 +19,85 @@ # ============================================================================ -# Field Conversion Helpers +# Conversion Helpers & Validators # ============================================================================ - -def meta(**kwargs: Any) -> dict[str, Any]: - """ - Create metadata for dataclass fields with conversion information. - - Args: - conversion: Conversion type ('device_bool', 'add_20', 'div_10', - 'decicelsius_to_f', 'enum') - enum_class: For enum conversions, the enum class to use - default_value: For enum conversions, the default value on error - - Returns: - Metadata dict for use with field(metadata=...) - """ - return kwargs - - -def apply_field_conversions( - cls: type[Any], data: dict[str, Any] -) -> dict[str, Any]: - """ - Apply conversions to data based on field metadata. - - This function reads conversion metadata from dataclass fields and applies - the appropriate transformations. This eliminates duplicate field lists and - makes conversion logic self-documenting. - - Args: - cls: The dataclass with field metadata - data: Raw data dictionary to convert - - Returns: - Converted data dictionary - """ - converted_data = data.copy() - - # Iterate through all fields and apply conversions based on metadata - for field_info in cls.__dataclass_fields__.values(): - field_name = field_info.name - if field_name not in converted_data: - continue - - metadata = field_info.metadata - conversion = metadata.get("conversion") - - if not conversion: - continue - - value = converted_data[field_name] - - # Apply the appropriate conversion - if conversion == "device_bool": - # Device encoding: 0 or 1 = false, 2 = true - converted_data[field_name] = value == 2 - - elif conversion == "add_20": - # Temperature offset conversion - converted_data[field_name] = value + 20 - - elif conversion == "div_10": - # Scale down by factor of 10 - converted_data[field_name] = value / 10.0 - - elif conversion == "decicelsius_to_f": - # Convert decicelsius (tenths of Celsius) to Fahrenheit - converted_data[field_name] = _decicelsius_to_fahrenheit(value) - - elif conversion == "enum": - # Convert to enum with error handling - enum_class = metadata.get("enum_class") - default_value = metadata.get("default_value") - - if enum_class: - try: - converted_data[field_name] = enum_class(value) - except ValueError: - if default_value is not None: - _logger.warning( - "Unknown %s value: %s. Defaulting to %s.", - field_name, - value, - default_value.name - if hasattr(default_value, "name") - else default_value, - ) - converted_data[field_name] = default_value - else: - # Re-raise if no default provided - raise - - return converted_data - - -def _decicelsius_to_fahrenheit(raw_value: float) -> float: - """ - Convert a raw decicelsius value to Fahrenheit. - - Args: - raw_value: Raw value in decicelsius (tenths of degrees Celsius) - - Returns: - Temperature in Fahrenheit - - Example: - >>> _decicelsius_to_fahrenheit(250) # 25.0°C - 77.0 - """ - celsius = raw_value / 10.0 - return (celsius * 9 / 5) + 32 +def _device_bool_validator(v: Any) -> bool: + """Convert device boolean (2=True, 0/1=False).""" + return v == 2 + +def _add_20_validator(v: Any) -> float: + """Add 20 to the value (temperature offset).""" + if isinstance(v, (int, float)): + return float(v) + 20.0 + return float(v) + +def _div_10_validator(v: Any) -> float: + """Divide by 10.""" + if isinstance(v, (int, float)): + return float(v) / 10.0 + return float(v) + +def _decicelsius_to_fahrenheit(v: Any) -> float: + """Convert decicelsius (tenths of Celsius) to Fahrenheit.""" + if isinstance(v, (int, float)): + celsius = float(v) / 10.0 + return (celsius * 9 / 5) + 32 + return float(v) + +# Reusable Annotated types for conversions +DeviceBool = Annotated[bool, BeforeValidator(_device_bool_validator)] +Add20 = Annotated[float, BeforeValidator(_add_20_validator)] +Div10 = Annotated[float, BeforeValidator(_div_10_validator)] +DeciCelsiusToF = Annotated[float, BeforeValidator(_decicelsius_to_fahrenheit)] + + +class NavienBaseModel(BaseModel): + """Base model for all Navien models.""" + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra='ignore' # Ignore unknown fields by default + ) class DhwOperationSetting(Enum): - """DHW operation setting modes (user-configured heating preferences). - - This enum represents the user's configured mode preference - what heating - mode - the device should use when it needs to heat water. These values appear in - the - dhwOperationSetting field and are set via user commands. - - These modes balance energy efficiency and recovery time based on user needs: - - Higher efficiency = longer recovery time, lower operating costs - - Lower efficiency = faster recovery time, higher operating costs - - Values are based on the MQTT protocol dhw-mode command parameter as - documented - in MQTT_MESSAGES.rst. - - Attributes: - HEAT_PUMP: Heat Pump Only - most efficient, slowest recovery - ELECTRIC: Electric Only - least efficient, fastest recovery - ENERGY_SAVER: Hybrid: Efficiency - balanced, good default - HIGH_DEMAND: Hybrid: Boost - maximum heating capacity - VACATION: Vacation mode - suspends heating to save energy - POWER_OFF: Device powered off - appears when device is turned off - """ - - HEAT_PUMP = 1 # Heat Pump Only - most efficient, slowest recovery - ELECTRIC = 2 # Electric Only - least efficient, fastest recovery - ENERGY_SAVER = 3 # Hybrid: Efficiency - balanced, good default - HIGH_DEMAND = 4 # Hybrid: Boost - maximum heating capacity - VACATION = 5 # Vacation mode - suspends heating to save energy - POWER_OFF = 6 # Device powered off - appears when device is turned off + """DHW operation setting modes (user-configured heating preferences).""" + HEAT_PUMP = 1 + ELECTRIC = 2 + ENERGY_SAVER = 3 + HIGH_DEMAND = 4 + VACATION = 5 + POWER_OFF = 6 class CurrentOperationMode(Enum): - """Current operation mode (real-time operational state). - - This enum represents the device's current actual operational state - what - the device is doing RIGHT NOW. These values appear in the operationMode - field and change automatically based on heating demand. - - Unlike DhwOperationSetting (user preference), this reflects real-time - operation and changes dynamically as the device starts/stops heating. - - Values are based on device status responses in MQTT messages as documented - in DEVICE_STATUS_FIELDS.rst. - - Attributes: - STANDBY: Device is idle, not actively heating - HEAT_PUMP_MODE: Heat pump is actively running to heat water - HYBRID_EFFICIENCY_MODE: Device actively heating in Energy Saver mode - HYBRID_BOOST_MODE: Device actively heating in High Demand mode - """ - - STANDBY = 0 # Device is idle, not actively heating - HEAT_PUMP_MODE = 32 # Heat pump is actively running to heat water - HYBRID_EFFICIENCY_MODE = 64 # Device actively heating in Energy Saver mode - HYBRID_BOOST_MODE = 96 # Device actively heating in High Demand mode + """Current operation mode (real-time operational state).""" + STANDBY = 0 + HEAT_PUMP_MODE = 32 + HYBRID_EFFICIENCY_MODE = 64 + HYBRID_BOOST_MODE = 96 class TemperatureUnit(Enum): - """Temperature unit enumeration. - - Attributes: - CELSIUS: Celsius temperature scale (°C) - FAHRENHEIT: Fahrenheit temperature scale (°F) - """ - + """Temperature unit enumeration.""" CELSIUS = 1 FAHRENHEIT = 2 -@dataclass -class DeviceInfo: - """Device information from API. - - Contains basic device identification and network status information - retrieved from the Navien Smart Control REST API. - - Attributes: - home_seq: Home sequence identifier - mac_address: Device MAC address (unique identifier) - additional_value: Additional device identifier value - device_type: Device type code (52 for NWP500) - device_name: User-assigned device name - connected: Connection status (1=offline, 2=online) - install_type: Installation type (optional) - """ - - home_seq: int - mac_address: str - additional_value: str - device_type: int - device_name: str - connected: int +class DeviceInfo(NavienBaseModel): + """Device information from API.""" + home_seq: int = 0 + mac_address: str = "" + additional_value: str = "" + device_type: int = 52 + device_name: str = "Unknown" + connected: int = 0 install_type: Optional[str] = None - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "DeviceInfo": - """Create DeviceInfo from API response dictionary.""" - return cls( - home_seq=data.get("homeSeq", 0), - mac_address=data.get("macAddress", ""), - additional_value=data.get("additionalValue", ""), - device_type=data.get("deviceType", 52), - device_name=data.get("deviceName", "Unknown"), - connected=data.get("connected", 0), - install_type=data.get("installType"), - ) - - -@dataclass -class Location: - """Location information for a device. - - Contains geographic and address information for a Navien device. - - Attributes: - state: State or province - city: City name - address: Street address - latitude: GPS latitude coordinate - longitude: GPS longitude coordinate - altitude: Altitude/elevation - """ +class Location(NavienBaseModel): + """Location information for a device.""" state: Optional[str] = None city: Optional[str] = None address: Optional[str] = None @@ -264,720 +105,288 @@ class Location: longitude: Optional[float] = None altitude: Optional[float] = None - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "Location": - """Create Location from API response dictionary.""" - return cls( - state=data.get("state"), - city=data.get("city"), - address=data.get("address"), - latitude=data.get("latitude"), - longitude=data.get("longitude"), - altitude=data.get("altitude"), - ) - - -@dataclass -class Device: - """Complete device information including location. - - Represents a complete Navien device with both identification/status - information and geographic location data. - - Attributes: - device_info: Device identification and status - location: Geographic location information - """ +class Device(NavienBaseModel): + """Complete device information including location.""" device_info: DeviceInfo location: Location - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "Device": - """Create Device from API response dictionary.""" - device_info_data = data.get("deviceInfo", {}) - location_data = data.get("location", {}) - - return cls( - device_info=DeviceInfo.from_dict(device_info_data), - location=Location.from_dict(location_data), - ) - - -@dataclass -class FirmwareInfo: - """Firmware information for a device. - - Contains version and update information for device firmware. - See FIRMWARE_TRACKING.rst for details on firmware version tracking. - - Attributes: - mac_address: Device MAC address - additional_value: Additional device identifier - device_type: Device type code - cur_sw_code: Current software code - cur_version: Current firmware version - downloaded_version: Downloaded firmware version (if available) - device_group: Device group identifier (optional) - """ - mac_address: str - additional_value: str - device_type: int - cur_sw_code: int - cur_version: int +class FirmwareInfo(NavienBaseModel): + """Firmware information for a device.""" + mac_address: str = "" + additional_value: str = "" + device_type: int = 52 + cur_sw_code: int = 0 + cur_version: int = 0 downloaded_version: Optional[int] = None device_group: Optional[str] = None - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "FirmwareInfo": - """Create FirmwareInfo from API response dictionary.""" - return cls( - mac_address=data.get("macAddress", ""), - additional_value=data.get("additionalValue", ""), - device_type=data.get("deviceType", 52), - cur_sw_code=data.get("curSwCode", 0), - cur_version=data.get("curVersion", 0), - downloaded_version=data.get("downloadedVersion"), - device_group=data.get("deviceGroup"), - ) - - -@dataclass -class TOUSchedule: - """Time of Use schedule information. - - Represents a Time-of-Use (TOU) pricing schedule for energy optimization. - See TIME_OF_USE.rst for detailed information about TOU configuration. - - Attributes: - season: Season bitfield (months when schedule applies) - intervals: List of time intervals with pricing information - """ - - season: int - intervals: list[dict[str, Any]] - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "TOUSchedule": - """Create TOUSchedule from API response dictionary.""" - return cls( - season=data.get("season", 0), intervals=data.get("interval", []) - ) - - -@dataclass -class TOUInfo: - """Time of Use information. - - Contains complete Time-of-Use (TOU) configuration including utility - information and pricing schedules. See TIME_OF_USE.rst for details - on configuring TOU optimization. - - Attributes: - register_path: Registration path - source_type: Source type identifier - controller_id: Controller identifier - manufacture_id: Manufacturer identifier - name: TOU schedule name - utility: Utility company name - zip_code: ZIP code for utility area - schedule: List of TOU schedules by season - """ - - register_path: str - source_type: str - controller_id: str - manufacture_id: str - name: str - utility: str - zip_code: int - schedule: list[TOUSchedule] +class TOUSchedule(NavienBaseModel): + """Time of Use schedule information.""" + season: int = 0 + intervals: list[dict[str, Any]] = Field(default_factory=list, alias="interval") + + +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) @classmethod - def from_dict(cls, data: dict[str, Any]) -> "TOUInfo": - """Create TOUInfo from API response dictionary.""" - tou_info_data = data.get("touInfo", {}) - schedule_data = tou_info_data.get("schedule", []) - - return cls( - register_path=data.get("registerPath", ""), - source_type=data.get("sourceType", ""), - controller_id=tou_info_data.get("controllerId", ""), - manufacture_id=tou_info_data.get("manufactureId", ""), - name=tou_info_data.get("name", ""), - utility=tou_info_data.get("utility", ""), - zip_code=tou_info_data.get("zipCode", 0), - schedule=[TOUSchedule.from_dict(s) for s in schedule_data], - ) - - -@dataclass -class DeviceStatus: - """ - Represents the status of the Navien water heater device. - - This data is typically found in the 'status' object of MQTT response - messages. This class provides a factory method `from_dict` to - create an instance from a raw dictionary, applying necessary data - conversions. - - Field metadata indicates conversion types: - - device_bool: Device-specific boolean encoding (0/1=false, 2=true) - - add_20: Temperature offset conversion (raw + 20) - - div_10: Scale division (raw / 10.0) - - decicelsius_to_f: Decicelsius to Fahrenheit conversion - - enum: Enum conversion with default fallback - """ - - # Basic status fields (no conversion needed) + def model_validate(cls, obj: Any, *, strict: Optional[bool] = None, from_attributes: Optional[bool] = None, context: Optional[dict[str, Any]] = None) -> "TOUInfo": + # Handle nested structure in API response where some fields are in 'touInfo' + if isinstance(obj, dict): + data = obj.copy() + if "touInfo" in data: + tou_data = data.pop("touInfo") + data.update(tou_data) + return super().model_validate(data, strict=strict, from_attributes=from_attributes, context=context) + return super().model_validate(obj, strict=strict, from_attributes=from_attributes, context=context) + + +class DeviceStatus(NavienBaseModel): + """Represents the status of the Navien water heater device.""" + + # Basic status fields command: int - outsideTemperature: float - specialFunctionStatus: int - errorCode: int - subErrorCode: int - smartDiagnostic: int - faultStatus1: int - faultStatus2: int - wifiRssi: int - dhwChargePer: float - drEventStatus: int - vacationDaySetting: int - vacationDayElapsed: int - antiLegionellaPeriod: int - programReservationType: int - tempFormulaType: str - currentStatenum: int - targetFanRpm: int - currentFanRpm: int - fanPwm: int - mixingRate: float - eevStep: int - airFilterAlarmPeriod: int - airFilterAlarmElapsed: int - cumulatedOpTimeEvaFan: int - cumulatedDhwFlowRate: float - touStatus: int - drOverrideStatus: int - touOverrideStatus: int - totalEnergyCapacity: float - availableEnergyCapacity: float - recircOperationMode: int - recircPumpOperationStatus: int - recircHotBtnReady: int - recircOperationReason: int - recircErrorStatus: int - currentInstPower: float - - # Boolean fields with device-specific encoding (0/1=false, 2=true) - didReload: bool = field(metadata=meta(conversion="device_bool")) - operationBusy: bool = field(metadata=meta(conversion="device_bool")) - freezeProtectionUse: bool = field(metadata=meta(conversion="device_bool")) - dhwUse: bool = field(metadata=meta(conversion="device_bool")) - dhwUseSustained: bool = field(metadata=meta(conversion="device_bool")) - programReservationUse: bool = field(metadata=meta(conversion="device_bool")) - ecoUse: bool = field(metadata=meta(conversion="device_bool")) - compUse: bool = field(metadata=meta(conversion="device_bool")) - eevUse: bool = field(metadata=meta(conversion="device_bool")) - evaFanUse: bool = field(metadata=meta(conversion="device_bool")) - shutOffValveUse: bool = field(metadata=meta(conversion="device_bool")) - conOvrSensorUse: bool = field(metadata=meta(conversion="device_bool")) - wtrOvrSensorUse: bool = field(metadata=meta(conversion="device_bool")) - antiLegionellaUse: bool = field(metadata=meta(conversion="device_bool")) - antiLegionellaOperationBusy: bool = field( - metadata=meta(conversion="device_bool") - ) - errorBuzzerUse: bool = field(metadata=meta(conversion="device_bool")) - currentHeatUse: bool = field(metadata=meta(conversion="device_bool")) - heatUpperUse: bool = field(metadata=meta(conversion="device_bool")) - heatLowerUse: bool = field(metadata=meta(conversion="device_bool")) - scaldUse: bool = field(metadata=meta(conversion="device_bool")) - airFilterAlarmUse: bool = field(metadata=meta(conversion="device_bool")) - recircOperationBusy: bool = field(metadata=meta(conversion="device_bool")) - recircReservationUse: bool = field(metadata=meta(conversion="device_bool")) + outside_temperature: float + special_function_status: int + error_code: int + sub_error_code: int + smart_diagnostic: int + fault_status1: int + fault_status2: int + wifi_rssi: int + dhw_charge_per: float + dr_event_status: int + vacation_day_setting: int + vacation_day_elapsed: int + anti_legionella_period: int + program_reservation_type: int + temp_formula_type: str + current_statenum: int + target_fan_rpm: int + current_fan_rpm: int + fan_pwm: int + mixing_rate: float + eev_step: int + air_filter_alarm_period: int + air_filter_alarm_elapsed: int + cumulated_op_time_eva_fan: int + cumulated_dhw_flow_rate: float + tou_status: int + dr_override_status: int + tou_override_status: int + total_energy_capacity: float + available_energy_capacity: float + recirc_operation_mode: int + recirc_pump_operation_status: int + recirc_hot_btn_ready: int + recirc_operation_reason: int + recirc_error_status: int + current_inst_power: float + + # Boolean fields with device-specific encoding + did_reload: DeviceBool + operation_busy: DeviceBool + freeze_protection_use: DeviceBool + dhw_use: DeviceBool + dhw_use_sustained: DeviceBool + program_reservation_use: DeviceBool + eco_use: DeviceBool + comp_use: DeviceBool + eev_use: DeviceBool + eva_fan_use: DeviceBool + shut_off_valve_use: DeviceBool + con_ovr_sensor_use: DeviceBool + wtr_ovr_sensor_use: DeviceBool + anti_legionella_use: DeviceBool + anti_legionella_operation_busy: DeviceBool + error_buzzer_use: DeviceBool + current_heat_use: DeviceBool + heat_upper_use: DeviceBool + heat_lower_use: DeviceBool + scald_use: DeviceBool + air_filter_alarm_use: DeviceBool + recirc_operation_busy: DeviceBool + recirc_reservation_use: DeviceBool # Temperature fields with offset (raw + 20) - dhwTemperature: float = field(metadata=meta(conversion="add_20")) - dhwTemperatureSetting: float = field(metadata=meta(conversion="add_20")) - dhwTargetTemperatureSetting: float = field( - metadata=meta(conversion="add_20") - ) - freezeProtectionTemperature: float = field( - metadata=meta(conversion="add_20") - ) - dhwTemperature2: float = field(metadata=meta(conversion="add_20")) - hpUpperOnTempSetting: float = field(metadata=meta(conversion="add_20")) - hpUpperOffTempSetting: float = field(metadata=meta(conversion="add_20")) - hpLowerOnTempSetting: float = field(metadata=meta(conversion="add_20")) - hpLowerOffTempSetting: float = field(metadata=meta(conversion="add_20")) - heUpperOnTempSetting: float = field(metadata=meta(conversion="add_20")) - heUpperOffTempSetting: float = field(metadata=meta(conversion="add_20")) - heLowerOnTempSetting: float = field(metadata=meta(conversion="add_20")) - heLowerOffTempSetting: float = field(metadata=meta(conversion="add_20")) - heatMinOpTemperature: float = field(metadata=meta(conversion="add_20")) - recircTempSetting: float = field(metadata=meta(conversion="add_20")) - recircTemperature: float = field(metadata=meta(conversion="add_20")) - recircFaucetTemperature: float = field(metadata=meta(conversion="add_20")) + dhw_temperature: Add20 + dhw_temperature_setting: Add20 + dhw_target_temperature_setting: Add20 + freeze_protection_temperature: Add20 + dhw_temperature2: Add20 + hp_upper_on_temp_setting: Add20 + hp_upper_off_temp_setting: Add20 + hp_lower_on_temp_setting: Add20 + hp_lower_off_temp_setting: Add20 + he_upper_on_temp_setting: Add20 + he_upper_off_temp_setting: Add20 + he_lower_on_temp_setting: Add20 + he_lower_off_temp_setting: Add20 + heat_min_op_temperature: Add20 + recirc_temp_setting: Add20 + recirc_temperature: Add20 + recirc_faucet_temperature: Add20 # Fields with scale division (raw / 10.0) - currentInletTemperature: float = field(metadata=meta(conversion="div_10")) - currentDhwFlowRate: float = field(metadata=meta(conversion="div_10")) - hpUpperOnDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - hpUpperOffDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - hpLowerOnDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - hpLowerOffDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - heUpperOnDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - heUpperOffDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - heLowerOnDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - heLowerOffDiffTempSetting: float = field(metadata=meta(conversion="div_10")) - recircDhwFlowRate: float = field(metadata=meta(conversion="div_10")) + current_inlet_temperature: Div10 + current_dhw_flow_rate: Div10 + hp_upper_on_diff_temp_setting: Div10 + hp_upper_off_diff_temp_setting: Div10 + hp_lower_on_diff_temp_setting: Div10 + hp_lower_off_diff_temp_setting: Div10 + he_upper_on_diff_temp_setting: Div10 + he_upper_off_diff_temp_setting: Div10 + he_lower_on_diff_temp_setting: Div10 = Field(alias="heLowerOnTDiffempSetting") # Handle typo + he_lower_off_diff_temp_setting: Div10 + recirc_dhw_flow_rate: Div10 # Temperature fields with decicelsius to Fahrenheit conversion - tankUpperTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - tankLowerTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - dischargeTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - suctionTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - evaporatorTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - ambientTemperature: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - targetSuperHeat: float = field(metadata=meta(conversion="decicelsius_to_f")) - currentSuperHeat: float = field( - metadata=meta(conversion="decicelsius_to_f") - ) - - # Enum fields with default fallbacks - operationMode: CurrentOperationMode = field( - metadata=meta( - conversion="enum", - enum_class=CurrentOperationMode, - default_value=CurrentOperationMode.STANDBY, - ) - ) - dhwOperationSetting: DhwOperationSetting = field( - metadata=meta( - conversion="enum", - enum_class=DhwOperationSetting, - default_value=DhwOperationSetting.ENERGY_SAVER, - ) - ) - temperatureType: TemperatureUnit = field( - metadata=meta( - conversion="enum", - enum_class=TemperatureUnit, - default_value=TemperatureUnit.FAHRENHEIT, - ) - ) - freezeProtectionTempMin: float = field( - default=43.0, metadata=meta(conversion="add_20") - ) - freezeProtectionTempMax: float = field( - default=65.0, metadata=meta(conversion="add_20") - ) + tank_upper_temperature: DeciCelsiusToF + tank_lower_temperature: DeciCelsiusToF + discharge_temperature: DeciCelsiusToF + suction_temperature: DeciCelsiusToF + evaporator_temperature: DeciCelsiusToF + ambient_temperature: DeciCelsiusToF + target_super_heat: DeciCelsiusToF + current_super_heat: DeciCelsiusToF + + # Enum fields + operation_mode: CurrentOperationMode = Field(default=CurrentOperationMode.STANDBY) + dhw_operation_setting: DhwOperationSetting = Field(default=DhwOperationSetting.ENERGY_SAVER) + temperature_type: TemperatureUnit = Field(default=TemperatureUnit.FAHRENHEIT) + + freeze_protection_temp_min: Add20 = 43.0 + freeze_protection_temp_max: Add20 = 65.0 @classmethod def from_dict(cls, data: dict[str, Any]) -> "DeviceStatus": - """Create a DeviceStatus object from a raw dictionary. - - Applies conversions based on field metadata, eliminating duplicate - field lists and making the code more maintainable. - - Args: - data: Raw status dictionary from MQTT or API response - - Returns: - DeviceStatus object with all conversions applied - """ - # Copy data to avoid modifying the original dictionary - converted_data = data.copy() - - # Get valid field names for this class - valid_fields = {f.name for f in cls.__dataclass_fields__.values()} - - # Handle key typo from documentation/API - if "heLowerOnTDiffempSetting" in converted_data: - converted_data["heLowerOnDiffTempSetting"] = converted_data.pop( - "heLowerOnTDiffempSetting" - ) - - # Apply all conversions based on field metadata - converted_data = apply_field_conversions(cls, converted_data) - - # Filter out any unknown fields not defined in the dataclass - # This handles new fields added by firmware updates gracefully - unknown_fields = set(converted_data.keys()) - valid_fields - if unknown_fields: - # Check if any unknown fields are documented in constants - known_firmware_fields = set( - constants.KNOWN_FIRMWARE_FIELD_CHANGES.keys() - ) - known_new_fields = unknown_fields & known_firmware_fields - truly_unknown = unknown_fields - known_firmware_fields - - if known_new_fields: - _logger.info( - "Ignoring known new fields from recent firmware: %s. " - "These fields are documented but not yet implemented " - "in DeviceStatus. Please report this with your " - "firmware version to help us track field changes.", - known_new_fields, - ) - - if truly_unknown: - _logger.warning( - "Discovered new unknown fields from device status: %s. " - "This may indicate a firmware update. Please report " - "this issue with your device firmware version " - "(controllerSwVersion, panelSwVersion, wifiSwVersion) " - "so we can update the library. See " - "constants.KNOWN_FIRMWARE_FIELD_CHANGES.", - truly_unknown, - ) - - converted_data = { - k: v for k, v in converted_data.items() if k in valid_fields - } - - return cls(**converted_data) - - -@dataclass -class DeviceFeature: - """ - Represents device capabilities, configuration, and firmware information. - - This data is found in the 'feature' object of MQTT response messages, - typically received in response to device info requests. It contains - device model information, firmware versions, capabilities, and limits. - - Field metadata indicates conversion types (same as DeviceStatus). - """ - - # Basic feature fields (no conversion needed) - countryCode: int - modelTypeCode: int - controlTypeCode: int - volumeCode: int - controllerSwVersion: int - panelSwVersion: int - wifiSwVersion: int - controllerSwCode: int - panelSwCode: int - wifiSwCode: int - controllerSerialNumber: str - powerUse: int - holidayUse: int - programReservationUse: int - dhwUse: int - dhwTemperatureSettingUse: int - smartDiagnosticUse: int - wifiRssiUse: int - tempFormulaType: int - energyUsageUse: int - freezeProtectionUse: int - mixingValueUse: int - drSettingUse: int - antiLegionellaSettingUse: int - hpwhUse: int - dhwRefillUse: int - ecoUse: int - electricUse: int - heatpumpUse: int - energySaverUse: int - highDemandUse: int + """Compatibility method for existing code.""" + # Handle the typo field explicitly if needed, though alias handles it + if "heLowerOnTDiffempSetting" in data: + # Pydantic alias will handle this, but if we want to be safe + pass + return cls.model_validate(data) + + +class DeviceFeature(NavienBaseModel): + """Represents device capabilities, configuration, and firmware information.""" + + country_code: int + model_type_code: int + control_type_code: int + volume_code: int + controller_sw_version: int + panel_sw_version: int + wifi_sw_version: int + controller_sw_code: int + panel_sw_code: int + wifi_sw_code: int + controller_serial_number: str + power_use: int + holiday_use: int + program_reservation_use: int + dhw_use: int + dhw_temperature_setting_use: int + smart_diagnostic_use: int + wifi_rssi_use: int + temp_formula_type: int + energy_usage_use: int + freeze_protection_use: int + mixing_value_use: int + dr_setting_use: int + anti_legionella_setting_use: int + hpwh_use: int + dhw_refill_use: int + eco_use: int + electric_use: int + heatpump_use: int + energy_saver_use: int + high_demand_use: int # Temperature limit fields with offset (raw + 20) - dhwTemperatureMin: int = field(metadata=meta(conversion="add_20")) - dhwTemperatureMax: int = field(metadata=meta(conversion="add_20")) - freezeProtectionTempMin: int = field(metadata=meta(conversion="add_20")) - freezeProtectionTempMax: int = field(metadata=meta(conversion="add_20")) - - # Enum field with default fallback - temperatureType: TemperatureUnit = field( - metadata=meta( - conversion="enum", - enum_class=TemperatureUnit, - default_value=TemperatureUnit.FAHRENHEIT, - ) - ) + dhw_temperature_min: Add20 + dhw_temperature_max: Add20 + freeze_protection_temp_min: Add20 + freeze_protection_temp_max: Add20 + + # Enum field + temperature_type: TemperatureUnit = Field(default=TemperatureUnit.FAHRENHEIT) @classmethod def from_dict(cls, data: dict[str, Any]) -> "DeviceFeature": - """Create a DeviceFeature object from a raw dictionary. - - Applies conversions based on field metadata. - - Args: - data: Raw feature dictionary from MQTT or API response - - Returns: - DeviceFeature object with all conversions applied - """ - # Copy data to avoid modifying the original dictionary - converted_data = data.copy() - - # Get valid field names for this class - valid_fields = {f.name for f in cls.__dataclass_fields__.values()} - - # Apply all conversions based on field metadata - converted_data = apply_field_conversions(cls, converted_data) - - # Filter out any unknown fields (similar to DeviceStatus) - unknown_fields = set(converted_data.keys()) - valid_fields - if unknown_fields: - _logger.info( - "Ignoring unknown fields from device feature: %s. " - "This may indicate new device capabilities from a " - "firmware update.", - unknown_fields, - ) - converted_data = { - k: v for k, v in converted_data.items() if k in valid_fields - } - - return cls(**converted_data) - - -@dataclass -class MqttRequest: - """MQTT command request payload. - - Represents the 'request' object within an MQTT command payload. This is a - flexible structure that accommodates various command types including status - requests, control commands, and queries. - - See MQTT_MESSAGES.rst for detailed documentation of all command types - and their required fields. - - Attributes: - command: Command code (from CommandCode enum) - deviceType: Device type code (52 for NWP500) - macAddress: Device MAC address - additionalValue: Additional device identifier - mode: Operation mode for control commands - param: Parameter list for control commands - paramStr: Parameter string for control commands - month: Month list for energy usage queries - year: Year for energy usage queries - """ + """Compatibility method.""" + return cls.model_validate(data) + +class MqttRequest(NavienBaseModel): + """MQTT command request payload.""" command: int - deviceType: int - macAddress: str - additionalValue: str = "..." - # Fields for control commands + device_type: int + mac_address: str + additional_value: str = "..." mode: Optional[str] = None - param: list[Union[int, float]] = field(default_factory=list) - paramStr: str = "" - # Fields for energy usage query + param: list[Union[int, float]] = Field(default_factory=list) + param_str: str = "" month: Optional[list[int]] = None year: Optional[int] = None -@dataclass -class MqttCommand: - """Represents an MQTT command message sent to a Navien device. - - This class structures the complete MQTT message including routing - information (topics), session tracking, and the actual command request. - - Attributes: - clientID: MQTT client identifier - sessionID: Session identifier for tracking requests/responses - requestTopic: MQTT topic to publish the command to - responseTopic: MQTT topic to subscribe for responses - request: The actual command request payload - protocolVersion: MQTT protocol version (default: 2) - """ - - clientID: str - sessionID: str - requestTopic: str - responseTopic: str - request: MqttRequest - protocolVersion: int = 2 - - -@dataclass -class EnergyUsageData: - """Daily or monthly energy usage data for a single period. - - This data shows the energy consumption and operating time for both - the heat pump and electric heating elements. See ENERGY_MONITORING.rst - for details on querying and interpreting energy usage data. - - Attributes: - heUsage: Heat Element usage in Watt-hours (Wh) - hpUsage: Heat Pump usage in Watt-hours (Wh) - heTime: Heat Element operating time in hours - hpTime: Heat Pump operating time in hours - """ - - heUsage: int # Heat Element usage in Watt-hours (Wh) - hpUsage: int # Heat Pump usage in Watt-hours (Wh) - heTime: int # Heat Element operating time in hours - hpTime: int # Heat Pump operating time in hours - - @property - def total_usage(self) -> int: - """Calculate total energy usage. - - Returns: - Total energy usage (heat element + heat pump) in Watt-hours - """ - return self.heUsage + self.hpUsage - - @property - def total_time(self) -> int: - """Calculate total operating time. - - Returns: - Total operating time (heat element + heat pump) in hours - """ - return self.heTime + self.hpTime - - -@dataclass -class MonthlyEnergyData: - """ - Represents energy usage data for a specific month. - - Contains daily breakdown of energy usage with one entry per day. - Days are indexed starting from 0 (day 1 is index 0). - """ - - year: int - month: int - data: list[EnergyUsageData] - - def get_day_usage(self, day: int) -> Optional[EnergyUsageData]: - """ - Get energy usage for a specific day of the month. - - Args: - day: Day of the month (1-31) - - Returns: - EnergyUsageData for that day, or None if invalid day - """ - if 1 <= day <= len(self.data): - return self.data[day - 1] - return None - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "MonthlyEnergyData": - """Create MonthlyEnergyData from a raw dictionary.""" - converted_data = data.copy() +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: Union[MqttRequest, dict[str, Any]] + protocol_version: int = 2 - # Convert list of dictionaries to EnergyUsageData objects - if "data" in converted_data: - converted_data["data"] = [ - EnergyUsageData(**day_data) - for day_data in converted_data["data"] - ] - - return cls(**converted_data) - - -@dataclass -class EnergyUsageTotal: - """Represents total energy usage across the queried period. - - Attributes: - heUsage: Total Heat Element usage in Watt-hours (Wh) - hpUsage: Total Heat Pump usage in Watt-hours (Wh) - """ - - heUsage: int # Total Heat Element usage in Watt-hours (Wh) - hpUsage: int # Total Heat Pump usage in Watt-hours (Wh) - heTime: int # Total Heat Element operating time in hours - hpTime: int # Total Heat Pump operating time in hours - - @property - def total_usage(self) -> int: - """Total energy usage (heat element + heat pump) in Wh.""" - return self.heUsage + self.hpUsage - - @property - def total_time(self) -> int: - """Total operating time (heat element + heat pump) in hours.""" - return self.heTime + self.hpTime +class EnergyUsageTotal(NavienBaseModel): + """Total energy usage data.""" + total_usage: int + heat_pump_usage: int + heat_element_usage: int + @property def heat_pump_percentage(self) -> float: - """Percentage of energy from heat pump (0-100).""" if self.total_usage == 0: return 0.0 - return (self.hpUsage / self.total_usage) * 100 + return (self.heat_pump_usage / self.total_usage) * 100.0 @property def heat_element_percentage(self) -> float: - """Percentage of energy from electric heating elements (0-100).""" if self.total_usage == 0: return 0.0 - return (self.heUsage / self.total_usage) * 100 + return (self.heat_element_usage / self.total_usage) * 100.0 -@dataclass -class EnergyUsageResponse: - """ - Represents the response to an energy usage query. +class EnergyUsageDay(NavienBaseModel): + """Daily energy usage data.""" + day: int + total_usage: int + heat_pump_usage: int + heat_element_usage: int + heat_pump_time: int + heat_element_time: int - This contains historical energy usage data broken down by day - for the requested month(s), plus totals for the entire period. - """ - deviceType: int - macAddress: str - additionalValue: str - typeOfUsage: int # 1 for daily data +class EnergyUsageResponse(NavienBaseModel): + """Response for energy usage query.""" total: EnergyUsageTotal - usage: list[MonthlyEnergyData] - - def get_month_data( - self, year: int, month: int - ) -> Optional[MonthlyEnergyData]: - """ - 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 + daily: list[EnergyUsageDay] @classmethod def from_dict(cls, data: dict[str, Any]) -> "EnergyUsageResponse": - """Create EnergyUsageResponse from a raw dictionary.""" - converted_data = data.copy() - - # Convert total to EnergyUsageTotal - if "total" in converted_data: - converted_data["total"] = EnergyUsageTotal( - **converted_data["total"] - ) - - # Convert usage list to MonthlyEnergyData objects - if "usage" in converted_data: - converted_data["usage"] = [ - MonthlyEnergyData.from_dict(month_data) - for month_data in converted_data["usage"] - ] - - return cls(**converted_data) + """Compatibility method.""" + return cls.model_validate(data) diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt_client.py index b539a37..9b17a7c 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt_client.py @@ -792,8 +792,8 @@ async def subscribe_device_status( Example (Traditional Callback):: >>> def on_status(status: DeviceStatus): - ... print(f"Temperature: {status.dhwTemperature}°F") - ... print(f"Mode: {status.operationMode}") + ... print(f"Temperature: {status.dhw_temperature}°F") + ... print(f"Mode: {status.operation_mode}") >>> >>> await mqtt_client.subscribe_device_status(device, on_status) diff --git a/src/nwp500/mqtt_subscriptions.py b/src/nwp500/mqtt_subscriptions.py index e4e37a2..bd1a951 100644 --- a/src/nwp500/mqtt_subscriptions.py +++ b/src/nwp500/mqtt_subscriptions.py @@ -416,8 +416,8 @@ async def subscribe_device_status( Example (Traditional Callback):: >>> def on_status(status: DeviceStatus): - ... print(f"Temperature: {status.dhwTemperature}°F") - ... print(f"Mode: {status.operationMode}") + ... print(f"Temperature: {status.dhw_temperature}°F") + ... print(f"Mode: {status.operation_mode}") >>> >>> await mqtt_client.subscribe_device_status(device, on_status) @@ -525,44 +525,44 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: try: # Temperature change - if status.dhwTemperature != prev.dhwTemperature: + if status.dhw_temperature != prev.dhw_temperature: await self._event_emitter.emit( "temperature_changed", - prev.dhwTemperature, - status.dhwTemperature, + prev.dhw_temperature, + status.dhw_temperature, ) _logger.debug( - f"Temperature changed: {prev.dhwTemperature}°F → " - f"{status.dhwTemperature}°F" + f"Temperature changed: {prev.dhw_temperature}°F → " + f"{status.dhw_temperature}°F" ) # Operation mode change - if status.operationMode != prev.operationMode: + if status.operation_mode != prev.operation_mode: await self._event_emitter.emit( "mode_changed", - prev.operationMode, - status.operationMode, + prev.operation_mode, + status.operation_mode, ) _logger.debug( - f"Mode changed: {prev.operationMode} → " - f"{status.operationMode}" + f"Mode changed: {prev.operation_mode} → " + f"{status.operation_mode}" ) # Power consumption change - if status.currentInstPower != prev.currentInstPower: + if status.current_inst_power != prev.current_inst_power: await self._event_emitter.emit( "power_changed", - prev.currentInstPower, - status.currentInstPower, + prev.current_inst_power, + status.current_inst_power, ) _logger.debug( - f"Power changed: {prev.currentInstPower}W → " - f"{status.currentInstPower}W" + f"Power changed: {prev.current_inst_power}W → " + f"{status.current_inst_power}W" ) # Heating started/stopped - prev_heating = prev.currentInstPower > 0 - curr_heating = status.currentInstPower > 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("heating_started", status) @@ -573,15 +573,15 @@ async def _detect_state_changes(self, status: DeviceStatus) -> None: _logger.debug("Heating stopped") # Error detection - if status.errorCode and not prev.errorCode: + if status.error_code and not prev.error_code: await self._event_emitter.emit( - "error_detected", status.errorCode, status + "error_detected", status.error_code, status ) - _logger.info(f"Error detected: {status.errorCode}") + _logger.info(f"Error detected: {status.error_code}") - if not status.errorCode and prev.errorCode: - await self._event_emitter.emit("error_cleared", prev.errorCode) - _logger.info(f"Error cleared: {prev.errorCode}") + 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) @@ -614,16 +614,16 @@ async def subscribe_device_feature( Example:: >>> def on_feature(feature: DeviceFeature): - ... print(f"Serial: {feature.controllerSerialNumber}") - ... print(f"FW Version: {feature.controllerSwVersion}") + ... print(f"Serial: {feature.controller_serial_number}") + ... print(f"FW Version: {feature.controller_sw_version}") ... print(f"Temp Range: - {feature.dhwTemperatureMin}-{feature.dhwTemperatureMax}°F") + {feature.dhw_temperature_min}-{feature.dhw_temperature_max}°F") >>> >>> await mqtt_client.subscribe_device_feature(device, on_feature) >>> # Or use event emitter >>> mqtt_client.on('feature_received', lambda f: print(f"FW: - {f.controllerSwVersion}")) + {f.controller_sw_version}")) >>> await mqtt_client.subscribe_device_feature(device, lambda f: None) """ From 8ff71636214e1a1f4b1fe210565652aa09e62bf6 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 18 Nov 2025 20:09:27 -0800 Subject: [PATCH 02/29] Fix linting errors Fixes whitespace, line length, and import sorting issues reported by ruff. --- src/nwp500/auth.py | 15 +++++------ src/nwp500/models.py | 63 +++++++++++++++++++++++++++++++------------- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index 595f7a3..e461168 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -78,14 +78,14 @@ class AuthTokens(NavienBaseModel): # Calculated fields issued_at: datetime = Field(default_factory=datetime.now) - + _expires_at: datetime = PrivateAttr() _aws_expires_at: Optional[datetime] = PrivateAttr(default=None) @model_validator(mode='before') @classmethod def handle_empty_aliases(cls, data: Any) -> Any: - """Handle cases where camelCase alias is empty but snake_case fallback exists.""" + """Handle empty camelCase aliases with snake_case fallbacks.""" if isinstance(data, dict): # Fields to check for fallback fields_to_check = [ @@ -93,7 +93,7 @@ def handle_empty_aliases(cls, data: Any) -> Any: ('accessKeyId', 'access_key_id'), ('secretKey', 'secret_key'), ] - + for camel, snake in fields_to_check: # If camel exists but is empty/None, and snake exists, use snake if camel in data and not data[camel] and snake in data: @@ -196,11 +196,10 @@ def from_dict( cls, response_data: dict[str, Any] ) -> "AuthenticationResponse": """Create AuthenticationResponse from API response.""" - # Map the nested structure of the API response to the flat model structure - # API response: { "code": ..., "msg": ..., "data": { "userInfo": ..., "token": ..., "legal": ... } } - + # 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), @@ -209,7 +208,7 @@ def from_dict( "tokens": data.get("token", {}), "legal": data.get("legal", []) } - + return cls.model_validate(model_data) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 56e5f1c..06ff159 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -10,11 +10,9 @@ from enum import Enum from typing import Annotated, Any, Optional, Union -from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, AliasGenerator +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field from pydantic.alias_generators import to_camel -from . import constants - _logger = logging.getLogger(__name__) @@ -126,7 +124,9 @@ class FirmwareInfo(NavienBaseModel): class TOUSchedule(NavienBaseModel): """Time of Use schedule information.""" season: int = 0 - intervals: list[dict[str, Any]] = Field(default_factory=list, alias="interval") + intervals: list[dict[str, Any]] = Field( + default_factory=list, alias="interval" + ) class TOUInfo(NavienBaseModel): @@ -141,20 +141,38 @@ class TOUInfo(NavienBaseModel): schedule: list[TOUSchedule] = Field(default_factory=list) @classmethod - def model_validate(cls, obj: Any, *, strict: Optional[bool] = None, from_attributes: Optional[bool] = None, context: Optional[dict[str, Any]] = None) -> "TOUInfo": - # Handle nested structure in API response where some fields are in 'touInfo' + @classmethod + def model_validate( + cls, + obj: Any, + *, + strict: Optional[bool] = None, + from_attributes: Optional[bool] = None, + context: Optional[dict[str, Any]] = None, + ) -> "TOUInfo": + # Handle nested structure where fields are in 'touInfo' if isinstance(obj, dict): data = obj.copy() if "touInfo" in data: tou_data = data.pop("touInfo") data.update(tou_data) - return super().model_validate(data, strict=strict, from_attributes=from_attributes, context=context) - return super().model_validate(obj, strict=strict, from_attributes=from_attributes, context=context) + return super().model_validate( + data, + strict=strict, + from_attributes=from_attributes, + context=context, + ) + return super().model_validate( + obj, + strict=strict, + from_attributes=from_attributes, + context=context, + ) class DeviceStatus(NavienBaseModel): """Represents the status of the Navien water heater device.""" - + # Basic status fields command: int outside_temperature: float @@ -247,7 +265,9 @@ class DeviceStatus(NavienBaseModel): hp_lower_off_diff_temp_setting: Div10 he_upper_on_diff_temp_setting: Div10 he_upper_off_diff_temp_setting: Div10 - he_lower_on_diff_temp_setting: Div10 = Field(alias="heLowerOnTDiffempSetting") # Handle typo + he_lower_on_diff_temp_setting: Div10 = Field( + alias="heLowerOnTDiffempSetting" + ) # Handle typo he_lower_off_diff_temp_setting: Div10 recirc_dhw_flow_rate: Div10 @@ -262,10 +282,15 @@ class DeviceStatus(NavienBaseModel): current_super_heat: DeciCelsiusToF # Enum fields - operation_mode: CurrentOperationMode = Field(default=CurrentOperationMode.STANDBY) - dhw_operation_setting: DhwOperationSetting = Field(default=DhwOperationSetting.ENERGY_SAVER) - temperature_type: TemperatureUnit = Field(default=TemperatureUnit.FAHRENHEIT) - + operation_mode: CurrentOperationMode = Field( + default=CurrentOperationMode.STANDBY + ) + dhw_operation_setting: DhwOperationSetting = Field( + default=DhwOperationSetting.ENERGY_SAVER + ) + temperature_type: TemperatureUnit = Field( + default=TemperatureUnit.FAHRENHEIT + ) freeze_protection_temp_min: Add20 = 43.0 freeze_protection_temp_max: Add20 = 65.0 @@ -280,8 +305,8 @@ def from_dict(cls, data: dict[str, Any]) -> "DeviceStatus": class DeviceFeature(NavienBaseModel): - """Represents device capabilities, configuration, and firmware information.""" - + """Device capabilities, configuration, and firmware info.""" + country_code: int model_type_code: int control_type_code: int @@ -321,7 +346,9 @@ class DeviceFeature(NavienBaseModel): freeze_protection_temp_max: Add20 # Enum field - temperature_type: TemperatureUnit = Field(default=TemperatureUnit.FAHRENHEIT) + temperature_type: TemperatureUnit = Field( + default=TemperatureUnit.FAHRENHEIT + ) @classmethod def from_dict(cls, data: dict[str, Any]) -> "DeviceFeature": @@ -357,7 +384,7 @@ class EnergyUsageTotal(NavienBaseModel): total_usage: int heat_pump_usage: int heat_element_usage: int - + @property def heat_pump_percentage(self) -> float: if self.total_usage == 0: From c87ebeab2d2c0c5050b74ed27b4273d936a892af Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 18 Nov 2025 20:12:48 -0800 Subject: [PATCH 03/29] Apply ruff formatting Auto-formatted code to pass CI checks. --- src/nwp500/auth.py | 17 ++++++++--------- src/nwp500/models.py | 22 +++++++++++++++++++++- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index e461168..ec9e2d0 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -37,10 +37,9 @@ class NavienBaseModel(BaseModel): """Base model for Navien authentication models.""" + model_config = ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - extra='ignore' + alias_generator=to_camel, populate_by_name=True, extra="ignore" ) @@ -82,16 +81,16 @@ class AuthTokens(NavienBaseModel): _expires_at: datetime = PrivateAttr() _aws_expires_at: Optional[datetime] = PrivateAttr(default=None) - @model_validator(mode='before') + @model_validator(mode="before") @classmethod def handle_empty_aliases(cls, data: Any) -> Any: """Handle empty camelCase aliases with snake_case fallbacks.""" if isinstance(data, dict): # Fields to check for fallback fields_to_check = [ - ('accessToken', 'access_token'), - ('accessKeyId', 'access_key_id'), - ('secretKey', 'secret_key'), + ("accessToken", "access_token"), + ("accessKeyId", "access_key_id"), + ("secretKey", "secret_key"), ] for camel, snake in fields_to_check: @@ -136,7 +135,7 @@ def to_dict(self) -> dict[str, Any]: Returns: Dictionary with snake_case keys suitable for JSON serialization """ - return self.model_dump(mode='json') + return self.model_dump(mode="json") @property def expires_at(self) -> datetime: @@ -206,7 +205,7 @@ def from_dict( "msg": response_data.get("msg", "SUCCESS"), "userInfo": data.get("userInfo", {}), "tokens": data.get("token", {}), - "legal": data.get("legal", []) + "legal": data.get("legal", []), } return cls.model_validate(model_data) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 06ff159..6e06d5e 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -20,22 +20,26 @@ # Conversion Helpers & Validators # ============================================================================ + def _device_bool_validator(v: Any) -> bool: """Convert device boolean (2=True, 0/1=False).""" return v == 2 + def _add_20_validator(v: Any) -> float: """Add 20 to the value (temperature offset).""" if isinstance(v, (int, float)): return float(v) + 20.0 return float(v) + def _div_10_validator(v: Any) -> float: """Divide by 10.""" if isinstance(v, (int, float)): return float(v) / 10.0 return float(v) + def _decicelsius_to_fahrenheit(v: Any) -> float: """Convert decicelsius (tenths of Celsius) to Fahrenheit.""" if isinstance(v, (int, float)): @@ -43,6 +47,7 @@ def _decicelsius_to_fahrenheit(v: Any) -> float: return (celsius * 9 / 5) + 32 return float(v) + # Reusable Annotated types for conversions DeviceBool = Annotated[bool, BeforeValidator(_device_bool_validator)] Add20 = Annotated[float, BeforeValidator(_add_20_validator)] @@ -52,15 +57,17 @@ def _decicelsius_to_fahrenheit(v: Any) -> float: class NavienBaseModel(BaseModel): """Base model for all Navien models.""" + model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, - extra='ignore' # Ignore unknown fields by default + extra="ignore", # Ignore unknown fields by default ) class DhwOperationSetting(Enum): """DHW operation setting modes (user-configured heating preferences).""" + HEAT_PUMP = 1 ELECTRIC = 2 ENERGY_SAVER = 3 @@ -71,6 +78,7 @@ class DhwOperationSetting(Enum): class CurrentOperationMode(Enum): """Current operation mode (real-time operational state).""" + STANDBY = 0 HEAT_PUMP_MODE = 32 HYBRID_EFFICIENCY_MODE = 64 @@ -79,12 +87,14 @@ class CurrentOperationMode(Enum): class TemperatureUnit(Enum): """Temperature unit enumeration.""" + CELSIUS = 1 FAHRENHEIT = 2 class DeviceInfo(NavienBaseModel): """Device information from API.""" + home_seq: int = 0 mac_address: str = "" additional_value: str = "" @@ -96,6 +106,7 @@ class DeviceInfo(NavienBaseModel): class Location(NavienBaseModel): """Location information for a device.""" + state: Optional[str] = None city: Optional[str] = None address: Optional[str] = None @@ -106,12 +117,14 @@ class Location(NavienBaseModel): class Device(NavienBaseModel): """Complete device information including location.""" + device_info: DeviceInfo location: Location class FirmwareInfo(NavienBaseModel): """Firmware information for a device.""" + mac_address: str = "" additional_value: str = "" device_type: int = 52 @@ -123,6 +136,7 @@ class FirmwareInfo(NavienBaseModel): class TOUSchedule(NavienBaseModel): """Time of Use schedule information.""" + season: int = 0 intervals: list[dict[str, Any]] = Field( default_factory=list, alias="interval" @@ -131,6 +145,7 @@ class TOUSchedule(NavienBaseModel): class TOUInfo(NavienBaseModel): """Time of Use information.""" + register_path: str = "" source_type: str = "" controller_id: str = "" @@ -358,6 +373,7 @@ def from_dict(cls, data: dict[str, Any]) -> "DeviceFeature": class MqttRequest(NavienBaseModel): """MQTT command request payload.""" + command: int device_type: int mac_address: str @@ -371,6 +387,7 @@ class MqttRequest(NavienBaseModel): class MqttCommand(NavienBaseModel): """Represents an MQTT command message.""" + client_id: str = Field(alias="clientID") session_id: str = Field(alias="sessionID") request_topic: str @@ -381,6 +398,7 @@ class MqttCommand(NavienBaseModel): class EnergyUsageTotal(NavienBaseModel): """Total energy usage data.""" + total_usage: int heat_pump_usage: int heat_element_usage: int @@ -400,6 +418,7 @@ def heat_element_percentage(self) -> float: class EnergyUsageDay(NavienBaseModel): """Daily energy usage data.""" + day: int total_usage: int heat_pump_usage: int @@ -410,6 +429,7 @@ class EnergyUsageDay(NavienBaseModel): class EnergyUsageResponse(NavienBaseModel): """Response for energy usage query.""" + total: EnergyUsageTotal daily: list[EnergyUsageDay] From 45d2c84bd728e137fbc2cb6fcf9f3598ef42e685 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 18 Nov 2025 20:18:32 -0800 Subject: [PATCH 04/29] Update comment for API typo workaround Clarifies that the alias handles an API-level typo. --- src/nwp500/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 6e06d5e..417ab5a 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -282,7 +282,7 @@ class DeviceStatus(NavienBaseModel): he_upper_off_diff_temp_setting: Div10 he_lower_on_diff_temp_setting: Div10 = Field( alias="heLowerOnTDiffempSetting" - ) # Handle typo + ) # Handle API typo: heLowerOnTDiffempSetting -> heLowerOnDiffTempSetting he_lower_off_diff_temp_setting: Div10 recirc_dhw_flow_rate: Div10 From feaac72c8edb1595e02754529db761b3ee6af960 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Tue, 18 Nov 2025 20:20:27 -0800 Subject: [PATCH 05/29] Remove dead code in from_dict Removes unnecessary conditional check for typo field as Pydantic alias handles it automatically. --- src/nwp500/models.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 417ab5a..bd902f2 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -312,10 +312,6 @@ class DeviceStatus(NavienBaseModel): @classmethod def from_dict(cls, data: dict[str, Any]) -> "DeviceStatus": """Compatibility method for existing code.""" - # Handle the typo field explicitly if needed, though alias handles it - if "heLowerOnTDiffempSetting" in data: - # Pydantic alias will handle this, but if we want to be safe - pass return cls.model_validate(data) From 7a37281799b8915eaeaa941cf18e512468325e25 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 07:58:14 -0800 Subject: [PATCH 06/29] Fix EnergyUsageTotal aliases Adds explicit aliases for heat_pump_usage and heat_element_usage to match API keys (hpUsage, heUsage). --- src/nwp500/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index bd902f2..85e4f8c 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -396,8 +396,8 @@ class EnergyUsageTotal(NavienBaseModel): """Total energy usage data.""" total_usage: int - heat_pump_usage: int - heat_element_usage: int + heat_pump_usage: int = Field(alias="hpUsage") + heat_element_usage: int = Field(alias="heUsage") @property def heat_pump_percentage(self) -> float: From ecb6bbc1cc7621b03b40087772446cc924299d0a Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 12:15:19 -0800 Subject: [PATCH 07/29] fix: Complete Pydantic model refactoring and example file fixes - Auth: Add idToken to handle_empty_aliases validator for consistency - Models: Restructure energy usage models to match actual API response - Add MonthlyEnergyData class for monthly grouping - Change EnergyUsageResponse from flat daily list to usage with monthly grouping - Add time fields to EnergyUsageTotal (heat_pump_time, heat_element_time) - Make total_usage computed properties where appropriate - Add get_month_data() method to EnergyUsageResponse - Models: Fix temp_formula_type to accept Union[int, str] for API compatibility - Exports: Remove duplicate MqttRequest/MqttCommand, add new model exports - Docs: Add datetime serialization backward compatibility notes - Examples: Fix 100+ camelCase attribute references to snake_case across all example files - Examples: Fix encoding function imports (decode_week_bitfield, decode_price) All changes verified with actual API responses from Navien device. --- examples/combined_callbacks.py | 18 +++---- examples/device_feature_callback.py | 38 ++++++------- examples/device_status_callback.py | 40 +++++++------- examples/device_status_callback_debug.py | 6 +-- examples/energy_usage_example.py | 12 ++--- examples/event_emitter_demo.py | 8 +-- examples/improved_auth_pattern.py | 6 +-- examples/mqtt_client_example.py | 18 +++---- examples/periodic_device_info.py | 8 +-- examples/periodic_requests.py | 12 ++--- examples/power_control_example.py | 10 ++-- examples/reconnection_demo.py | 2 +- examples/reservation_schedule_example.py | 3 +- examples/set_dhw_temperature_example.py | 12 ++--- examples/set_mode_example.py | 8 +-- examples/simple_auto_recovery.py | 4 +- examples/simple_periodic_info.py | 2 +- examples/simple_periodic_status.py | 2 +- examples/test_periodic_minimal.py | 4 +- examples/tou_openei_example.py | 2 +- examples/tou_schedule_example.py | 11 ++-- src/nwp500/__init__.py | 6 ++- src/nwp500/auth.py | 9 +++- src/nwp500/models.py | 68 +++++++++++++++++++----- 24 files changed, 182 insertions(+), 127 deletions(-) diff --git a/examples/combined_callbacks.py b/examples/combined_callbacks.py index 2b41c58..5ab1db6 100644 --- a/examples/combined_callbacks.py +++ b/examples/combined_callbacks.py @@ -85,22 +85,22 @@ async def main(): def on_status(status: DeviceStatus): counts["status"] += 1 print(f"\n📊 Status Update #{counts['status']}") - print(f" Mode: {status.operationMode.name}") - print(f" DHW Temp: {status.dhwTemperature:.1f}°F") - print(f" DHW Charge: {status.dhwChargePer:.1f}%") - print(f" Compressor: {'On' if status.compUse else 'Off'}") + print(f" Mode: {status.operation_mode.name}") + print(f" DHW Temp: {status.dhw_temperature:.1f}°F") + print(f" DHW Charge: {status.dhw_charge_per:.1f}%") + print(f" Compressor: {'On' if status.comp_use else 'Off'}") # Callback for feature/capability info def on_feature(feature: DeviceFeature): counts["feature"] += 1 print(f"\n📋 Feature Info #{counts['feature']}") - print(f" Serial: {feature.controllerSerialNumber}") - print(f" FW Version: {feature.controllerSwVersion}") + print(f" Serial: {feature.controller_serial_number}") + print(f" FW Version: {feature.controller_sw_version}") print( - f" Temp Range: {feature.dhwTemperatureMin}-{feature.dhwTemperatureMax}°F" + f" Temp Range: {feature.dhw_temperature_min}-{feature.dhw_temperature_max}°F" ) - print(f" Heat Pump: {'Yes' if feature.heatpumpUse == 2 else 'No'}") - print(f" Electric: {'Yes' if feature.electricUse == 2 else 'No'}") + print(f" Heat Pump: {'Yes' if feature.heatpump_use == 2 else 'No'}") + print(f" Electric: {'Yes' if feature.electric_use == 2 else 'No'}") # Subscribe to broader topics to catch all messages print("Subscribing to status and feature callbacks...") diff --git a/examples/device_feature_callback.py b/examples/device_feature_callback.py index dd39416..44e4ae9 100644 --- a/examples/device_feature_callback.py +++ b/examples/device_feature_callback.py @@ -119,31 +119,31 @@ def on_device_feature(feature: DeviceFeature): # Access typed feature fields directly print("Device Identity:") - print(f" Serial Number: {feature.controllerSerialNumber}") - print(f" Country Code: {feature.countryCode}") - print(f" Model Type: {feature.modelTypeCode}") - print(f" Control Type: {feature.controlTypeCode}") - print(f" Volume Code: {feature.volumeCode}") + print(f" Serial Number: {feature.controller_serial_number}") + print(f" Country Code: {feature.country_code}") + print(f" Model Type: {feature.model_type_code}") + print(f" Control Type: {feature.control_type_code}") + print(f" Volume Code: {feature.volume_code}") print("\nFirmware Versions:") print( - f" Controller SW: {feature.controllerSwVersion} (code: {feature.controllerSwCode})" + f" Controller SW: {feature.controller_sw_version} (code: {feature.controller_sw_code})" ) print( - f" Panel SW: {feature.panelSwVersion} (code: {feature.panelSwCode})" + f" Panel SW: {feature.panel_sw_version} (code: {feature.panel_sw_code})" ) print( - f" WiFi SW: {feature.wifiSwVersion} (code: {feature.wifiSwCode})" + f" WiFi SW: {feature.wifi_sw_version} (code: {feature.wifi_sw_code})" ) print("\nConfiguration:") print(f" Temperature Unit: {feature.temperatureType.name}") print(f" Temp Formula Type: {feature.tempFormulaType}") print( - f" DHW Temp Range: {feature.dhwTemperatureMin}°F - {feature.dhwTemperatureMax}°F" + f" DHW Temp Range: {feature.dhw_temperature_min}°F - {feature.dhw_temperature_max}°F" ) print( - f" Freeze Prot Range: {feature.freezeProtectionTempMin}°F - {feature.freezeProtectionTempMax}°F" + f" Freeze Prot Range: {feature.freeze_protection_temp_min}°F - {feature.freeze_protection_temp_max}°F" ) print("\nFeature Support:") @@ -151,16 +151,16 @@ def on_device_feature(feature: DeviceFeature): f" Power Control: {'Supported' if feature.powerUse == 2 else 'Not Available'}" ) print( - f" DHW Control: {'Supported' if feature.dhwUse == 2 else 'Not Available'}" + f" DHW Control: {'Supported' if feature.dhw_use == 2 else 'Not Available'}" ) print( - f" DHW Temp Setting: Level {feature.dhwTemperatureSettingUse}" + f" DHW Temp Setting: Level {feature.dhw_temperature_settingUse}" ) print( - f" Heat Pump Mode: {'Supported' if feature.heatpumpUse == 2 else 'Not Available'}" + f" Heat Pump Mode: {'Supported' if feature.heatpump_use == 2 else 'Not Available'}" ) print( - f" Electric Mode: {'Supported' if feature.electricUse == 2 else 'Not Available'}" + f" Electric Mode: {'Supported' if feature.electric_use == 2 else 'Not Available'}" ) print( f" Energy Saver: {'Supported' if feature.energySaverUse == 2 else 'Not Available'}" @@ -169,7 +169,7 @@ def on_device_feature(feature: DeviceFeature): f" High Demand: {'Supported' if feature.highDemandUse == 2 else 'Not Available'}" ) print( - f" Eco Mode: {'Supported' if feature.ecoUse == 2 else 'Not Available'}" + f" Eco Mode: {'Supported' if feature.eco_use == 2 else 'Not Available'}" ) print("\nAdvanced Features:") @@ -177,19 +177,19 @@ def on_device_feature(feature: DeviceFeature): f" Holiday Mode: {'Supported' if feature.holidayUse == 2 else 'Not Available'}" ) print( - f" Program Schedule: {'Supported' if feature.programReservationUse == 2 else 'Not Available'}" + f" Program Schedule: {'Supported' if feature.program_reservation_use == 2 else 'Not Available'}" ) print( - f" Smart Diagnostic: {'Supported' if feature.smartDiagnosticUse == 1 else 'Not Available'}" + f" Smart Diagnostic: {'Supported' if feature.smart_diagnosticUse == 1 else 'Not Available'}" ) print( - f" WiFi RSSI: {'Supported' if feature.wifiRssiUse == 2 else 'Not Available'}" + f" WiFi RSSI: {'Supported' if feature.wifi_rssiUse == 2 else 'Not Available'}" ) print( f" Energy Usage: {'Supported' if feature.energyUsageUse == 2 else 'Not Available'}" ) print( - f" Freeze Protection: {'Supported' if feature.freezeProtectionUse == 2 else 'Not Available'}" + f" Freeze Protection: {'Supported' if feature.freeze_protection_use == 2 else 'Not Available'}" ) print( f" Mixing Valve: {'Supported' if feature.mixingValueUse == 1 else 'Not Available'}" diff --git a/examples/device_status_callback.py b/examples/device_status_callback.py index 2634f27..df9749b 100755 --- a/examples/device_status_callback.py +++ b/examples/device_status_callback.py @@ -137,44 +137,44 @@ def on_device_status(status: DeviceStatus): # Access typed status fields directly print("Temperatures:") - print(f" DHW Temperature: {status.dhwTemperature:.1f}°F") + print(f" DHW Temperature: {status.dhw_temperature:.1f}°F") print( - f" DHW Target Setting: {status.dhwTargetTemperatureSetting:.1f}°F" + f" DHW Target Setting: {status.dhw_target_temperature_setting:.1f}°F" ) print( - f" Tank Upper: {status.tankUpperTemperature:.1f}°F" + f" Tank Upper: {status.tank_upper_temperature:.1f}°F" ) print( - f" Tank Lower: {status.tankLowerTemperature:.1f}°F" + f" Tank Lower: {status.tank_lower_temperature:.1f}°F" ) print( - f" Discharge: {status.dischargeTemperature:.1f}°F" + f" Discharge: {status.discharge_temperature:.1f}°F" ) print( - f" Ambient: {status.ambientTemperature:.1f}°F" + f" Ambient: {status.ambient_temperature:.1f}°F" ) print("\nOperation:") - print(f" Mode: {status.operationMode.name}") - print(f" Operation Busy: {status.operationBusy}") - print(f" DHW Active: {status.dhwUse}") - print(f" Compressor Active: {status.compUse}") - print(f" Evaporator Fan Active: {status.evaFanUse}") - print(f" Current Power: {status.currentInstPower:.1f}W") + print(f" Mode: {status.operation_mode.name}") + print(f" Operation Busy: {status.operation_busy}") + print(f" DHW Active: {status.dhw_use}") + print(f" Compressor Active: {status.comp_use}") + print(f" Evaporator Fan Active: {status.eva_fan_use}") + print(f" Current Power: {status.current_inst_power:.1f}W") print("\nSystem Status:") - print(f" Error Code: {status.errorCode}") - print(f" WiFi RSSI: {status.wifiRssi} dBm") - print(f" DHW Charge: {status.dhwChargePer:.1f}%") - print(f" Eco Mode: {status.ecoUse}") - print(f" Freeze Protection: {status.freezeProtectionUse}") + print(f" Error Code: {status.error_code}") + print(f" WiFi RSSI: {status.wifi_rssi} dBm") + print(f" DHW Charge: {status.dhw_charge_per:.1f}%") + print(f" Eco Mode: {status.eco_use}") + print(f" Freeze Protection: {status.freeze_protection_use}") print("\nAdvanced:") print( - f" Fan RPM: {status.currentFanRpm}/{status.targetFanRpm}" + f" Fan RPM: {status.current_fan_rpm}/{status.target_fan_rpm}" ) - print(f" EEV Step: {status.eevStep}") - print(f" Super Heat: {status.currentSuperHeat:.1f}°F") + print(f" EEV Step: {status.eev_step}") + print(f" Super Heat: {status.current_super_heat:.1f}°F") print( f" Flow Rate: {status.currentDhwFlowRate:.1f} GPM" ) diff --git a/examples/device_status_callback_debug.py b/examples/device_status_callback_debug.py index 9eb67a2..46855e5 100644 --- a/examples/device_status_callback_debug.py +++ b/examples/device_status_callback_debug.py @@ -131,9 +131,9 @@ def on_device_status(status: DeviceStatus): print( f"\n[SUCCESS] PARSED Status Update #{message_count['status']}" ) - print(f" DHW Temperature: {status.dhwTemperature:.1f}°F") - print(f" Operation Mode: {status.operationMode.name}") - print(f" Compressor: {status.compUse}") + print(f" DHW Temperature: {status.dhw_temperature:.1f}°F") + print(f" Operation Mode: {status.operation_mode.name}") + print(f" Compressor: {status.comp_use}") # Subscribe with raw handler first print("Subscribing to raw messages...") diff --git a/examples/energy_usage_example.py b/examples/energy_usage_example.py index e8aface..d6aaa5e 100755 --- a/examples/energy_usage_example.py +++ b/examples/energy_usage_example.py @@ -50,17 +50,17 @@ def on_energy_usage(energy: EnergyUsageResponse): # Heat pump details print("🔵 HEAT PUMP") print( - f" Energy Usage: {energy.total.hpUsage:,} Wh ({energy.total.heat_pump_percentage:.1f}%)" + f" Energy Usage: {energy.total.heat_pump_usage:,} Wh ({energy.total.heat_pump_percentage:.1f}%)" ) - print(f" Operating Time: {energy.total.hpTime} hours") + print(f" Operating Time: {energy.total.heat_pump_time} hours") print() # Electric heater details print("🔴 ELECTRIC HEATER") print( - f" Energy Usage: {energy.total.heUsage:,} Wh ({energy.total.heat_element_percentage:.1f}%)" + f" Energy Usage: {energy.total.heat_element_usage:,} Wh ({energy.total.heat_element_percentage:.1f}%)" ) - print(f" Operating Time: {energy.total.heTime} hours") + print(f" Operating Time: {energy.total.heat_element_time} hours") print() # Efficiency analysis @@ -91,14 +91,14 @@ def on_energy_usage(energy: EnergyUsageResponse): if day_data.total_usage > 0: # Only show days with usage date_str = f"{month_data.year}-{month_data.month:02d}-{day_num:02d}" hp_pct_day = ( - (day_data.hpUsage / day_data.total_usage * 100) + (day_data.heat_pump_usage / day_data.total_usage * 100) if day_data.total_usage > 0 else 0 ) print( f" {date_str}: {day_data.total_usage:5,} Wh " - f"(HP: {day_data.hpUsage:5,} Wh, HE: {day_data.heUsage:4,} Wh, " + f"(HP: {day_data.heat_pump_usage:5,} Wh, HE: {day_data.heat_element_usage:4,} Wh, " f"HP%: {hp_pct_day:4.1f}%)" ) diff --git a/examples/event_emitter_demo.py b/examples/event_emitter_demo.py index 9508f98..b727f50 100644 --- a/examples/event_emitter_demo.py +++ b/examples/event_emitter_demo.py @@ -70,7 +70,7 @@ def optimize_on_mode_change( # Example 3: Power state handlers def on_heating_started(status: DeviceStatus): """Handler for when heating starts.""" - print(f"🔥 [Power] Heating STARTED - Power: {status.currentInstPower}W") + print(f"🔥 [Power] Heating STARTED - Power: {status.current_inst_power}W") def on_heating_stopped(status: DeviceStatus): @@ -82,8 +82,8 @@ def on_heating_stopped(status: DeviceStatus): def on_error_detected(error_code: str, status: DeviceStatus): """Handler for error detection.""" print(f"[ERROR] [Error] ERROR DETECTED: {error_code}") - print(f" Temperature: {status.dhwTemperature}°F") - print(f" Mode: {status.operationMode}") + print(f" Temperature: {status.dhw_temperature}°F") + print(f" Mode: {status.operation_mode}") def on_error_cleared(error_code: str): @@ -179,7 +179,7 @@ async def main(): # One-time listener example mqtt_client.once( "status_received", - lambda s: print(f" 🎉 First status received: {s.dhwTemperature}°F"), + lambda s: print(f" 🎉 First status received: {s.dhw_temperature}°F"), ) print(" [SUCCESS] Registered one-time status handler") print() diff --git a/examples/improved_auth_pattern.py b/examples/improved_auth_pattern.py index 5c64898..577a4b8 100644 --- a/examples/improved_auth_pattern.py +++ b/examples/improved_auth_pattern.py @@ -45,9 +45,9 @@ async def main(): # Step 4: Monitor device status def on_status(status): print("\n📊 Device Status:") - print(f" Temperature: {status.dhwTemperature}°F") - print(f" Target: {status.dhwTemperatureSetting}°F") - print(f" Power: {status.currentInstPower}W") + print(f" Temperature: {status.dhw_temperature}°F") + print(f" Target: {status.dhw_temperature_setting}°F") + print(f" Power: {status.current_inst_power}W") await mqtt.subscribe_device_status(device, on_status) await mqtt.request_device_status(device) diff --git a/examples/mqtt_client_example.py b/examples/mqtt_client_example.py index e0d070e..08a2217 100755 --- a/examples/mqtt_client_example.py +++ b/examples/mqtt_client_example.py @@ -149,12 +149,12 @@ def on_device_status(status: DeviceStatus): print( f"\n📊 Status Update #{message_count['status']} (Message #{message_count['count']})" ) - print(f" - DHW Temperature: {status.dhwTemperature:.1f}°F") - print(f" - Tank Upper: {status.tankUpperTemperature:.1f}°F") - print(f" - Tank Lower: {status.tankLowerTemperature:.1f}°F") - print(f" - Operation Mode: {status.operationMode}") - print(f" - DHW Active: {status.dhwUse}") - print(f" - Compressor: {status.compUse}") + print(f" - DHW Temperature: {status.dhw_temperature:.1f}°F") + print(f" - Tank Upper: {status.tank_upper_temperature:.1f}°F") + print(f" - Tank Lower: {status.tank_lower_temperature:.1f}°F") + print(f" - Operation Mode: {status.operation_mode}") + print(f" - DHW Active: {status.dhw_use}") + print(f" - Compressor: {status.comp_use}") def on_device_feature(feature: DeviceFeature): """Typed callback for device features.""" @@ -163,9 +163,9 @@ def on_device_feature(feature: DeviceFeature): print( f"\n📋 Device Info #{message_count['feature']} (Message #{message_count['count']})" ) - print(f" - Serial: {feature.controllerSerialNumber}") - print(f" - SW Version: {feature.controllerSwVersion}") - print(f" - Heat Pump: {feature.heatpumpUse}") + print(f" - Serial: {feature.controller_serial_number}") + print(f" - SW Version: {feature.controller_sw_version}") + print(f" - Heat Pump: {feature.heatpump_use}") # Subscribe with typed parsing wrappers diff --git a/examples/periodic_device_info.py b/examples/periodic_device_info.py index 7ada6b6..d48ca00 100755 --- a/examples/periodic_device_info.py +++ b/examples/periodic_device_info.py @@ -77,11 +77,11 @@ def on_device_feature(feature: DeviceFeature): info_count += 1 print(f"\n--- Device Info Response #{info_count} ---") - print(f"Controller Serial: {feature.controllerSerialNumber}") - print(f"Controller SW Version: {feature.controllerSwVersion}") - print(f"Heat Pump Use: {feature.heatpumpUse}") + print(f"Controller Serial: {feature.controller_serial_number}") + print(f"Controller SW Version: {feature.controller_sw_version}") + print(f"Heat Pump Use: {feature.heatpump_use}") print( - f"DHW Temp Min/Max: {feature.dhwTemperatureMin}/{feature.dhwTemperatureMax}°F" + f"DHW Temp Min/Max: {feature.dhw_temperature_min}/{feature.dhw_temperature_max}°F" ) # Subscribe with typed parsing diff --git a/examples/periodic_requests.py b/examples/periodic_requests.py index 00d31f1..631f4fd 100755 --- a/examples/periodic_requests.py +++ b/examples/periodic_requests.py @@ -73,9 +73,9 @@ def on_device_status(status: DeviceStatus): status_count += 1 print(f"\n--- Status Response #{status_count} ---") - print(f" Temperature: {status.dhwTemperature:.1f}°F") - print(f" Power: {status.currentInstPower:.1f}W") - print(f" Available Energy: {status.availableEnergyCapacity:.0f} Wh") + print(f" Temperature: {status.dhw_temperature:.1f}°F") + print(f" Power: {status.current_inst_power:.1f}W") + print(f" Available Energy: {status.available_energy_capacity:.0f} Wh") def on_device_feature(feature: DeviceFeature): """Callback receives parsed DeviceFeature objects.""" @@ -83,9 +83,9 @@ def on_device_feature(feature: DeviceFeature): info_count += 1 print(f"\n--- Device Info Response #{info_count} ---") - print(f" Serial: {feature.controllerSerialNumber}") - print(f" FW Version: {feature.controllerSwVersion}") - print(f" Heat Pump: {feature.heatpumpUse}") + print(f" Serial: {feature.controller_serial_number}") + print(f" FW Version: {feature.controller_sw_version}") + print(f" Heat Pump: {feature.heatpump_use}") # Subscribe using typed callbacks await mqtt.subscribe_device_status(device, on_device_status) diff --git a/examples/power_control_example.py b/examples/power_control_example.py index 4a0fdfd..e43cfe6 100644 --- a/examples/power_control_example.py +++ b/examples/power_control_example.py @@ -47,8 +47,8 @@ async def power_control_example(): def on_current_status(status): nonlocal current_status current_status = status - logger.info(f"Current operation mode: {status.operationMode.name}") - logger.info(f"Current DHW temperature: {status.dhwTemperature}°F") + logger.info(f"Current operation mode: {status.operation_mode.name}") + logger.info(f"Current DHW temperature: {status.dhw_temperature}°F") await mqtt_client.subscribe_device_status(device, on_current_status) await mqtt_client.request_device_status(device) @@ -62,7 +62,7 @@ def on_current_status(status): def on_power_off_response(status): nonlocal power_off_complete logger.info("Power OFF response received!") - logger.info(f"Operation mode: {status.operationMode.name}") + logger.info(f"Operation mode: {status.operation_mode.name}") logger.info(f"DHW Operation Setting: {status.dhwOperationSetting.name}") power_off_complete = True @@ -90,9 +90,9 @@ def on_power_off_response(status): def on_power_on_response(status): nonlocal power_on_complete logger.info("Power ON response received!") - logger.info(f"Operation mode: {status.operationMode.name}") + logger.info(f"Operation mode: {status.operation_mode.name}") logger.info(f"DHW Operation Setting: {status.dhwOperationSetting.name}") - logger.info(f"Tank charge: {status.dhwChargePer}%") + logger.info(f"Tank charge: {status.dhw_charge_per}%") power_on_complete = True await mqtt_client.subscribe_device_status(device, on_power_on_response) diff --git a/examples/reconnection_demo.py b/examples/reconnection_demo.py index 5055747..090102a 100644 --- a/examples/reconnection_demo.py +++ b/examples/reconnection_demo.py @@ -89,7 +89,7 @@ def on_status(status): nonlocal status_count status_count += 1 print(f"\n📊 Status update #{status_count}:") - print(f" Temperature: {status.dhwTemperature}°F") + print(f" Temperature: {status.dhw_temperature}°F") print(f" Connected: {mqtt_client.is_connected}") if mqtt_client.is_reconnecting: print(f" Reconnecting: attempt {mqtt_client.reconnect_attempts}...") diff --git a/examples/reservation_schedule_example.py b/examples/reservation_schedule_example.py index 56bb9d8..636c9f2 100644 --- a/examples/reservation_schedule_example.py +++ b/examples/reservation_schedule_example.py @@ -7,6 +7,7 @@ from typing import Any from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient +from nwp500.encoding import decode_week_bitfield from nwp500.encoding import build_reservation_entry @@ -50,7 +51,7 @@ def on_reservation_update(topic: str, message: dict[str, Any]) -> None: ) print(f" entries: {len(reservations)}") for idx, entry in enumerate(reservations, start=1): - week_days = NavienAPIClient.decode_week_bitfield(entry.get("week", 0)) + week_days = decode_week_bitfield(entry.get("week", 0)) display_temp = entry.get("param", 0) + 20 print( " - #{idx}: {time:02d}:{minute:02d} mode={mode} display_temp={temp}F days={days}".format( diff --git a/examples/set_dhw_temperature_example.py b/examples/set_dhw_temperature_example.py index a1f8984..f44ab56 100644 --- a/examples/set_dhw_temperature_example.py +++ b/examples/set_dhw_temperature_example.py @@ -48,9 +48,9 @@ def on_current_status(status): nonlocal current_status current_status = status logger.info( - f"Current DHW target temperature: {status.dhwTargetTemperatureSetting}°F" + f"Current DHW target temperature: {status.dhw_target_temperature_setting}°F" ) - logger.info(f"Current DHW temperature: {status.dhwTemperature}°F") + logger.info(f"Current DHW temperature: {status.dhw_temperature}°F") await mqtt_client.subscribe_device_status(device, on_current_status) await mqtt_client.request_device_status(device) @@ -67,11 +67,11 @@ def on_temp_change_response(status): nonlocal temp_changed logger.info("Temperature change response received!") logger.info( - f"New target temperature: {status.dhwTargetTemperatureSetting}°F" + f"New target temperature: {status.dhw_target_temperature_setting}°F" ) - logger.info(f"Current DHW temperature: {status.dhwTemperature}°F") - logger.info(f"Operation mode: {status.operationMode.name}") - logger.info(f"Tank charge: {status.dhwChargePer}%") + logger.info(f"Current DHW temperature: {status.dhw_temperature}°F") + logger.info(f"Operation mode: {status.operation_mode.name}") + logger.info(f"Tank charge: {status.dhw_charge_per}%") temp_changed = True await mqtt_client.subscribe_device_status(device, on_temp_change_response) diff --git a/examples/set_mode_example.py b/examples/set_mode_example.py index 3cec4ff..393e14f 100644 --- a/examples/set_mode_example.py +++ b/examples/set_mode_example.py @@ -47,7 +47,7 @@ async def set_mode_example(): def on_current_status(status): nonlocal current_status current_status = status - logger.info(f"Current mode: {status.operationMode.name}") + logger.info(f"Current mode: {status.operation_mode.name}") await mqtt_client.subscribe_device_status(device, on_current_status) await mqtt_client.request_device_status(device) @@ -62,9 +62,9 @@ def on_current_status(status): def on_mode_change_response(status): nonlocal mode_changed logger.info("Mode change response received!") - logger.info(f"New mode: {status.operationMode.name}") - logger.info(f"DHW Temperature: {status.dhwTemperature}°F") - logger.info(f"Tank Charge: {status.dhwChargePer}%") + logger.info(f"New mode: {status.operation_mode.name}") + logger.info(f"DHW Temperature: {status.dhw_temperature}°F") + logger.info(f"Tank Charge: {status.dhw_charge_per}%") mode_changed = True await mqtt_client.subscribe_device_status(device, on_mode_change_response) diff --git a/examples/simple_auto_recovery.py b/examples/simple_auto_recovery.py index 8e8b0d8..ce4e421 100644 --- a/examples/simple_auto_recovery.py +++ b/examples/simple_auto_recovery.py @@ -200,8 +200,8 @@ def on_status(status): nonlocal status_count status_count += 1 logger.info( - f"Status #{status_count}: Temp={status.dhwTemperature}°F, " - f"Mode={status.operationMode}" + f"Status #{status_count}: Temp={status.dhw_temperature}°F, " + f"Mode={status.operation_mode}" ) # Create resilient MQTT client diff --git a/examples/simple_periodic_info.py b/examples/simple_periodic_info.py index a58ea77..0578e70 100644 --- a/examples/simple_periodic_info.py +++ b/examples/simple_periodic_info.py @@ -39,7 +39,7 @@ async def main(): # Typed callback def on_feature(feature: DeviceFeature): print( - f"Device info: Serial {feature.controllerSerialNumber}, FW {feature.controllerSwVersion}" + f"Device info: Serial {feature.controller_serial_number}, FW {feature.controller_sw_version}" ) # Subscribe with typed parsing diff --git a/examples/simple_periodic_status.py b/examples/simple_periodic_status.py index 01876f1..27072b1 100755 --- a/examples/simple_periodic_status.py +++ b/examples/simple_periodic_status.py @@ -39,7 +39,7 @@ async def main(): # Typed callback def on_status(status: DeviceStatus): - print(f"Status: {status.dhwTemperature:.1f}°F, {status.currentInstPower:.1f}W") + print(f"Status: {status.dhw_temperature:.1f}°F, {status.current_inst_power:.1f}W") # Subscribe with typed parsing await mqtt.subscribe_device_status(device, on_status) diff --git a/examples/test_periodic_minimal.py b/examples/test_periodic_minimal.py index d0a235f..bdb67c3 100755 --- a/examples/test_periodic_minimal.py +++ b/examples/test_periodic_minimal.py @@ -54,8 +54,8 @@ def on_device_status(status: DeviceStatus): message_count += 1 timestamp = datetime.now().strftime("%H:%M:%S") print(f"[{timestamp}] Status #{message_count}") - print(f" Temperature: {status.dhwTemperature:.1f}°F") - print(f" Power: {status.currentInstPower:.1f}W") + print(f" Temperature: {status.dhw_temperature:.1f}°F") + print(f" Power: {status.current_inst_power:.1f}W") # Subscribe with typed parsing print("Subscribing...") diff --git a/examples/tou_openei_example.py b/examples/tou_openei_example.py index e8492b9..0bf2fb2 100755 --- a/examples/tou_openei_example.py +++ b/examples/tou_openei_example.py @@ -214,7 +214,7 @@ def capture_feature(feature) -> None: await mqtt_client.subscribe_device_feature(device, capture_feature) await mqtt_client.request_device_info(device) feature = await asyncio.wait_for(feature_future, timeout=15) - return feature.controllerSerialNumber + return feature.controller_serial_number async def main() -> None: diff --git a/examples/tou_schedule_example.py b/examples/tou_schedule_example.py index 0d91fc5..2d3579d 100644 --- a/examples/tou_schedule_example.py +++ b/examples/tou_schedule_example.py @@ -6,7 +6,8 @@ import sys from typing import Any -from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient, build_tou_period +from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient +from nwp500.encoding import decode_week_bitfield, decode_price, build_tou_period async def _wait_for_controller_serial(mqtt_client: NavienMqttClient, device) -> str: @@ -25,7 +26,7 @@ def capture_feature(feature) -> None: # Wait for the response feature = await asyncio.wait_for(feature_future, timeout=15) - return feature.controllerSerialNumber + return feature.controller_serial_number async def main() -> None: @@ -88,11 +89,11 @@ def on_tou_response(topic: str, message: dict[str, Any]) -> None: print("\nTOU response received:") print(f" reservationUse: {response.get('reservationUse')}") for idx, entry in enumerate(reservation, start=1): - week_days = NavienAPIClient.decode_week_bitfield(entry.get("week", 0)) - price_min_value = NavienAPIClient.decode_price( + week_days = decode_week_bitfield(entry.get("week", 0)) + price_min_value = decode_price( entry.get("priceMin", 0), entry.get("decimalPoint", 0) ) - price_max_value = NavienAPIClient.decode_price( + price_max_value = decode_price( entry.get("priceMax", 0), entry.get("decimalPoint", 0) ) print( diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index ef3292f..9e3e37e 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -72,10 +72,12 @@ DeviceInfo, DeviceStatus, DhwOperationSetting, + EnergyUsageDay, EnergyUsageResponse, EnergyUsageTotal, FirmwareInfo, Location, + MonthlyEnergyData, MqttCommand, MqttRequest, TemperatureUnit, @@ -104,9 +106,9 @@ "TemperatureUnit", "MqttRequest", "MqttCommand", - "MqttRequest", - "MqttCommand", "EnergyUsageTotal", + "EnergyUsageDay", + "MonthlyEnergyData", "EnergyUsageResponse", # Authentication "NavienAuthClient", diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index ec9e2d0..1e79fe6 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -91,6 +91,11 @@ def handle_empty_aliases(cls, data: Any) -> Any: ("accessToken", "access_token"), ("accessKeyId", "access_key_id"), ("secretKey", "secret_key"), + ("refreshToken", "refresh_token"), + ("sessionToken", "session_token"), + ("authenticationExpiresIn", "authentication_expires_in"), + ("authorizationExpiresIn", "authorization_expires_in"), + ("idToken", "id_token"), ] for camel, snake in fields_to_check: @@ -133,7 +138,9 @@ def to_dict(self) -> dict[str, Any]: """Convert AuthTokens to a dictionary for storage. Returns: - Dictionary with snake_case keys suitable for JSON serialization + 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") diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 85e4f8c..c81d155 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -204,7 +204,7 @@ class DeviceStatus(NavienBaseModel): vacation_day_elapsed: int anti_legionella_period: int program_reservation_type: int - temp_formula_type: str + temp_formula_type: Union[int, str] current_statenum: int target_fan_rpm: int current_fan_rpm: int @@ -395,9 +395,15 @@ class MqttCommand(NavienBaseModel): class EnergyUsageTotal(NavienBaseModel): """Total energy usage data.""" - total_usage: int - heat_pump_usage: int = Field(alias="hpUsage") - heat_element_usage: int = Field(alias="heUsage") + 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: + """Total energy usage (heat pump + heat element).""" + return self.heat_pump_usage + self.heat_element_usage @property def heat_pump_percentage(self) -> float: @@ -411,23 +417,61 @@ def heat_element_percentage(self) -> float: return 0.0 return (self.heat_element_usage / self.total_usage) * 100.0 + @property + def total_time(self) -> int: + """Total operating time (heat pump + heat element).""" + return self.heat_pump_time + self.heat_element_time + class EnergyUsageDay(NavienBaseModel): - """Daily energy usage data.""" + """Daily energy usage data. + + Note: The API returns a fixed-length array (30 elements) for each month, + with unused days having all zeros. The day number is implicit from the + array index (0-based). + """ + + heat_pump_usage: int = Field(alias="hpUsage") + heat_element_usage: int = Field(alias="heUsage") + heat_pump_time: int = Field(alias="hpTime") + heat_element_time: int = Field(alias="heTime") + + @property + def total_usage(self) -> int: + """Total energy usage (heat pump + heat element).""" + return self.heat_pump_usage + self.heat_element_usage + + +class MonthlyEnergyData(NavienBaseModel): + """Monthly energy usage data grouping.""" - day: int - total_usage: int - heat_pump_usage: int - heat_element_usage: int - heat_pump_time: int - heat_element_time: int + year: int + month: int + data: list[EnergyUsageDay] class EnergyUsageResponse(NavienBaseModel): """Response for energy usage query.""" total: EnergyUsageTotal - daily: list[EnergyUsageDay] + usage: list[MonthlyEnergyData] + + def get_month_data( + self, year: int, month: int + ) -> Optional[MonthlyEnergyData]: + """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": From e2771a4204976ea0fe8ebcd423a38b179c2554c0 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 12:22:05 -0800 Subject: [PATCH 08/29] fix: Apply ruff linting and formatting fixes - Remove trailing whitespace from blank lines - Apply ruff formatting to modified files --- .agent/workflows/pre-completion-testing.md | 73 ++++++++++++++++++++++ examples/combined_callbacks.py | 4 +- examples/device_status_callback.py | 4 +- examples/simple_periodic_status.py | 4 +- src/nwp500/models.py | 6 +- 5 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 .agent/workflows/pre-completion-testing.md diff --git a/.agent/workflows/pre-completion-testing.md b/.agent/workflows/pre-completion-testing.md new file mode 100644 index 0000000..39396bd --- /dev/null +++ b/.agent/workflows/pre-completion-testing.md @@ -0,0 +1,73 @@ +--- +description: Run linting and testing before completing tasks +--- + +# Pre-Completion Testing Workflow + +Before marking any code-related task as complete, you MUST run the following checks: + +## 1. Linting with Ruff + +Run ruff to check for code style and quality issues: + +```bash +ruff check src/ tests/ examples/ +``` + +If there are any errors, fix them before proceeding. You can auto-fix many issues with: + +```bash +ruff check --fix src/ tests/ examples/ +``` + +## 2. Format Check with Ruff + +Verify code formatting is correct: + +```bash +ruff format --check src/ tests/ examples/ +``` + +If formatting issues are found, apply formatting: + +```bash +ruff format src/ tests/ examples/ +``` + +## 3. Run Unit Tests + +Execute the test suite to ensure no regressions: + +```bash +pytest tests/ +``` + +All tests must pass before completing the task. + +## 4. Type Checking (Optional but Recommended) + +If you've modified type annotations or core logic, run mypy: + +```bash +mypy src/ +``` + +## Summary + +**Required before task completion:** +- ✅ Ruff linting passes (no errors) +- ✅ Ruff formatting check passes +- ✅ All pytest tests pass + +**Recommended:** +- ✅ Mypy type checking passes (if types were modified) + +## Quick Command + +You can run all checks with: + +```bash +ruff check src/ tests/ examples/ && ruff format --check src/ tests/ examples/ && pytest tests/ +``` + +**IMPORTANT**: Do not claim a task is complete without running these checks. If any check fails, fix the issues and re-run the checks. diff --git a/examples/combined_callbacks.py b/examples/combined_callbacks.py index 5ab1db6..5d42b79 100644 --- a/examples/combined_callbacks.py +++ b/examples/combined_callbacks.py @@ -99,7 +99,9 @@ def on_feature(feature: DeviceFeature): print( f" Temp Range: {feature.dhw_temperature_min}-{feature.dhw_temperature_max}°F" ) - print(f" Heat Pump: {'Yes' if feature.heatpump_use == 2 else 'No'}") + print( + f" Heat Pump: {'Yes' if feature.heatpump_use == 2 else 'No'}" + ) print(f" Electric: {'Yes' if feature.electric_use == 2 else 'No'}") # Subscribe to broader topics to catch all messages diff --git a/examples/device_status_callback.py b/examples/device_status_callback.py index df9749b..2e426f7 100755 --- a/examples/device_status_callback.py +++ b/examples/device_status_callback.py @@ -174,7 +174,9 @@ def on_device_status(status: DeviceStatus): f" Fan RPM: {status.current_fan_rpm}/{status.target_fan_rpm}" ) print(f" EEV Step: {status.eev_step}") - print(f" Super Heat: {status.current_super_heat:.1f}°F") + print( + f" Super Heat: {status.current_super_heat:.1f}°F" + ) print( f" Flow Rate: {status.currentDhwFlowRate:.1f} GPM" ) diff --git a/examples/simple_periodic_status.py b/examples/simple_periodic_status.py index 27072b1..3e0a7cf 100755 --- a/examples/simple_periodic_status.py +++ b/examples/simple_periodic_status.py @@ -39,7 +39,9 @@ async def main(): # Typed callback def on_status(status: DeviceStatus): - print(f"Status: {status.dhw_temperature:.1f}°F, {status.current_inst_power:.1f}W") + print( + f"Status: {status.dhw_temperature:.1f}°F, {status.current_inst_power:.1f}W" + ) # Subscribe with typed parsing await mqtt.subscribe_device_status(device, on_status) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index c81d155..9537615 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -399,7 +399,7 @@ class EnergyUsageTotal(NavienBaseModel): 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: """Total energy usage (heat pump + heat element).""" @@ -425,7 +425,7 @@ def total_time(self) -> int: class EnergyUsageDay(NavienBaseModel): """Daily energy usage data. - + Note: The API returns a fixed-length array (30 elements) for each month, with unused days having all zeros. The day number is implicit from the array index (0-based). @@ -435,7 +435,7 @@ class EnergyUsageDay(NavienBaseModel): heat_element_usage: int = Field(alias="heUsage") heat_pump_time: int = Field(alias="hpTime") heat_element_time: int = Field(alias="heTime") - + @property def total_usage(self) -> int: """Total energy usage (heat pump + heat element).""" From 4cd91aa5d9b3adb154fa2135e0bbc870dca75755 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 14:03:35 -0800 Subject: [PATCH 09/29] fix: Resolve mypy type errors and CLI attribute naming - Fix camelCase attribute usage in CLI commands and monitoring - Fix return type annotation in boolean validator - Update TOUInfo.model_validate signature to match base class - Fix Optional[ClientSession] assignment in NavienAPIClient --- src/nwp500/api_client.py | 6 ++++-- src/nwp500/cli/commands.py | 6 +++--- src/nwp500/cli/monitoring.py | 4 ++-- src/nwp500/models.py | 3 ++- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index 9ad8e08..93d58dc 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -70,9 +70,11 @@ def __init__( self.base_url = base_url.rstrip("/") self._auth_client = auth_client - self._session: aiohttp.ClientSession = session or auth_client._session - if self._session is None: + self._auth_client = auth_client + _session = session or auth_client._session + if _session is None: raise ValueError("auth_client must have an active session") + self._session = _session self._owned_session = ( False # Never own session when auth_client is provided ) diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py index 24a8b48..1bab777 100644 --- a/src/nwp500/cli/commands.py +++ b/src/nwp500/cli/commands.py @@ -31,7 +31,7 @@ async def get_controller_serial_number( def on_feature(feature: DeviceFeature) -> None: if not future.done(): - future.set_result(feature.controllerSerialNumber) + future.set_result(feature.controller_serial_number) await mqtt.subscribe_device_feature(device, on_feature) _logger.info("Requesting controller serial number...") @@ -232,7 +232,7 @@ def on_status_response(status: DeviceStatus) -> None: ) _logger.info( f"Mode change successful. New mode: " - f"{status.operationMode.name}" + f"{status.operation_mode.name}" ) else: _logger.warning( @@ -308,7 +308,7 @@ def on_status_response(status: DeviceStatus) -> None: ) _logger.info( f"Temperature change successful. New target: " - f"{status.dhwTargetTemperatureSetting}°F" + f"{status.dhw_target_temperature_setting}°F" ) else: _logger.warning( diff --git a/src/nwp500/cli/monitoring.py b/src/nwp500/cli/monitoring.py index 5f48fde..548e169 100644 --- a/src/nwp500/cli/monitoring.py +++ b/src/nwp500/cli/monitoring.py @@ -31,8 +31,8 @@ async def handle_monitoring( def on_status_update(status: DeviceStatus) -> None: _logger.info( - f"Received status update: Temp={status.dhwTemperature}°F, " - f"Power={'ON' if status.dhwUse else 'OFF'}" + f"Received status update: Temp={status.dhw_temperature}°F, " + f"Power={'ON' if status.dhw_use else 'OFF'}" ) write_status_to_csv(output_file, status) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 9537615..3289d10 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -23,7 +23,7 @@ def _device_bool_validator(v: Any) -> bool: """Convert device boolean (2=True, 0/1=False).""" - return v == 2 + return bool(v == 2) def _add_20_validator(v: Any) -> float: @@ -164,6 +164,7 @@ def model_validate( strict: Optional[bool] = None, from_attributes: Optional[bool] = None, context: Optional[dict[str, Any]] = None, + **kwargs: Any, ) -> "TOUInfo": # Handle nested structure where fields are in 'touInfo' if isinstance(obj, dict): From 956cf8f6cdebddf3a744ab2cea889216d1d39b40 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 14:32:34 -0800 Subject: [PATCH 10/29] refactor: improve MQTT client readability and fix token example - Extract shared topic matching utility to mqtt_utils - Refactor MqttSubscriptionManager to use shared utility - Clean up MqttClient imports and remove dead code - Make save/restore arguments optional in token_restoration_example.py --- examples/token_restoration_example.py | 11 ++-- src/nwp500/mqtt_client.py | 82 ++++++++------------------- src/nwp500/mqtt_subscriptions.py | 61 +------------------- src/nwp500/mqtt_utils.py | 57 +++++++++++++++++++ 4 files changed, 88 insertions(+), 123 deletions(-) diff --git a/examples/token_restoration_example.py b/examples/token_restoration_example.py index 67e21b2..8c7ef2f 100644 --- a/examples/token_restoration_example.py +++ b/examples/token_restoration_example.py @@ -126,11 +126,11 @@ async def main(): parser = argparse.ArgumentParser( description="Token restoration example for nwp500-python" ) - group = parser.add_mutually_exclusive_group(required=True) + group = parser.add_mutually_exclusive_group(required=False) group.add_argument( "--save", action="store_true", - help="Authenticate and save tokens for future use", + help="Authenticate and save tokens for future use (default)", ) group.add_argument( "--restore", @@ -141,10 +141,11 @@ async def main(): args = parser.parse_args() try: - if args.save: - await save_tokens_example() - else: + if args.restore: await restore_tokens_example() + else: + # Default to save mode if --save is specified or no args provided + await save_tokens_example() except Exception as e: logger.error(f"Error: {e}") raise diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt_client.py index 9b17a7c..1143925 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt_client.py @@ -9,12 +9,14 @@ the authentication flow. """ +from __future__ import annotations + import asyncio import json import logging import uuid from collections.abc import Sequence -from typing import Any, Callable, Optional +from typing import TYPE_CHECKING, Any, Callable from awscrt import mqtt from awscrt.exceptions import AwsCrtError @@ -29,12 +31,14 @@ MqttPublishError, TokenRefreshError, ) -from .models import ( - Device, - DeviceFeature, - DeviceStatus, - EnergyUsageResponse, -) + +if TYPE_CHECKING: + from .models import ( + Device, + DeviceFeature, + DeviceStatus, + EnergyUsageResponse, + ) from .mqtt_command_queue import MqttCommandQueue from .mqtt_connection import MqttConnection from .mqtt_device_control import MqttDeviceController @@ -123,7 +127,7 @@ class NavienMqttClient(EventEmitter): def __init__( self, auth_client: NavienAuthClient, - config: Optional[MqttConnectionConfig] = None, + config: MqttConnectionConfig | None = None, ): """ Initialize the MQTT client. @@ -162,22 +166,22 @@ def __init__( self._session_id = uuid.uuid4().hex # Store event loop reference for thread-safe coroutine scheduling - self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop: asyncio.AbstractEventLoop | None = None # Initialize specialized components # Command queue (independent, can be created immediately) self._command_queue = MqttCommandQueue(config=self.config) # Components that depend on connection (initialized in connect()) - self._connection_manager: Optional[MqttConnection] = None - self._reconnection_handler: Optional[MqttReconnectionHandler] = None - self._subscription_manager: Optional[MqttSubscriptionManager] = None - self._device_controller: Optional[MqttDeviceController] = None - self._reconnect_task: Optional[asyncio.Task[None]] = None - self._periodic_manager: Optional[MqttPeriodicRequestManager] = None + 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 # Connection state (simpler than checking _connection_manager) - self._connection: Optional[mqtt.Connection] = None + self._connection: mqtt.Connection | None = None self._connected = False _logger.info( @@ -588,44 +592,6 @@ def _on_message_received( except (AttributeError, KeyError, TypeError) as e: _logger.error(f"Error processing message: {e}") - def _topic_matches_pattern(self, topic: str, pattern: str) -> bool: - """Check if a topic matches a subscription pattern with wildcards.""" - # Handle exact match - if topic == pattern: - return True - - # Handle wildcards - topic_parts = topic.split("/") - pattern_parts = pattern.split("/") - - # Multi-level wildcard # matches everything after - if "#" in pattern_parts: - hash_idx = pattern_parts.index("#") - # Must be at the end - if hash_idx != len(pattern_parts) - 1: - return False - # Topic must have at least as many parts as before the # - if len(topic_parts) < hash_idx: - return False - # Check parts before # with + wildcard support - for i in range(hash_idx): - if ( - pattern_parts[i] != "+" - and topic_parts[i] != pattern_parts[i] - ): - return False - return True - - # Single-level wildcard + matches one level - if len(topic_parts) != len(pattern_parts): - return False - - for topic_part, pattern_part in zip(topic_parts, pattern_parts): - if pattern_part != "+" and topic_part != pattern_part: - return False - - return True - async def subscribe( self, topic: str, @@ -923,7 +889,7 @@ async def set_dhw_mode( self, device: Device, mode_id: int, - vacation_days: Optional[int] = None, + vacation_days: int | None = None, ) -> int: """ Set DHW (Domestic Hot Water) operation mode. @@ -1283,7 +1249,7 @@ async def start_periodic_requests( async def stop_periodic_requests( self, device: Device, - request_type: Optional[PeriodicRequestType] = None, + request_type: PeriodicRequestType | None = None, ) -> None: """ Stop sending periodic requests for a device. @@ -1404,9 +1370,7 @@ async def stop_periodic_device_status_requests( device ) - async def stop_all_periodic_tasks( - self, _reason: Optional[str] = None - ) -> None: + async def stop_all_periodic_tasks(self, _reason: str | None = None) -> None: """ Stop all periodic request tasks. diff --git a/src/nwp500/mqtt_subscriptions.py b/src/nwp500/mqtt_subscriptions.py index bd1a951..3936e2c 100644 --- a/src/nwp500/mqtt_subscriptions.py +++ b/src/nwp500/mqtt_subscriptions.py @@ -20,7 +20,7 @@ from .events import EventEmitter from .exceptions import MqttNotConnectedError from .models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse -from .mqtt_utils import redact_topic +from .mqtt_utils import redact_topic, topic_matches_pattern __author__ = "Emmanuel Levijarvi" @@ -115,7 +115,7 @@ def _on_message_received( subscription_pattern, handlers, ) in self._message_handlers.items(): - if self._topic_matches_pattern(topic, subscription_pattern): + if topic_matches_pattern(topic, subscription_pattern): for handler in handlers: try: handler(topic, message) @@ -127,64 +127,7 @@ def _on_message_received( except (AttributeError, KeyError, TypeError) as e: _logger.error(f"Error processing message: {e}") - def _topic_matches_pattern(self, topic: str, pattern: str) -> bool: - """ - Check if a topic matches a subscription pattern with wildcards. - - Supports MQTT wildcards: - - '+' matches a single level - - '#' matches multiple levels (must be at end) - - Args: - topic: Actual topic (e.g., "cmd/52/navilink-ABC/status") - pattern: Pattern with wildcards (e.g., "cmd/52/+/#") - Returns: - True if topic matches pattern - - Examples: - >>> _topic_matches_pattern("cmd/52/device1/status", - "cmd/52/+/status") - True - >>> _topic_matches_pattern("cmd/52/device1/status/extra", - "cmd/52/device1/#") - True - """ - # Handle exact match - if topic == pattern: - return True - - # Handle wildcards - topic_parts = topic.split("/") - pattern_parts = pattern.split("/") - - # Multi-level wildcard # matches everything after - if "#" in pattern_parts: - hash_idx = pattern_parts.index("#") - # Must be at the end - if hash_idx != len(pattern_parts) - 1: - return False - # Topic must have at least as many parts as before the # - if len(topic_parts) < hash_idx: - return False - # Check parts before # with + wildcard support - for i in range(hash_idx): - if ( - pattern_parts[i] != "+" - and topic_parts[i] != pattern_parts[i] - ): - return False - return True - - # Single-level wildcard + matches one level - if len(topic_parts) != len(pattern_parts): - return False - - for topic_part, pattern_part in zip(topic_parts, pattern_parts): - if pattern_part != "+" and topic_part != pattern_part: - return False - - return True async def subscribe( self, diff --git a/src/nwp500/mqtt_utils.py b/src/nwp500/mqtt_utils.py index 3cdc5db..c6a4570 100644 --- a/src/nwp500/mqtt_utils.py +++ b/src/nwp500/mqtt_utils.py @@ -219,3 +219,60 @@ class PeriodicRequestType(Enum): DEVICE_INFO = "device_info" DEVICE_STATUS = "device_status" + + +def topic_matches_pattern(topic: str, pattern: str) -> bool: + """ + Check if a topic matches a subscription pattern with wildcards. + + Supports MQTT wildcards: + - '+' matches a single level + - '#' matches multiple levels (must be at end) + + Args: + topic: Actual topic (e.g., "cmd/52/navilink-ABC/status") + pattern: Pattern with wildcards (e.g., "cmd/52/+/#") + + Returns: + True if topic matches pattern + + Examples: + >>> topic_matches_pattern("cmd/52/device1/status", "cmd/52/+/status") + True + >>> topic_matches_pattern( + ... "cmd/52/device1/status/extra", "cmd/52/device1/#" + ... ) + True + """ + # Handle exact match + if topic == pattern: + return True + + # Handle wildcards + topic_parts = topic.split("/") + pattern_parts = pattern.split("/") + + # Multi-level wildcard # matches everything after + if "#" in pattern_parts: + hash_idx = pattern_parts.index("#") + # Must be at the end + if hash_idx != len(pattern_parts) - 1: + return False + # Topic must have at least as many parts as before the # + if len(topic_parts) < hash_idx: + return False + # Check parts before # with + wildcard support + for i in range(hash_idx): + if pattern_parts[i] != "+" and topic_parts[i] != pattern_parts[i]: + return False + return True + + # Single-level wildcard + matches one level + if len(topic_parts) != len(pattern_parts): + return False + + for topic_part, pattern_part in zip(topic_parts, pattern_parts): + if pattern_part != "+" and topic_part != pattern_part: + return False + + return True From e6b2cafe1bceb4fcc7681d6803c11fe11857e440 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 14:33:04 -0800 Subject: [PATCH 11/29] docs: improve structure and add protocol disclaimers - Refine configuration.rst to reduce duplication - Move Protocol Reference to Advanced section in index.rst - Add warnings to all protocol docs clarifying internal nature --- docs/configuration.rst | 68 +++++------------------------ docs/index.rst | 22 +++++----- docs/protocol/data_conversions.rst | 5 +++ docs/protocol/device_features.rst | 7 ++- docs/protocol/device_status.rst | 5 +++ docs/protocol/error_codes.rst | 6 ++- docs/protocol/firmware_tracking.rst | 5 +++ docs/protocol/mqtt_protocol.rst | 9 ++-- docs/protocol/rest_api.rst | 4 ++ 9 files changed, 56 insertions(+), 75 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 790e804..c5228c3 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -140,53 +140,15 @@ MQTT Configuration ================== The MQTT client supports various configuration options through -``MqttConnectionConfig``: +``MqttConnectionConfig``. -Basic Configuration -------------------- +For detailed configuration guides, see: -.. code-block:: python - - from nwp500 import NavienMqttClient, MqttConnectionConfig - from nwp500.mqtt_utils import MqttConnectionConfig - - config = MqttConnectionConfig( - client_id="my-custom-client", # or None for auto-generated - clean_session=True, - keep_alive_secs=1200 - ) - - mqtt = NavienMqttClient(auth, config=config) - -Reconnection Settings ---------------------- - -Configure automatic reconnection behavior: - -.. code-block:: python - - config = MqttConnectionConfig( - auto_reconnect=True, - max_reconnect_attempts=15, - initial_reconnect_delay=1.0, # seconds - max_reconnect_delay=120.0, # seconds - reconnect_backoff_multiplier=2.0 - ) - -Command Queue Settings ----------------------- - -Configure command queueing when disconnected: - -.. code-block:: python +* :doc:`guides/auto_recovery` - Connection recovery settings +* :doc:`guides/command_queue` - Offline command queuing - config = MqttConnectionConfig( - enable_command_queue=True, - max_queued_commands=100 - ) - -Complete Example ----------------- +Basic Example +------------- .. code-block:: python @@ -194,23 +156,13 @@ Complete Example from nwp500.mqtt_utils import MqttConnectionConfig config = MqttConnectionConfig( - # Connection - endpoint="a1t30mldyslmuq-ats.iot.us-east-1.amazonaws.com", - region="us-east-1", - client_id="my-app-client", - clean_session=True, + # Connection settings + client_id="my-custom-client", keep_alive_secs=1200, - # Reconnection + # Enable features (see guides for details) auto_reconnect=True, - max_reconnect_attempts=10, - initial_reconnect_delay=1.0, - max_reconnect_delay=120.0, - reconnect_backoff_multiplier=2.0, - - # Command queue - enable_command_queue=True, - max_queued_commands=100 + enable_command_queue=True ) mqtt = NavienMqttClient(auth, config=config) diff --git a/docs/index.rst b/docs/index.rst index d2a701e..ea54696 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -112,17 +112,7 @@ Documentation Index api/modules -.. toctree:: - :maxdepth: 2 - :caption: Protocol Reference - protocol/rest_api - protocol/mqtt_protocol - protocol/device_status - protocol/data_conversions - protocol/device_features - protocol/error_codes - protocol/firmware_tracking .. toctree:: :maxdepth: 1 @@ -136,6 +126,18 @@ Documentation Index guides/command_queue guides/auto_recovery +.. toctree:: + :maxdepth: 2 + :caption: Advanced: Protocol Reference + + protocol/rest_api + protocol/mqtt_protocol + protocol/device_status + protocol/data_conversions + protocol/device_features + protocol/error_codes + protocol/firmware_tracking + .. toctree:: :maxdepth: 1 :caption: Development diff --git a/docs/protocol/data_conversions.rst b/docs/protocol/data_conversions.rst index 9888942..702abad 100644 --- a/docs/protocol/data_conversions.rst +++ b/docs/protocol/data_conversions.rst @@ -3,6 +3,11 @@ Data Conversions and Units Reference This document provides comprehensive details on all data conversions applied to device status messages, field units, and the meaning of various data structures. +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/models`) instead of implementing + conversions manually. + Overview of Conversion Types ---------------------------- diff --git a/docs/protocol/device_features.rst b/docs/protocol/device_features.rst index 2538947..64c02e7 100644 --- a/docs/protocol/device_features.rst +++ b/docs/protocol/device_features.rst @@ -1,7 +1,12 @@ Device Feature Fields ===================== -This document lists the fields found in the ``DeviceFeature`` object returned by MQTT device info requests. +This document lists the fields found in the ``feature`` object (also known as + +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/mqtt_client`) instead of implementing + the protocol directly.by MQTT device info requests. The DeviceFeature data contains comprehensive device capabilities, configuration, and firmware information received via MQTT when calling ``request_device_info()``. This data is much more detailed than the basic device information available through the REST API and corresponds to the actual device specifications and capabilities as documented in the official Navien NWP500 Installation and User manuals. diff --git a/docs/protocol/device_status.rst b/docs/protocol/device_status.rst index 22228fc..acb4e2d 100644 --- a/docs/protocol/device_status.rst +++ b/docs/protocol/device_status.rst @@ -4,6 +4,11 @@ Device Status Fields This document lists the fields found in the ``status`` object of device status messages. +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/models`) instead of implementing + the protocol directly. + .. list-table:: :header-rows: 1 :widths: 10 10 10 36 35 diff --git a/docs/protocol/error_codes.rst b/docs/protocol/error_codes.rst index 5237602..c5ed665 100644 --- a/docs/protocol/error_codes.rst +++ b/docs/protocol/error_codes.rst @@ -1,7 +1,11 @@ Error Codes =========== -This document provides a comprehensive reference for NWP500 heat pump water heater error codes. When an error occurs, the front panel display flashes red and shows the error code. For Level 1 errors, operation continues while displaying the error. +This document provides a comprehensive reference for NWP500 heat pump water heater error codes. + +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/models`) which handles error parsing automatically. When an error occurs, the front panel display flashes red and shows the error code. For Level 1 errors, operation continues while displaying the error. Error Code Reference -------------------- diff --git a/docs/protocol/firmware_tracking.rst b/docs/protocol/firmware_tracking.rst index afdf35d..20d083c 100644 --- a/docs/protocol/firmware_tracking.rst +++ b/docs/protocol/firmware_tracking.rst @@ -4,6 +4,11 @@ Firmware Version Tracking This document tracks firmware versions and the device status fields they introduce or modify. +.. warning:: + This document describes the underlying protocol details. Most users should use the + Python client library (:doc:`../python_api/mqtt_client`) instead of implementing + the protocol directly. + Purpose ------- diff --git a/docs/protocol/mqtt_protocol.rst b/docs/protocol/mqtt_protocol.rst index 7020351..18acbe1 100644 --- a/docs/protocol/mqtt_protocol.rst +++ b/docs/protocol/mqtt_protocol.rst @@ -5,11 +5,10 @@ MQTT Protocol This document describes the MQTT protocol used for real-time communication with Navien NWP500 devices via AWS IoT Core. -.. note:: - Most users should use the Python :doc:`../python_api/mqtt_client` rather than - implementing the protocol directly. This documentation is for - understanding the underlying protocol or implementing clients in other - languages. +.. warning:: + This document describes the underlying MQTT protocol. Most users should use the + Python client library (:doc:`../python_api/mqtt_client`) instead of implementing + the protocol directly. Overview ======== diff --git a/docs/protocol/rest_api.rst b/docs/protocol/rest_api.rst index 23ecca1..4d791b9 100644 --- a/docs/protocol/rest_api.rst +++ b/docs/protocol/rest_api.rst @@ -5,6 +5,10 @@ REST API Protocol This document describes the Navien Smart Control REST API protocol based on the OpenAPI 3.1 specification. +.. warning:: + This document describes the underlying REST API protocol. Most users should use the + Python client library (:doc:`../python_api/api_client`) instead of using the API directly. + Base URL ======== From 6c53ff0b41c321a7b835bc6e01740701b5a5bb71 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 14:37:48 -0800 Subject: [PATCH 12/29] chore: fix linting and formatting issues - Fix deprecated typing.Tuple usage in scripts/bump_version.py - Fix line length issues in scripts/ - Apply ruff formatting to all files --- scripts/bump_version.py | 112 ++++++++++++++++++++----------- scripts/format.py | 50 +++++++++----- scripts/lint.py | 50 +++++++++----- scripts/setup-dev.py | 27 ++++---- src/nwp500/mqtt_subscriptions.py | 2 - 5 files changed, 158 insertions(+), 83 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index ef7f033..800ee0f 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -22,7 +22,6 @@ import re import subprocess import sys -from typing import Tuple def run_git_command(args: list) -> str: @@ -44,36 +43,38 @@ def run_git_command(args: list) -> str: def get_current_version() -> str: """Get the current version from git tags.""" # Get all tags sorted by version - tags_output = run_git_command(["tag", "-l", "v*", "--sort=-version:refname"]) - + tags_output = run_git_command( + ["tag", "-l", "v*", "--sort=-version:refname"] + ) + if not tags_output: print("No version tags found. Starting from v0.0.0") return "0.0.0" - + # Get the most recent tag latest_tag = tags_output.split("\n")[0] - + # Remove the 'v' prefix version = latest_tag[1:] if latest_tag.startswith("v") else latest_tag - + return version -def parse_version(version_str: str) -> Tuple[int, int, int]: +def parse_version(version_str: str) -> tuple[int, int, int]: """Parse a version string into (major, minor, patch) tuple.""" match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str) if not match: print(f"Error: Invalid version format: {version_str}", file=sys.stderr) print("Version must be in format: X.Y.Z", file=sys.stderr) sys.exit(1) - + return int(match.group(1)), int(match.group(2)), int(match.group(3)) def bump_version(version_str: str, bump_type: str) -> str: """Bump a version string according to the bump type.""" major, minor, patch = parse_version(version_str) - + if bump_type == "major": return f"{major + 1}.0.0" elif bump_type == "minor": @@ -90,30 +91,40 @@ def validate_version_progression(current: str, new: str) -> None: """Validate that the new version is a proper progression from current.""" curr_major, curr_minor, curr_patch = parse_version(current) new_major, new_minor, new_patch = parse_version(new) - + # Check if new version is greater than current curr_tuple = (curr_major, curr_minor, curr_patch) new_tuple = (new_major, new_minor, new_patch) - + if new_tuple <= curr_tuple: - print(f"Error: New version {new} is not greater than current version {current}", file=sys.stderr) + print( + f"Error: New version {new} is not greater than current version " + f"{current}", + file=sys.stderr, + ) sys.exit(1) - - # Check for unreasonable jumps (more than 1 major version or unusual patterns) + + # Check for unreasonable jumps major_jump = new_major - curr_major minor_jump = new_minor - curr_minor patch_jump = new_patch - curr_patch - + if major_jump > 1: - print(f"Warning: Large major version jump detected ({current} -> {new})") + print( + f"Warning: Large major version jump detected ({current} -> {new})" + ) print(f"This will jump from {curr_major}.x.x to {new_major}.x.x") - + if major_jump == 0 and minor_jump > 5: - print(f"Warning: Large minor version jump detected ({current} -> {new})") + print( + f"Warning: Large minor version jump detected ({current} -> {new})" + ) print(f"This will jump from x.{curr_minor}.x to x.{new_minor}.x") - + if major_jump == 0 and minor_jump == 0 and patch_jump > 10: - print(f"Warning: Large patch version jump detected ({current} -> {new})") + print( + f"Warning: Large patch version jump detected ({current} -> {new})" + ) print(f"This will jump from x.x.{curr_patch} to x.x.{new_patch}") @@ -122,14 +133,17 @@ def check_working_directory_clean() -> None: status = run_git_command(["status", "--porcelain"]) if status: print("Error: Working directory is not clean.", file=sys.stderr) - print("Please commit or stash your changes before bumping version.", file=sys.stderr) + print( + "Please commit or stash your changes before bumping version.", + file=sys.stderr, + ) sys.exit(1) def create_tag(version: str, message: str = None) -> None: """Create a git tag for the version.""" tag_name = f"v{version}" - + # Check if tag already exists try: subprocess.run( @@ -142,29 +156,46 @@ def create_tag(version: str, message: str = None) -> None: except subprocess.CalledProcessError: # Tag doesn't exist, which is what we want pass - + # Create the tag if message: run_git_command(["tag", "-a", tag_name, "-m", message]) else: - run_git_command(["tag", "-a", tag_name, "-m", f"Release version {version}"]) - + run_git_command( + ["tag", "-a", tag_name, "-m", f"Release version {version}"] + ) + print(f"[OK] Created tag: {tag_name}") def main() -> None: """Main entry point.""" if len(sys.argv) != 2: - print("Usage: python scripts/bump_version.py [major|minor|patch|X.Y.Z]", file=sys.stderr) + print( + "Usage: python scripts/bump_version.py [major|minor|patch|X.Y.Z]", + file=sys.stderr, + ) print("\nExamples:", file=sys.stderr) - print(" python scripts/bump_version.py patch # Bump patch version", file=sys.stderr) - print(" python scripts/bump_version.py minor # Bump minor version", file=sys.stderr) - print(" python scripts/bump_version.py major # Bump major version", file=sys.stderr) - print(" python scripts/bump_version.py 3.1.5 # Set explicit version", file=sys.stderr) + print( + " python scripts/bump_version.py patch # Bump patch version", + file=sys.stderr, + ) + print( + " python scripts/bump_version.py minor # Bump minor version", + file=sys.stderr, + ) + print( + " python scripts/bump_version.py major # Bump major version", + file=sys.stderr, + ) + print( + " python scripts/bump_version.py 3.1.5 # Set explicit version", + file=sys.stderr, + ) sys.exit(1) - + bump_type = sys.argv[1] - + # Validate bump type if bump_type not in ["major", "minor", "patch"]: # Check if it's a valid version number @@ -172,27 +203,30 @@ def main() -> None: parse_version(bump_type) except SystemExit: print(f"Error: Invalid bump type: {bump_type}", file=sys.stderr) - print("Must be one of: major, minor, patch, or X.Y.Z", file=sys.stderr) + print( + "Must be one of: major, minor, patch, or X.Y.Z", + file=sys.stderr, + ) sys.exit(1) - + # Check working directory is clean check_working_directory_clean() - + # Get current version current_version = get_current_version() print(f"Current version: {current_version}") - + # Calculate new version new_version = bump_version(current_version, bump_type) print(f"New version: {new_version}") - + # Validate version progression validate_version_progression(current_version, new_version) - + # Create the tag print(f"\nCreating tag v{new_version}...") create_tag(new_version) - + print("\n[OK] Version bump complete!") print("\nNext steps:") print(f" 1. Push the tag: git push origin v{new_version}") diff --git a/scripts/format.py b/scripts/format.py index 75ebb13..3430d0c 100644 --- a/scripts/format.py +++ b/scripts/format.py @@ -4,16 +4,17 @@ Auto-fixes linting issues and formats code consistently with CI. """ +import os import subprocess import sys -import os from pathlib import Path + def run_command(cmd, description): """Run a command and return success status.""" print(f"\n🔧 {description}") print(f"Command: {' '.join(cmd)}") - + try: result = subprocess.run(cmd, check=True, capture_output=True, text=True) print(f"[OK] {description} - COMPLETED") @@ -32,36 +33,54 @@ def run_command(cmd, description): print("Install ruff with: python3 -m pip install ruff>=0.1.0") return False + def main(): """Main formatting function that mirrors tox format environment.""" - + # Change to project root project_root = Path(__file__).parent.parent os.chdir(project_root) - + print("[START] Running local formatting (mirroring tox format environment)") print(f"Working directory: {project_root}") - + # Define the same commands used in tox.ini format environment format_commands = [ ( - ["python3", "-m", "ruff", "check", "--fix", "src/", "tests/", "examples/"], - "Auto-fixing linting issues" + [ + "python3", + "-m", + "ruff", + "check", + "--fix", + "src/", + "tests/", + "examples/", + ], + "Auto-fixing linting issues", ), ( - ["python3", "-m", "ruff", "format", "src/", "tests/", "examples/"], - "Formatting code" - ) + [ + "python3", + "-m", + "ruff", + "format", + "src/", + "tests/", + "examples/", + ], + "Formatting code", + ), ] - + all_passed = True - + for cmd, description in format_commands: success = run_command(cmd, description) if not success: all_passed = False - - print("\n" + "="*50) + + print("\n" + "=" * 50) if all_passed: print("🎉 All formatting COMPLETED successfully!") print("Your code is now formatted consistently with CI requirements.") @@ -71,5 +90,6 @@ def main(): print("Check the output above for details.") return 1 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/scripts/lint.py b/scripts/lint.py index cbc3c03..89bbeb9 100644 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -4,16 +4,17 @@ This ensures local and CI linting results are identical. """ +import os import subprocess import sys -import os from pathlib import Path + def run_command(cmd, description): """Run a command and return success status.""" print(f"\n🔍 {description}") print(f"Command: {' '.join(cmd)}") - + try: result = subprocess.run(cmd, check=True, capture_output=True, text=True) print(f"[OK] {description} - PASSED") @@ -32,36 +33,54 @@ def run_command(cmd, description): print("Install ruff with: python3 -m pip install ruff>=0.1.0") return False + def main(): """Main linting function that mirrors tox lint environment.""" - + # Change to project root project_root = Path(__file__).parent.parent os.chdir(project_root) - + print("[START] Running local linting (mirroring CI environment)") print(f"Working directory: {project_root}") - + # Define the same commands used in tox.ini lint_commands = [ ( - ["python3", "-m", "ruff", "check", "src/", "tests/", "examples/"], - "Ruff linting check" + [ + "python3", + "-m", + "ruff", + "check", + "src/", + "tests/", + "examples/", + ], + "Ruff linting check", ), ( - ["python3", "-m", "ruff", "format", "--check", "src/", "tests/", "examples/"], - "Ruff format check" - ) + [ + "python3", + "-m", + "ruff", + "format", + "--check", + "src/", + "tests/", + "examples/", + ], + "Ruff format check", + ), ] - + all_passed = True - + for cmd, description in lint_commands: success = run_command(cmd, description) if not success: all_passed = False - - print("\n" + "="*50) + + print("\n" + "=" * 50) if all_passed: print("🎉 All linting checks PASSED!") print("Your code matches the CI environment requirements.") @@ -73,5 +92,6 @@ def main(): print(" python3 -m ruff format src/ tests/ examples/") return 1 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/scripts/setup-dev.py b/scripts/setup-dev.py index 8fe7952..7dce27d 100644 --- a/scripts/setup-dev.py +++ b/scripts/setup-dev.py @@ -4,16 +4,17 @@ Installs the minimal dependencies needed for local linting that matches CI. """ +import os import subprocess import sys -import os from pathlib import Path + def run_command(cmd, description): """Run a command and return success status.""" print(f"\n🔧 {description}") print(f"Command: {' '.join(cmd)}") - + try: result = subprocess.run(cmd, check=True, capture_output=True, text=True) print(f"[OK] {description} - SUCCESS") @@ -31,38 +32,39 @@ def run_command(cmd, description): print(f"[ERROR] {description} - FAILED (command not found)") return False + def main(): """Set up development environment.""" - + # Change to project root project_root = Path(__file__).parent.parent os.chdir(project_root) - + print("[START] Setting up development environment") print(f"Working directory: {project_root}") - + # Install ruff for linting (matches CI requirement) install_commands = [ ( [sys.executable, "-m", "pip", "install", "--user", "ruff>=0.1.0"], - "Installing ruff (linter/formatter)" + "Installing ruff (linter/formatter)", ) ] - + all_passed = True - + for cmd, description in install_commands: success = run_command(cmd, description) if not success: all_passed = False - - print("\n" + "="*50) + + print("\n" + "=" * 50) if all_passed: print("🎉 Development environment setup COMPLETED!") print() print("Next steps:") print(" 1. Run linting: make ci-lint") - print(" 2. Auto-format: make ci-format") + print(" 2. Auto-format: make ci-format") print(" 3. Full check: make ci-check") print() print("Or use the scripts directly:") @@ -74,5 +76,6 @@ def main(): print("Check the output above for details.") return 1 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/src/nwp500/mqtt_subscriptions.py b/src/nwp500/mqtt_subscriptions.py index 3936e2c..6de2b71 100644 --- a/src/nwp500/mqtt_subscriptions.py +++ b/src/nwp500/mqtt_subscriptions.py @@ -127,8 +127,6 @@ def _on_message_received( except (AttributeError, KeyError, TypeError) as e: _logger.error(f"Error processing message: {e}") - - async def subscribe( self, topic: str, From a019f23fb8e9f9d9067c7e32e1f1419bc4bb32b4 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 14:45:50 -0800 Subject: [PATCH 13/29] docs: update Python code examples to use snake_case attributes --- docs/guides/auto_recovery.rst | 2 +- docs/guides/energy_monitoring.rst | 52 +++++++++++++-------------- docs/guides/event_system.rst | 60 +++++++++++++++---------------- docs/index.rst | 4 +-- docs/quickstart.rst | 8 ++--- 5 files changed, 63 insertions(+), 63 deletions(-) diff --git a/docs/guides/auto_recovery.rst b/docs/guides/auto_recovery.rst index 71872f5..026fba7 100644 --- a/docs/guides/auto_recovery.rst +++ b/docs/guides/auto_recovery.rst @@ -236,7 +236,7 @@ Use exponential backoff between recovery attempts with token refresh. device = await api_client.get_first_device() def on_status(status): - print(f"Temperature: {status.dhwTemperature}°F") + print(f"Temperature: {status.dhw_temperature}°F") # Create resilient client mqtt_config = MqttConnectionConfig( diff --git a/docs/guides/energy_monitoring.rst b/docs/guides/energy_monitoring.rst index c10c026..d3d879a 100644 --- a/docs/guides/energy_monitoring.rst +++ b/docs/guides/energy_monitoring.rst @@ -26,7 +26,7 @@ The most important metric for energy monitoring: def on_status(status: DeviceStatus): # Total power consumption in Watts - power_watts = status.currentInstPower + power_watts = status.current_inst_power print(f"Current Power: {power_watts} W") | **Field:** ``currentInstPower`` @@ -43,13 +43,13 @@ Know which heating components are currently active: .. code:: python def on_status(status: DeviceStatus): - if status.compUse: + if status.comp_use: print("Heat pump compressor is running") - if status.heatUpperUse: + if status.heat_upper_use: print("Upper electric heater is running") - if status.heatLowerUse: + if status.heat_lower_use: print("Lower electric heater is running") | **Fields:** - ``compUse`` (bool): Heat pump compressor status - @@ -65,9 +65,9 @@ Track total runtime for each heating component: def on_status(status: DeviceStatus): # Convert minutes to hours - comp_hours = status.compRunningMinuteTotal / 60 - heater1_hours = status.heater1RunningMinuteTotal / 60 - heater2_hours = status.heater2RunningMinuteTotal / 60 + comp_hours = status.comp_running_minute_total / 60 + heater1_hours = status.heater1_running_minute_total / 60 + heater2_hours = status.heater2_running_minute_total / 60 print(f"Heat Pump Runtime: {comp_hours:.1f} hours") print(f"Upper Heater Runtime: {heater1_hours:.1f} hours") @@ -126,7 +126,7 @@ Monitor available stored energy: .. code:: python def on_status(status: DeviceStatus): - capacity = status.availableEnergyCapacity + capacity = status.available_energy_capacity print(f"Energy Capacity: {capacity}%") if capacity < 20: @@ -150,8 +150,8 @@ Water Temperature def on_status(status: DeviceStatus): # Current water temperature - current_temp = status.dhwTemperature - target_temp = status.dhwTemperatureSetting + current_temp = status.dhw_temperature + target_temp = status.dhw_temperature_setting print(f"Water Temperature: {current_temp}°F (Target: {target_temp}°F)") @@ -168,10 +168,10 @@ Monitor individual heating component temperatures: .. code:: python def on_status(status: DeviceStatus): - print(f"Compressor Temp: {status.compTemp}°F") - print(f"Upper Tank Temp: {status.dhwTankUpperTemp}°F") - print(f"Lower Tank Temp: {status.dhwTankLowerTemp}°F") - print(f"Heat Exchanger Out: {status.dhwHeatexOutTemp}°F") + print(f"Compressor Temp: {status.comp_temp}°F") + print(f"Upper Tank Temp: {status.dhw_tank_upper_temp}°F") + print(f"Lower Tank Temp: {status.dhw_tank_lower_temp}°F") + print(f"Heat Exchanger Out: {status.dhw_heatex_out_temp}°F") Complete Energy Monitoring Example ---------------------------------- @@ -204,15 +204,15 @@ Complete Energy Monitoring Example print("="*50) # Real-time power - print(f"\nCurrent Power: {status.currentInstPower} W") + print(f"\nCurrent Power: {status.current_inst_power} W") # Active components components = [] - if status.compUse: + if status.comp_use: components.append("Heat Pump") - if status.heatUpperUse: + if status.heat_upper_use: components.append("Upper Heater") - if status.heatLowerUse: + if status.heat_lower_use: components.append("Lower Heater") if components: @@ -222,18 +222,18 @@ Complete Energy Monitoring Example # Cumulative runtime print(f"\nCumulative Runtime:") - print(f" Heat Pump: {status.compRunningMinuteTotal / 60:.1f} hours") - print(f" Upper Heater: {status.heater1RunningMinuteTotal / 60:.1f} hours") - print(f" Lower Heater: {status.heater2RunningMinuteTotal / 60:.1f} hours") + print(f" Heat Pump: {status.comp_running_minute_total / 60:.1f} hours") + print(f" Upper Heater: {status.heater1_running_minute_total / 60:.1f} hours") + print(f" Lower Heater: {status.heater2_running_minute_total / 60:.1f} hours") # Energy capacity and temperature - print(f"\nEnergy Capacity: {status.availableEnergyCapacity}%") - print(f"Water Temp: {status.dhwTemperature}°F " - f"(Target: {status.dhwTemperatureSetting}°F)") + print(f"\nEnergy Capacity: {status.available_energy_capacity}%") + print(f"Water Temp: {status.dhw_temperature}°F " + f"(Target: {status.dhw_temperature_setting}°F)") # Estimated hourly cost (if running continuously at current power) - if status.currentInstPower > 0: - hourly_cost = calculate_power_cost(status.currentInstPower, 1.0) + if status.current_inst_power > 0: + hourly_cost = calculate_power_cost(status.current_inst_power, 1.0) print(f"\nEstimated Cost (if sustained): ${hourly_cost:.3f}/hour") # Subscribe to device status diff --git a/docs/guides/event_system.rst b/docs/guides/event_system.rst index 6ee4e05..9cce974 100644 --- a/docs/guides/event_system.rst +++ b/docs/guides/event_system.rst @@ -54,8 +54,8 @@ Simple Event Handler # Define event handler def on_status_update(status): - print(f"Temperature: {status.dhwTemperature}°F") - print(f"Power: {status.currentInstPower}W") + print(f"Temperature: {status.dhw_temperature}°F") + print(f"Power: {status.current_inst_power}W") # Subscribe to status updates await mqtt.subscribe_device_status(device, on_status_update) @@ -93,14 +93,14 @@ Track state changes and react only when values change significantly. def on_status(self, status): # Temperature changed by more than 2°F - if self.last_temp is None or abs(status.dhwTemperature - self.last_temp) >= 2: - print(f"Temperature changed: {self.last_temp}°F → {status.dhwTemperature}°F") - self.last_temp = status.dhwTemperature + if self.last_temp is None or abs(status.dhw_temperature - self.last_temp) >= 2: + print(f"Temperature changed: {self.last_temp}°F → {status.dhw_temperature}°F") + self.last_temp = status.dhw_temperature # Power changed by more than 100W - if self.last_power is None or abs(status.currentInstPower - self.last_power) >= 100: - print(f"Power changed: {self.last_power}W → {status.currentInstPower}W") - self.last_power = status.currentInstPower + if self.last_power is None or abs(status.current_inst_power - self.last_power) >= 100: + print(f"Power changed: {self.last_power}W → {status.current_inst_power}W") + self.last_power = status.current_inst_power # Usage async def main(): @@ -150,8 +150,8 @@ Monitor multiple devices with individual callbacks. device_name = device_data['device'].device_info.device_name print(f"[{device_name}]") - print(f" Temperature: {status.dhwTemperature}°F") - print(f" Power: {status.currentInstPower}W") + print(f" Temperature: {status.dhw_temperature}°F") + print(f" Power: {status.current_inst_power}W") print() device_data['last_status'] = status @@ -245,26 +245,26 @@ Build an alert system that triggers on specific conditions. # Define alert rules alerts.add_rule(AlertRule( name="Low Temperature", - condition=lambda s: s.dhwTemperature < 110, + condition=lambda s: s.dhw_temperature < 110, action=lambda s: send_email( "Low Water Temperature", - f"Temperature dropped to {s.dhwTemperature}°F" + f"Temperature dropped to {s.dhw_temperature}°F" ) )) alerts.add_rule(AlertRule( name="High Power", - condition=lambda s: s.currentInstPower > 2000, + condition=lambda s: s.current_inst_power > 2000, action=lambda s: log_alert( - f"High power usage: {s.currentInstPower}W" + f"High power usage: {s.current_inst_power}W" ) )) alerts.add_rule(AlertRule( name="Error Detected", - condition=lambda s: s.errorCode != 0, + condition=lambda s: s.error_code != 0, action=lambda s: send_sms( - f"Device error: {s.errorCode}" + f"Device error: {s.error_code}" ) )) @@ -330,12 +330,12 @@ Log device data to a database or file. """, ( timestamp, device_mac, - status.dhwTemperature, - status.dhwTemperatureSetting, - status.currentInstPower, - status.dhwOperationSetting.name, - status.operationMode.name, - status.errorCode + status.dhw_temperature, + status.dhw_temperature_setting, + status.current_inst_power, + status.dhw_operation_setting.name, + status.operation_mode.name, + status.error_code )) conn.commit() conn.close() @@ -390,12 +390,12 @@ Integrate with Home Assistant, OpenHAB, or custom systems. # Prepare state data state_data = { - 'temperature': status.dhwTemperature, - 'target_temperature': status.dhwTemperatureSetting, - 'power': status.currentInstPower, - 'mode': status.dhwOperationSetting.name, - 'state': status.operationMode.name, - 'error': status.errorCode + 'temperature': status.dhw_temperature, + 'target_temperature': status.dhw_temperature_setting, + 'power': status.current_inst_power, + 'mode': status.dhw_operation_setting.name, + 'state': status.operation_mode.name, + 'error': status.error_code } # Publish to HA @@ -408,7 +408,7 @@ Integrate with Home Assistant, OpenHAB, or custom systems. url = f"{self.ha_url}/api/states/sensor.navien_{device_mac}" async with session.post(url, headers=headers, json={ - 'state': status.dhwTemperature, + 'state': status.dhw_temperature, 'attributes': state_data }) as resp: if resp.status == 200: @@ -470,7 +470,7 @@ Best Practices .. code-block:: python # Track callback references - callback = lambda s: print(s.dhwTemperature) + callback = lambda s: print(s.dhw_temperature) await mqtt.subscribe_device_status(device, callback) diff --git a/docs/index.rst b/docs/index.rst index ea54696..945c8a9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -67,8 +67,8 @@ Basic Example # Monitor device status def on_status(status): - print(f"Temp: {status.dhwTemperature}°F") - print(f"Power: {status.currentInstPower}W") + print(f"Temp: {status.dhw_temperature}°F") + print(f"Power: {status.current_inst_power}W") await mqtt.subscribe_device_status(device, on_status) await mqtt.request_device_status(device) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index cc574ce..f3cbbe4 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -119,10 +119,10 @@ Connect to MQTT for real-time device monitoring: # Define status callback def on_status(status): print(f"\nDevice Status:") - print(f" Water Temp: {status.dhwTemperature}°F") - print(f" Target: {status.dhwTemperatureSetting}°F") - print(f" Power: {status.currentInstPower}W") - print(f" Mode: {status.dhwOperationSetting.name}") + print(f" Water Temp: {status.dhw_temperature}°F") + print(f" Target: {status.dhw_temperature_setting}°F") + print(f" Power: {status.current_inst_power}W") + print(f" Mode: {status.dhw_operation_setting.name}") # Subscribe and request status await mqtt.subscribe_device_status(device, on_status) From 108732fd0e6a8923b170d84646a162718972e56b Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 14:54:26 -0800 Subject: [PATCH 14/29] docs: fix reStructuredText heading hierarchy in advanced_features_explained.rst --- docs/guides/advanced_features_explained.rst | 36 ++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/guides/advanced_features_explained.rst b/docs/guides/advanced_features_explained.rst index e6634ed..9ac041b 100644 --- a/docs/guides/advanced_features_explained.rst +++ b/docs/guides/advanced_features_explained.rst @@ -16,12 +16,12 @@ Weather-Responsive Heating ========================== Feature Overview -^^^^^^^^^^^^^^^^ +---------------- The device continuously monitors ambient air temperature to optimize heat pump performance and adjust heating strategies. This enables the system to maintain comfort while adapting to seasonal conditions automatically. Technical Implementation -^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------ **Data Sources**: @@ -31,7 +31,7 @@ Technical Implementation How It Works -^^^^^^^^^^^^ +------------ **Temperature Thresholds and Heating Adjustments**: @@ -78,7 +78,7 @@ The device maintains internal target superheat values that adjust based on ambie - **Recovery Override**: Pre-charging during known demand periods (morning peak) Practical Applications -^^^^^^^^^^^^^^^^^^^^^^^ +---------------------- **Morning Peak Scenario (40°F Ambient)**: @@ -102,7 +102,7 @@ Practical Applications 4. Achieves 3.5+ COP (for every 1 kW electrical, 3.5 kW of heat) Integration with MQTT Status Message -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------------------ The ``outsideTemperature`` field is transmitted in the device status update. Python clients can monitor this field: @@ -120,7 +120,7 @@ Demand Response Integration (CTA-2045) ====================================== Feature Overview -^^^^^^^^^^^^^^^^ +---------------- The NWP500 supports demand response signals per the CTA-2045 (Consumer Technology Association) standard, enabling integration with smart grid programs and demand response events. @@ -129,9 +129,9 @@ The NWP500 supports demand response signals per the CTA-2045 (Consumer Technolog A protocol that allows utilities to send control signals to networked devices (like water heaters) to manage demand during peak periods or grid stress conditions. Technical Implementation -^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------ DR Event Status Field -^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~ **Field**: ``drEventStatus`` (bitfield) @@ -186,7 +186,7 @@ DR Event Status Field 4:30 PM Normal operation restored 0b00000000 Resume standard schedule DR Override Status Field -^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~ **Field**: ``drOverrideStatus`` (integer flag) @@ -207,7 +207,7 @@ DR Override Status Field 7. After override period expires, device returns to DR command compliance Implementation in Device Firmware -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Decision Tree** (inferred from status fields): @@ -231,13 +231,13 @@ Implementation in Device Firmware 4. **Cost Reduction**: Shift heating to low-price periods automatically Utility Integration Requirements -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To use demand response with your NWP500: Tank Temperature Sensors -^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------ **Upper Tank Sensor** (``tankUpperTemperature``) @@ -256,7 +256,7 @@ Tank Temperature Sensors - **Control Target**: Used to trigger lower electric heating element and lower heat pump stage Tank Stratification Explained -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **What Is Stratification?** @@ -282,7 +282,7 @@ In a vertical tank, naturally occurring density differences create layers: 4. **User Comfort**: Upper zone always available at target temperature for draw Practical Stratification Scenarios -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Scenario 1: Excellent Stratification (Efficient)** @@ -325,7 +325,7 @@ Practical Stratification Scenarios → Device may alert or switch to safety mode Device Control Strategy Based on Stratification -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Two-Stage Heating with Stratification**: @@ -365,7 +365,7 @@ Device Control Strategy Based on Stratification - **Optimal**: ~25-30°F differential maximizes recovery time vs. efficiency tradeoff Heat Pump Integration with Stratification -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The two-stage control extends to heat pump operation: @@ -379,7 +379,7 @@ Modern control systems may use "superheat modulation" where: - Looser superheat (safer operation) when stratification poor Monitoring Stratification from Python -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python @@ -416,7 +416,7 @@ Monitoring Stratification from Python } Factors Affecting Stratification -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **Positive Factors** (Preserve Stratification): From 52350e0d444d3b9cc7190698a036b07c3ce83e0e Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 14:55:49 -0800 Subject: [PATCH 15/29] fix: remove duplicate @classmethod decorator in TOUInfo.model_validate --- src/nwp500/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nwp500/models.py b/src/nwp500/models.py index 3289d10..2f4d342 100644 --- a/src/nwp500/models.py +++ b/src/nwp500/models.py @@ -155,7 +155,6 @@ class TOUInfo(NavienBaseModel): zip_code: int = 0 schedule: list[TOUSchedule] = Field(default_factory=list) - @classmethod @classmethod def model_validate( cls, From b16b6b6f2410074ab6e739fb8d1a427f03e146fc Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 14:57:24 -0800 Subject: [PATCH 16/29] docs: add advanced_features_explained to User Guides toctree --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index 945c8a9..8524436 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -125,6 +125,7 @@ Documentation Index guides/event_system guides/command_queue guides/auto_recovery + guides/advanced_features_explained .. toctree:: :maxdepth: 2 From d2114b81a0c536d19503fb39fc7c5ecf4d2bb5f9 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 15:01:24 -0800 Subject: [PATCH 17/29] docs: update all remaining Python examples to use snake_case attributes --- docs/protocol/device_status.rst | 12 ++++---- docs/python_api/events.rst | 8 +++--- docs/python_api/models.rst | 34 +++++++++++----------- docs/python_api/mqtt_client.rst | 50 ++++++++++++++++----------------- 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/docs/protocol/device_status.rst b/docs/protocol/device_status.rst index acb4e2d..c3d3eb9 100644 --- a/docs/protocol/device_status.rst +++ b/docs/protocol/device_status.rst @@ -672,7 +672,7 @@ For user-facing applications, follow these guidelines: """Format mode and status for UI display.""" # Check if device is powered off first - if status.dhwOperationSetting == DhwOperationSetting.POWER_OFF: + if status.dhw_operation_setting == DhwOperationSetting.POWER_OFF: return { 'configured_mode': 'Off', 'operational_state': 'Powered Off', @@ -682,19 +682,19 @@ For user-facing applications, follow these guidelines: } # User's configured mode (what they selected) - configured_mode = status.dhwOperationSetting.name.replace('_', ' ').title() + configured_mode = status.dhw_operation_setting.name.replace('_', ' ').title() # Current operational state - if status.operationMode == CurrentOperationMode.STANDBY: + if status.operation_mode == CurrentOperationMode.STANDBY: operational_state = "Idle" is_heating = False - elif status.operationMode == CurrentOperationMode.HEAT_PUMP_MODE: + elif status.operation_mode == CurrentOperationMode.HEAT_PUMP_MODE: operational_state = "Heating (Heat Pump)" is_heating = True - elif status.operationMode == CurrentOperationMode.HYBRID_EFFICIENCY_MODE: + elif status.operation_mode == CurrentOperationMode.HYBRID_EFFICIENCY_MODE: operational_state = "Heating (Energy Saver)" is_heating = True - elif status.operationMode == CurrentOperationMode.HYBRID_BOOST_MODE: + elif status.operation_mode == CurrentOperationMode.HYBRID_BOOST_MODE: operational_state = "Heating (High Demand)" is_heating = True else: diff --git a/docs/python_api/events.rst b/docs/python_api/events.rst index f645f78..c9ae218 100644 --- a/docs/python_api/events.rst +++ b/docs/python_api/events.rst @@ -141,8 +141,8 @@ Emitted when device status update is received. .. code-block:: python def handle_status(status): - print(f"Temperature: {status.dhwTemperature}°F") - print(f"Power: {status.currentInstPower}W") + print(f"Temperature: {status.dhw_temperature}°F") + print(f"Power: {status.current_inst_power}W") mqtt.on('status_received', handle_status) @@ -267,11 +267,11 @@ Example 3: Temperature Alerts mqtt = NavienMqttClient(auth) def check_temp(status): - if status.dhwTemperature < 110: + if status.dhw_temperature < 110: print("WARNING: Temperature below 110°F") send_alert("Low water temperature") - if status.dhwTemperature > 145: + if status.dhw_temperature > 145: print("WARNING: Temperature above 145°F") send_alert("High water temperature") diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index 25735c6..26689de 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -85,7 +85,7 @@ preference. # Check current mode from status def on_status(status): - if status.dhwOperationSetting == DhwOperationSetting.ENERGY_SAVER: + if status.dhw_operation_setting == DhwOperationSetting.ENERGY_SAVER: print("Running in Energy Saver mode") CurrentOperationMode @@ -112,16 +112,16 @@ Current real-time operational state - what the device is doing **right now**. from nwp500 import CurrentOperationMode def on_status(status): - mode = status.operationMode + mode = status.operation_mode if mode == CurrentOperationMode.IDLE: print("Device idle") elif mode == CurrentOperationMode.HEAT_PUMP: - print(f"Heat pump running at {status.currentInstPower}W") + print(f"Heat pump running at {status.current_inst_power}W") elif mode == CurrentOperationMode.ELECTRIC_HEATER: - print(f"Electric heater at {status.currentInstPower}W") + print(f"Electric heater at {status.current_inst_power}W") elif mode == CurrentOperationMode.HEAT_PUMP_AND_HEATER: - print(f"Both running at {status.currentInstPower}W") + print(f"Both running at {status.current_inst_power}W") TemperatureUnit --------------- @@ -141,9 +141,9 @@ Temperature scale enumeration. def on_status(status): if status.temperatureType == TemperatureUnit.FAHRENHEIT: - print(f"Temperature: {status.dhwTemperature}°F") + print(f"Temperature: {status.dhw_temperature}°F") else: - print(f"Temperature: {status.dhwTemperature}°C") + print(f"Temperature: {status.dhw_temperature}°C") Device Models ============= @@ -365,27 +365,27 @@ Complete real-time device status with 100+ fields. def on_status(status): # Temperature monitoring - print(f"Water: {status.dhwTemperature}°F") - print(f"Target: {status.dhwTemperatureSetting}°F") + print(f"Water: {status.dhw_temperature}°F") + print(f"Target: {status.dhw_temperatureSetting}°F") print(f"Upper Tank: {status.tankUpperTemperature}°F") print(f"Lower Tank: {status.tankLowerTemperature}°F") # Power consumption - print(f"Power: {status.currentInstPower}W") + print(f"Power: {status.current_inst_power}W") print(f"Energy: {status.availableEnergyCapacity}%") # Operation mode - print(f"Mode: {status.dhwOperationSetting.name}") - print(f"State: {status.operationMode.name}") + print(f"Mode: {status.dhw_operation_setting.name}") + print(f"State: {status.operation_mode.name}") # Active heating if status.operationBusy: print("Heating water:") - if status.compUse: + if status.comp_use: print(" - Heat pump running") - if status.heatUpperUse: + if status.heat_upper_use: print(" - Upper heater active") - if status.heatLowerUse: + if status.heat_lower_use: print(" - Lower heater active") # Water usage detection @@ -701,10 +701,10 @@ Best Practices def on_status(status): # User's mode preference - user_mode = status.dhwOperationSetting + user_mode = status.dhw_operation_setting # Current real-time state - current_state = status.operationMode + current_state = status.operation_mode # These can differ! # User sets ENERGY_SAVER, device might be in HEAT_PUMP state diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index aa03982..b07eb2d 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -49,10 +49,10 @@ Basic Monitoring # Subscribe to status updates def on_status(status): - print(f"Water Temp: {status.dhwTemperature}°F") - print(f"Target: {status.dhwTemperatureSetting}°F") - print(f"Power: {status.currentInstPower}W") - print(f"Mode: {status.dhwOperationSetting.name}") + print(f"Water Temp: {status.dhw_temperature}°F") + print(f"Target: {status.dhw_temperatureSetting}°F") + print(f"Power: {status.current_inst_power}W") + print(f"Mode: {status.dhw_operation_setting.name}") await mqtt.subscribe_device_status(device, on_status) await mqtt.request_device_status(device) @@ -203,20 +203,20 @@ subscribe_device_status() def on_status(status): """Called every time device status updates.""" - print(f"Temperature: {status.dhwTemperature}°F") - print(f"Target: {status.dhwTemperatureSetting}°F") - print(f"Mode: {status.dhwOperationSetting.name}") - print(f"Power: {status.currentInstPower}W") + print(f"Temperature: {status.dhw_temperature}°F") + print(f"Target: {status.dhw_temperatureSetting}°F") + print(f"Mode: {status.dhw_operation_setting.name}") + print(f"Power: {status.current_inst_power}W") print(f"Energy: {status.availableEnergyCapacity}%") # Check if actively heating if status.operationBusy: print("Device is heating water") - if status.compUse: + if status.comp_use: print(" - Heat pump running") - if status.heatUpperUse: + if status.heat_upper_use: print(" - Upper heater active") - if status.heatLowerUse: + if status.heat_lower_use: print(" - Lower heater active") # Check water usage @@ -879,24 +879,24 @@ Example 1: Complete Monitoring Application now = datetime.now().strftime("%H:%M:%S") # Temperature changed - if last_temp != status.dhwTemperature: - print(f"[{now}] Temperature: {status.dhwTemperature}°F " - f"(Target: {status.dhwTemperatureSetting}°F)") - last_temp = status.dhwTemperature + if last_temp != status.dhw_temperature: + print(f"[{now}] Temperature: {status.dhw_temperature}°F " + f"(Target: {status.dhw_temperatureSetting}°F)") + last_temp = status.dhw_temperature # Power changed - if last_power != status.currentInstPower: - print(f"[{now}] Power: {status.currentInstPower}W") - last_power = status.currentInstPower + if last_power != status.current_inst_power: + print(f"[{now}] Power: {status.current_inst_power}W") + last_power = status.current_inst_power # Heating state if status.operationBusy: components = [] - if status.compUse: + if status.comp_use: components.append("HP") - if status.heatUpperUse: + if status.heat_upper_use: components.append("Upper") - if status.heatLowerUse: + if status.heat_lower_use: components.append("Lower") print(f"[{now}] Heating: {', '.join(components)}") @@ -939,7 +939,7 @@ Example 2: Automatic Temperature Control last_use_time = datetime.now() # If temp dropped below 130°F, boost to high demand - if status.dhwTemperature < 130: + if status.dhw_temperature < 130: asyncio.create_task( mqtt.set_dhw_mode(device, 4) # High Demand ) @@ -978,9 +978,9 @@ Example 3: Multi-Device Monitoring # Create callback for each device def create_callback(device_name): def callback(status): - print(f"[{device_name}] {status.dhwTemperature}°F, " - f"{status.currentInstPower}W, " - f"{status.dhwOperationSetting.name}") + print(f"[{device_name}] {status.dhw_temperature}°F, " + f"{status.current_inst_power}W, " + f"{status.dhw_operation_setting.name}") return callback # Subscribe to all devices From a4b5281c468ea4461a8275d0ecb63c63cae283b6 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 15:37:18 -0800 Subject: [PATCH 18/29] docs: convert field name listings in models.rst to snake_case --- docs/python_api/models.rst | 164 ++++++++++++++++++------------------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index 26689de..2c0211e 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -283,14 +283,14 @@ Complete real-time device status with 100+ fields. **Key Temperature Fields:** - * ``dhwTemperature`` (float) - Current water temperature (°F or °C) - * ``dhwTemperatureSetting`` (float) - Target temperature setting - * ``dhwTargetTemperatureSetting`` (float) - Target with offsets applied - * ``tankUpperTemperature`` (float) - Upper tank sensor - * ``tankLowerTemperature`` (float) - Lower tank sensor - * ``currentInletTemperature`` (float) - Cold water inlet temperature - * ``outsideTemperature`` (float) - Outdoor temperature - * ``ambientTemperature`` (float) - Ambient air temperature + * ``dhw_temperature`` (float) - Current water temperature (°F or °C) + * ``dhw_temperature_setting`` (float) - Target temperature setting + * ``dhw_target_temperature_setting`` (float) - Target with offsets applied + * ``tank_upper_temperature`` (float) - Upper tank sensor + * ``tank_lower_temperature`` (float) - Lower tank sensor + * ``current_inlet_temperature`` (float) - Cold water inlet temperature + * ``outside_temperature`` (float) - Outdoor temperature + * ``ambient_temperature`` (float) - Ambient air temperature .. note:: Temperature display values are 20°F higher than message values. @@ -298,66 +298,66 @@ Complete real-time device status with 100+ fields. **Key Power/Energy Fields:** - * ``currentInstPower`` (float) - Current power consumption (Watts) - * ``totalEnergyCapacity`` (float) - Total energy capacity (%) - * ``availableEnergyCapacity`` (float) - Available energy (%) - * ``dhwChargePer`` (float) - DHW charge percentage + * ``current_inst_power`` (float) - Current power consumption (Watts) + * ``total_energy_capacity`` (float) - Total energy capacity (%) + * ``available_energy_capacity`` (float) - Available energy (%) + * ``dhw_charge_per`` (float) - DHW charge percentage **Operation Mode Fields:** - * ``operationMode`` (CurrentOperationMode) - Current operational state - * ``dhwOperationSetting`` (DhwOperationSetting) - User's mode preference - * ``temperatureType`` (TemperatureUnit) - Temperature unit + * ``operation_mode`` (CurrentOperationMode) - Current operational state + * ``dhw_operation_setting`` (DhwOperationSetting) - User's mode preference + * ``temperature_type`` (TemperatureUnit) - Temperature unit **Boolean Status Fields:** - * ``operationBusy`` (bool) - Device actively heating water - * ``dhwUse`` (bool) - Water being used (short-term detection) - * ``dhwUseSustained`` (bool) - Water being used (sustained) - * ``compUse`` (bool) - Compressor/heat pump running - * ``heatUpperUse`` (bool) - Upper electric heater active - * ``heatLowerUse`` (bool) - Lower electric heater active - * ``evaFanUse`` (bool) - Evaporator fan running - * ``antiLegionellaUse`` (bool) - Anti-Legionella enabled - * ``antiLegionellaOperationBusy`` (bool) - Anti-Legionella cycle active - * ``programReservationUse`` (bool) - Reservation schedule enabled - * ``freezeProtectionUse`` (bool) - Freeze protection enabled + * ``operation_busy`` (bool) - Device actively heating water + * ``dhw_use`` (bool) - Water being used (short-term detection) + * ``dhw_use_sustained`` (bool) - Water being used (sustained) + * ``comp_use`` (bool) - Compressor/heat pump running + * ``heat_upper_use`` (bool) - Upper electric heater active + * ``heat_lower_use`` (bool) - Lower electric heater active + * ``eva_fan_use`` (bool) - Evaporator fan running + * ``anti_legionella_use`` (bool) - Anti-Legionella enabled + * ``anti_legionella_operation_busy`` (bool) - Anti-Legionella cycle active + * ``program_reservation_use`` (bool) - Reservation schedule enabled + * ``freeze_protection_use`` (bool) - Freeze protection enabled **Error/Diagnostic Fields:** - * ``errorCode`` (int) - Error code (0 = no error) - * ``subErrorCode`` (int) - Sub-error code - * ``smartDiagnostic`` (int) - Smart diagnostic status - * ``faultStatus1`` (int) - Fault status flags - * ``faultStatus2`` (int) - Additional fault flags + * ``error_code`` (int) - Error code (0 = no error) + * ``sub_error_code`` (int) - Sub-error code + * ``smart_diagnostic`` (int) - Smart diagnostic status + * ``fault_status1`` (int) - Fault status flags + * ``fault_status2`` (int) - Additional fault flags **Network/Communication:** - * ``wifiRssi`` (int) - WiFi signal strength (dBm) + * ``wifi_rssi`` (int) - WiFi signal strength (dBm) **Vacation/Schedule:** - * ``vacationDaySetting`` (int) - Vacation days configured - * ``vacationDayElapsed`` (int) - Vacation days elapsed - * ``antiLegionellaPeriod`` (int) - Anti-Legionella cycle period + * ``vacation_day_setting`` (int) - Vacation days configured + * ``vacation_day_elapsed`` (int) - Vacation days elapsed + * ``anti_legionella_period`` (int) - Anti-Legionella cycle period **Time-of-Use (TOU):** - * ``touStatus`` (int) - TOU status - * ``touOverrideStatus`` (int) - TOU override status + * ``tou_status`` (int) - TOU status + * ``tou_override_status`` (int) - TOU override status **Heat Pump Detailed Status:** - * ``targetFanRpm`` (int) - Target fan RPM - * ``currentFanRpm`` (int) - Current fan RPM - * ``fanPwm`` (int) - Fan PWM duty cycle - * ``mixingRate`` (float) - Mixing valve rate - * ``eevStep`` (int) - Electronic expansion valve position - * ``dischargeTemperature`` (float) - Compressor discharge temp - * ``suctionTemperature`` (float) - Compressor suction temp - * ``evaporatorTemperature`` (float) - Evaporator temperature - * ``targetSuperHeat`` (float) - Target superheat - * ``currentSuperHeat`` (float) - Current superheat + * ``target_fan_rpm`` (int) - Target fan RPM + * ``current_fan_rpm`` (int) - Current fan RPM + * ``fan_pwm`` (int) - Fan PWM duty cycle + * ``mixing_rate`` (float) - Mixing valve rate + * ``eev_step`` (int) - Electronic expansion valve position + * ``discharge_temperature`` (float) - Compressor discharge temp + * ``suction_temperature`` (float) - Compressor suction temp + * ``evaporator_temperature`` (float) - Evaporator temperature + * ``target_super_heat`` (float) - Target superheat + * ``current_super_heat`` (float) - Current superheat **Example:** @@ -409,50 +409,50 @@ Device capabilities, features, and firmware information. **Firmware Version Fields:** - * ``controllerSwVersion`` (int) - Controller firmware version - * ``panelSwVersion`` (int) - Panel firmware version - * ``wifiSwVersion`` (int) - WiFi module firmware version - * ``controllerSwCode`` (int) - Controller software code - * ``panelSwCode`` (int) - Panel software code - * ``wifiSwCode`` (int) - WiFi software code - * ``controllerSerialNumber`` (str) - Controller serial number + * ``controller_sw_version`` (int) - Controller firmware version + * ``panel_sw_version`` (int) - Panel firmware version + * ``wifi_sw_version`` (int) - WiFi module firmware version + * ``controller_sw_code`` (int) - Controller software code + * ``panel_sw_code`` (int) - Panel software code + * ``wifi_sw_code`` (int) - WiFi software code + * ``controller_serial_number`` (str) - Controller serial number **Device Configuration:** - * ``countryCode`` (int) - Country code - * ``modelTypeCode`` (int) - Model type - * ``controlTypeCode`` (int) - Control type - * ``volumeCode`` (int) - Tank volume code - * ``tempFormulaType`` (int) - Temperature formula type - * ``temperatureType`` (TemperatureUnit) - Temperature unit + * ``country_code`` (int) - Country code + * ``model_type_code`` (int) - Model type + * ``control_type_code`` (int) - Control type + * ``volume_code`` (int) - Tank volume code + * ``temp_formula_type`` (int) - Temperature formula type + * ``temperature_type`` (TemperatureUnit) - Temperature unit **Temperature Limits:** - * ``dhwTemperatureMin`` (int) - Minimum DHW temperature - * ``dhwTemperatureMax`` (int) - Maximum DHW temperature - * ``freezeProtectionTempMin`` (int) - Min freeze protection temp - * ``freezeProtectionTempMax`` (int) - Max freeze protection temp + * ``dhw_temperature_min`` (int) - Minimum DHW temperature + * ``dhw_temperature_max`` (int) - Maximum DHW temperature + * ``freeze_protection_temp_min`` (int) - Min freeze protection temp + * ``freeze_protection_temp_max`` (int) - Max freeze protection temp **Feature Flags (all int, 0=disabled, 1=enabled):** - * ``powerUse`` - Power control supported - * ``dhwUse`` - DHW functionality - * ``dhwTemperatureSettingUse`` - Temperature control - * ``energyUsageUse`` - Energy monitoring supported - * ``antiLegionellaSettingUse`` - Anti-Legionella supported - * ``programReservationUse`` - Reservation scheduling supported - * ``freezeProtectionUse`` - Freeze protection available - * ``heatpumpUse`` - Heat pump mode available - * ``electricUse`` - Electric mode available - * ``energySaverUse`` - Energy Saver mode available - * ``highDemandUse`` - High Demand mode available - * ``smartDiagnosticUse`` - Smart diagnostics available - * ``wifiRssiUse`` - WiFi signal strength available - * ``holidayUse`` - Holiday/vacation mode - * ``mixingValueUse`` - Mixing valve - * ``drSettingUse`` - Demand response - * ``dhwRefillUse`` - DHW refill - * ``ecoUse`` - Eco mode + * ``power_use`` - Power control supported + * ``dhw_use`` - DHW functionality + * ``dhw_temperature_setting_use`` - Temperature control + * ``energy_usage_use`` - Energy monitoring supported + * ``anti_legionella_setting_use`` - Anti-Legionella supported + * ``program_reservation_use`` - Reservation scheduling supported + * ``freeze_protection_use`` - Freeze protection available + * ``heatpump_use`` - Heat pump mode available + * ``electric_use`` - Electric mode available + * ``energy_saver_use`` - Energy Saver mode available + * ``high_demand_use`` - High Demand mode available + * ``smart_diagnostic_use`` - Smart diagnostics available + * ``wifi_rssi_use`` - WiFi signal strength available + * ``holiday_use`` - Holiday/vacation mode + * ``mixing_value_use`` - Mixing valve + * ``dr_setting_use`` - Demand response + * ``dhw_refill_use`` - DHW refill + * ``eco_use`` - Eco mode **Example:** From 9f000dca170b4f879b512063c7bd098540530415 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 15:47:52 -0800 Subject: [PATCH 19/29] docs: convert remaining field names in models.rst to snake_case - Updated Energy model fields (heUsage -> heat_element_usage, etc.) - Updated MQTT model fields (clientID -> client_id, etc.) - Updated common fields (deviceType -> device_type, etc.) --- docs/python_api/models.rst | 46 +++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index 2c0211e..2ad1bf6 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -495,10 +495,10 @@ Complete energy usage response with daily breakdown. **Fields:** - * ``deviceType`` (int) - Device type - * ``macAddress`` (str) - Device MAC - * ``additionalValue`` (str) - Additional identifier - * ``typeOfUsage`` (int) - Usage type code + * ``device_type`` (int) - Device type + * ``mac_address`` (str) - Device MAC + * ``additional_value`` (str) - Additional identifier + * ``type_of_usage`` (int) - Usage type code * ``total`` (EnergyUsageTotal) - Total usage summary * ``usage`` (list[MonthlyEnergyData]) - Monthly data with daily breakdown @@ -521,8 +521,8 @@ Complete energy usage response with daily breakdown. for day_num, day in enumerate(month_data.data, 1): if day.total_usage > 0: print(f" Day {day_num}: {day.total_usage} Wh") - print(f" HP: {day.hpUsage} Wh ({day.hpTime}h)") - print(f" HE: {day.heUsage} Wh ({day.heTime}h)") + print(f" HP: {day.heat_pump_usage} Wh ({day.heat_pump_time}h)") + print(f" HE: {day.heat_element_usage} Wh ({day.heat_element_time}h)") EnergyUsageTotal ---------------- @@ -533,10 +533,10 @@ Summary totals for energy usage. **Fields:** - * ``heUsage`` (int) - Total heat element usage (Wh) - * ``hpUsage`` (int) - Total heat pump usage (Wh) - * ``heTime`` (int) - Total heat element time (hours) - * ``hpTime`` (int) - Total heat pump time (hours) + * ``heat_element_usage`` (int) - Total heat element usage (Wh) + * ``heat_pump_usage`` (int) - Total heat pump usage (Wh) + * ``heat_element_time`` (int) - Total heat element time (hours) + * ``heat_pump_time`` (int) - Total heat pump time (hours) **Computed Properties:** @@ -566,10 +566,10 @@ Energy data for a single day. **Fields:** - * ``heUsage`` (int) - Heat element usage (Wh) - * ``hpUsage`` (int) - Heat pump usage (Wh) - * ``heTime`` (int) - Heat element time (hours) - * ``hpTime`` (int) - Heat pump time (hours) + * ``heat_element_usage`` (int) - Heat element usage (Wh) + * ``heat_pump_usage`` (int) - Heat pump usage (Wh) + * ``heat_element_time`` (int) - Heat element time (hours) + * ``heat_pump_time`` (int) - Heat pump time (hours) **Computed Properties:** @@ -635,12 +635,12 @@ Complete MQTT command message. **Fields:** - * ``clientID`` (str) - MQTT client ID - * ``sessionID`` (str) - Session ID - * ``requestTopic`` (str) - Request topic - * ``responseTopic`` (str) - Response topic + * ``client_id`` (str) - MQTT client ID + * ``session_id`` (str) - Session ID + * ``request_topic`` (str) - Request topic + * ``response_topic`` (str) - Response topic * ``request`` (MqttRequest) - Request payload - * ``protocolVersion`` (int) - Protocol version (default: 2) + * ``protocol_version`` (int) - Protocol version (default: 2) MqttRequest ----------- @@ -652,12 +652,12 @@ MQTT request payload. **Fields:** * ``command`` (int) - Command code (see CommandCode) - * ``deviceType`` (int) - Device type - * ``macAddress`` (str) - Device MAC - * ``additionalValue`` (str) - Additional identifier + * ``device_type`` (int) - Device type + * ``mac_address`` (str) - Device MAC + * ``additional_value`` (str) - Additional identifier * ``mode`` (str, optional) - Mode parameter * ``param`` (list[int | float]) - Numeric parameters - * ``paramStr`` (str) - String parameters + * ``param_str`` (str) - String parameters * ``month`` (list[int], optional) - Month list for energy queries * ``year`` (int, optional) - Year for energy queries From 0a60276f0063b2a326401b3355f7d68a86bf4c2d Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 15:52:02 -0800 Subject: [PATCH 20/29] docs: convert code example attributes in models.rst to snake_case --- docs/python_api/models.rst | 48 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index 2ad1bf6..b4070ff 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -140,7 +140,7 @@ Temperature scale enumeration. .. code-block:: python def on_status(status): - if status.temperatureType == TemperatureUnit.FAHRENHEIT: + if status.temperature_type == TemperatureUnit.FAHRENHEIT: print(f"Temperature: {status.dhw_temperature}°F") else: print(f"Temperature: {status.dhw_temperature}°C") @@ -367,19 +367,19 @@ Complete real-time device status with 100+ fields. # Temperature monitoring print(f"Water: {status.dhw_temperature}°F") print(f"Target: {status.dhw_temperatureSetting}°F") - print(f"Upper Tank: {status.tankUpperTemperature}°F") - print(f"Lower Tank: {status.tankLowerTemperature}°F") + print(f"Upper Tank: {status.tank_upper_temperature}°F") + print(f"Lower Tank: {status.tank_lower_temperature}°F") # Power consumption print(f"Power: {status.current_inst_power}W") - print(f"Energy: {status.availableEnergyCapacity}%") + print(f"Energy: {status.available_energy_capacity}%") # Operation mode print(f"Mode: {status.dhw_operation_setting.name}") print(f"State: {status.operation_mode.name}") # Active heating - if status.operationBusy: + if status.operation_busy: print("Heating water:") if status.comp_use: print(" - Heat pump running") @@ -389,16 +389,16 @@ Complete real-time device status with 100+ fields. print(" - Lower heater active") # Water usage detection - if status.dhwUse: + if status.dhw_use: print("Water usage detected (short-term)") - if status.dhwUseSustained: + if status.dhw_useSustained: print("Water usage detected (sustained)") # Errors - if status.errorCode != 0: - print(f"ERROR: {status.errorCode}") - if status.subErrorCode != 0: - print(f" Sub-error: {status.subErrorCode}") + if status.error_code != 0: + print(f"ERROR: {status.error_code}") + if status.sub_error_code != 0: + print(f" Sub-error: {status.sub_error_code}") DeviceFeature ------------- @@ -459,28 +459,28 @@ Device capabilities, features, and firmware information. .. code-block:: python def on_feature(feature): - print(f"Serial: {feature.controllerSerialNumber}") - print(f"Firmware: {feature.controllerSwVersion}") - print(f"WiFi: {feature.wifiSwVersion}") + print(f"Serial: {feature.controller_serial_number}") + print(f"Firmware: {feature.controller_sw_version}") + print(f"WiFi: {feature.wifi_sw_version}") print(f"\nTemperature Range:") - print(f" Min: {feature.dhwTemperatureMin}°F") - print(f" Max: {feature.dhwTemperatureMax}°F") + print(f" Min: {feature.dhw_temperature_min}°F") + print(f" Max: {feature.dhw_temperature_max}°F") print(f"\nSupported Features:") - if feature.energyUsageUse: + if feature.energy_usage_use: print(" [OK] Energy monitoring") - if feature.antiLegionellaSettingUse: + if feature.anti_legionella_setting_use: print(" [OK] Anti-Legionella") - if feature.programReservationUse: + if feature.program_reservation_use: print(" [OK] Reservations") - if feature.heatpumpUse: + if feature.heatpump_use: print(" [OK] Heat pump mode") - if feature.electricUse: + if feature.electric_use: print(" [OK] Electric mode") - if feature.energySaverUse: + if feature.energy_saver_use: print(" [OK] Energy Saver mode") - if feature.highDemandUse: + if feature.high_demand_use: print(" [OK] High Demand mode") Energy Models @@ -680,7 +680,7 @@ Best Practices .. code-block:: python def on_feature(feature): - if feature.energyUsageUse: + if feature.energy_usage_use: # Device supports energy monitoring await mqtt.request_energy_usage(device, year, months) From 5c62b1f9144b77c99e277e2c858b02dad48b9517 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 17:42:20 -0800 Subject: [PATCH 21/29] docs: normalize snake_case field names in mqtt_client examples --- docs/python_api/mqtt_client.rst | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index b07eb2d..b7d9ba1 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -204,13 +204,13 @@ subscribe_device_status() def on_status(status): """Called every time device status updates.""" print(f"Temperature: {status.dhw_temperature}°F") - print(f"Target: {status.dhw_temperatureSetting}°F") + print(f"Target: {status.dhw_temperature_setting}°F") print(f"Mode: {status.dhw_operation_setting.name}") print(f"Power: {status.current_inst_power}W") - print(f"Energy: {status.availableEnergyCapacity}%") + print(f"Energy: {status.available_energy_capacity}%") # Check if actively heating - if status.operationBusy: + if status.operation_busy: print("Device is heating water") if status.comp_use: print(" - Heat pump running") @@ -220,14 +220,14 @@ subscribe_device_status() print(" - Lower heater active") # Check water usage - if status.dhwUse: + if status.dhw_use: print("Water is being used (short-term)") - if status.dhwUseSustained: + if status.dhw_use_sustained: print("Water is being used (sustained)") # Check for errors - if status.errorCode != 0: - print(f"ERROR: {status.errorCode}") + if status.error_code != 0: + print(f"ERROR: {status.error_code}") await mqtt.subscribe_device_status(device, on_status) await mqtt.request_device_status(device) @@ -282,17 +282,17 @@ subscribe_device_feature() def on_feature(feature): """Called when device features/info received.""" - print(f"Serial: {feature.controllerSerialNumber}") - print(f"Firmware: {feature.controllerSwVersion}") - print(f"Temp Range: {feature.dhwTemperatureMin}°F - " - f"{feature.dhwTemperatureMax}°F") + print(f"Serial: {feature.controller_serial_number}") + print(f"Firmware: {feature.controller_sw_version}") + print(f"Temp Range: {feature.dhw_temperature_min}°F - " + f"{feature.dhw_temperature_max}°F") # Check capabilities - if feature.energyUsageUse: + if feature.energy_usage_use: print("Energy monitoring: Supported") - if feature.antiLegionellaSettingUse: + if feature.anti_legionella_setting_use: print("Anti-Legionella: Supported") - if feature.reservationUse: + if feature.reservation_use: print("Reservations: Supported") await mqtt.subscribe_device_feature(device, on_feature) From f86519bf8995cc59331f5df9a3bb347c6c7743c5 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 17:45:19 -0800 Subject: [PATCH 22/29] docs: add :no-index: to package automodule to suppress duplicate warnings --- docs/api/nwp500.rst | 158 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/api/nwp500.rst diff --git a/docs/api/nwp500.rst b/docs/api/nwp500.rst new file mode 100644 index 0000000..a1908eb --- /dev/null +++ b/docs/api/nwp500.rst @@ -0,0 +1,158 @@ +nwp500 package +============== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + nwp500.cli + +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.config module +-------------------- + +.. automodule:: nwp500.config + :members: + :show-inheritance: + :undoc-members: + +nwp500.constants module +----------------------- + +.. automodule:: nwp500.constants + :members: + :show-inheritance: + :undoc-members: + +nwp500.encoding module +---------------------- + +.. automodule:: nwp500.encoding + :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.models module +-------------------- + +.. automodule:: nwp500.models + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_client module +-------------------------- + +.. automodule:: nwp500.mqtt_client + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_command\_queue module +---------------------------------- + +.. automodule:: nwp500.mqtt_command_queue + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_connection module +------------------------------ + +.. automodule:: nwp500.mqtt_connection + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_device\_control module +----------------------------------- + +.. automodule:: nwp500.mqtt_device_control + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_periodic module +---------------------------- + +.. automodule:: nwp500.mqtt_periodic + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_reconnection module +-------------------------------- + +.. automodule:: nwp500.mqtt_reconnection + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_subscriptions module +--------------------------------- + +.. automodule:: nwp500.mqtt_subscriptions + :members: + :show-inheritance: + :undoc-members: + +nwp500.mqtt\_utils module +------------------------- + +.. automodule:: nwp500.mqtt_utils + :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: + :no-index: From 9e3d4718283c7abb58c3b1f55e0b7595a74172b7 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 17:49:11 -0800 Subject: [PATCH 23/29] docs: convert remaining camelCase to snake_case in mqtt examples --- docs/protocol/mqtt_protocol.rst | 16 ++++++++-------- docs/python_api/mqtt_client.rst | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/protocol/mqtt_protocol.rst b/docs/protocol/mqtt_protocol.rst index 18acbe1..7d35b0b 100644 --- a/docs/protocol/mqtt_protocol.rst +++ b/docs/protocol/mqtt_protocol.rst @@ -305,9 +305,9 @@ Status Response "deviceType": 52, "macAddress": "...", "status": { - "dhwTemperature": 120, - "dhwTemperatureSetting": 120, - "currentInstPower": 450, + "dhw_temperature": 120, + "dhw_temperature_setting": 120, + "current_inst_power": 450, "operationMode": 64, "dhwOperationSetting": 3, "operationBusy": 2, @@ -335,11 +335,11 @@ Feature/Info Response { "response": { "feature": { - "controllerSerialNumber": "ABC123", - "controllerSwVersion": 184614912, - "dhwTemperatureMin": 75, - "dhwTemperatureMax": 130, - "energyUsageUse": 1, + "controller_serial_number": "ABC123", + "controller_sw_version": 184614912, + "dhw_temperature_min": 75, + "dhw_temperature_max": 130, + "energy_usage_use": 1, ... } } diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index b7d9ba1..5f5442d 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -890,7 +890,7 @@ Example 1: Complete Monitoring Application last_power = status.current_inst_power # Heating state - if status.operationBusy: + if status.operation_busy: components = [] if status.comp_use: components.append("HP") @@ -935,7 +935,7 @@ Example 2: Automatic Temperature Control nonlocal last_use_time # Water is being used - if status.dhwUse or status.dhwUseSustained: + if status.dhw_use or status.dhw_use_sustained: last_use_time = datetime.now() # If temp dropped below 130°F, boost to high demand From 022bededf874711ea1ec1cace992a7b3b1c5f13f Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 17:55:06 -0800 Subject: [PATCH 24/29] docs: fix remaining dhw_temperatureSetting and exclude duplicated members --- docs/api/nwp500.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/api/nwp500.rst b/docs/api/nwp500.rst index a1908eb..5e2f5cd 100644 --- a/docs/api/nwp500.rst +++ b/docs/api/nwp500.rst @@ -155,4 +155,3 @@ Module contents :members: :show-inheritance: :undoc-members: - :no-index: From d25fc6a39bb30b2d6d3c5e65f10cbf9eef92ad3b Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 17:56:00 -0800 Subject: [PATCH 25/29] docs: finalize snake_case field and switch problematic JSON examples to text --- docs/python_api/models.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index b4070ff..36a72aa 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -540,9 +540,9 @@ Summary totals for energy usage. **Computed Properties:** - * ``total_usage`` (int) - heUsage + hpUsage - * ``heat_pump_percentage`` (float) - (hpUsage / total) × 100 - * ``heat_element_percentage`` (float) - (heUsage / total) × 100 + * ``total_usage`` (int) - heat_element_usage + heat_pump_usage + * ``heat_pump_percentage`` (float) - (heat_pump_usage / total) × 100 + * ``heat_element_percentage`` (float) - (heat_element_usage / total) × 100 MonthlyEnergyData ----------------- @@ -573,7 +573,7 @@ Energy data for a single day. **Computed Properties:** - * ``total_usage`` (int) - heUsage + hpUsage + * ``total_usage`` (int) - heat_element_usage + heat_pump_usage Time-of-Use Models ================== From d3b4f9db11a52ad368cc5177432fc66cec70b9be Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 17:56:34 -0800 Subject: [PATCH 26/29] docs: finalize snake_case and switch json placeholders to text --- docs/python_api/models.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/python_api/models.rst b/docs/python_api/models.rst index 36a72aa..7c11d3f 100644 --- a/docs/python_api/models.rst +++ b/docs/python_api/models.rst @@ -366,7 +366,7 @@ Complete real-time device status with 100+ fields. def on_status(status): # Temperature monitoring print(f"Water: {status.dhw_temperature}°F") - print(f"Target: {status.dhw_temperatureSetting}°F") + print(f"Target: {status.dhw_temperature_setting}°F") print(f"Upper Tank: {status.tank_upper_temperature}°F") print(f"Lower Tank: {status.tank_lower_temperature}°F") From 8c6eec27f7a40de234724262f00291a3d73b4d0d Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 18:12:49 -0800 Subject: [PATCH 27/29] docs: update REST API and MQTT client docs (2025-11-20) --- docs/protocol/rest_api.rst | 4 ++-- docs/python_api/mqtt_client.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/protocol/rest_api.rst b/docs/protocol/rest_api.rst index 4d791b9..31efb48 100644 --- a/docs/protocol/rest_api.rst +++ b/docs/protocol/rest_api.rst @@ -343,8 +343,8 @@ All error responses follow this format: .. code-block:: json { - "code": , - "msg": "", + "code": 404, + "msg": "NOT_FOUND", "data": null } diff --git a/docs/python_api/mqtt_client.rst b/docs/python_api/mqtt_client.rst index 5f5442d..6a50186 100644 --- a/docs/python_api/mqtt_client.rst +++ b/docs/python_api/mqtt_client.rst @@ -50,7 +50,7 @@ Basic Monitoring # Subscribe to status updates def on_status(status): print(f"Water Temp: {status.dhw_temperature}°F") - print(f"Target: {status.dhw_temperatureSetting}°F") + print(f"Target: {status.dhw_temperature_setting}°F") print(f"Power: {status.current_inst_power}W") print(f"Mode: {status.dhw_operation_setting.name}") From 4dbb2a8bc05fbc831c728670c9e441738499eb38 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 18:21:00 -0800 Subject: [PATCH 28/29] docs: update changelog with v6.0.3 model migration breaking changes --- CHANGELOG.rst | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a1794cc..b964f34 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,89 @@ Changelog ========= +Version 6.0.3 (2025-11-20) +========================== + +**BREAKING CHANGES**: Migration from custom dataclass-based models to Pydantic BaseModel implementations with automatic field validation and alias handling. + +Removed +------- + +- Removed legacy dataclass implementations for models (DeviceInfo, Location, Device, FirmwareInfo, DeviceStatus, DeviceFeature, EnergyUsage*). All models now inherit from ``NavienBaseModel`` (Pydantic). +- Removed manual ``from_dict`` constructors relying on camelCase key mapping logic. +- Removed field metadata conversion system (``meta()`` + ``apply_field_conversions()``) in favor of Pydantic ``BeforeValidator`` pipeline. + +Changed +------- + +- Models now use snake_case attribute names consistently; camelCase keys from API/MQTT are mapped automatically via Pydantic ``alias_generator=to_camel``. +- Boolean device fields now validated via ``DeviceBool`` Annotated type (device value 2 -> True, 0/1 -> False) replacing manual conversion code. +- Temperature offset (+20), scale division (/10) and decicelsius-to-Fahrenheit conversions implemented with lightweight ``BeforeValidator`` functions (``Add20``, ``Div10``, ``DeciCelsiusToF``) instead of post-processing. +- Enum parsing now handled directly by Pydantic; unknown values default safely via explicit Field defaults instead of try/except conversion loops. +- Field names updated (examples & docs) to snake_case: e.g. ``operationMode`` -> ``operation_mode``, ``dhwTemperatureSetting`` -> ``dhw_temperature_setting``. +- API typo handled using Field alias (``heLowerOnTDiffempSetting`` -> ``he_lower_on_diff_temp_setting``) rather than custom dictionary mutation. +- DeviceStatus conversion now performed on parse instead of separate transformation step, improving performance and reducing memory copies. +- Improved validation error messages from Pydantic on malformed payloads. +- Simplified energy usage model accessors; removed manual percentage methods duplication. + +Added +----- + +- Introduced ``NavienBaseModel`` configuring alias generation, population by name, and ignoring unknown fields for forward compatibility. +- Added structured Annotated types: ``DeviceBool``, ``Add20``, ``Div10``, ``DeciCelsiusToF`` for declarative conversion definitions. +- Added consistent default enum values directly in field declarations (e.g. ``operation_mode=STANDBY``). + +Migration Guide (v6.0.2 -> v6.0.3) +---------------------------------- + +1. Replace any imports of dataclass models with Pydantic versions (paths unchanged). No code change required if you only accessed attributes. +2. Remove calls to ``Model.from_dict(data)``: Either use ``Model.model_validate(data)`` or continue calling ``from_dict`` where still provided (thin wrapper for backward compatibility on some classes). Preferred: ``DeviceStatus.model_validate(raw_payload)``. +3. Update attribute access to snake_case. Common mappings: + - ``deviceInfo.macAddress`` -> ``device.device_info.mac_address`` + - ``deviceStatus.operationMode`` -> ``status.operation_mode`` + - ``deviceStatus.dhwTemperatureSetting`` -> ``status.dhw_temperature_setting`` + - ``deviceStatus.currentInletTemperature`` -> ``status.current_inlet_temperature`` +4. Remove manual conversion code. Raw numeric values are converted automatically; stop adding +20 or dividing by 10 in user code. +5. Stop performing boolean normalization (``value == 2``) manually; attributes already return proper bools. +6. For enum handling, remove try/except wrappers; rely on defaulted fields (e.g. ``operation_mode`` defaults to ``STANDBY``). +7. If you previously mutated raw payload keys to snake_case, eliminate that transformation step. +8. If you logged intermediate converted dictionaries, you can access ``model.model_dump()`` for a fully converted representation. +9. Replace any custom validation logic with Pydantic validators or continue using existing patterns; most prior validation code is now unnecessary. +10. Energy usage: Access percentages via properties unchanged; object types now Pydantic models. + +Quick Example +~~~~~~~~~~~~~ + +.. code-block:: python + + # OLD (v6.0.2) + raw = mqtt_payload["deviceStatus"] + converted = apply_field_conversions(DeviceStatus, raw) + status = DeviceStatus(**converted) + print(converted["dhwTemperatureSetting"] + 20) # manual offset + + # NEW (v6.0.3) + status = DeviceStatus.model_validate(mqtt_payload["deviceStatus"]) + print(status.dhw_temperature_setting) # already includes +20 offset + + # OLD boolean and enum handling + is_heating = converted["currentHeatUse"] == 2 + mode = CurrentOperationMode(converted["operationMode"]) if converted["operationMode"] in (0,32,64,96) else CurrentOperationMode.STANDBY + + # NEW simplified + is_heating = status.current_heat_use + mode = status.operation_mode + +Benefits +~~~~~~~~ + +- Declarative conversions reduce 400+ lines of imperative transformation logic. +- Improved performance (single parse vs copy + transform). +- Automatic camelCase key mapping; less brittle than manual dict key copying. +- Rich validation errors for debugging malformed device messages. +- Cleaner, shorter model definitions with clearer intent. +- Easier extension: add new fields with conversion by combining Annotated + validator. + Version 6.0.2 (2025-11-15) ========================== From fb73625ccbdc8267b17d00000472d97f95216644 Mon Sep 17 00:00:00 2001 From: Emmanuel Levijarvi Date: Wed, 19 Nov 2025 18:40:51 -0800 Subject: [PATCH 29/29] clean-up readme --- README.rst | 116 +++++------------------------------------------------ 1 file changed, 11 insertions(+), 105 deletions(-) diff --git a/README.rst b/README.rst index 6cb5dc7..df122d1 100644 --- a/README.rst +++ b/README.rst @@ -13,19 +13,15 @@ A Python library for monitoring and controlling the Navien NWP500 Heat Pump Wate 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 -* **Device Monitoring**: Access real-time status information including temperatures, power consumption, and tank charge level -* **Temperature Control**: Set target water temperature (90-151°F) -* **Operation Mode Control**: Switch between Heat Pump, Energy Saver, High Demand, Electric, and Vacation modes -* **Reservation Management**: Schedule automatic temperature and mode changes -* **Time of Use (TOU)**: Configure energy pricing schedules for demand response -* **Anti-Legionella Protection**: Monitor periodic disinfection cycles (140°F heating) -* **Comprehensive Status Data**: Access to 70+ device status fields including compressor status, heater status, flow rates, and more -* **MQTT Protocol Support**: Low-level MQTT communication with Navien devices -* **Non-Blocking Async Operations**: Fully compatible with async event loops (Home Assistant safe) -* **Automatic Reconnection**: Reconnects automatically with exponential backoff during network interruptions -* **Command Queuing**: Commands sent while disconnected are queued and sent automatically when reconnected -* **Data Models**: Type-safe data classes with automatic unit conversions +* Async friendly Quick Start =========== @@ -119,8 +115,8 @@ The library includes a command line interface for quick monitoring and device in **Available CLI Options:** * ``--status``: Print current device status as JSON. Can be combined with control commands to see updated status. -* ``--device-info``: Print comprehensive device information (firmware, model, capabilities) via MQTT as JSON and exit -* ``--device-feature``: Print device capabilities and feature settings via MQTT as JSON and exit +* ``--device-info``: Print comprehensive device information (firmware, model, capabilities) as JSON and exit +* ``--device-feature``: Print device capabilities and feature settings as JSON and exit * ``--power-on``: Turn the device on and display response * ``--power-off``: Turn the device off and display response * ``--set-mode MODE``: Set operation mode and display response. Valid modes: heat-pump, energy-saver, high-demand, electric, vacation, standby @@ -161,63 +157,10 @@ The library provides access to comprehensive device status information: * Cumulative operation time * Flow rates -Operation Modes -=============== - -.. list-table:: Operation Modes - :header-rows: 1 - :widths: 25 10 65 - - * - Mode - - ID - - Description - * - Heat Pump Mode - - 1 - - Most energy-efficient mode using only the heat pump. Longest recovery time. - * - Electric Mode - - 2 - - Fastest recovery using only electric heaters. Least energy-efficient. - * - Energy Saver Mode - - 3 - - Default mode. Balances efficiency and recovery time using both heat pump and electric heater. - * - High Demand Mode - - 4 - - Uses electric heater more frequently for faster recovery time. - * - Vacation Mode - - 5 - - Suspends heating to save energy during extended absences. - -**Important:** When you set a mode, you're configuring the ``dhwOperationSetting`` (what mode to use when heating). The device's current operational state is reported in ``operationMode`` (0=Standby, 32=Heat Pump active, 64=Energy Saver active, 96=High Demand active). - -MQTT Protocol -============= - -The library supports low-level MQTT communication with Navien devices: - -**Control Topics** - * ``cmd/{deviceType}/{deviceId}/ctrl`` - Send control commands - * ``cmd/{deviceType}/{deviceId}/ctrl/rsv/rd`` - Manage reservations - * ``cmd/{deviceType}/{deviceId}/ctrl/tou/rd`` - Time of Use settings - * ``cmd/{deviceType}/{deviceId}/st`` - Request status updates - -**Control Commands** - * Power control (on/off) - * DHW mode changes (including vacation mode) - * Temperature settings - * Reservation management (scheduled mode/temperature changes) - * Time of Use (TOU) pricing schedules - -**Status Requests** - * Device information - * General device status - * Energy usage queries - * Reservation information - * TOU settings - Documentation ============= -For detailed information on device status fields, MQTT protocol, authentication, and more, see the complete documentation at https://nwp500-python.readthedocs.io/ +Full docs: https://nwp500-python.readthedocs.io/ Data Models =========== @@ -228,12 +171,6 @@ The library includes type-safe data models with automatic unit conversions: * **DeviceFeature**: Device capabilities, firmware versions, and configuration limits * **OperationMode**: Enumeration of available operation modes * **TemperatureUnit**: Celsius/Fahrenheit handling -* **MqttRequest/MqttCommand**: MQTT message structures - -Temperature conversions are handled automatically: - * DHW temperatures: ``raw_value + 20`` (°F) - * Heat pump temperatures: ``raw_value / 10.0`` (°F) - * Ambient temperature: ``(raw_value * 9/5) + 32`` (°F) Requirements ============ @@ -245,37 +182,6 @@ Requirements * pydantic >= 2.0.0 * awsiotsdk >= 1.21.0 -Development -=========== -To set up a development environment: - -.. code-block:: bash - - # Clone the repository - git clone https://github.com/eman/nwp500-python.git - cd nwp500-python - - # Install in development mode - pip install -e . - - # Run tests - pytest - -**Linting and CI Consistency** - -To ensure your local linting matches CI exactly: - -.. code-block:: bash - - # Install tox (recommended) - pip install tox - - # Run linting exactly as CI does - tox -e lint - - # Auto-fix and format - tox -e format - License =======