Source code for nwp500.command_decorators

"""Decorators for device command validation and capability checking.

This module provides decorators that automatically validate device capabilities
before command execution, preventing unsupported commands from being sent.
"""

from __future__ import annotations

import functools
import inspect
import logging
from collections.abc import Callable
from typing import Any, TypeVar

from .device_capabilities import MqttDeviceCapabilityChecker
from .exceptions import DeviceCapabilityError

__author__ = "Emmanuel Levijarvi"

_logger = logging.getLogger(__name__)

# Type variable for async functions
F = TypeVar("F", bound=Callable[..., Any])


[docs] def requires_capability(feature: str) -> Callable[[F], F]: """Decorator that validates device capability before executing command. This decorator automatically checks if a device supports a specific controllable feature before allowing the command to execute. If the device doesn't support the feature, a DeviceCapabilityError is raised. The decorator automatically caches device info on first call using _get_device_features(), which internally calls ensure_device_info_cached(). This means capability validation is transparent to the caller - no manual caching is required. The decorator expects the command method to: 1. Have 'self' (controller instance with _device_info_cache) 2. Have 'device' parameter (Device object with mac_address) Args: feature: Name of the required capability (e.g., "recirculation_mode") Returns: Decorator function Raises: DeviceCapabilityError: If device doesn't support the feature ValueError: If feature name is not recognized Example: >>> class MyController: ... def __init__(self, cache): ... self._device_info_cache = cache ... ... @requires_capability("recirculation_mode") ... async def set_recirculation_mode(self, device, mode): ... # Device info automatically cached on first call ... # Capability automatically validated before execution ... return await self._publish(...) """ def decorator(func: F) -> F: # Determine if this is an async function is_async = inspect.iscoroutinefunction(func) if is_async: @functools.wraps(func) async def async_wrapper( self: Any, device: Any, *args: Any, **kwargs: Any ) -> Any: # Get cached features, auto-requesting if necessary _logger.info( f"Checking capability '{feature}' for {func.__name__}" ) try: cached_features = await self._get_device_features(device) except DeviceCapabilityError: # Re-raise capability errors as-is (don't mask them) raise except Exception as e: # Wrap other errors (timeouts, connection issues, etc) raise DeviceCapabilityError( feature, f"Cannot execute {func.__name__}: {str(e)}", ) from e if cached_features is None: raise DeviceCapabilityError( feature, f"Cannot execute {func.__name__}: " f"Device info could not be obtained.", ) # Validate capability if feature is defined in DeviceFeature if hasattr(cached_features, feature): supported = MqttDeviceCapabilityChecker.supports( feature, cached_features ) _logger.debug( f"Cap '{feature}': {'OK' if supported else 'FAIL'}" ) MqttDeviceCapabilityChecker.assert_supported( feature, cached_features ) else: raise DeviceCapabilityError( feature, f"Feature '{feature}' missing. Prevented." ) # Execute command return await func(self, device, *args, **kwargs) return async_wrapper # type: ignore else: @functools.wraps(func) def sync_wrapper( self: Any, device: Any, *args: Any, **kwargs: Any ) -> Any: # Sync functions cannot support capability checking # as it requires async device info lookup raise TypeError( f"{func.__name__} must be async to use " f"@requires_capability decorator. Capability checking " f"requires async device info cache access." ) return sync_wrapper # type: ignore return decorator