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