diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fc454098..500f21bb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,74 @@ Changelog Unreleased ========== +**BREAKING CHANGES**: Public API surface trimmed and dead code removed. +These changes require a major version bump. + +Removed +------- +- **Deprecated ``.control`` property**: removed + ``NavienMqttClient.control`` (deprecated shim slated for v9.0.0; the + project policy is to remove rather than deprecate). Use the delegated + methods on the client directly: + + .. code-block:: python + + # OLD (removed) + await client.control.set_power(device, True) + + # NEW + await client.set_power(device, True) + +- **Unused exception classes**: removed ``TokenExpiredError``, + ``MqttSubscriptionError``, ``DeviceNotFoundError``, + ``DeviceOfflineError``, and ``DeviceOperationError``. None of them was + ever raised by the library, so no working error handling can break; + catch the parent classes (``AuthenticationError``, ``MqttError``, + ``DeviceError``) instead. +- **Internal plumbing removed from the top-level namespace**: the + package no longer re-exports ``requires_capability``, + ``MqttDeviceInfoCache``, ``MqttDeviceCapabilityChecker``, + ``log_performance``, or the bit-encoding helpers + (``encode_week_bitfield``, ``decode_week_bitfield``, + ``encode_season_bitfield``, ``decode_season_bitfield``, + ``encode_price``, ``decode_price``, ``build_reservation_entry``, + ``build_tou_period``). Import them from their owning modules: + + .. code-block:: python + + # OLD (removed) + from nwp500 import build_reservation_entry, encode_price + + # NEW + from nwp500.encoding import build_reservation_entry, encode_price + +- **Dead code**: removed the never-imported ``nwp500.cli.commands`` + module (its metadata had drifted from the real click commands), the + unused ``converters.str_enum_validator``, the unused module-level + ``temperature.half_celsius_to_fahrenheit`` / + ``deci_celsius_to_fahrenheit`` wrappers, the no-op + ``NavienMqttClient._on_message_received`` placeholder, and unused + width calculations in the CLI formatters. + +Changed +------- +- **Temperature classes deduplicated**: ``HalfCelsius``, ``DeciCelsius``, + ``RawCelsius``, and ``DeciCelsiusDelta`` were four near-identical + copies differing only in a scale constant. All conversions are now + implemented once on the ``Temperature`` base class with a per-class + ``_scale``; only special rounding (``RawCelsius``) and delta semantics + (``DeciCelsiusDelta``) are overridden. Behavior is unchanged + (~200 lines removed). +- **field_factory deduplicated**: the four field factories shared a + copy-pasted metadata-merge block, now extracted into a single private + helper. Behavior is unchanged. +- **Device boolean encoding centralized**: ``mqtt/control.py`` now uses + ``converters.device_bool_from_python()`` instead of inline + ``2 if enabled else 1`` literals. +- **Version resolution decoupled**: ``auth.py`` resolves the package + version from distribution metadata instead of ``from . import + __version__``, removing an order-dependent near-circular import. + Bug Fixes --------- - **Fix malformed weekly reservation and recirculation schedule diff --git a/docs/how-to/optimize-tou.rst b/docs/how-to/optimize-tou.rst index bc23a508..a6c936aa 100644 --- a/docs/how-to/optimize-tou.rst +++ b/docs/how-to/optimize-tou.rst @@ -418,7 +418,7 @@ Encodes a floating-point price into an integer for transmission. .. code-block:: python - from nwp500 import encode_price + from nwp500.encoding import encode_price # Encode $0.45000 per kWh encoded = encode_price(0.45, decimal_point=5) @@ -437,7 +437,7 @@ Decodes an integer price back to floating-point. .. code-block:: python - from nwp500 import decode_price + from nwp500.encoding import decode_price # Decode price from device price = decode_price(45000, decimal_point=5) @@ -466,7 +466,7 @@ Encodes a list of day names into a bitfield. .. code-block:: python - from nwp500 import encode_week_bitfield + from nwp500.encoding import encode_week_bitfield # Weekdays only bitfield = encode_week_bitfield([ @@ -487,7 +487,7 @@ Decodes a bitfield back into a list of day names. .. code-block:: python - from nwp500 import decode_week_bitfield + from nwp500.encoding import decode_week_bitfield # Decode weekday bitfield days = decode_week_bitfield(62) @@ -504,7 +504,8 @@ Configure two rate periods - off-peak and peak pricing: .. code-block:: python import asyncio - from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient, build_tou_period + from nwp500 import NavienAPIClient, NavienAuthClient, NavienMqttClient + from nwp500.encoding import build_tou_period async def configure_simple_tou(): async with NavienAuthClient("user@example.com", "password") as auth_client: @@ -654,7 +655,7 @@ Query the device for its current TOU configuration: .. code-block:: python - from nwp500 import decode_week_bitfield, decode_price + from nwp500.encoding import decode_week_bitfield, decode_price async def check_tou_settings(): async with NavienAuthClient("user@example.com", "password") as auth_client: diff --git a/docs/how-to/schedule-operation.rst b/docs/how-to/schedule-operation.rst index 12875782..fa5976de 100644 --- a/docs/how-to/schedule-operation.rst +++ b/docs/how-to/schedule-operation.rst @@ -64,8 +64,8 @@ Quick Example NavienAuthClient, NavienAPIClient, NavienMqttClient, - build_reservation_entry, ) + from nwp500.encoding import build_reservation_entry async def main(): async with NavienAuthClient( @@ -279,7 +279,7 @@ Helper Functions .. code-block:: python - from nwp500 import build_reservation_entry + from nwp500.encoding import build_reservation_entry entry = build_reservation_entry( enabled=True, @@ -589,8 +589,8 @@ want to send the whole weekly program as one typed object. from nwp500 import ( WeeklyReservationEntry, WeeklyReservationSchedule, - build_reservation_entry, ) + from nwp500.encoding import build_reservation_entry morning = WeeklyReservationEntry.model_validate( build_reservation_entry( diff --git a/docs/reference/python_api/auth_client.rst b/docs/reference/python_api/auth_client.rst index 546f9167..0b322601 100644 --- a/docs/reference/python_api/auth_client.rst +++ b/docs/reference/python_api/auth_client.rst @@ -529,7 +529,6 @@ Error Handling from nwp500 import ( InvalidCredentialsError, - TokenExpiredError, TokenRefreshError, AuthenticationError ) @@ -543,9 +542,6 @@ Error Handling except InvalidCredentialsError: print("Wrong email or password") - except TokenExpiredError: - print("Token expired and refresh failed") - except TokenRefreshError: print("Could not refresh token - sign in again") diff --git a/docs/reference/python_api/exceptions.rst b/docs/reference/python_api/exceptions.rst index 58818483..b21e886f 100644 --- a/docs/reference/python_api/exceptions.rst +++ b/docs/reference/python_api/exceptions.rst @@ -15,22 +15,17 @@ All library exceptions inherit from ``Nwp500Error``:: Nwp500Error (base) ├── AuthenticationError │ ├── InvalidCredentialsError - │ ├── TokenExpiredError │ └── TokenRefreshError ├── APIError ├── MqttError │ ├── MqttConnectionError │ ├── MqttNotConnectedError │ ├── MqttPublishError - │ ├── MqttSubscriptionError │ └── MqttCredentialsError ├── ValidationError │ ├── ParameterValidationError │ └── RangeValidationError └── DeviceError - ├── DeviceNotFoundError - ├── DeviceOfflineError - ├── DeviceOperationError └── DeviceCapabilityError Base Exception @@ -134,16 +129,6 @@ InvalidCredentialsError print("Please check your email and password") # Prompt user to re-enter credentials -TokenExpiredError ------------------ - -.. py:class:: TokenExpiredError - - Raised when an authentication token has expired. - - Subclass of :py:class:`AuthenticationError`. Tokens have a limited lifetime - and must be refreshed periodically. - TokenRefreshError ----------------- @@ -305,16 +290,6 @@ MqttPublishError else: raise # Not retriable or max retries reached -MqttSubscriptionError ---------------------- - -.. py:class:: MqttSubscriptionError - - Failed to subscribe to MQTT topic. - - Raised when subscription to an MQTT topic fails. This may occur if the - connection is interrupted or if the client lacks permissions for the topic. - MqttCredentialsError -------------------- @@ -409,38 +384,6 @@ DeviceError All device-related errors inherit from this base class. -DeviceNotFoundError -------------------- - -.. py:class:: DeviceNotFoundError - - Requested device not found. - - Raised when a device cannot be found in the user's device list or when - attempting to access a non-existent device. - -DeviceOfflineError ------------------- - -.. py:class:: DeviceOfflineError - - Device is offline or unreachable. - - Raised when a device is offline and cannot respond to commands or status - requests. The device may be powered off, disconnected from the network, - or experiencing connectivity issues. - -DeviceOperationError --------------------- - -.. py:class:: DeviceOperationError - - Device operation failed. - - Raised when a device operation (mode change, temperature setting, etc.) - fails. This may occur due to invalid commands, device restrictions, or - device-side errors. - DeviceCapabilityError --------------------- diff --git a/docs/reference/python_api/mqtt_client.rst b/docs/reference/python_api/mqtt_client.rst index 7eb4ec4b..0b8f5ff1 100644 --- a/docs/reference/python_api/mqtt_client.rst +++ b/docs/reference/python_api/mqtt_client.rst @@ -467,7 +467,7 @@ update_reservations() .. code-block:: python - from nwp500 import build_reservation_entry + from nwp500.encoding import build_reservation_entry reservations = [ build_reservation_entry( diff --git a/examples/advanced/tou_openei.py b/examples/advanced/tou_openei.py index 5d123229..3a33fe4d 100755 --- a/examples/advanced/tou_openei.py +++ b/examples/advanced/tou_openei.py @@ -21,6 +21,8 @@ NavienAuthClient, NavienMqttClient, OpenEIClient, +) +from nwp500.encoding import ( decode_price, decode_week_bitfield, ) diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index 5c0d8805..01ce3925 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -32,25 +32,6 @@ authenticate, refresh_access_token, ) -from nwp500.command_decorators import ( - requires_capability, -) -from nwp500.device_capabilities import ( - MqttDeviceCapabilityChecker, -) -from nwp500.device_info_cache import ( - MqttDeviceInfoCache, -) -from nwp500.encoding import ( - build_reservation_entry, - build_tou_period, - decode_price, - decode_season_bitfield, - decode_week_bitfield, - encode_price, - encode_season_bitfield, - encode_week_bitfield, -) from nwp500.enums import ( CommandCode, CurrentOperationMode, @@ -79,20 +60,15 @@ AuthenticationError, DeviceCapabilityError, DeviceError, - DeviceNotFoundError, - DeviceOfflineError, - DeviceOperationError, InvalidCredentialsError, MqttConnectionError, MqttCredentialsError, MqttError, MqttNotConnectedError, MqttPublishError, - MqttSubscriptionError, Nwp500Error, ParameterValidationError, RangeValidationError, - TokenExpiredError, TokenRefreshError, ValidationError, ) @@ -154,17 +130,11 @@ reset_unit_system, set_unit_system, ) -from nwp500.utils import ( - log_performance, -) __all__ = [ "__version__", - # Device Capabilities & Caching - "MqttDeviceCapabilityChecker", + # Device Capabilities "DeviceCapabilityError", - "MqttDeviceInfoCache", - "requires_capability", # Factory functions "create_navien_clients", # Models @@ -225,22 +195,17 @@ "Nwp500Error", "AuthenticationError", "InvalidCredentialsError", - "TokenExpiredError", "TokenRefreshError", "APIError", "MqttError", "MqttConnectionError", "MqttNotConnectedError", "MqttPublishError", - "MqttSubscriptionError", "MqttCredentialsError", "ValidationError", "ParameterValidationError", "RangeValidationError", "DeviceError", - "DeviceNotFoundError", - "DeviceOfflineError", - "DeviceOperationError", # API Client "NavienAPIClient", # OpenEI Client @@ -262,17 +227,6 @@ "EventEmitter", "EventListener", "MqttClientEvents", - # Encoding utilities - "encode_week_bitfield", - "decode_week_bitfield", - "encode_season_bitfield", - "decode_season_bitfield", - "encode_price", - "decode_price", - "build_reservation_entry", - "build_tou_period", - # Utilities - "log_performance", # Unit system management "set_unit_system", "get_unit_system", diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index 599c4bc8..84dab355 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -17,6 +17,8 @@ import json import logging from datetime import UTC, datetime, timedelta +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _dist_version from typing import Any, Self, cast import aiohttp @@ -27,7 +29,6 @@ model_validator, ) -from . import __version__ from ._base import NavienBaseModel from .config import API_BASE_URL, REFRESH_ENDPOINT, SIGN_IN_ENDPOINT from .exceptions import ( @@ -37,6 +38,15 @@ ) from .unit_system import UnitSystemType +# Resolve the package version directly from distribution metadata instead +# of importing it from the package __init__ (which imports this module, +# making `from . import __version__` an order-dependent near-circular +# import). +try: + __version__ = _dist_version("nwp500-python") +except PackageNotFoundError: # pragma: no cover + __version__ = "unknown" + __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" __license__ = "MIT" diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py deleted file mode 100644 index 54febc24..00000000 --- a/src/nwp500/cli/commands.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Command registry for NWP500 CLI.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from . import handlers - - -@dataclass -class CliCommand: - """Definition of a CLI command.""" - - name: str - help: str - callback: Callable[..., Any] - args: list[str] # Required arguments - options: list[str] # Optional arguments - examples: list[str] # Usage examples - - -CLI_COMMANDS = [ - CliCommand( - name="status", - help="Show current device status", - callback=handlers.handle_status_request, - args=[], - options=["--format {text,json,csv}"], - examples=["nwp-cli status"], - ), - CliCommand( - name="info", - help="Show device information", - callback=handlers.handle_device_info_request, - args=[], - options=["--raw"], - examples=["nwp-cli info"], - ), - CliCommand( - name="mode", - help="Set operation mode", - callback=handlers.handle_set_mode_request, - args=["MODE"], - options=[], - examples=["nwp-cli mode heat-pump"], - ), - CliCommand( - name="power", - help="Turn device on or off", - callback=handlers.handle_power_request, - args=["STATE"], - options=[], - examples=["nwp-cli power on"], - ), - CliCommand( - name="temp", - help="Set target hot water temperature", - callback=handlers.handle_set_dhw_temp_request, - args=["VALUE"], - options=[], - examples=["nwp-cli temp 120"], - ), - CliCommand( - name="vacation", - help="Enable vacation mode for N days", - callback=handlers.handle_set_vacation_days_request, - args=["DAYS"], - options=[], - examples=["nwp-cli vacation 7"], - ), - CliCommand( - name="recirc", - help="Set recirculation pump mode", - callback=handlers.handle_set_recirculation_mode_request, - args=["MODE"], - options=[], - examples=["nwp-cli recirc 2"], - ), -] - - -def get_command(name: str) -> CliCommand | None: - """Lookup command by name.""" - return next((c for c in CLI_COMMANDS if c.name == name), None) - - -def list_commands() -> list[CliCommand]: - """Get all available commands.""" - return CLI_COMMANDS diff --git a/src/nwp500/cli/output_formatters.py b/src/nwp500/cli/output_formatters.py index 70c65c49..5652fd7f 100644 --- a/src/nwp500/cli/output_formatters.py +++ b/src/nwp500/cli/output_formatters.py @@ -897,13 +897,6 @@ def print_device_status(device_status: Any) -> None: ) ) - # Calculate widths dynamically - max_label_len = max((len(label) for _, label, _ in all_items), default=20) - max_value_len = max( - (len(str(value)) for _, _, value in all_items), default=20 - ) - _line_width = max_label_len + max_value_len + 4 # +4 for padding - # Use rich formatter for output formatter = get_formatter() formatter.print_status_table(all_items) @@ -1109,13 +1102,6 @@ def print_device_info(device_feature: Any) -> None: status = "Yes" if value else "No" all_items.append(("SUPPORTED FEATURES", label, status)) - # Calculate widths dynamically - max_label_len = max((len(label) for _, label, _ in all_items), default=20) - max_value_len = max( - (len(str(value)) for _, _, value in all_items), default=20 - ) - _line_width = max_label_len + max_value_len + 4 # +4 for padding - # Use rich formatter for output formatter = get_formatter() formatter.print_status_table(all_items) diff --git a/src/nwp500/converters.py b/src/nwp500/converters.py index d1778b2a..d9ec1167 100644 --- a/src/nwp500/converters.py +++ b/src/nwp500/converters.py @@ -19,7 +19,6 @@ "div_10", "mul_10", "enum_validator", - "str_enum_validator", ] @@ -103,8 +102,6 @@ def div_10(value: Any) -> float: >>> div_10(25.5) 2.55 """ - if isinstance(value, (int, float)): - return float(value) / 10.0 return float(value) / 10.0 @@ -126,8 +123,6 @@ def mul_10(value: Any) -> float: >>> mul_10(25.5) 255.0 """ - if isinstance(value, (int, float)): - return float(value) * 10.0 return float(value) * 10.0 @@ -159,33 +154,3 @@ def validate(value: Any) -> Any: return enum_class(int(value)) return validate - - -def str_enum_validator(enum_class: type[Any]) -> Callable[[Any], Any]: - """Create a validator for converting string to str-based Enum. - - Args: - enum_class: The str Enum class to validate against. - - Returns: - A validator function compatible with Pydantic BeforeValidator. - - Example: - >>> from enum import Enum - >>> class Status(str, Enum): - ... ACTIVE = "A" - ... INACTIVE = "I" - >>> validator = str_enum_validator(Status) - >>> validator("A") - - """ - - def validate(value: Any) -> Any: - """Validate and convert value to enum.""" - if isinstance(value, enum_class): - return value - if isinstance(value, str): - return enum_class(value) - return enum_class(str(value)) - - return validate diff --git a/src/nwp500/exceptions.py b/src/nwp500/exceptions.py index 39e0ae62..ab861efd 100644 --- a/src/nwp500/exceptions.py +++ b/src/nwp500/exceptions.py @@ -9,22 +9,17 @@ Nwp500Error (base) ├── AuthenticationError │ ├── InvalidCredentialsError - │ ├── TokenExpiredError │ └── TokenRefreshError ├── APIError ├── MqttError │ ├── MqttConnectionError │ ├── MqttNotConnectedError │ ├── MqttPublishError - │ ├── MqttSubscriptionError │ └── MqttCredentialsError ├── ValidationError │ ├── ParameterValidationError │ └── RangeValidationError └── DeviceError - ├── DeviceNotFoundError - ├── DeviceOfflineError - ├── DeviceOperationError └── DeviceCapabilityError Migration from v4.x @@ -181,16 +176,6 @@ class InvalidCredentialsError(AuthenticationError): pass -class TokenExpiredError(AuthenticationError): - """Raised when an authentication token has expired. - - Tokens have a limited lifetime and must be refreshed periodically. - This exception indicates that a token has passed its expiration time. - """ - - pass - - class TokenRefreshError(AuthenticationError): """Raised when token refresh operation fails. @@ -292,16 +277,6 @@ class MqttPublishError(MqttError): pass -class MqttSubscriptionError(MqttError): - """Failed to subscribe to MQTT topic. - - Raised when subscription to an MQTT topic fails. This may occur if the - connection is interrupted or if the client lacks permissions for the topic. - """ - - pass - - class MqttCredentialsError(MqttError): """AWS credentials invalid or expired. @@ -416,38 +391,6 @@ class DeviceError(Nwp500Error): pass -class DeviceNotFoundError(DeviceError): - """Requested device not found. - - Raised when a device cannot be found in the user's device list or - when attempting to access a non-existent device. - """ - - pass - - -class DeviceOfflineError(DeviceError): - """Device is offline or unreachable. - - Raised when a device is offline and cannot respond to commands or - status requests. The device may be powered off, disconnected from - the network, or experiencing connectivity issues. - """ - - pass - - -class DeviceOperationError(DeviceError): - """Device operation failed. - - Raised when a device operation (mode change, temperature setting, etc.) - fails. This may occur due to invalid commands, device restrictions, - or device-side errors. - """ - - pass - - class DeviceCapabilityError(DeviceError): """Device does not support a requested capability. diff --git a/src/nwp500/field_factory.py b/src/nwp500/field_factory.py index 1c6a8306..234e9b3f 100644 --- a/src/nwp500/field_factory.py +++ b/src/nwp500/field_factory.py @@ -34,6 +34,39 @@ ] +def _metadata_field( + description: str, + metadata: dict[str, Any], + default: Any, + kwargs: dict[str, Any], +) -> Any: + """Build a Pydantic Field with device metadata in json_schema_extra. + + Args: + description: Field description + metadata: Base json_schema_extra metadata for this field type + default: Default value or Pydantic default + kwargs: Additional Pydantic Field arguments; a caller-provided + json_schema_extra dict is merged over the base metadata + + Returns: + Pydantic Field with the merged metadata + """ + json_schema_extra = dict(metadata) + if "json_schema_extra" in kwargs: + extra = kwargs.pop("json_schema_extra") + if isinstance(extra, dict): + # Explicitly cast to dict[str, Any] for type safety + json_schema_extra.update(cast(dict[str, Any], extra)) + + return Field( + default=default, + description=description, + json_schema_extra=json_schema_extra, + **kwargs, + ) + + def temperature_field( description: str, *, @@ -60,23 +93,15 @@ def temperature_field( Returns: Pydantic Field with temperature metadata """ - json_schema_extra: dict[str, Any] = { - "unit_of_measurement": unit, - "device_class": "temperature", - "suggested_display_precision": 1, - } - if "json_schema_extra" in kwargs: - extra = kwargs.pop("json_schema_extra") - if isinstance(extra, dict): - # Explicitly cast to dict[str, Any] for type safety - typed_extra = cast(dict[str, Any], extra) - json_schema_extra.update(typed_extra) - - return Field( - default=default, - description=description, - json_schema_extra=json_schema_extra, - **kwargs, + return _metadata_field( + description, + { + "unit_of_measurement": unit, + "device_class": "temperature", + "suggested_display_precision": 1, + }, + default, + kwargs, ) @@ -98,22 +123,11 @@ def signal_strength_field( Returns: Pydantic Field with signal strength metadata """ - json_schema_extra: dict[str, Any] = { - "unit_of_measurement": unit, - "device_class": "signal_strength", - } - if "json_schema_extra" in kwargs: - extra = kwargs.pop("json_schema_extra") - if isinstance(extra, dict): - # Explicitly cast to dict[str, Any] for type safety - typed_extra = cast(dict[str, Any], extra) - json_schema_extra.update(typed_extra) - - return Field( - default=default, - description=description, - json_schema_extra=json_schema_extra, - **kwargs, + return _metadata_field( + description, + {"unit_of_measurement": unit, "device_class": "signal_strength"}, + default, + kwargs, ) @@ -135,22 +149,11 @@ def energy_field( Returns: Pydantic Field with energy metadata """ - json_schema_extra: dict[str, Any] = { - "unit_of_measurement": unit, - "device_class": "energy", - } - if "json_schema_extra" in kwargs: - extra = kwargs.pop("json_schema_extra") - if isinstance(extra, dict): - # Explicitly cast to dict[str, Any] for type safety - typed_extra = cast(dict[str, Any], extra) - json_schema_extra.update(typed_extra) - - return Field( - default=default, - description=description, - json_schema_extra=json_schema_extra, - **kwargs, + return _metadata_field( + description, + {"unit_of_measurement": unit, "device_class": "energy"}, + default, + kwargs, ) @@ -172,20 +175,9 @@ def power_field( Returns: Pydantic Field with power metadata """ - json_schema_extra: dict[str, Any] = { - "unit_of_measurement": unit, - "device_class": "power", - } - if "json_schema_extra" in kwargs: - extra = kwargs.pop("json_schema_extra") - if isinstance(extra, dict): - # Explicitly cast to dict[str, Any] for type safety - typed_extra = cast(dict[str, Any], extra) - json_schema_extra.update(typed_extra) - - return Field( - default=default, - description=description, - json_schema_extra=json_schema_extra, - **kwargs, + return _metadata_field( + description, + {"unit_of_measurement": unit, "device_class": "power"}, + default, + kwargs, ) diff --git a/src/nwp500/mqtt/client.py b/src/nwp500/mqtt/client.py index 38a63053..938bac51 100644 --- a/src/nwp500/mqtt/client.py +++ b/src/nwp500/mqtt/client.py @@ -13,10 +13,8 @@ import asyncio import concurrent.futures -import json import logging import uuid -import warnings from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any, cast @@ -885,25 +883,6 @@ async def disconnect(self) -> None: _logger.error(f"Error during disconnect: {e}") raise - def _on_message_received( - self, topic: str, payload: bytes, **kwargs: Any - ) -> None: - """Internal callback for received messages.""" - try: - # Parse JSON payload and delegate to subscription manager - _logger.debug("Received message on topic: %s", topic) - - # Call registered handlers via subscription manager - if self._subscription_manager: - # The subscription manager will handle matching - # and calling handlers - pass # Subscription manager handles this internally - - except json.JSONDecodeError as e: - _logger.error(f"Failed to parse message payload: {e}") - except (AttributeError, KeyError, TypeError) as e: - _logger.error(f"Error processing message: {e}") - async def subscribe( self, topic: str, @@ -1475,16 +1454,6 @@ def _control(self) -> MqttDeviceController: """ return self._device_controller - @property - @warnings.deprecated( - "The .control attribute is deprecated and will be removed in v9.0.0. " - "Use the delegated methods on NavienMqttClient directly (e.g., " - "client.set_power() instead of client.control.set_power())." - ) - def control(self) -> MqttDeviceController: - """Deprecated access to device controller.""" - return self._device_controller - async def start_periodic_requests( self, device: Device, diff --git a/src/nwp500/mqtt/control.py b/src/nwp500/mqtt/control.py index 1ebc7415..038abdd7 100644 --- a/src/nwp500/mqtt/control.py +++ b/src/nwp500/mqtt/control.py @@ -26,6 +26,7 @@ from ..command_decorators import requires_capability from ..config import MQTT_PROTOCOL_VERSION +from ..converters import device_bool_from_python from ..device_capabilities import MqttDeviceCapabilityChecker from ..device_info_cache import MqttDeviceInfoCache from ..enums import CommandCode, DhwOperationSetting @@ -460,7 +461,7 @@ async def update_reservations( # See docs/protocol/mqtt_protocol.rst "Reservation Management" for the # command code (16777226) and the reservation object fields # (enable, week, hour, min, mode, param). - reservation_use = 2 if enabled else 1 + reservation_use = device_bool_from_python(enabled) reservation_payload = [dict(entry) for entry in reservations] return await self._send_command( @@ -527,7 +528,7 @@ async def configure_tou_schedule( "At least one TOU period must be provided", parameter="periods" ) - reservation_use = 2 if enabled else 1 + reservation_use = device_bool_from_python(enabled) reservation_payload = [dict(period) for period in periods] return await self._send_command( diff --git a/src/nwp500/temperature.py b/src/nwp500/temperature.py index 09dd729d..d7276cac 100644 --- a/src/nwp500/temperature.py +++ b/src/nwp500/temperature.py @@ -10,14 +10,21 @@ from __future__ import annotations import math -from abc import ABC, abstractmethod -from typing import Any +from typing import ClassVar, Self from .enums import TempFormulaType -class Temperature(ABC): - """Base class for temperature conversions with device protocol support.""" +class Temperature: + """Base class for temperature conversions with device protocol support. + + Subclasses set ``_scale`` (raw device units per degree Celsius) and + inherit all conversions; only formats with special rounding or delta + semantics override the Fahrenheit conversions. + """ + + #: Raw device units per degree Celsius (2 = half-degrees, 10 = deci). + _scale: ClassVar[float] = 1.0 def __init__(self, raw_value: int | float): """Initialize with raw device value. @@ -27,21 +34,21 @@ def __init__(self, raw_value: int | float): """ self.raw_value = float(raw_value) - @abstractmethod def to_celsius(self) -> float: """Convert to Celsius. Returns: Temperature in Celsius. """ + return self.raw_value / self._scale - @abstractmethod def to_fahrenheit(self) -> float: """Convert to Fahrenheit. Returns: Temperature in Fahrenheit. """ + return self.to_celsius() * 9 / 5 + 32 def to_preferred(self, is_celsius: bool = False) -> float: """Convert to preferred unit (Celsius or Fahrenheit). @@ -55,8 +62,8 @@ def to_preferred(self, is_celsius: bool = False) -> float: return self.to_celsius() if is_celsius else self.to_fahrenheit() @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> Temperature: - """Create instance from Fahrenheit value (for commands). + def from_fahrenheit(cls, fahrenheit: float) -> Self: + """Create instance from Fahrenheit value (for device commands). Args: fahrenheit: Temperature in Fahrenheit. @@ -64,13 +71,12 @@ def from_fahrenheit(cls, fahrenheit: float) -> Temperature: Returns: Instance with raw value set for device command. """ - raise NotImplementedError( - f"{cls.__name__} does not support creation from Fahrenheit" - ) + celsius = (fahrenheit - 32) * 5 / 9 + return cls(round(celsius * cls._scale)) @classmethod - def from_celsius(cls, celsius: float) -> Temperature: - """Create instance from Celsius value (for commands). + def from_celsius(cls, celsius: float) -> Self: + """Create instance from Celsius value (for device commands). Args: celsius: Temperature in Celsius. @@ -78,14 +84,10 @@ def from_celsius(cls, celsius: float) -> Temperature: Returns: Instance with raw value set for device command. """ - raise NotImplementedError( - f"{cls.__name__} does not support creation from Celsius" - ) + return cls(round(celsius * cls._scale)) @classmethod - def from_preferred( - cls, value: float, is_celsius: bool = False - ) -> Temperature: + def from_preferred(cls, value: float, is_celsius: bool = False) -> Self: """Create instance from preferred unit (C or F). Args: @@ -114,61 +116,13 @@ class HalfCelsius(Temperature): 60.0 >>> temp.to_fahrenheit() 140.0 + >>> HalfCelsius.from_fahrenheit(140.0).raw_value + 120.0 + >>> HalfCelsius.from_celsius(60.0).raw_value + 120.0 """ - def to_celsius(self) -> float: - """Convert to Celsius. - - Returns: - Temperature in Celsius. - """ - return self.raw_value / 2.0 - - def to_fahrenheit(self) -> float: - """Convert to Fahrenheit. - - Returns: - Temperature in Fahrenheit. - """ - celsius = self.to_celsius() - return celsius * 9 / 5 + 32 - - @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> HalfCelsius: - """Create HalfCelsius from Fahrenheit (for device commands). - - Args: - fahrenheit: Temperature in Fahrenheit. - - Returns: - HalfCelsius instance with raw value for device. - - Example: - >>> temp = HalfCelsius.from_fahrenheit(140.0) - >>> temp.raw_value - 120.0 - """ - celsius = (fahrenheit - 32) * 5 / 9 - raw_value = round(celsius * 2) - return cls(raw_value) - - @classmethod - def from_celsius(cls, celsius: float) -> HalfCelsius: - """Create HalfCelsius from Celsius (for device commands). - - Args: - celsius: Temperature in Celsius. - - Returns: - HalfCelsius instance with raw value for device. - - Example: - >>> temp = HalfCelsius.from_celsius(60.0) - >>> temp.raw_value - 120.0 - """ - raw_value = round(celsius * 2) - return cls(raw_value) + _scale: ClassVar[float] = 2.0 class DeciCelsius(Temperature): @@ -183,61 +137,13 @@ class DeciCelsius(Temperature): 60.0 >>> temp.to_fahrenheit() 140.0 + >>> DeciCelsius.from_fahrenheit(140.0).raw_value + 600.0 + >>> DeciCelsius.from_celsius(60.0).raw_value + 600.0 """ - def to_celsius(self) -> float: - """Convert to Celsius. - - Returns: - Temperature in Celsius. - """ - return self.raw_value / 10.0 - - def to_fahrenheit(self) -> float: - """Convert to Fahrenheit. - - Returns: - Temperature in Fahrenheit. - """ - celsius = self.to_celsius() - return celsius * 9 / 5 + 32 - - @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> DeciCelsius: - """Create DeciCelsius from Fahrenheit (for device commands). - - Args: - fahrenheit: Temperature in Fahrenheit. - - Returns: - DeciCelsius instance with raw value for device. - - Example: - >>> temp = DeciCelsius.from_fahrenheit(140.0) - >>> temp.raw_value - 600.0 - """ - celsius = (fahrenheit - 32) * 5 / 9 - raw_value = round(celsius * 10) - return cls(raw_value) - - @classmethod - def from_celsius(cls, celsius: float) -> DeciCelsius: - """Create DeciCelsius from Celsius (for device commands). - - Args: - celsius: Temperature in Celsius. - - Returns: - DeciCelsius instance with raw value for device. - - Example: - >>> temp = DeciCelsius.from_celsius(60.0) - >>> temp.raw_value - 600.0 - """ - raw_value = round(celsius * 10) - return cls(raw_value) + _scale: ClassVar[float] = 10.0 class RawCelsius(Temperature): @@ -259,13 +165,7 @@ class RawCelsius(Temperature): 140.0 """ - def to_celsius(self) -> float: - """Convert to Celsius. - - Returns: - Temperature in Celsius. - """ - return self.raw_value / 2.0 + _scale: ClassVar[float] = 2.0 def to_fahrenheit(self) -> float: """Convert to Fahrenheit using standard rounding. @@ -274,8 +174,7 @@ def to_fahrenheit(self) -> float: Temperature in Fahrenheit (rounded to a whole degree, but returned as float for consistency with the base class API). """ - celsius = self.to_celsius() - return float(round((celsius * 9 / 5) + 32)) + return float(round(super().to_fahrenheit())) def to_fahrenheit_with_formula( self, formula_type: TempFormulaType @@ -288,8 +187,7 @@ def to_fahrenheit_with_formula( Returns: Temperature in Fahrenheit. """ - celsius = self.to_celsius() - fahrenheit_value = (celsius * 9 / 5) + 32 + fahrenheit_value = self.to_celsius() * 9 / 5 + 32 match formula_type: case TempFormulaType.ASYMMETRIC: @@ -310,75 +208,6 @@ def to_fahrenheit_with_formula( # Standard Rounding (default for STANDARD and any future types) return round(fahrenheit_value) - @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> RawCelsius: - """Create RawCelsius from Fahrenheit (for device commands). - - Args: - fahrenheit: Temperature in Fahrenheit. - - Returns: - RawCelsius instance with raw value for device. - - Example: - >>> temp = RawCelsius.from_fahrenheit(140.0) - >>> temp.raw_value - 120.0 - """ - celsius = (fahrenheit - 32) * 5 / 9 - raw_value = round(celsius * 2) - return cls(raw_value) - - @classmethod - def from_celsius(cls, celsius: float) -> RawCelsius: - """Create RawCelsius from Celsius (for device commands). - - Args: - celsius: Temperature in Celsius. - - Returns: - RawCelsius instance with raw value for device. - - Example: - >>> temp = RawCelsius.from_celsius(60.0) - >>> temp.raw_value - 120.0 - """ - raw_value = round(celsius * 2) - return cls(raw_value) - - -def half_celsius_to_fahrenheit(value: Any) -> float: - """Convert half-degrees Celsius to Fahrenheit. - - Validator function for Pydantic fields using HalfCelsius format. - - Args: - value: Raw device value in half-Celsius format. - - Returns: - Temperature in Fahrenheit. - """ - if isinstance(value, (int, float)): - return HalfCelsius(value).to_fahrenheit() - return float(value) - - -def deci_celsius_to_fahrenheit(value: Any) -> float: - """Convert decicelsius to Fahrenheit. - - Validator function for Pydantic fields using DeciCelsius format. - - Args: - value: Raw device value in decicelsius format. - - Returns: - Temperature in Fahrenheit. - """ - if isinstance(value, (int, float)): - return DeciCelsius(value).to_fahrenheit() - return float(value) - class DeciCelsiusDelta(Temperature): """Temperature delta in decicelsius (0.1°C precision). @@ -397,15 +226,13 @@ class DeciCelsiusDelta(Temperature): 0.5 >>> temp.to_fahrenheit() 0.9 + >>> DeciCelsiusDelta.from_fahrenheit(0.9).raw_value + 5.0 + >>> DeciCelsiusDelta.from_celsius(0.5).raw_value + 5.0 """ - def to_celsius(self) -> float: - """Convert to Celsius delta. - - Returns: - Temperature delta in Celsius. - """ - return self.raw_value / 10.0 + _scale: ClassVar[float] = 10.0 def to_fahrenheit(self) -> float: """Convert to Fahrenheit delta (without +32 offset). @@ -413,42 +240,17 @@ def to_fahrenheit(self) -> float: Returns: Temperature delta in Fahrenheit. """ - celsius = self.to_celsius() - return celsius * 9 / 5 + return self.to_celsius() * 9 / 5 @classmethod - def from_fahrenheit(cls, fahrenheit: float) -> DeciCelsiusDelta: - """Create DeciCelsiusDelta from Fahrenheit delta (for device commands). + def from_fahrenheit(cls, fahrenheit: float) -> Self: + """Create DeciCelsiusDelta from Fahrenheit delta (no -32 offset). Args: fahrenheit: Temperature delta in Fahrenheit. Returns: DeciCelsiusDelta instance with raw value for device. - - Example: - >>> temp = DeciCelsiusDelta.from_fahrenheit(0.9) - >>> temp.raw_value - 5.0 """ celsius = fahrenheit * 5 / 9 - raw_value = round(celsius * 10) - return cls(raw_value) - - @classmethod - def from_celsius(cls, celsius: float) -> DeciCelsiusDelta: - """Create DeciCelsiusDelta from Celsius delta (for device commands). - - Args: - celsius: Temperature delta in Celsius. - - Returns: - DeciCelsiusDelta instance with raw value for device. - - Example: - >>> temp = DeciCelsiusDelta.from_celsius(0.5) - >>> temp.raw_value - 5.0 - """ - raw_value = round(celsius * 10) - return cls(raw_value) + return cls(round(celsius * cls._scale)) diff --git a/tests/test_auth.py b/tests/test_auth.py index 62fa6e2a..53d0a8c9 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -15,7 +15,6 @@ from nwp500.exceptions import ( AuthenticationError, InvalidCredentialsError, - TokenExpiredError, TokenRefreshError, ) @@ -346,14 +345,6 @@ def test_invalid_credentials_error(): assert isinstance(error, AuthenticationError) -def test_token_expired_error(): - """Test TokenExpiredError exception.""" - error = TokenExpiredError("Token expired") - - assert str(error) == "Token expired" - assert isinstance(error, AuthenticationError) - - def test_token_refresh_error(): """Test TokenRefreshError exception.""" error = TokenRefreshError("Refresh failed", status_code=403) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 9aec279c..48078ef5 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -6,20 +6,15 @@ APIError, AuthenticationError, DeviceError, - DeviceNotFoundError, - DeviceOfflineError, - DeviceOperationError, InvalidCredentialsError, MqttConnectionError, MqttCredentialsError, MqttError, MqttNotConnectedError, MqttPublishError, - MqttSubscriptionError, Nwp500Error, ParameterValidationError, RangeValidationError, - TokenExpiredError, TokenRefreshError, ValidationError, ) @@ -39,7 +34,6 @@ def test_base_exception_hierarchy(self): def test_authentication_exception_hierarchy(self): """Test authentication exception inheritance.""" assert issubclass(InvalidCredentialsError, AuthenticationError) - assert issubclass(TokenExpiredError, AuthenticationError) assert issubclass(TokenRefreshError, AuthenticationError) def test_mqtt_exception_hierarchy(self): @@ -47,7 +41,6 @@ def test_mqtt_exception_hierarchy(self): assert issubclass(MqttConnectionError, MqttError) assert issubclass(MqttNotConnectedError, MqttError) assert issubclass(MqttPublishError, MqttError) - assert issubclass(MqttSubscriptionError, MqttError) assert issubclass(MqttCredentialsError, MqttError) def test_validation_exception_hierarchy(self): @@ -55,12 +48,6 @@ def test_validation_exception_hierarchy(self): assert issubclass(ParameterValidationError, ValidationError) assert issubclass(RangeValidationError, ValidationError) - def test_device_exception_hierarchy(self): - """Test device exception inheritance.""" - assert issubclass(DeviceNotFoundError, DeviceError) - assert issubclass(DeviceOfflineError, DeviceError) - assert issubclass(DeviceOperationError, DeviceError) - def test_all_inherit_from_base_exception(self): """Test that all custom exceptions inherit from Exception.""" assert issubclass(Nwp500Error, Exception) @@ -161,12 +148,6 @@ def test_invalid_credentials_error(self): assert error.message == "Invalid password" assert error.status_code == 401 - def test_token_expired_error(self): - """Test TokenExpiredError.""" - error = TokenExpiredError("Token has expired") - assert isinstance(error, AuthenticationError) - assert error.message == "Token has expired" - def test_token_refresh_error(self): """Test TokenRefreshError.""" error = TokenRefreshError("Refresh failed", status_code=400) @@ -214,11 +195,6 @@ def test_mqtt_publish_error(self): assert error.retriable is True assert error.error_code == "PUB_001" - def test_mqtt_subscription_error(self): - """Test MqttSubscriptionError.""" - error = MqttSubscriptionError("Subscribe failed") - assert isinstance(error, MqttError) - def test_mqtt_credentials_error(self): """Test MqttCredentialsError.""" error = MqttCredentialsError("AWS credentials expired") @@ -270,21 +246,6 @@ def test_range_validation_error_message(self): class TestDeviceExceptions: """Test device-related exceptions.""" - def test_device_not_found_error(self): - """Test DeviceNotFoundError.""" - error = DeviceNotFoundError("Device ABC123 not found") - assert isinstance(error, DeviceError) - - def test_device_offline_error(self): - """Test DeviceOfflineError.""" - error = DeviceOfflineError("Device is offline") - assert isinstance(error, DeviceError) - - def test_device_operation_error(self): - """Test DeviceOperationError.""" - error = DeviceOperationError("Failed to change mode") - assert isinstance(error, DeviceError) - class TestExceptionChaining: """Test exception chaining with 'from' clause.""" diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 00000000..e6438f9e --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,63 @@ +"""Tests for the public API surface of the nwp500 package.""" + +from __future__ import annotations + +import nwp500 + + +def test_all_names_resolve(): + """Every name in __all__ must exist on the package.""" + missing = [name for name in nwp500.__all__ if not hasattr(nwp500, name)] + assert missing == [] + + +def test_internal_plumbing_not_exported(): + """Internal helpers must not be part of the public surface.""" + internal = [ + "requires_capability", + "MqttDeviceInfoCache", + "MqttDeviceCapabilityChecker", + "log_performance", + "encode_week_bitfield", + "decode_week_bitfield", + "encode_season_bitfield", + "decode_season_bitfield", + "encode_price", + "decode_price", + "build_reservation_entry", + "build_tou_period", + ] + exported = [name for name in internal if name in nwp500.__all__] + assert exported == [] + + +def test_removed_exceptions_are_gone(): + """Dead exception classes were removed (never raised anywhere).""" + removed = [ + "TokenExpiredError", + "MqttSubscriptionError", + "DeviceNotFoundError", + "DeviceOfflineError", + "DeviceOperationError", + ] + still_present = [name for name in removed if hasattr(nwp500, name)] + assert still_present == [] + + +def test_encoding_helpers_importable_from_submodule(): + """Encoding helpers remain available from nwp500.encoding.""" + from nwp500.encoding import ( + build_reservation_entry, + build_tou_period, + decode_price, + decode_week_bitfield, + encode_price, + encode_week_bitfield, + ) + + assert callable(build_reservation_entry) + assert callable(build_tou_period) + assert callable(encode_price) + assert callable(decode_price) + assert callable(encode_week_bitfield) + assert callable(decode_week_bitfield)