Source code for kasa.feature

"""Interact with feature.

Features are implemented by devices to represent individual pieces of functionality like
state, time, firmware.

>>> from kasa import Discover, Module
>>>
>>> dev = await Discover.discover_single(
>>>     "127.0.0.3",
>>>     username="user@example.com",
>>>     password="great_password"
>>> )
>>> await dev.update()
>>> print(dev.alias)
Living Room Bulb

Features allow for instrospection and can be interacted with as new features are added
to the API:

>>> for feature_id, feature in dev.features.items():
>>>     print(f"{feature.name} ({feature_id}): {feature.value}")
Device ID (device_id): 0000000000000000000000000000000000000000
State (state): True
Signal Level (signal_level): 2
RSSI (rssi): -52
SSID (ssid): #MASKED_SSID#
Reboot (reboot): <Action>
Device time (device_time): 2024-02-23 02:40:15+01:00
Brightness (brightness): 100
Cloud connection (cloud_connection): True
HSV (hsv): HSV(hue=0, saturation=100, value=100)
Color temperature (color_temperature): 2700
Auto update enabled (auto_update_enabled): False
Update available (update_available): None
Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828
Available firmware version (available_firmware_version): None
Check latest firmware (check_latest_firmware): <Action>
Light effect (light_effect): Off
Light preset (light_preset): Not set
Smooth transition on (smooth_transition_on): 2
Smooth transition off (smooth_transition_off): 2
Overheated (overheated): False

To see whether a device supports a feature, check for the existence of it:

>>> if feature := dev.features.get("brightness"):
>>>     print(feature.value)
100

You can update the value of a feature

>>> await feature.set_value(50)
>>> await dev.update()
>>> print(feature.value)
50

Features have types that can be used for introspection:

>>> feature = dev.features["light_preset"]
>>> print(feature.type)
Type.Choice

>>> print(feature.choices)
['Not set', 'Light preset 1', 'Light preset 2', 'Light preset 3',\
 'Light preset 4', 'Light preset 5', 'Light preset 6', 'Light preset 7']
"""

from __future__ import annotations

import logging
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from enum import Enum, auto
from functools import cached_property
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from .device import Device
    from .module import Module

_LOGGER = logging.getLogger(__name__)


[docs] @dataclass class Feature: """Feature defines a generic interface for device features."""
[docs] class Type(Enum): """Type to help decide how to present the feature.""" #: Sensor is an informative read-only value Sensor = auto() #: BinarySensor is a read-only boolean BinarySensor = auto() #: Switch is a boolean setting Switch = auto() #: Action triggers some action on device Action = auto() #: Number defines a numeric setting #: See :attr:`range_getter`, :attr:`Feature.minimum_value`, #: and :attr:`maximum_value` Number = auto() #: Choice defines a setting with pre-defined values Choice = auto() Unknown = -1
# Aliases for easy access Sensor = Type.Sensor BinarySensor = Type.BinarySensor Switch = Type.Switch Action = Type.Action Number = Type.Number Choice = Type.Choice DEFAULT_MAX = 2**16 # Arbitrary max
[docs] class Category(Enum): """Category hint to allow feature grouping.""" #: Primary features control the device state directly. #: Examples include turning the device on/off, or adjusting its brightness. Primary = auto() #: Config features change device behavior without immediate state changes. Config = auto() #: Informative/sensor features deliver some potentially interesting information. Info = auto() #: Debug features deliver more verbose information then informative features. #: You may want to hide these per default to avoid cluttering your UI. Debug = auto() #: The default category if none is specified. Unset = -1
#: Device instance required for getting and setting values device: Device #: Identifier id: str #: User-friendly short description name: str #: Type of the feature type: Feature.Type #: Callable or name of the property that allows accessing the value attribute_getter: str | Callable | None = None #: Callable coroutine or name of the method that allows changing the value attribute_setter: str | Callable[..., Coroutine[Any, Any, Any]] | None = None #: Container storing the data, this overrides 'device' for getters container: Device | Module | None = None #: Icon suggestion icon: str | None = None #: Attribute containing the name of the unit getter property. #: If set, this property will be used to get the *unit*. unit_getter: str | Callable[[], str] | None = None #: Category hint for downstreams category: Feature.Category = Category.Unset # Display hints offer a way suggest how the value should be shown to users #: Hint to help rounding the sensor values to given after-comma digits precision_hint: int | None = None #: Attribute containing the name of the range getter property. #: If set, this property will be used to set *minimum_value* and *maximum_value*. range_getter: str | Callable[[], tuple[int, int]] | None = None #: Attribute name of the choices getter property. #: If set, this property will be used to get *choices*. choices_getter: str | Callable[[], list[str]] | None = None def __post_init__(self) -> None: """Handle late-binding of members.""" # Populate minimum & maximum values, if range_getter is given self._container = self.container if self.container is not None else self.device # Set the category, if unset if self.category is Feature.Category.Unset: if self.attribute_setter: self.category = Feature.Category.Config else: self.category = Feature.Category.Info if self.type in ( Feature.Type.Sensor, Feature.Type.BinarySensor, ): if self.category == Feature.Category.Config: raise ValueError( f"Invalid type for configurable feature: {self.name} ({self.id}):" f" {self.type}" ) elif self.attribute_setter is not None: raise ValueError( f"Read-only feat defines attribute_setter: {self.name} ({self.id}):" ) def _get_property_value(self, getter: str | Callable | None) -> Any: if getter is None: return None if isinstance(getter, str): return getattr(self._container, getter) if callable(getter): return getter() raise ValueError("Invalid getter: %s", getter) # pragma: no cover @property def choices(self) -> list[str] | None: """List of choices.""" return self._get_property_value(self.choices_getter) @property def unit(self) -> str | None: """Unit if applicable.""" return self._get_property_value(self.unit_getter)
[docs] @cached_property def range(self) -> tuple[int, int] | None: """Range of values if applicable.""" return self._get_property_value(self.range_getter)
@property def maximum_value(self) -> int: """Maximum value.""" if range := self.range: return range[1] return self.DEFAULT_MAX @property def minimum_value(self) -> int: """Minimum value.""" if range := self.range: return range[0] return 0 @property def value(self) -> int | float | bool | str | Enum | None: """Return the current value.""" if self.type == Feature.Type.Action: return "<Action>" if self.attribute_getter is None: raise ValueError("Not an action and no attribute_getter set") container = self.container if self.container is not None else self.device if callable(self.attribute_getter): return self.attribute_getter(container) return getattr(container, self.attribute_getter)
[docs] async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: """Set the value.""" if self.attribute_setter is None: raise ValueError("Tried to set read-only feature.") if self.type == Feature.Type.Number: # noqa: SIM102 if not isinstance(value, int | float): raise ValueError("value must be a number") if value < self.minimum_value or value > self.maximum_value: raise ValueError( f"Value {value} out of range " f"[{self.minimum_value}, {self.maximum_value}]" ) elif self.type == Feature.Type.Choice: # noqa: SIM102 if not self.choices or value not in self.choices: raise ValueError( f"Unexpected value for {self.name}: '{value}'" f" - allowed: {self.choices}" ) if callable(self.attribute_setter): attribute_setter = self.attribute_setter else: container = self.container if self.container is not None else self.device attribute_setter = getattr(container, self.attribute_setter) if self.type == Feature.Type.Action: return await attribute_setter() return await attribute_setter(value)
def __repr__(self) -> str: try: value = self.value choices = self.choices except Exception as ex: return f"Unable to read value ({self.id}): {ex}" if self.type == Feature.Type.Choice: if not isinstance(choices, list): _LOGGER.error( "Choices are not properly defined for %s (%s). Type: <%s> Value: %s", # noqa: E501 self.name, self.id, type(choices), choices, ) return f"{self.name} ({self.id}): improperly defined choice set." if (value not in choices) and ( isinstance(value, Enum) and value.name not in choices ): _LOGGER.warning( "Invalid value for for choice %s (%s): %s not in %s", self.name, self.id, value, choices, ) return ( f"{self.name} ({self.id}): invalid value '{value}' not in {choices}" ) value = " ".join( [ f"*{choice}*" if choice == value or (isinstance(value, Enum) and choice == value.name) else f"{choice}" for choice in choices ] ) if self.precision_hint is not None and isinstance(value, float): value = round(value, self.precision_hint) if isinstance(value, Enum): value = repr(value) s = f"{self.name} ({self.id}): {value}" if (unit := self.unit) is not None: if isinstance(unit, Enum): unit = repr(unit) s += f" {unit}" if self.type == Feature.Type.Number: s += f" (range: {self.minimum_value}-{self.maximum_value})" return s