diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b360f37..4dd76bc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,186 @@ Changelog ========= +Version 5.0.0 (2025-10-27) +========================== + +**BREAKING CHANGES**: This release introduces a comprehensive enterprise exception architecture. +See migration guide below for details on updating your code. + +Added +----- + +- **Enterprise Exception Architecture**: Complete exception hierarchy for better error handling + + - Created ``exceptions.py`` module with comprehensive exception hierarchy + - Added ``Nwp500Error`` as base exception for all library errors + - Added MQTT-specific exceptions: ``MqttError``, ``MqttConnectionError``, ``MqttNotConnectedError``, + ``MqttPublishError``, ``MqttSubscriptionError``, ``MqttCredentialsError`` + - Added validation exceptions: ``ValidationError``, ``ParameterValidationError``, ``RangeValidationError`` + - Added device exceptions: ``DeviceError``, ``DeviceNotFoundError``, ``DeviceOfflineError``, + ``DeviceOperationError`` + - All exceptions now include ``error_code``, ``details``, and ``retriable`` attributes + - Added ``to_dict()`` method to all exceptions for structured logging + - Added comprehensive test suite in ``tests/test_exceptions.py`` + - **Added comprehensive exception handling example** (``examples/exception_handling_example.py``) + - **Updated key examples** to demonstrate new exception handling patterns + +Changed +------- + +- **Exception Handling Improvements**: + + - All exception wrapping now uses exception chaining (``raise ... from e``) to preserve stack traces + - Replaced 19+ instances of ``RuntimeError("Not connected to MQTT broker")`` with ``MqttNotConnectedError`` + - Replaced ``ValueError`` in validation code with ``RangeValidationError`` and ``ParameterValidationError`` + - Replaced ``ValueError`` for credentials with ``MqttCredentialsError`` + - Replaced ``RuntimeError`` for connection issues with ``MqttConnectionError`` + - Enhanced ``AwsCrtError`` wrapping in MQTT code with proper exception chaining + - Moved authentication exceptions from ``auth.py`` to ``exceptions.py`` + - Moved ``APIError`` from ``api_client.py`` to ``exceptions.py`` + - **CLI now handles specific exception types** with better error messages and user guidance + +Migration Guide (v4.x to v5.0) +------------------------------- + +**Breaking Changes Summary**: + +The library now uses specific exception types instead of generic ``RuntimeError`` and ``ValueError``. +This improves error handling but requires updates to exception handling code. + +**1. MQTT Connection Errors** + +.. code-block:: python + + # OLD CODE (v4.x) - will break + try: + await mqtt_client.request_device_status(device) + except RuntimeError as e: + if "Not connected" in str(e): + await mqtt_client.connect() + + # NEW CODE (v5.0+) + from nwp500 import MqttNotConnectedError, MqttError + + try: + await mqtt_client.request_device_status(device) + except MqttNotConnectedError: + # Handle not connected - attempt reconnection + await mqtt_client.connect() + await mqtt_client.request_device_status(device) + except MqttError as e: + # Handle other MQTT errors + logger.error(f"MQTT error: {e}") + +**2. Validation Errors** + +.. code-block:: python + + # OLD CODE (v4.x) - will break + try: + set_vacation_mode(device, days=35) + except ValueError as e: + print(f"Invalid input: {e}") + + # NEW CODE (v5.0+) + from nwp500 import RangeValidationError, ValidationError + + try: + set_vacation_mode(device, days=35) + except RangeValidationError as e: + # Access structured error information + print(f"Invalid {e.field}: must be {e.min_value}-{e.max_value}") + print(f"You provided: {e.value}") + except ValidationError as e: + # Handle other validation errors + print(f"Validation error: {e}") + +**3. AWS Credentials Errors** + +.. code-block:: python + + # OLD CODE (v4.x) - will break + try: + mqtt_client = NavienMqttClient(auth_client) + except ValueError as e: + if "credentials" in str(e).lower(): + # handle missing credentials + + # NEW CODE (v5.0+) + from nwp500 import MqttCredentialsError + + try: + mqtt_client = NavienMqttClient(auth_client) + except MqttCredentialsError as e: + # Handle missing or invalid AWS credentials + logger.error(f"Credentials error: {e}") + await re_authenticate() + +**4. Catching All Library Errors** + +.. code-block:: python + + # NEW CODE (v5.0+) - catch all library exceptions + from nwp500 import Nwp500Error + + try: + # Any library operation + await mqtt_client.request_device_status(device) + except Nwp500Error as e: + # All nwp500 exceptions inherit from Nwp500Error + logger.error(f"Library error: {e.to_dict()}") + + # Check if retriable + if e.retriable: + await retry_operation() + +**5. Enhanced Error Information** + +All exceptions now include structured information: + +.. code-block:: python + + from nwp500 import MqttPublishError + + try: + await mqtt_client.publish(topic, payload) + except MqttPublishError as e: + # Access structured error information + error_info = e.to_dict() + # { + # 'error_type': 'MqttPublishError', + # 'message': 'Publish failed', + # 'error_code': 'AWS_ERROR_...', + # 'details': {}, + # 'retriable': True + # } + + # Log for monitoring/alerting + logger.error("Publish failed", extra=error_info) + + # Implement retry logic + if e.retriable: + await asyncio.sleep(1) + await mqtt_client.publish(topic, payload) + +**Quick Migration Strategy**: + +1. Import new exception types: ``from nwp500 import MqttNotConnectedError, MqttError, ValidationError`` +2. Replace ``except RuntimeError`` with ``except MqttNotConnectedError`` for connection checks +3. Replace ``except ValueError`` with ``except ValidationError`` for parameter validation +4. Use ``except Nwp500Error`` to catch all library errors +5. Test error handling paths thoroughly + +**Benefits of New Architecture**: + +- Specific exception types for specific errors (no more string matching) +- Preserved stack traces with exception chaining (``from e``) +- Structured error information via ``to_dict()`` +- Retriable flag for implementing retry logic +- Better integration with monitoring/logging systems +- Type-safe error handling +- Clearer API documentation + Version 4.8.0 (2025-10-27) ========================== diff --git a/docs/configuration.rst b/docs/configuration.rst index 9f9e459..790e804 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -42,13 +42,17 @@ Then in your code: .. code-block:: python import os - from nwp500 import NavienAuthClient + from nwp500 import NavienAuthClient, InvalidCredentialsError email = os.getenv("NAVIEN_EMAIL") password = os.getenv("NAVIEN_PASSWORD") - async with NavienAuthClient(email, password) as auth: - # ... + try: + async with NavienAuthClient(email, password) as auth: + # ... + except InvalidCredentialsError: + print("Invalid email or password") + # Check credentials Configuration File ------------------ diff --git a/docs/python_api/exceptions.rst b/docs/python_api/exceptions.rst index 1a3c295..ebdebb7 100644 --- a/docs/python_api/exceptions.rst +++ b/docs/python_api/exceptions.rst @@ -2,20 +2,89 @@ Exceptions ========== -Exception classes for error handling in the nwp500 library. +**New in v5.0:** Complete exception architecture with enterprise-grade error handling. + +The nwp500 library provides a comprehensive exception hierarchy for robust error handling. +All custom exceptions inherit from a base class and provide structured error information. + +Exception Hierarchy +=================== + +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 + +Base Exception +============== + +Nwp500Error +----------- + +.. py:class:: Nwp500Error(message, *, error_code=None, details=None, retriable=False) + + Base exception for all nwp500 library errors. + + All custom exceptions in the library inherit from this base class, allowing + consumers to catch all library-specific errors with a single handler. + + :param message: Human-readable error message + :type message: str + :param error_code: Machine-readable error code (optional) + :type error_code: str or None + :param details: Additional context as dictionary (optional) + :type details: dict or None + :param retriable: Whether the operation can be retried (optional) + :type retriable: bool + + **Attributes:** -Overview -======== + * ``message`` (str) - Human-readable error message + * ``error_code`` (str or None) - Machine-readable error code + * ``details`` (dict) - Additional context + * ``retriable`` (bool) - Whether operation can be retried -The library provides specific exception types for different error -scenarios: + **Methods:** -* **Authentication errors** - Sign-in, token refresh failures -* **API errors** - REST API request failures -* **MQTT errors** - Connection and communication issues + * ``to_dict()`` - Serialize exception for logging/monitoring + + **Example - Catching all library errors:** + + .. code-block:: python -All exceptions inherit from Python's base ``Exception`` class and -provide additional context through attributes. + from nwp500 import NavienMqttClient, Nwp500Error + + try: + mqtt = NavienMqttClient(auth) + await mqtt.connect() + await mqtt.request_device_status(device) + except Nwp500Error as e: + # Catches all library exceptions + print(f"Library error: {e}") + + # Check if retriable + if e.retriable: + print("This operation can be retried") + + # Log structured data + logger.error("Operation failed", extra=e.to_dict()) Authentication Exceptions ========================= @@ -23,17 +92,15 @@ Authentication Exceptions AuthenticationError ------------------- -Base exception for all authentication-related errors. +.. py:class:: AuthenticationError(message, status_code=None, response=None, **kwargs) -.. py:class:: AuthenticationError(message, status_code=None, response=None) - - Base class for authentication failures. + Base exception for authentication-related errors. :param message: Error description :type message: str - :param status_code: HTTP status code if available + :param status_code: HTTP status code (optional) :type status_code: int or None - :param response: Complete API response dictionary + :param response: Complete API response dictionary (optional) :type response: dict or None **Attributes:** @@ -42,92 +109,61 @@ Base exception for all authentication-related errors. * ``status_code`` (int or None) - HTTP status code * ``response`` (dict or None) - Full API response - **Example:** - - .. code-block:: python - - from nwp500 import AuthenticationError - - try: - async with NavienAuthClient(email, password) as auth: - # Operations - pass - except AuthenticationError as e: - print(f"Auth failed: {e.message}") - if e.status_code: - print(f"Status code: {e.status_code}") - if e.response: - print(f"Response: {e.response}") - InvalidCredentialsError ----------------------- -Raised when email/password combination is incorrect. - .. py:class:: InvalidCredentialsError - Subclass of :py:class:`AuthenticationError`. + Raised when email/password combination is incorrect. - Raised during ``sign_in()`` when credentials are rejected. + Subclass of :py:class:`AuthenticationError`. Typically indicates a 401 + Unauthorized response from the API. **Example:** .. code-block:: python - from nwp500 import InvalidCredentialsError + from nwp500 import NavienAuthClient, InvalidCredentialsError try: - await auth.sign_in("wrong@email.com", "wrong_password") - except InvalidCredentialsError: - print("Invalid email or password") + async with NavienAuthClient(email, password) as auth: + pass + except InvalidCredentialsError as e: + print(f"Invalid credentials: {e}") + print("Please check your email and password") # Prompt user to re-enter credentials TokenExpiredError ----------------- -Raised when an authentication token has expired. - .. py:class:: TokenExpiredError - Subclass of :py:class:`AuthenticationError`. + Raised when an authentication token has expired. - Usually raised when token refresh fails and re-authentication is - required. - - **Example:** - - .. code-block:: python - - from nwp500 import TokenExpiredError - - try: - await api.list_devices() - except TokenExpiredError: - print("Token expired - please sign in again") - # Re-authenticate + Subclass of :py:class:`AuthenticationError`. Tokens have a limited lifetime + and must be refreshed periodically. TokenRefreshError ----------------- -Raised when automatic token refresh fails. - .. py:class:: TokenRefreshError - Subclass of :py:class:`AuthenticationError`. + Raised when token refresh operation fails. - Occurs when refresh token is invalid or expired, requiring new - sign-in. + Subclass of :py:class:`AuthenticationError`. Occurs when refresh token is + invalid or expired, requiring full re-authentication. **Example:** .. code-block:: python - from nwp500 import TokenRefreshError + from nwp500 import NavienAuthClient, TokenRefreshError try: await auth.ensure_valid_token() - except TokenRefreshError: - print("Cannot refresh token - signing in again") + except TokenRefreshError as e: + print(f"Token refresh failed: {e}") + print("Re-authenticating with fresh credentials") await auth.sign_in(email, password) API Exceptions @@ -136,25 +172,17 @@ API Exceptions APIError -------- -Raised when REST API returns an error response. - -.. py:class:: APIError(message, code=None, response=None) +.. py:class:: APIError(message, code=None, response=None, **kwargs) - Exception for REST API failures. + Raised when REST API returns an error response. :param message: Error description :type message: str - :param code: HTTP or API error code + :param code: HTTP or API error code (optional) :type code: int or None - :param response: Complete API response dictionary + :param response: Complete API response dictionary (optional) :type response: dict or None - **Attributes:** - - * ``message`` (str) - Error message - * ``code`` (int or None) - HTTP/API error code - * ``response`` (dict or None) - Full API response - **Common HTTP codes:** * 400 - Bad request (invalid parameters) @@ -168,14 +196,13 @@ Raised when REST API returns an error response. .. code-block:: python - from nwp500 import APIError + from nwp500 import NavienAPIClient, APIError try: device = await api.get_device_info("invalid_mac") except APIError as e: print(f"API error: {e.message}") - print(f"Code: {e.code}") - + if e.code == 404: print("Device not found") elif e.code == 401: @@ -186,54 +213,232 @@ Raised when REST API returns an error response. MQTT Exceptions =============== -MQTT-related errors typically manifest as Python exceptions from the -underlying ``awscrt`` and ``awsiot`` libraries. +MqttError +--------- -Common MQTT Errors ------------------- +.. py:class:: MqttError -**Connection Failures:** + Base exception for MQTT operations. -* ``ConnectionError`` - Failed to connect to AWS IoT Core -* ``TimeoutError`` - Connection attempt timed out -* ``ssl.SSLError`` - TLS/SSL handshake failed + All MQTT-related errors inherit from this base class, allowing consumers + to handle all MQTT issues with a single exception handler. -**Authentication Failures:** +MqttConnectionError +------------------- -* ``Exception`` with "unauthorized" - Invalid AWS credentials -* ``Exception`` with "forbidden" - AWS policy denies access +.. py:class:: MqttConnectionError -**Network Errors:** + Connection establishment or maintenance failed. -* ``OSError`` - Network interface issues -* ``socket.error`` - Socket-level errors + Raised when the MQTT connection to AWS IoT Core cannot be established or + when an existing connection fails. May be due to network issues, invalid + credentials, or AWS service problems. -Example MQTT Error Handling ----------------------------- + **Example:** -.. code-block:: python + .. code-block:: python - from nwp500 import NavienMqttClient - import asyncio + from nwp500 import NavienMqttClient, MqttConnectionError + + try: + mqtt = NavienMqttClient(auth) + await mqtt.connect() + except MqttConnectionError as e: + print(f"Connection failed: {e}") + print("Check network connectivity and AWS credentials") - async def safe_mqtt_connect(): - mqtt = NavienMqttClient(auth) +MqttNotConnectedError +--------------------- - try: - await mqtt.connect() - print("Connected successfully") +.. py:class:: MqttNotConnectedError + + Operation requires active MQTT connection. + + Raised when attempting MQTT operations (publish, subscribe, etc.) without + an established connection. Call ``connect()`` before performing operations. + + **Example:** + + .. code-block:: python + + from nwp500 import NavienMqttClient, MqttNotConnectedError + + mqtt = NavienMqttClient(auth) + + try: + await mqtt.request_device_status(device) + except MqttNotConnectedError: + # Not connected - establish connection first + await mqtt.connect() + await mqtt.request_device_status(device) + +MqttPublishError +---------------- + +.. py:class:: MqttPublishError + + Failed to publish message to MQTT broker. - except ConnectionError as e: - print(f"Connection failed: {e}") - # Check network, credentials + Raised when a message cannot be published to an MQTT topic. This may occur + during connection interruptions or when the broker rejects the message. - except TimeoutError: - print("Connection timed out") - # Retry with longer timeout + Often includes ``retriable=True`` flag for intelligent retry strategies. - except Exception as e: - print(f"Unexpected error: {e}") - # Log for debugging + **Example with retry:** + + .. code-block:: python + + from nwp500 import MqttPublishError + import asyncio + + async def publish_with_retry(mqtt, topic, payload, max_retries=3): + for attempt in range(max_retries): + try: + await mqtt.publish(topic, payload) + return # Success + except MqttPublishError as e: + if e.retriable and attempt < max_retries - 1: + wait_time = 2 ** attempt # Exponential backoff + print(f"Retry in {wait_time}s...") + await asyncio.sleep(wait_time) + 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 +-------------------- + +.. py:class:: MqttCredentialsError + + AWS credentials invalid or expired. + + Raised when AWS IoT credentials are missing, invalid, or expired. + Re-authentication may be required to obtain fresh credentials. + + **Example:** + + .. code-block:: python + + from nwp500 import NavienMqttClient, MqttCredentialsError + + try: + mqtt = NavienMqttClient(auth) + except MqttCredentialsError as e: + print(f"Credentials error: {e}") + print("Re-authenticating to get fresh AWS credentials") + await auth.sign_in(email, password) + +Validation Exceptions +===================== + +ValidationError +--------------- + +.. py:class:: ValidationError + + Base exception for validation failures. + + Raised when input parameters or data fail validation checks. + +ParameterValidationError +------------------------ + +.. py:class:: ParameterValidationError(message, parameter=None, value=None, **kwargs) + + Invalid parameter value provided. + + Raised when a parameter value is invalid for reasons other than being + out of range (e.g., wrong type, invalid format). + + :param parameter: Name of the invalid parameter + :type parameter: str or None + :param value: The invalid value provided + :type value: Any + +RangeValidationError +-------------------- + +.. py:class:: RangeValidationError(message, field=None, value=None, min_value=None, max_value=None, **kwargs) + + Value outside acceptable range. + + Raised when a numeric value is outside its valid range. + + :param field: Name of the field + :type field: str or None + :param value: The invalid value provided + :type value: Any + :param min_value: Minimum acceptable value + :type min_value: Any + :param max_value: Maximum acceptable value + :type max_value: Any + + **Example:** + + .. code-block:: python + + from nwp500 import NavienMqttClient, RangeValidationError + + try: + await mqtt.set_dhw_temperature(device, temperature=200) + except RangeValidationError as e: + print(f"Invalid {e.field}: {e.value}") + print(f"Valid range: {e.min_value} to {e.max_value}") + # Output: Invalid temperature: 200 + # Valid range: 100 to 140 + +Device Exceptions +================= + +DeviceError +----------- + +.. py:class:: DeviceError + + Base exception for device operations. + + 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. Error Handling Patterns ======================= @@ -241,160 +446,223 @@ Error Handling Patterns Pattern 1: Specific Exception Handling --------------------------------------- +Handle specific exception types for granular control: + .. code-block:: python from nwp500 import ( NavienAuthClient, + NavienMqttClient, InvalidCredentialsError, - TokenExpiredError, - APIError + MqttNotConnectedError, + RangeValidationError, ) async def robust_operation(): try: async with NavienAuthClient(email, password) as auth: - api = NavienAPIClient(auth) - devices = await api.list_devices() - return devices - + mqtt = NavienMqttClient(auth) + await mqtt.connect() + + await mqtt.set_dhw_temperature(device, temperature=120) + except InvalidCredentialsError: - print("Invalid credentials") - # Re-prompt user - - except TokenExpiredError: - print("Token expired") - # Force re-authentication - - except APIError as e: - if e.code == 429: - print("Rate limited - waiting...") - await asyncio.sleep(60) - # Retry - else: - print(f"API error: {e.message}") - - except Exception as e: - print(f"Unexpected error: {e}") - # Log and notify - -Pattern 2: Base Exception Handling + print("Invalid credentials - check email/password") + + except MqttNotConnectedError: + print("MQTT not connected - device may be offline") + + except RangeValidationError as e: + print(f"Invalid {e.field}: {e.value}") + print(f"Valid range: {e.min_value} to {e.max_value}") + +Pattern 2: Category-Based Handling ----------------------------------- -.. code-block:: python - - from nwp500 import AuthenticationError, APIError - - async def simple_handling(): - try: - async with NavienAuthClient(email, password) as auth: - api = NavienAPIClient(auth) - return await api.list_devices() +Catch exception categories (Auth, MQTT, Validation): - except AuthenticationError as e: - # Handles all auth errors - print(f"Authentication failed: {e.message}") - return None +.. code-block:: python - except APIError as e: - # Handles all API errors - print(f"API request failed: {e.message}") - return None + from nwp500 import ( + AuthenticationError, + MqttError, + ValidationError, + Nwp500Error, + ) -Pattern 3: Retry Logic ------------------------ + try: + # Operations + pass + + except AuthenticationError as e: + print(f"Authentication failed: {e}") + # Re-authenticate + + except MqttError as e: + print(f"MQTT error: {e}") + # Check connection + + except ValidationError as e: + print(f"Invalid input: {e}") + # Fix parameters + +Pattern 3: Retry Logic with retriable Flag +------------------------------------------- + +Implement intelligent retry strategies: .. code-block:: python - from nwp500 import APIError + from nwp500 import MqttPublishError import asyncio - async def retry_on_failure(max_retries=3): + async def operation_with_retry(max_retries=3): for attempt in range(max_retries): try: - async with NavienAuthClient(email, password) as auth: - api = NavienAPIClient(auth) - return await api.list_devices() - - except APIError as e: - if e.code >= 500: - # Server error - retry - print(f"Attempt {attempt + 1} failed: {e.message}") - if attempt < max_retries - 1: - await asyncio.sleep(2 ** attempt) # Exponential backoff - else: - raise # Give up after max retries + await mqtt.publish(topic, payload) + return # Success + + except MqttPublishError as e: + if e.retriable and attempt < max_retries - 1: + wait_time = 2 ** attempt # Exponential backoff + print(f"Attempt {attempt + 1} failed, retrying in {wait_time}s") + await asyncio.sleep(wait_time) else: - # Client error - don't retry + print(f"Operation failed: {e}") raise -Pattern 4: Graceful Degradation --------------------------------- +Pattern 4: Structured Logging +------------------------------ + +Use ``to_dict()`` for structured error logging: .. code-block:: python - from nwp500 import APIError, AuthenticationError + import logging + from nwp500 import Nwp500Error - async def with_fallback(): - try: - async with NavienAuthClient(email, password) as auth: - api = NavienAPIClient(auth) - devices = await api.list_devices() - return devices + logger = logging.getLogger(__name__) + + try: + await mqtt.request_device_status(device) + except Nwp500Error as e: + # Log structured error data + logger.error("Operation failed", extra=e.to_dict()) + # Output includes: error_type, message, error_code, details, retriable - except AuthenticationError: - print("Cannot authenticate - using cached data") - return load_cached_devices() +Pattern 5: Catch-All with Base Exception +----------------------------------------- + +Catch all library exceptions with ``Nwp500Error``: + +.. code-block:: python + + from nwp500 import Nwp500Error + + try: + # Any library operation + await mqtt.connect() + await mqtt.request_device_status(device) + + except Nwp500Error as e: + # All nwp500 exceptions inherit from Nwp500Error + print(f"Library error: {e}") + + # Check if retriable + if e.retriable: + print("This operation can be retried") + + # Log for debugging + logger.error("Operation failed", extra=e.to_dict()) + +Exception Chaining +================== + +**New in v5.0:** All exception wrapping preserves the original exception chain. + +When the library wraps exceptions (e.g., wrapping ``aiohttp.ClientError`` in +``AuthenticationError``), the original exception is preserved using Python's +``raise ... from`` syntax. + +**Example - Inspecting exception chains:** + +.. code-block:: python - except APIError: - print("API unavailable - using cached data") - return load_cached_devices() + from nwp500 import AuthenticationError + import aiohttp + + try: + async with NavienAuthClient(email, password) as auth: + pass + except AuthenticationError as e: + print(f"Authentication error: {e}") + + # Check for original cause + if e.__cause__: + print(f"Original error: {e.__cause__}") + print(f"Original type: {type(e.__cause__).__name__}") + + # Was it a network error? + if isinstance(e.__cause__, aiohttp.ClientError): + print("Network connectivity issue") + +This preserves full stack traces for debugging in production. Best Practices ============== -1. **Catch specific exceptions first:** +1. **Catch specific exceptions first, then general:** .. code-block:: python try: - await auth.sign_in(email, password) - except InvalidCredentialsError: - # Handle specifically + await mqtt.connect() + except MqttNotConnectedError: + # Handle specific case pass - except AuthenticationError: - # Handle generally + except MqttError: + # Handle general MQTT errors pass - except Exception: - # Handle anything else + except Nwp500Error: + # Handle any library error pass -2. **Use exception attributes:** +2. **Use exception attributes for user-friendly messages:** .. code-block:: python try: - await api.list_devices() - except APIError as e: - # Use error details - log.error(f"API error: {e.message}") - log.error(f"Code: {e.code}") - log.debug(f"Response: {e.response}") + await mqtt.set_dhw_temperature(device, temperature=200) + except RangeValidationError as e: + # Show helpful message + print(f"Temperature must be between {e.min_value}°F and {e.max_value}°F") -3. **Implement retry logic for transient errors:** +3. **Check retriable flag before retrying:** .. code-block:: python - async def with_retry(func, max_attempts=3): - for i in range(max_attempts): - try: - return await func() - except APIError as e: - if e.code >= 500 and i < max_attempts - 1: - await asyncio.sleep(2 ** i) - else: - raise + try: + await mqtt.publish(topic, payload) + except MqttPublishError as e: + if e.retriable: + # Safe to retry + await asyncio.sleep(1) + await mqtt.publish(topic, payload) + else: + # Don't retry + raise + +4. **Use to_dict() for monitoring/logging:** -4. **Always cleanup resources:** + .. code-block:: python + + try: + await operation() + except Nwp500Error as e: + # Send structured data to monitoring system + monitoring.record_exception(e.to_dict()) + +5. **Always cleanup resources:** .. code-block:: python @@ -402,24 +670,39 @@ Best Practices try: await mqtt.connect() # Operations - except Exception as e: + except Nwp500Error as e: print(f"Error: {e}") finally: await mqtt.disconnect() -5. **Log for debugging:** +Migration from v4.x +=================== - .. code-block:: python +If upgrading from v4.x, update your exception handling: - import logging +**Before (v4.x):** - try: - await api.list_devices() - except APIError as e: - logging.error(f"API error: {e.message}", extra={ - 'code': e.code, - 'response': e.response - }) +.. code-block:: python + + try: + await mqtt.request_device_status(device) + except RuntimeError as e: + if "Not connected" in str(e): + await mqtt.connect() + +**After (v5.0+):** + +.. code-block:: python + + from nwp500 import MqttNotConnectedError + + try: + await mqtt.request_device_status(device) + except MqttNotConnectedError: + await mqtt.connect() + await mqtt.request_device_status(device) + +See the CHANGELOG.rst for complete migration guide with more examples. Related Documentation ===================== @@ -427,3 +710,4 @@ Related Documentation * :doc:`auth_client` - Authentication client * :doc:`api_client` - REST API client * :doc:`mqtt_client` - MQTT client +* Complete example: ``examples/exception_handling_example.py`` diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 6af7704..cb50e23 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -228,7 +228,7 @@ Then in your code: .. code-block:: python import os - from nwp500 import NavienAuthClient, NavienAPIClient + from nwp500 import NavienAuthClient, InvalidCredentialsError async def main(): email = os.getenv("NAVIEN_EMAIL") @@ -240,10 +240,14 @@ Then in your code: "environment variables" ) - async with NavienAuthClient(email, password) as auth: - api = NavienAPIClient(auth) - devices = await api.list_devices() - # ... + try: + async with NavienAuthClient(email, password) as auth: + api = NavienAPIClient(auth) + devices = await api.list_devices() + # ... + except InvalidCredentialsError: + print("Invalid email or password") + # Re-prompt for credentials Next Steps ========== diff --git a/examples/api_client_example.py b/examples/api_client_example.py index cbfe733..2c72877 100644 --- a/examples/api_client_example.py +++ b/examples/api_client_example.py @@ -22,8 +22,11 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) from nwp500 import NavienAPIClient -from nwp500.api_client import APIError -from nwp500.auth import AuthenticationError, NavienAuthClient +from nwp500.auth import NavienAuthClient +from nwp500.exceptions import ( + APIError, + AuthenticationError, +) import re diff --git a/examples/authenticate.py b/examples/authenticate.py index 275d60b..d77d02d 100755 --- a/examples/authenticate.py +++ b/examples/authenticate.py @@ -21,10 +21,10 @@ if __name__ == "__main__": sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) -from nwp500.auth import ( +from nwp500.auth import NavienAuthClient +from nwp500.exceptions import ( AuthenticationError, InvalidCredentialsError, - NavienAuthClient, ) diff --git a/examples/exception_handling_example.py b/examples/exception_handling_example.py new file mode 100755 index 0000000..66ba01a --- /dev/null +++ b/examples/exception_handling_example.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +Example: Comprehensive Exception Handling (v5.0+) + +This example demonstrates best practices for exception handling with the +new exception architecture introduced in nwp500-python v5.0. + +Features demonstrated: +1. Specific exception handling for different error types +2. Using exception attributes (error_code, retriable, etc.) +3. Implementing retry logic with retriable exceptions +4. Structured error logging with to_dict() +5. User-friendly error messages +6. Exception chaining inspection +""" + +import asyncio +import json +import logging +import os +import sys + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) + +# If running from examples directory, add parent to path +if __name__ == "__main__": + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from nwp500 import NavienAPIClient, NavienMqttClient +from nwp500.auth import NavienAuthClient +from nwp500.exceptions import ( + AuthenticationError, + InvalidCredentialsError, + MqttConnectionError, + MqttError, + MqttNotConnectedError, + MqttPublishError, + Nwp500Error, + RangeValidationError, + TokenRefreshError, + ValidationError, +) + +logger = logging.getLogger(__name__) + + +async def example_authentication_errors(): + """Demonstrate authentication error handling.""" + print("\n" + "=" * 70) + print("EXAMPLE 1: Authentication Error Handling") + print("=" * 70) + + # Intentionally use invalid credentials + try: + async with NavienAuthClient("invalid@example.com", "wrong_password") as _: + pass + except InvalidCredentialsError as e: + print(f"✓ Caught InvalidCredentialsError: {e}") + print(f" Status code: {e.status_code}") + print(" Can check credentials and retry") + + except TokenRefreshError as e: + print(f"✓ Caught TokenRefreshError: {e}") + print(" Need to re-authenticate with fresh credentials") + + except AuthenticationError as e: + print(f"✓ Caught AuthenticationError: {e}") + print(" General authentication failure") + + # Show structured error data + error = InvalidCredentialsError("Invalid email or password", status_code=401) + print(f"\nStructured error data: {json.dumps(error.to_dict(), indent=2)}") + + +async def example_mqtt_errors(): + """Demonstrate MQTT error handling.""" + print("\n" + "=" * 70) + print("EXAMPLE 2: MQTT Error Handling") + print("=" * 70) + + email = os.getenv("NAVIEN_EMAIL", "your_email@example.com") + password = os.getenv("NAVIEN_PASSWORD", "your_password") + + if email == "your_email@example.com": + print("⚠️ Set NAVIEN_EMAIL and NAVIEN_PASSWORD to run this example") + return + + try: + async with NavienAuthClient(email, password) as auth_client: + mqtt = NavienMqttClient(auth_client) + await mqtt.connect() + + # Get first device + api = NavienAPIClient(auth_client) + devices = await api.list_devices() + if not devices: + print("No devices found") + return + + device = devices[0] + + # Intentionally disconnect and try to use MQTT + await mqtt.disconnect() + + try: + await mqtt.request_device_status(device) + except MqttNotConnectedError as e: + print(f"✓ Caught MqttNotConnectedError: {e}") + print(" Can reconnect and retry the operation") + + except MqttConnectionError as e: + print(f"✓ Caught MqttConnectionError: {e}") + print(f" Error code: {e.error_code}") + print(" Network or AWS IoT connection issue") + + except MqttPublishError as e: + print(f"✓ Caught MqttPublishError: {e}") + if e.retriable: + print(" ✓ This error is retriable!") + print(" Can implement exponential backoff retry") + + except MqttError as e: + print(f"✓ Caught MqttError (base class): {e}") + print(" Catches all MQTT-related errors") + + +async def example_validation_errors(): + """Demonstrate validation error handling.""" + print("\n" + "=" * 70) + print("EXAMPLE 3: Validation Error Handling") + print("=" * 70) + + email = os.getenv("NAVIEN_EMAIL", "your_email@example.com") + password = os.getenv("NAVIEN_PASSWORD", "your_password") + + if email == "your_email@example.com": + print("⚠️ Set NAVIEN_EMAIL and NAVIEN_PASSWORD to run this example") + return + + try: + async with NavienAuthClient(email, password) as auth_client: + mqtt = NavienMqttClient(auth_client) + await mqtt.connect() + + api = NavienAPIClient(auth_client) + devices = await api.list_devices() + if not devices: + print("No devices found") + return + + device = devices[0] + + # Try to set invalid vacation days + try: + await mqtt.set_dhw_operation_setting( + device, mode_id=5, vacation_days=50 + ) + except RangeValidationError as e: + print(f"✓ Caught RangeValidationError: {e}") + print(f" Field: {e.field}") + print(f" Invalid value: {e.value}") + print(f" Valid range: {e.min_value} to {e.max_value}") + print(" Can show user-friendly error message!") + + except ValidationError as e: + print(f"✓ Caught ValidationError (base class): {e}") + + await mqtt.disconnect() + + except Nwp500Error as e: + print(f"Caught library error: {e}") + + +async def example_retry_logic(): + """Demonstrate retry logic with retriable exceptions.""" + print("\n" + "=" * 70) + print("EXAMPLE 4: Retry Logic with Retriable Exceptions") + print("=" * 70) + + async def operation_with_retry(max_retries=3): + """Example operation with retry logic.""" + for attempt in range(max_retries): + try: + # Simulated operation + print(f" Attempt {attempt + 1}/{max_retries}") + + # Create a retriable error for demonstration + raise MqttPublishError( + "Publish cancelled during reconnection", + error_code="AWS_ERROR_MQTT_CANCELLED", + retriable=True, + ) + + except MqttPublishError as e: + if e.retriable and attempt < max_retries - 1: + wait_time = 2**attempt # Exponential backoff + print( + f" ✓ Retriable error: {e.error_code}, " + f"retrying in {wait_time}s..." + ) + await asyncio.sleep(wait_time) + continue + else: + print(" ✗ Max retries reached or not retriable") + raise + + try: + await operation_with_retry() + except MqttPublishError as e: + print("\nFinal result: Operation failed after retries") + print(f"Error: {e}") + + +async def example_structured_logging(): + """Demonstrate structured error logging.""" + print("\n" + "=" * 70) + print("EXAMPLE 5: Structured Error Logging") + print("=" * 70) + + # Create various errors + errors = [ + MqttConnectionError( + "AWS IoT connection failed", + error_code="CONNECTION_TIMEOUT", + details={"endpoint": "example.iot.amazonaws.com", "port": 443}, + ), + RangeValidationError( + "Temperature out of range", + field="temperature", + value=200, + min_value=100, + max_value=140, + ), + MqttPublishError("Publish failed", error_code="MQTT_TIMEOUT", retriable=True), + ] + + print("\nStructured error data for logging/monitoring:\n") + for error in errors: + error_dict = error.to_dict() + print(json.dumps(error_dict, indent=2)) + print() + + print("This structured data can be sent to:") + print(" - Logging systems (ELK, Splunk, etc.)") + print(" - Monitoring/alerting systems") + print(" - Error tracking services") + + +async def example_catch_all_library_errors(): + """Demonstrate catching all library errors.""" + print("\n" + "=" * 70) + print("EXAMPLE 6: Catching All Library Errors") + print("=" * 70) + + try: + # Simulate various library operations + raise MqttNotConnectedError("Not connected") + + except Nwp500Error as e: + print(f"✓ Caught Nwp500Error (base for all library errors): {e}") + print(f" Error type: {type(e).__name__}") + print(" Can catch all library exceptions with single handler") + + # Check specific error type + if isinstance(e, MqttError): + print(" ✓ This is an MQTT error") + elif isinstance(e, AuthenticationError): + print(" ✓ This is an authentication error") + elif isinstance(e, ValidationError): + print(" ✓ This is a validation error") + + +async def example_exception_chaining(): + """Demonstrate exception chaining inspection.""" + print("\n" + "=" * 70) + print("EXAMPLE 7: Exception Chain Inspection") + print("=" * 70) + + try: + # Simulate wrapped exception (library does this internally) + try: + import aiohttp + + raise aiohttp.ClientError("Connection refused") + except aiohttp.ClientError as e: + raise AuthenticationError("Network error during sign-in") from e + + except AuthenticationError as e: + print(f"✓ Caught AuthenticationError: {e}") + print(f" Original cause: {e.__cause__}") + print(f" Original cause type: {type(e.__cause__).__name__}") + print("\nFull exception chain is preserved for debugging!") + + +async def main(): + """Run all examples.""" + print("\n" + "=" * 70) + print("COMPREHENSIVE EXCEPTION HANDLING EXAMPLES (v5.0+)") + print("=" * 70) + + # Run all examples + await example_authentication_errors() + await example_mqtt_errors() + await example_validation_errors() + await example_retry_logic() + await example_structured_logging() + await example_catch_all_library_errors() + await example_exception_chaining() + + print("\n" + "=" * 70) + print("✅ All examples completed!") + print("=" * 70) + print("\nKey Takeaways:") + print(" 1. Use specific exception types for better error handling") + print(" 2. Check 'retriable' flag for retry logic") + print(" 3. Use to_dict() for structured logging") + print(" 4. Access exception attributes for user-friendly messages") + print(" 5. Use Nwp500Error to catch all library errors") + print(" 6. Exception chains are preserved (use __cause__)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/mqtt_client_example.py b/examples/mqtt_client_example.py index 1ff7c46..06d7ea5 100755 --- a/examples/mqtt_client_example.py +++ b/examples/mqtt_client_example.py @@ -30,7 +30,10 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) from nwp500.api_client import NavienAPIClient -from nwp500.auth import AuthenticationError, NavienAuthClient +from nwp500.auth import NavienAuthClient +from nwp500.exceptions import ( + AuthenticationError, +) from nwp500.models import DeviceFeature, DeviceStatus from nwp500.mqtt_client import NavienMqttClient diff --git a/examples/reservation_schedule_example.py b/examples/reservation_schedule_example.py index aa09de2..56bb9d8 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 build_reservation_entry async def main() -> None: @@ -25,7 +26,7 @@ async def main() -> None: return # Build a weekday morning reservation for High Demand mode at 140°F display (120°F message) - weekday_reservation = NavienAPIClient.build_reservation_entry( + weekday_reservation = build_reservation_entry( enabled=True, days=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], hour=6, diff --git a/src/nwp500/__init__.py b/src/nwp500/__init__.py index a67b25e..d68f026 100644 --- a/src/nwp500/__init__.py +++ b/src/nwp500/__init__.py @@ -20,17 +20,12 @@ # Export main components from nwp500.api_client import ( - APIError, NavienAPIClient, ) from nwp500.auth import ( - AuthenticationError, AuthenticationResponse, AuthTokens, - InvalidCredentialsError, NavienAuthClient, - TokenExpiredError, - TokenRefreshError, UserInfo, authenticate, refresh_access_token, @@ -49,6 +44,27 @@ EventEmitter, EventListener, ) +from nwp500.exceptions import ( + APIError, + AuthenticationError, + DeviceError, + DeviceNotFoundError, + DeviceOfflineError, + DeviceOperationError, + InvalidCredentialsError, + MqttConnectionError, + MqttCredentialsError, + MqttError, + MqttNotConnectedError, + MqttPublishError, + MqttSubscriptionError, + Nwp500Error, + ParameterValidationError, + RangeValidationError, + TokenExpiredError, + TokenRefreshError, + ValidationError, +) from nwp500.models import ( CurrentOperationMode, Device, @@ -99,17 +115,32 @@ "AuthenticationResponse", "AuthTokens", "UserInfo", + "authenticate", + "refresh_access_token", + # Exceptions (all in one place) + "Nwp500Error", "AuthenticationError", "InvalidCredentialsError", "TokenExpiredError", "TokenRefreshError", - "authenticate", - "refresh_access_token", + "APIError", + "MqttError", + "MqttConnectionError", + "MqttNotConnectedError", + "MqttPublishError", + "MqttSubscriptionError", + "MqttCredentialsError", + "ValidationError", + "ParameterValidationError", + "RangeValidationError", + "DeviceError", + "DeviceNotFoundError", + "DeviceOfflineError", + "DeviceOperationError", # Constants "constants", # API Client "NavienAPIClient", - "APIError", # MQTT Client "NavienMqttClient", "MqttConnectionConfig", diff --git a/src/nwp500/api_client.py b/src/nwp500/api_client.py index ac90246..912a3dc 100644 --- a/src/nwp500/api_client.py +++ b/src/nwp500/api_client.py @@ -15,6 +15,7 @@ TokenRefreshError, ) from .config import API_BASE_URL +from .exceptions import APIError from .models import Device, FirmwareInfo, TOUInfo __author__ = "Emmanuel Levijarvi" @@ -24,32 +25,12 @@ _logger = logging.getLogger(__name__) -class APIError(Exception): - """Raised when API returns an error response. - - Attributes: - message: Error message describing the failure - code: HTTP or API error code - response: Complete API response dictionary - """ - - def __init__( - self, - message: str, - code: Optional[int] = None, - response: Optional[dict[str, Any]] = None, - ): - """Initialize API error. - - Args: - message: Error message describing the failure - code: HTTP or API error code - response: Complete API response dictionary - """ - self.message = message - self.code = code - self.response = response - super().__init__(self.message) +# Exception class moved to exceptions.py module +# Import it here for backward compatibility +__all__ = [ + "NavienAPIClient", + "APIError", +] class NavienAPIClient: @@ -211,7 +192,7 @@ async def _make_request( except aiohttp.ClientError as e: _logger.error(f"Network error: {e}") - raise APIError(f"Network error: {str(e)}") + raise APIError(f"Network error: {str(e)}") from e # Device Management Endpoints diff --git a/src/nwp500/auth.py b/src/nwp500/auth.py index 3d4b22e..f092fc9 100644 --- a/src/nwp500/auth.py +++ b/src/nwp500/auth.py @@ -20,6 +20,12 @@ import aiohttp from .config import API_BASE_URL, REFRESH_ENDPOINT, SIGN_IN_ENDPOINT +from .exceptions import ( + AuthenticationError, + InvalidCredentialsError, + TokenExpiredError, + TokenRefreshError, +) __author__ = "Emmanuel Levijarvi" __copyright__ = "Emmanuel Levijarvi" @@ -255,50 +261,20 @@ def from_dict( ) -class AuthenticationError(Exception): - """Base exception for authentication errors. - - Attributes: - message: Error message describing the failure - status_code: HTTP status code - response: Complete API response dictionary - """ - - def __init__( - self, - message: str, - status_code: Optional[int] = None, - response: Optional[dict[str, Any]] = None, - ): - """Initialize authentication error. - - Args: - message: Error message describing the failure - status_code: HTTP status code - response: Complete API response dictionary - """ - self.message = message - self.status_code = status_code - self.response = response - super().__init__(self.message) - - -class InvalidCredentialsError(AuthenticationError): - """Raised when credentials are invalid.""" - - pass - - -class TokenExpiredError(AuthenticationError): - """Raised when a token has expired.""" - - pass - - -class TokenRefreshError(AuthenticationError): - """Raised when token refresh fails.""" - - pass +# Exception classes moved to exceptions.py module +# Import them here for backward compatibility +__all__ = [ + "UserInfo", + "AuthTokens", + "AuthenticationResponse", + "AuthenticationError", + "InvalidCredentialsError", + "TokenExpiredError", + "TokenRefreshError", + "NavienAuthClient", + "authenticate", + "refresh_access_token", +] class NavienAuthClient: @@ -501,10 +477,12 @@ async def sign_in( except aiohttp.ClientError as e: _logger.error(f"Network error during sign-in: {e}") - raise AuthenticationError(f"Network error: {str(e)}") + raise AuthenticationError(f"Network error: {str(e)}") from e except (KeyError, ValueError, json.JSONDecodeError) as e: _logger.error(f"Failed to parse authentication response: {e}") - raise AuthenticationError(f"Invalid response format: {str(e)}") + raise AuthenticationError( + f"Invalid response format: {str(e)}" + ) from e async def refresh_token(self, refresh_token: str) -> AuthTokens: """ @@ -587,10 +565,10 @@ async def refresh_token(self, refresh_token: str) -> AuthTokens: except aiohttp.ClientError as e: _logger.error(f"Network error during token refresh: {e}") - raise TokenRefreshError(f"Network error: {str(e)}") + raise TokenRefreshError(f"Network error: {str(e)}") from e except (KeyError, ValueError, json.JSONDecodeError) as e: _logger.error(f"Failed to parse refresh response: {e}") - raise TokenRefreshError(f"Invalid response format: {str(e)}") + raise TokenRefreshError(f"Invalid response format: {str(e)}") from e async def re_authenticate(self) -> AuthenticationResponse: """ diff --git a/src/nwp500/cli/__main__.py b/src/nwp500/cli/__main__.py index b452cea..07f2fb1 100644 --- a/src/nwp500/cli/__main__.py +++ b/src/nwp500/cli/__main__.py @@ -11,7 +11,17 @@ import sys from nwp500 import NavienAPIClient, NavienAuthClient, __version__ -from nwp500.auth import InvalidCredentialsError +from nwp500.exceptions import ( + AuthenticationError, + InvalidCredentialsError, + MqttConnectionError, + MqttError, + MqttNotConnectedError, + Nwp500Error, + RangeValidationError, + TokenRefreshError, + ValidationError, +) from .commands import ( handle_device_feature_request, @@ -197,9 +207,42 @@ async def async_main(args: argparse.Namespace) -> int: except InvalidCredentialsError: _logger.error("Invalid email or password.") return 1 + except TokenRefreshError as e: + _logger.error(f"Token refresh failed: {e}") + _logger.info("Try logging in again with fresh credentials.") + return 1 + except AuthenticationError as e: + _logger.error(f"Authentication failed: {e}") + return 1 + except MqttNotConnectedError: + _logger.error("MQTT connection not established.") + _logger.info( + "The device may be offline or network connectivity issues exist." + ) + return 1 + except MqttConnectionError as e: + _logger.error(f"MQTT connection error: {e}") + _logger.info("Check network connectivity and try again.") + return 1 + except MqttError as e: + _logger.error(f"MQTT error: {e}") + return 1 + except ValidationError as e: + _logger.error(f"Invalid input: {e}") + # RangeValidationError has min_value/max_value attributes + if isinstance(e, RangeValidationError): + _logger.info( + f"Valid range for {e.field}: {e.min_value} to {e.max_value}" + ) + return 1 except asyncio.CancelledError: _logger.info("Operation cancelled by user.") return 1 + except Nwp500Error as e: + _logger.error(f"Library error: {e}") + if hasattr(e, "retriable") and e.retriable: + _logger.info("This operation may be retried.") + return 1 except Exception as e: _logger.error(f"An unexpected error occurred: {e}", exc_info=True) return 1 diff --git a/src/nwp500/cli/commands.py b/src/nwp500/cli/commands.py index ea0f9e7..24a8b48 100644 --- a/src/nwp500/cli/commands.py +++ b/src/nwp500/cli/commands.py @@ -7,6 +7,7 @@ from typing import Any, Optional from nwp500 import Device, DeviceFeature, DeviceStatus, NavienMqttClient +from nwp500.exceptions import MqttError, Nwp500Error, ValidationError from .output_formatters import _json_default_serializer @@ -241,8 +242,16 @@ def on_status_response(status: DeviceStatus) -> None: except asyncio.TimeoutError: _logger.error("Timed out waiting for mode change confirmation") - except Exception as e: + except ValidationError as e: + _logger.error(f"Invalid mode or parameters: {e}") + if hasattr(e, "field"): + _logger.info(f"Check the value for: {e.field}") + except MqttError as e: + _logger.error(f"MQTT error setting mode: {e}") + except Nwp500Error as e: _logger.error(f"Error setting mode: {e}") + except Exception as e: + _logger.error(f"Unexpected error setting mode: {e}") async def handle_set_dhw_temp_request( @@ -311,8 +320,16 @@ def on_status_response(status: DeviceStatus) -> None: "Timed out waiting for temperature change confirmation" ) - except Exception as e: + except ValidationError as e: + _logger.error(f"Invalid temperature: {e}") + if hasattr(e, "min_value") and hasattr(e, "max_value"): + _logger.info(f"Valid range: {e.min_value}°F to {e.max_value}°F") + except MqttError as e: + _logger.error(f"MQTT error setting temperature: {e}") + except Nwp500Error as e: _logger.error(f"Error setting temperature: {e}") + except Exception as e: + _logger.error(f"Unexpected error setting temperature: {e}") async def handle_power_request( @@ -374,8 +391,12 @@ def on_power_change_response(status: DeviceStatus) -> None: except asyncio.TimeoutError: _logger.error(f"Timed out waiting for power {action} confirmation") - except Exception as e: + except MqttError as e: + _logger.error(f"MQTT error turning device {action}: {e}") + except Nwp500Error as e: _logger.error(f"Error turning device {action}: {e}") + except Exception as e: + _logger.error(f"Unexpected error turning device {action}: {e}") async def handle_get_reservations_request( @@ -544,8 +565,14 @@ async def handle_get_tou_request( ) ) + except MqttError as e: + _logger.error(f"MQTT error fetching TOU settings: {e}") + except Nwp500Error as e: + _logger.error(f"Error fetching TOU settings: {e}") except Exception as e: - _logger.error(f"Error fetching TOU settings: {e}", exc_info=True) + _logger.error( + f"Unexpected error fetching TOU settings: {e}", exc_info=True + ) async def handle_set_tou_enabled_request( @@ -585,8 +612,12 @@ def on_status_response(status: DeviceStatus) -> None: except asyncio.TimeoutError: _logger.error(f"Timed out waiting for TOU {action} confirmation") - except Exception as e: + except MqttError as e: + _logger.error(f"MQTT error {action} TOU: {e}") + except Nwp500Error as e: _logger.error(f"Error {action} TOU: {e}") + except Exception as e: + _logger.error(f"Unexpected error {action} TOU: {e}") async def handle_get_energy_request( diff --git a/src/nwp500/encoding.py b/src/nwp500/encoding.py index 24abfb0..53ea260 100644 --- a/src/nwp500/encoding.py +++ b/src/nwp500/encoding.py @@ -10,6 +10,8 @@ from numbers import Real from typing import Union +from .exceptions import ParameterValidationError, RangeValidationError + # Weekday constants WEEKDAY_ORDER = [ "Sunday", @@ -46,7 +48,8 @@ def encode_week_bitfield(days: Iterable[Union[str, int]]) -> int: Monday=bit 1, etc.) Raises: - ValueError: If day name is invalid or index is out of range + ParameterValidationError: If day name is unknown/invalid + RangeValidationError: If day index is out of range (not 0-7) TypeError: If day value is neither string nor integer Examples: @@ -64,7 +67,11 @@ def encode_week_bitfield(days: Iterable[Union[str, int]]) -> int: if isinstance(value, str): key = value.strip().lower() if key not in WEEKDAY_NAME_TO_BIT: - raise ValueError(f"Unknown weekday: {value}") + raise ParameterValidationError( + f"Unknown weekday: {value}", + parameter="weekday", + value=value, + ) bitfield |= WEEKDAY_NAME_TO_BIT[key] elif isinstance(value, int): if 0 <= value <= 6: @@ -73,7 +80,13 @@ def encode_week_bitfield(days: Iterable[Union[str, int]]) -> int: # Support 1-7 indexing (Monday=1, Sunday=7) bitfield |= 1 << (value - 1) else: - raise ValueError("Day index must be between 0-6 or 1-7") + raise RangeValidationError( + "Day index must be between 0-6 or 1-7", + field="day_index", + value=value, + min_value=0, + max_value=7, + ) else: raise TypeError("Weekday values must be strings or integers") return bitfield @@ -135,7 +148,13 @@ def encode_season_bitfield(months: Iterable[int]) -> int: bitfield = 0 for month in months: if month not in MONTH_TO_BIT: - raise ValueError("Month values must be in the range 1-12") + raise RangeValidationError( + "Month values must be in the range 1-12", + field="month", + value=month, + min_value=1, + max_value=12, + ) bitfield |= MONTH_TO_BIT[month] return bitfield @@ -179,13 +198,13 @@ def encode_price(value: Real, decimal_point: int) -> int: Args: value: Price value (float or Decimal) - decimal_point: Number of decimal places (0-4 typically) + decimal_point: Number of decimal places (0-10, typically 2-5) Returns: Integer representation of the price Raises: - ValueError: If decimal_point is negative + RangeValidationError: If decimal_point is not in range 0-10 Examples: >>> encode_price(12.34, 2) @@ -197,8 +216,14 @@ def encode_price(value: Real, decimal_point: int) -> int: >>> encode_price(100, 0) 100 """ - if decimal_point < 0: - raise ValueError("decimal_point must be >= 0") + if not 0 <= decimal_point <= 10: + raise RangeValidationError( + "decimal_point must be between 0 and 10", + field="decimal_point", + value=decimal_point, + min_value=0, + max_value=10, + ) scale = 10**decimal_point return int(round(float(value) * scale)) @@ -209,13 +234,13 @@ def decode_price(value: int, decimal_point: int) -> float: Args: value: Integer price value from device - decimal_point: Number of decimal places + decimal_point: Number of decimal places (0-10, typically 2-5) Returns: Floating-point price value Raises: - ValueError: If decimal_point is negative + RangeValidationError: If decimal_point is not in range 0-10 Examples: >>> decode_price(1234, 2) @@ -227,8 +252,14 @@ def decode_price(value: int, decimal_point: int) -> float: >>> decode_price(100, 0) 100.0 """ - if decimal_point < 0: - raise ValueError("decimal_point must be >= 0") + if not 0 <= decimal_point <= 10: + raise RangeValidationError( + "decimal_point must be between 0 and 10", + field="decimal_point", + value=decimal_point, + min_value=0, + max_value=10, + ) scale = 10**decimal_point return value / scale if scale else float(value) @@ -307,14 +338,15 @@ def build_reservation_entry( days: Collection of weekday names or indices hour: Hour (0-23) minute: Minute (0-59) - mode_id: DHW operation mode ID + mode_id: DHW operation mode ID (1-6, see DhwOperationSetting) param: Additional parameter value Returns: Dictionary with reservation entry fields Raises: - ValueError: If any parameter is out of valid range + RangeValidationError: If hour, minute, or mode_id is out of range + ParameterValidationError: If enabled type is invalid Examples: >>> build_reservation_entry( @@ -328,18 +360,40 @@ def build_reservation_entry( {'enable': 1, 'week': 42, 'hour': 6, 'min': 30, 'mode': 3, 'param': 120} """ if not 0 <= hour <= 23: - raise ValueError("hour must be between 0 and 23") + raise RangeValidationError( + "hour must be between 0 and 23", + field="hour", + value=hour, + min_value=0, + max_value=23, + ) if not 0 <= minute <= 59: - raise ValueError("minute must be between 0 and 59") - if mode_id < 0: - raise ValueError("mode_id must be non-negative") + raise RangeValidationError( + "minute must be between 0 and 59", + field="minute", + value=minute, + min_value=0, + max_value=59, + ) + if not 1 <= mode_id <= 6: + raise RangeValidationError( + "mode_id must be between 1 and 6 (see DhwOperationSetting)", + field="mode_id", + value=mode_id, + min_value=1, + max_value=6, + ) if isinstance(enabled, bool): enable_flag = 1 if enabled else 2 elif enabled in (1, 2): enable_flag = int(enabled) else: - raise ValueError("enabled must be True/False or 1/2") + raise ParameterValidationError( + "enabled must be True/False or 1/2", + parameter="enabled", + value=enabled, + ) week_bitfield = encode_week_bitfield(days) @@ -407,14 +461,26 @@ def build_tou_period( ("end_hour", end_hour, 23), ): if not 0 <= value <= upper: - raise ValueError(f"{label} must be between 0 and {upper}") + raise RangeValidationError( + f"{label} must be between 0 and {upper}", + field=label, + value=value, + min_value=0, + max_value=upper, + ) for label, value in ( ("start_minute", start_minute), ("end_minute", end_minute), ): if not 0 <= value <= 59: - raise ValueError(f"{label} must be between 0 and 59") + raise RangeValidationError( + f"{label} must be between 0 and 59", + field=label, + value=value, + min_value=0, + max_value=59, + ) # Encode bitfields week_bitfield = encode_week_bitfield(week_days) diff --git a/src/nwp500/exceptions.py b/src/nwp500/exceptions.py new file mode 100644 index 0000000..6958c3e --- /dev/null +++ b/src/nwp500/exceptions.py @@ -0,0 +1,445 @@ +""" +Exception hierarchy for nwp500-python library. + +This module defines all custom exceptions used throughout the library, +providing a clear hierarchy for error handling and better developer experience. + +Exception Hierarchy:: + + Nwp500Error (base) + ├── AuthenticationError + │ ├── InvalidCredentialsError + │ ├── TokenExpiredError + │ └── TokenRefreshError + ├── APIError + ├── MqttError + │ ├── MqttConnectionError + │ ├── MqttNotConnectedError + │ ├── MqttPublishError + │ ├── MqttSubscriptionError + │ └── MqttCredentialsError + ├── ValidationError + │ ├── ParameterValidationError + │ └── RangeValidationError + └── DeviceError + ├── DeviceNotFoundError + ├── DeviceOfflineError + └── DeviceOperationError + +Migration from v4.x +------------------- + +If you were catching generic exceptions in your code, update as follows: + +.. code-block:: python + + # Old code (v4.x) + try: + await mqtt_client.request_device_status(device) + except RuntimeError as e: + if "Not connected" in str(e): + # handle connection error + + # New code (v5.0+) + try: + await mqtt_client.request_device_status(device) + except MqttNotConnectedError: + # handle connection error + except MqttError: + # handle other MQTT errors + + # Old code (v4.x) + try: + set_vacation_mode(days=35) + except ValueError as e: + # handle validation error + + # New code (v5.0+) + try: + set_vacation_mode(days=35) + except RangeValidationError as e: + print(f"Invalid {e.field}: {e.message}") + print(f"Valid range: {e.min_value} to {e.max_value}") + except ValidationError: + # handle other validation errors +""" + +from typing import Any, Optional + +__author__ = "Emmanuel Levijarvi" +__copyright__ = "Emmanuel Levijarvi" +__license__ = "MIT" + + +class Nwp500Error(Exception): + """Base exception for all nwp500 library errors. + + All custom exceptions in the nwp500 library inherit from this base class, + allowing consumers to catch all library-specific errors with a single + exception handler if desired. + + Attributes: + message: Human-readable error message + error_code: Machine-readable error code (optional) + details: Additional context as a dictionary (optional) + retriable: Whether the operation can be retried (optional) + """ + + def __init__( + self, + message: str, + *, + error_code: Optional[str] = None, + details: Optional[dict[str, Any]] = None, + retriable: bool = False, + ): + """Initialize base exception. + + Args: + message: Human-readable error message + error_code: Machine-readable error code + details: Additional context (dict) + retriable: Whether operation can be retried + """ + self.message = message + self.error_code = error_code + self.details = details or {} + self.retriable = retriable + super().__init__(self.message) + + def __str__(self) -> str: + """Return formatted error message with optional metadata.""" + parts = [self.message] + if self.error_code: + parts.append(f"[{self.error_code}]") + if self.retriable: + parts.append("(retriable)") + return " ".join(parts) + + def to_dict(self) -> dict[str, Any]: + """Serialize exception for logging/monitoring. + + Returns: + Dictionary with error type, message, code, details, and retriability + """ + return { + "error_type": self.__class__.__name__, + "message": self.message, + "error_code": self.error_code, + "details": self.details, + "retriable": self.retriable, + } + + +# ============================================================================= +# Authentication Exceptions +# ============================================================================= + + +class AuthenticationError(Nwp500Error): + """Base exception for authentication errors. + + Raised when authentication-related operations fail, including sign-in, + token management, and credential validation. + + Attributes: + message: Error message describing the failure + status_code: HTTP status code (optional) + response: Complete API response dictionary (optional) + """ + + def __init__( + self, + message: str, + status_code: Optional[int] = None, + response: Optional[dict[str, Any]] = None, + **kwargs: Any, + ): + """Initialize authentication error. + + Args: + message: Error message describing the failure + status_code: HTTP status code + response: Complete API response dictionary + **kwargs: Additional arguments passed to base class + """ + super().__init__(message, **kwargs) + self.status_code = status_code + self.response = response + + +class InvalidCredentialsError(AuthenticationError): + """Raised when user credentials are invalid. + + This typically indicates a 401 Unauthorized response from the API + due to incorrect email/password combination. + """ + + 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. + + Token refresh can fail due to invalid refresh tokens, network issues, + or API errors. When this occurs, full re-authentication may be required. + """ + + pass + + +# ============================================================================= +# API Exceptions +# ============================================================================= + + +class APIError(Nwp500Error): + """Raised when API returns an error response. + + This exception is raised for various API-related failures including + network errors, invalid responses, and API endpoint errors. + + Attributes: + message: Error message describing the failure + code: HTTP or API error code + response: Complete API response dictionary (optional) + """ + + def __init__( + self, + message: str, + code: Optional[int] = None, + response: Optional[dict[str, Any]] = None, + **kwargs: Any, + ): + """Initialize API error. + + Args: + message: Error message describing the failure + code: HTTP or API error code + response: Complete API response dictionary + **kwargs: Additional arguments passed to base class + """ + super().__init__(message, **kwargs) + self.code = code + self.response = response + + +# ============================================================================= +# MQTT Exceptions +# ============================================================================= + + +class MqttError(Nwp500Error): + """Base exception for MQTT operations. + + All MQTT-related errors inherit from this base class, allowing consumers + to handle all MQTT issues with a single exception handler. + """ + + pass + + +class MqttConnectionError(MqttError): + """Connection establishment or maintenance failed. + + Raised when the MQTT connection to AWS IoT Core cannot be established + or when an existing connection fails. This may be due to network issues, + invalid credentials, or AWS service problems. + """ + + pass + + +class MqttNotConnectedError(MqttError): + """Operation requires active MQTT connection. + + Raised when attempting MQTT operations (publish, subscribe, etc.) without + an established connection. Call connect() before performing MQTT operations. + + Example:: + + mqtt_client = NavienMqttClient(auth_client) + # Must connect first + await mqtt_client.connect() + await mqtt_client.request_device_status(device) + """ + + pass + + +class MqttPublishError(MqttError): + """Failed to publish message to MQTT broker. + + Raised when a message cannot be published to an MQTT topic. This may + occur during connection interruptions or when the broker rejects the + message. + """ + + 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. + + Raised when AWS IoT credentials are missing, invalid, or expired. + Re-authentication may be required to obtain fresh credentials. + """ + + pass + + +# ============================================================================= +# Validation Exceptions +# ============================================================================= + + +class ValidationError(Nwp500Error): + """Base exception for validation failures. + + Raised when input parameters or data fail validation checks. + """ + + pass + + +class ParameterValidationError(ValidationError): + """Invalid parameter value provided. + + Raised when a parameter value is invalid for reasons other than + being out of range (e.g., wrong type, invalid format). + + Attributes: + parameter: Name of the invalid parameter + value: The invalid value provided + """ + + def __init__( + self, + message: str, + parameter: Optional[str] = None, + value: Any = None, + **kwargs: Any, + ): + """Initialize parameter validation error. + + Args: + message: Error message + parameter: Name of the invalid parameter + value: The invalid value provided + **kwargs: Additional arguments passed to base class + """ + super().__init__(message, **kwargs) + self.parameter = parameter + self.value = value + + +class RangeValidationError(ValidationError): + """Value outside acceptable range. + + Raised when a numeric value is outside its valid range. + + Attributes: + field: Name of the field + value: The invalid value provided + min_value: Minimum acceptable value + max_value: Maximum acceptable value + + Example:: + + try: + set_temperature(200) + except RangeValidationError as e: + print(f"Invalid {e.field}: must be {e.min_value}-{e.max_value}") + """ + + def __init__( + self, + message: str, + field: Optional[str] = None, + value: Any = None, + min_value: Any = None, + max_value: Any = None, + **kwargs: Any, + ): + """Initialize range validation error. + + Args: + message: Error message + field: Name of the field + value: The invalid value provided + min_value: Minimum acceptable value + max_value: Maximum acceptable value + **kwargs: Additional arguments passed to base class + """ + super().__init__(message, **kwargs) + self.field = field + self.value = value + self.min_value = min_value + self.max_value = max_value + + +# ============================================================================= +# Device Exceptions +# ============================================================================= + + +class DeviceError(Nwp500Error): + """Base exception for device operations. + + All device-related errors inherit from this base class. + """ + + 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 diff --git a/src/nwp500/mqtt_client.py b/src/nwp500/mqtt_client.py index 8f69e21..2ee279d 100644 --- a/src/nwp500/mqtt_client.py +++ b/src/nwp500/mqtt_client.py @@ -25,6 +25,12 @@ TokenRefreshError, ) from .events import EventEmitter +from .exceptions import ( + MqttConnectionError, + MqttCredentialsError, + MqttNotConnectedError, + MqttPublishError, +) from .models import ( Device, DeviceFeature, @@ -137,17 +143,17 @@ def __init__( credentials are not available """ if not auth_client.is_authenticated: - raise ValueError( + raise MqttCredentialsError( "Authentication client must be authenticated before " "creating MQTT client. Call auth_client.sign_in() first." ) if not auth_client.current_tokens: - raise ValueError("No tokens available from auth client") + raise MqttCredentialsError("No tokens available from auth client") auth_tokens = auth_client.current_tokens if not auth_tokens.access_key_id or not auth_tokens.secret_key: - raise ValueError( + raise MqttCredentialsError( "AWS credentials not available in auth tokens. " "Ensure authentication provides AWS IoT credentials." ) @@ -394,7 +400,9 @@ async def _deep_reconnect(self) -> None: ) else: _logger.warning("No refresh token available") - raise ValueError("No refresh token available for refresh") + raise MqttCredentialsError( + "No refresh token available for refresh" + ) except (TokenRefreshError, ValueError, AuthenticationError) as e: # If refresh fails, try full re-authentication with stored # credentials @@ -550,7 +558,7 @@ def _create_credentials_provider(self) -> Any: # Get current tokens from auth client auth_tokens = self._auth_client.current_tokens if not auth_tokens: - raise ValueError("No tokens available from auth client") + raise MqttCredentialsError("No tokens available from auth client") return AwsCredentialsProvider.new_static( access_key_id=auth_tokens.access_key_id, @@ -666,7 +674,7 @@ async def subscribe( Exception: If subscription fails """ if not self._connected or not self._subscription_manager: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") # Delegate to subscription manager return await self._subscription_manager.subscribe(topic, callback, qos) @@ -685,7 +693,7 @@ async def unsubscribe(self, topic: str) -> int: Exception: If unsubscribe fails """ if not self._connected or not self._subscription_manager: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") # Delegate to subscription manager return await self._subscription_manager.unsubscribe(topic) @@ -721,11 +729,11 @@ async def publish( self._command_queue.enqueue(topic, payload, qos) return 0 # Return 0 to indicate command was queued else: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") # Delegate to connection manager if not self._connection_manager: - raise RuntimeError("Connection manager not initialized") + raise MqttConnectionError("Connection manager not initialized") try: return await self._connection_manager.publish(topic, payload, qos) @@ -748,9 +756,10 @@ async def publish( self._command_queue.enqueue(topic, payload, qos) return 0 # Return 0 to indicate command was queued # Otherwise, raise an error so the caller can handle the failure - raise RuntimeError( + raise MqttPublishError( "Publish cancelled due to clean session and " - "command queue is disabled" + "command queue is disabled", + retriable=True, ) from e # Other AWS CRT errors @@ -773,7 +782,7 @@ async def subscribe_device( Subscription packet ID """ if not self._connected or not self._subscription_manager: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") # Delegate to subscription manager return await self._subscription_manager.subscribe_device( @@ -833,7 +842,7 @@ async def subscribe_device_status( ... ) """ if not self._connected or not self._subscription_manager: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") # Delegate to subscription manager (it handles state change # detection and events) @@ -885,7 +894,7 @@ async def subscribe_device_feature( ... ) """ if not self._connected or not self._subscription_manager: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") # Delegate to subscription manager return await self._subscription_manager.subscribe_device_feature( @@ -903,7 +912,7 @@ async def request_device_status(self, device: Device) -> int: Publish packet ID """ if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.request_device_status(device) @@ -915,7 +924,7 @@ async def request_device_info(self, device: Device) -> int: Publish packet ID """ if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.request_device_info(device) @@ -933,7 +942,7 @@ async def set_power(self, device: Device, power_on: bool) -> int: Publish packet ID """ if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.set_power(device, power_on) @@ -969,7 +978,7 @@ async def set_dhw_mode( - 5: Vacation Mode (requires vacation_days parameter) """ if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.set_dhw_mode( device, mode_id, vacation_days @@ -1001,7 +1010,7 @@ async def enable_anti_legionella( ValueError: If period_days is not in the valid range [1, 30] """ if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.enable_anti_legionella( device, period_days @@ -1025,7 +1034,7 @@ async def disable_anti_legionella(self, device: Device) -> int: The message ID of the published command """ if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.disable_anti_legionella(device) @@ -1056,7 +1065,7 @@ async def set_dhw_temperature( await client.set_dhw_temperature(device, 120) """ if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.set_dhw_temperature( device, temperature @@ -1098,7 +1107,7 @@ async def update_reservations( ) -> int: """Update programmed reservations for temperature/mode changes.""" if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.update_reservations( device, reservations, enabled=enabled @@ -1107,7 +1116,7 @@ async def update_reservations( async def request_reservations(self, device: Device) -> int: """Request the current reservation program from the device.""" if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.request_reservations(device) @@ -1121,7 +1130,7 @@ async def configure_tou_schedule( ) -> int: """Configure Time-of-Use pricing schedule via MQTT.""" if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.configure_tou_schedule( device, controller_serial_number, periods, enabled=enabled @@ -1134,7 +1143,7 @@ async def request_tou_settings( ) -> int: """Request current Time-of-Use schedule from the device.""" if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.request_tou_settings( device, controller_serial_number @@ -1144,7 +1153,7 @@ async def set_tou_enabled(self, device: Device, enabled: bool) -> int: """Quickly toggle Time-of-Use functionality without modifying the schedule.""" if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.set_tou_enabled(device, enabled) @@ -1184,7 +1193,7 @@ async def request_energy_usage( ) """ if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.request_energy_usage( device, year, months @@ -1227,7 +1236,7 @@ async def subscribe_energy_usage( >>> await mqtt_client.request_energy_usage(device, 2025, [9]) """ if not self._connected or not self._subscription_manager: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") # Delegate to subscription manager return await self._subscription_manager.subscribe_energy_usage( @@ -1245,7 +1254,7 @@ async def signal_app_connection(self, device: Device) -> int: Publish packet ID """ if not self._connected or not self._device_controller: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") return await self._device_controller.signal_app_connection(device) @@ -1290,7 +1299,9 @@ async def start_periodic_requests( - All tasks automatically stop when client disconnects """ if not self._periodic_manager: - raise RuntimeError("Periodic request manager not initialized") + raise MqttConnectionError( + "Periodic request manager not initialized" + ) await self._periodic_manager.start_periodic_requests( device, request_type, period_seconds @@ -1320,7 +1331,9 @@ async def stop_periodic_requests( >>> await mqtt_client.stop_periodic_requests(device) """ if not self._periodic_manager: - raise RuntimeError("Periodic request manager not initialized") + raise MqttConnectionError( + "Periodic request manager not initialized" + ) await self._periodic_manager.stop_periodic_requests( device, request_type @@ -1352,7 +1365,9 @@ async def start_periodic_device_info_requests( (default: 300 = 5 minutes) """ if not self._periodic_manager: - raise RuntimeError("Periodic request manager not initialized") + raise MqttConnectionError( + "Periodic request manager not initialized" + ) await self._periodic_manager.start_periodic_device_info_requests( device, period_seconds @@ -1372,7 +1387,9 @@ async def start_periodic_device_status_requests( (default: 300 = 5 minutes) """ if not self._periodic_manager: - raise RuntimeError("Periodic request manager not initialized") + raise MqttConnectionError( + "Periodic request manager not initialized" + ) await self._periodic_manager.start_periodic_device_status_requests( device, period_seconds @@ -1388,7 +1405,9 @@ async def stop_periodic_device_info_requests(self, device: Device) -> None: device: Device object """ if not self._periodic_manager: - raise RuntimeError("Periodic request manager not initialized") + raise MqttConnectionError( + "Periodic request manager not initialized" + ) await self._periodic_manager.stop_periodic_device_info_requests(device) @@ -1404,7 +1423,9 @@ async def stop_periodic_device_status_requests( device: Device object """ if not self._periodic_manager: - raise RuntimeError("Periodic request manager not initialized") + raise MqttConnectionError( + "Periodic request manager not initialized" + ) await self._periodic_manager.stop_periodic_device_status_requests( device @@ -1426,7 +1447,9 @@ async def stop_all_periodic_tasks( >>> await mqtt_client.stop_all_periodic_tasks() """ if not self._periodic_manager: - raise RuntimeError("Periodic request manager not initialized") + raise MqttConnectionError( + "Periodic request manager not initialized" + ) await self._periodic_manager.stop_all_periodic_tasks(_reason) diff --git a/src/nwp500/mqtt_connection.py b/src/nwp500/mqtt_connection.py index 9eff060..d149a8b 100644 --- a/src/nwp500/mqtt_connection.py +++ b/src/nwp500/mqtt_connection.py @@ -15,6 +15,12 @@ from awscrt.exceptions import AwsCrtError from awsiot import mqtt_connection_builder +from .exceptions import ( + MqttConnectionError, + MqttCredentialsError, + MqttNotConnectedError, +) + if TYPE_CHECKING: from .auth import NavienAuthClient from .mqtt_utils import MqttConnectionConfig @@ -66,7 +72,7 @@ def __init__( ) if not auth_client.current_tokens: - raise ValueError("No tokens available from auth client") + raise MqttCredentialsError("No tokens available from auth client") auth_tokens = auth_client.current_tokens if not auth_tokens.access_key_id or not auth_tokens.secret_key: @@ -138,7 +144,7 @@ async def connect(self) -> bool: connect_future = self._connection.connect() connect_result = await asyncio.wrap_future(connect_future) else: - raise RuntimeError("Connection not initialized") + raise MqttConnectionError("Connection not initialized") self._connected = True _logger.info( @@ -167,7 +173,7 @@ def _create_credentials_provider(self) -> Any: # Get current tokens from auth client auth_tokens = self._auth_client.current_tokens if not auth_tokens: - raise ValueError("No tokens available from auth client") + raise MqttCredentialsError("No tokens available from auth client") return AwsCredentialsProvider.new_static( access_key_id=auth_tokens.access_key_id, @@ -221,7 +227,7 @@ async def subscribe( RuntimeError: If not connected """ if not self._connected or not self._connection: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") _logger.debug(f"Subscribing to topic: {topic}") @@ -248,7 +254,7 @@ async def unsubscribe(self, topic: str) -> int: RuntimeError: If not connected """ if not self._connected or not self._connection: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") _logger.debug(f"Unsubscribing from topic: {topic}") @@ -282,7 +288,7 @@ async def publish( RuntimeError: If not connected """ if not self._connected or not self._connection: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") _logger.debug(f"Publishing to topic: {topic}") diff --git a/src/nwp500/mqtt_device_control.py b/src/nwp500/mqtt_device_control.py index 2acd1d7..2e643c9 100644 --- a/src/nwp500/mqtt_device_control.py +++ b/src/nwp500/mqtt_device_control.py @@ -19,6 +19,7 @@ from typing import Any, Callable, Optional from .constants import CommandCode +from .exceptions import ParameterValidationError, RangeValidationError from .models import Device, DhwOperationSetting __author__ = "Emmanuel Levijarvi" @@ -215,14 +216,24 @@ async def set_dhw_mode( """ if mode_id == DhwOperationSetting.VACATION.value: if vacation_days is None: - raise ValueError("Vacation mode requires vacation_days (1-30)") + raise ParameterValidationError( + "Vacation mode requires vacation_days (1-30)", + parameter="vacation_days", + ) if not 1 <= vacation_days <= 30: - raise ValueError("vacation_days must be between 1 and 30") + raise RangeValidationError( + "vacation_days must be between 1 and 30", + field="vacation_days", + value=vacation_days, + min_value=1, + max_value=30, + ) param = [mode_id, vacation_days] else: if vacation_days is not None: - raise ValueError( - "vacation_days is only valid for vacation mode (mode 5)" + raise ParameterValidationError( + "vacation_days is only valid for vacation mode (mode 5)", + parameter="vacation_days", ) param = [mode_id] @@ -272,7 +283,13 @@ async def enable_anti_legionella( ValueError: If period_days is not in the valid range [1, 30] """ if not 1 <= period_days <= 30: - raise ValueError("period_days must be between 1 and 30") + raise RangeValidationError( + "period_days must be between 1 and 30", + field="period_days", + value=period_days, + min_value=1, + max_value=30, + ) device_id = device.device_info.mac_address device_type = device.device_info.device_type @@ -505,9 +522,14 @@ async def configure_tou_schedule( # (season, week, startHour, startMinute, endHour, endMinute, # priceMin, priceMax, decimalPoint). if not controller_serial_number: - raise ValueError("controller_serial_number is required") + raise ParameterValidationError( + "controller_serial_number is required", + parameter="controller_serial_number", + ) if not periods: - raise ValueError("At least one TOU period must be provided") + raise ParameterValidationError( + "At least one TOU period must be provided", parameter="periods" + ) device_id = device.device_info.mac_address device_type = device.device_info.device_type @@ -553,7 +575,10 @@ async def request_tou_settings( ValueError: If controller_serial_number is empty """ if not controller_serial_number: - raise ValueError("controller_serial_number is required") + raise ParameterValidationError( + "controller_serial_number is required", + parameter="controller_serial_number", + ) device_id = device.device_info.mac_address device_type = device.device_info.device_type diff --git a/src/nwp500/mqtt_subscriptions.py b/src/nwp500/mqtt_subscriptions.py index 3bf74cf..42eb5ab 100644 --- a/src/nwp500/mqtt_subscriptions.py +++ b/src/nwp500/mqtt_subscriptions.py @@ -18,6 +18,7 @@ from awscrt.exceptions import AwsCrtError from .events import EventEmitter +from .exceptions import MqttNotConnectedError from .models import Device, DeviceFeature, DeviceStatus, EnergyUsageResponse from .mqtt_utils import redact_topic @@ -207,7 +208,7 @@ async def subscribe( Exception: If subscription fails """ if not self._connection: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") _logger.info(f"Subscribing to topic: {redact_topic(topic)}") @@ -252,7 +253,7 @@ async def unsubscribe(self, topic: str) -> int: Exception: If unsubscribe fails """ if not self._connection: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") _logger.info(f"Unsubscribing from topic: {redact_topic(topic)}") @@ -293,7 +294,7 @@ async def resubscribe_all(self) -> None: Exception: If any subscription fails """ if not self._connection: - raise RuntimeError("Not connected to MQTT broker") + raise MqttNotConnectedError("Not connected to MQTT broker") if not self._subscriptions: _logger.debug("No subscriptions to restore") diff --git a/tests/test_api_helpers.py b/tests/test_api_helpers.py index 5968925..85eed07 100644 --- a/tests/test_api_helpers.py +++ b/tests/test_api_helpers.py @@ -14,6 +14,10 @@ encode_season_bitfield, encode_week_bitfield, ) +from nwp500.exceptions import ( + ParameterValidationError, + RangeValidationError, +) def test_encode_decode_week_bitfield(): @@ -27,9 +31,14 @@ def test_encode_decode_week_bitfield(): assert encode_week_bitfield([0, 6]) == (1 | 64) assert encode_week_bitfield([1, 7]) == (2 | 64) - with pytest.raises(ValueError): + # Invalid weekday name raises ParameterValidationError + with pytest.raises(ParameterValidationError): encode_week_bitfield(["Funday"]) # type: ignore[arg-type] + # Invalid weekday index raises RangeValidationError + with pytest.raises(RangeValidationError): + encode_week_bitfield([10]) # type: ignore[arg-type] + def test_encode_decode_season_bitfield(): months = [1, 6, 12] @@ -38,7 +47,7 @@ def test_encode_decode_season_bitfield(): decoded = decode_season_bitfield(bitfield) assert decoded == months - with pytest.raises(ValueError): + with pytest.raises(RangeValidationError): encode_season_bitfield([0]) @@ -48,7 +57,7 @@ def test_price_encoding_round_trip(): decoded = decode_price(encoded, 5) assert math.isclose(decoded, 0.34831, rel_tol=1e-9) - with pytest.raises(ValueError): + with pytest.raises(RangeValidationError): encode_price(1.23, -1) @@ -69,7 +78,7 @@ def test_build_reservation_entry(): assert reservation["mode"] == 4 assert reservation["param"] == 120 - with pytest.raises(ValueError): + with pytest.raises(RangeValidationError): build_reservation_entry( enabled=True, days=["Monday"], @@ -100,7 +109,7 @@ def test_build_tou_period(): assert period["priceMin"] == 34831 assert period["priceMax"] == 36217 - with pytest.raises(ValueError): + with pytest.raises(RangeValidationError): build_tou_period( season_months=[1], week_days=["Sunday"], diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..9aec279 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,377 @@ +"""Tests for exception hierarchy and exception handling.""" + +import pytest + +from nwp500.exceptions import ( + APIError, + AuthenticationError, + DeviceError, + DeviceNotFoundError, + DeviceOfflineError, + DeviceOperationError, + InvalidCredentialsError, + MqttConnectionError, + MqttCredentialsError, + MqttError, + MqttNotConnectedError, + MqttPublishError, + MqttSubscriptionError, + Nwp500Error, + ParameterValidationError, + RangeValidationError, + TokenExpiredError, + TokenRefreshError, + ValidationError, +) + + +class TestExceptionHierarchy: + """Test exception inheritance relationships.""" + + def test_base_exception_hierarchy(self): + """Test that all exceptions inherit from Nwp500Error.""" + assert issubclass(AuthenticationError, Nwp500Error) + assert issubclass(APIError, Nwp500Error) + assert issubclass(MqttError, Nwp500Error) + assert issubclass(ValidationError, Nwp500Error) + assert issubclass(DeviceError, Nwp500Error) + + 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): + """Test MQTT exception inheritance.""" + 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): + """Test validation exception inheritance.""" + 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) + assert issubclass(AuthenticationError, Exception) + assert issubclass(MqttNotConnectedError, Exception) + + +class TestBaseExceptionAttributes: + """Test Nwp500Error base class attributes and methods.""" + + def test_basic_error_creation(self): + """Test creating a basic error with just a message.""" + error = Nwp500Error("Test error message") + assert error.message == "Test error message" + assert error.error_code is None + assert error.details == {} + assert error.retriable is False + + def test_error_with_code(self): + """Test creating an error with an error code.""" + error = Nwp500Error("Test error", error_code="TEST_001") + assert error.error_code == "TEST_001" + assert "TEST_001" in str(error) + assert "[TEST_001]" in str(error) + + def test_error_with_details(self): + """Test creating an error with additional details.""" + details = {"foo": "bar", "baz": 123} + error = Nwp500Error("Test error", details=details) + assert error.details == details + + def test_retriable_error(self): + """Test creating a retriable error.""" + error = Nwp500Error("Test error", retriable=True) + assert error.retriable is True + assert "(retriable)" in str(error) + + def test_error_to_dict(self): + """Test serializing error to dictionary.""" + error = Nwp500Error( + "Test error", + error_code="TEST_001", + details={"key": "value"}, + retriable=True, + ) + error_dict = error.to_dict() + + assert error_dict["error_type"] == "Nwp500Error" + assert error_dict["message"] == "Test error" + assert error_dict["error_code"] == "TEST_001" + assert error_dict["details"] == {"key": "value"} + assert error_dict["retriable"] is True + + def test_error_string_representation(self): + """Test error string formatting.""" + # Basic error + error1 = Nwp500Error("Simple error") + assert str(error1) == "Simple error" + + # With error code + error2 = Nwp500Error("Error with code", error_code="ERR_123") + assert "Error with code" in str(error2) + assert "[ERR_123]" in str(error2) + + # Retriable + error3 = Nwp500Error("Retriable error", retriable=True) + assert "Retriable error" in str(error3) + assert "(retriable)" in str(error3) + + # All together + error4 = Nwp500Error( + "Complex error", error_code="ERR_456", retriable=True + ) + result = str(error4) + assert "Complex error" in result + assert "[ERR_456]" in result + assert "(retriable)" in result + + +class TestAuthenticationExceptions: + """Test authentication-related exceptions.""" + + def test_authentication_error_attributes(self): + """Test AuthenticationError attributes.""" + error = AuthenticationError( + "Auth failed", + status_code=401, + response={"code": 401, "msg": "Unauthorized"}, + ) + assert error.message == "Auth failed" + assert error.status_code == 401 + assert error.response == {"code": 401, "msg": "Unauthorized"} + + def test_invalid_credentials_error(self): + """Test InvalidCredentialsError.""" + error = InvalidCredentialsError("Invalid password", status_code=401) + assert isinstance(error, AuthenticationError) + 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) + assert isinstance(error, AuthenticationError) + assert error.message == "Refresh failed" + + +class TestAPIExceptions: + """Test API-related exceptions.""" + + def test_api_error_attributes(self): + """Test APIError attributes.""" + error = APIError( + "API request failed", + code=500, + response={"error": "Internal Server Error"}, + ) + assert error.message == "API request failed" + assert error.code == 500 + assert error.response == {"error": "Internal Server Error"} + + +class TestMQTTExceptions: + """Test MQTT-related exceptions.""" + + def test_mqtt_connection_error(self): + """Test MqttConnectionError.""" + error = MqttConnectionError("Connection failed", error_code="CONN_001") + assert isinstance(error, MqttError) + assert error.message == "Connection failed" + assert error.error_code == "CONN_001" + + def test_mqtt_not_connected_error(self): + """Test MqttNotConnectedError.""" + error = MqttNotConnectedError("Not connected to broker") + assert isinstance(error, MqttError) + assert "Not connected" in error.message + + def test_mqtt_publish_error(self): + """Test MqttPublishError.""" + error = MqttPublishError( + "Publish failed", error_code="PUB_001", retriable=True + ) + assert isinstance(error, MqttError) + 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") + assert isinstance(error, MqttError) + + +class TestValidationExceptions: + """Test validation-related exceptions.""" + + def test_parameter_validation_error(self): + """Test ParameterValidationError.""" + error = ParameterValidationError( + "Invalid parameter", + parameter="username", + value="", + ) + assert isinstance(error, ValidationError) + assert error.parameter == "username" + assert error.value == "" + + def test_range_validation_error(self): + """Test RangeValidationError.""" + error = RangeValidationError( + "Value out of range", + field="temperature", + value=200, + min_value=100, + max_value=140, + ) + assert isinstance(error, ValidationError) + assert error.field == "temperature" + assert error.value == 200 + assert error.min_value == 100 + assert error.max_value == 140 + + def test_range_validation_error_message(self): + """Test RangeValidationError with detailed message.""" + error = RangeValidationError( + "Temperature must be between 100 and 140", + field="temperature", + value=150, + min_value=100, + max_value=140, + ) + assert "100" in error.message + assert "140" in error.message + + +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.""" + + def test_exception_chain_preserved(self): + """Test that exception chains are preserved.""" + original = ValueError("Original error") + + try: + try: + raise original + except ValueError as e: + raise MqttConnectionError("Wrapped error") from e + except MqttConnectionError as e: + assert e.__cause__ is original + assert isinstance(e.__cause__, ValueError) + + def test_exception_chain_with_details(self): + """Test exception chain with additional details.""" + original = KeyError("missing_key") + + try: + try: + raise original + except KeyError as e: + raise APIError( + "Invalid response format", + code=500, + error_code="INVALID_JSON", + ) from e + except APIError as e: + assert e.__cause__ is original + assert e.error_code == "INVALID_JSON" + + +class TestExceptionUsagePatterns: + """Test common exception usage patterns.""" + + def test_catching_base_exception(self): + """Test catching all nwp500 exceptions.""" + with pytest.raises(Nwp500Error): + raise MqttNotConnectedError("Not connected") + + def test_catching_specific_mqtt_error(self): + """Test catching specific MQTT error.""" + with pytest.raises(MqttNotConnectedError): + raise MqttNotConnectedError("Not connected") + + def test_catching_mqtt_base_class(self): + """Test catching all MQTT errors.""" + with pytest.raises(MqttError): + raise MqttPublishError("Publish failed") + + def test_catching_validation_errors(self): + """Test catching validation errors.""" + with pytest.raises(ValidationError): + raise RangeValidationError( + "Out of range", + field="temp", + value=200, + min_value=0, + max_value=100, + ) + + def test_multiple_exception_handling(self): + """Test handling multiple exception types.""" + + def operation_that_may_fail(fail_type): + if fail_type == "connection": + raise MqttConnectionError("Connection failed") + elif fail_type == "credentials": + raise MqttCredentialsError("Invalid credentials") + elif fail_type == "validation": + raise RangeValidationError( + "Invalid range", + field="x", + value=10, + min_value=0, + max_value=5, + ) + + # Test each case + with pytest.raises(MqttConnectionError): + operation_that_may_fail("connection") + + with pytest.raises(MqttCredentialsError): + operation_that_may_fail("credentials") + + with pytest.raises(RangeValidationError): + operation_that_may_fail("validation")