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.
"""
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__}: {e!s}",
) 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