Embed FeatureType inside Feature (#860)

Moves `FeatureType` into `Feature` to make it easier to use the API.
This also enforces that no invalid types are accepted (i.e.,
`Category.Config` cannot be a `Sensor`)
If `--verbose` is used with the cli tool, some extra information is
displayed for features when in the state command.
This commit is contained in:
Teemu R 2024-04-24 18:38:52 +02:00 committed by GitHub
parent e410e4f3f3
commit 65874c0365
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 135 additions and 69 deletions

View File

@ -35,7 +35,7 @@ from kasa.exceptions import (
TimeoutError,
UnsupportedDeviceError,
)
from kasa.feature import Feature, FeatureType
from kasa.feature import Feature
from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.iotprotocol import (
IotProtocol,
@ -58,7 +58,6 @@ __all__ = [
"TurnOnBehavior",
"DeviceType",
"Feature",
"FeatureType",
"EmeterStatus",
"Device",
"Bulb",

View File

@ -585,7 +585,10 @@ async def sysinfo(dev):
def _echo_features(
features: dict[str, Feature], title: str, category: Feature.Category | None = None
features: dict[str, Feature],
title: str,
category: Feature.Category | None = None,
verbose: bool = False,
):
"""Print out a listing of features and their values."""
if category is not None:
@ -599,24 +602,42 @@ def _echo_features(
for _, feat in features.items():
try:
echo(f"\t{feat}")
if verbose:
echo(f"\t\tType: {feat.type}")
echo(f"\t\tCategory: {feat.category}")
echo(f"\t\tIcon: {feat.icon}")
except Exception as ex:
echo(f"\t{feat.name} ({feat.id}): got exception (%s)" % ex)
def _echo_all_features(features, title_prefix=None):
def _echo_all_features(features, *, verbose=False, title_prefix=None):
"""Print out all features by category."""
if title_prefix is not None:
echo(f"[bold]\n\t == {title_prefix} ==[/bold]")
_echo_features(
features, title="\n\t== Primary features ==", category=Feature.Category.Primary
features,
title="\n\t== Primary features ==",
category=Feature.Category.Primary,
verbose=verbose,
)
_echo_features(
features, title="\n\t== Information ==", category=Feature.Category.Info
features,
title="\n\t== Information ==",
category=Feature.Category.Info,
verbose=verbose,
)
_echo_features(
features, title="\n\t== Configuration ==", category=Feature.Category.Config
features,
title="\n\t== Configuration ==",
category=Feature.Category.Config,
verbose=verbose,
)
_echo_features(
features,
title="\n\t== Debug ==",
category=Feature.Category.Debug,
verbose=verbose,
)
_echo_features(features, title="\n\t== Debug ==", category=Feature.Category.Debug)
@cli.command()
@ -636,6 +657,7 @@ async def state(ctx, dev: Device):
_echo_all_features(
child.features,
title_prefix=f"{child.alias} ({child.model}, {child.device_type})",
verbose=verbose,
)
echo()
@ -647,7 +669,7 @@ async def state(ctx, dev: Device):
echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})")
echo(f"\tLocation: {dev.location}")
_echo_all_features(dev.features)
_echo_all_features(dev.features, verbose=verbose)
echo("\n\t[bold]== Modules ==[/bold]")
for module in dev.modules.values():

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, Callable
@ -10,26 +11,44 @@ if TYPE_CHECKING:
from .device import Device
# TODO: This is only useful for Feature, so maybe move to Feature.Type?
class FeatureType(Enum):
"""Type to help decide how to present the feature."""
Sensor = auto()
BinarySensor = auto()
Switch = auto()
Action = auto()
Number = auto()
_LOGGER = logging.getLogger(__name__)
@dataclass
class Feature:
"""Feature defines a generic interface for device features."""
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 :ref:`range_getter`, :ref:`minimum_value`, and :ref:`maximum_value`
Number = auto()
#: Choice defines a setting with pre-defined values
Choice = auto()
Unknown = -1
# TODO: unsure if this is a great idea..
Sensor = Type.Sensor
BinarySensor = Type.BinarySensor
Switch = Type.Switch
Action = Type.Action
Number = Type.Number
Choice = Type.Choice
class Category(Enum):
"""Category hint for downstreams."""
"""Category hint to allow feature grouping."""
#: Primary features control the device state directly.
#: Examples including turning the device on, or adjust its brightness.
#: Examples include turning the device on/off, or adjusting its brightness.
Primary = auto()
#: Config features change device behavior without immediate state changes.
Config = auto()
@ -58,7 +77,7 @@ class Feature:
#: Category hint for downstreams
category: Feature.Category = Category.Unset
#: Type of the feature
type: FeatureType = FeatureType.Sensor
type: Feature.Type = Type.Sensor
# Number-specific attributes
#: Minimum value
@ -92,10 +111,19 @@ class Feature:
else:
self.category = Feature.Category.Info
if self.category == Feature.Category.Config and self.type in [
Feature.Type.Sensor,
Feature.Type.BinarySensor,
]:
raise ValueError(
f"Invalid type for configurable feature: {self.name} ({self.id}):"
f" {self.type}"
)
@property
def value(self):
"""Return the current value."""
if self.type == FeatureType.Action:
if self.type == Feature.Type.Action:
return "<Action>"
if self.attribute_getter is None:
raise ValueError("Not an action and no attribute_getter set")
@ -109,7 +137,7 @@ class Feature:
"""Set the value."""
if self.attribute_setter is None:
raise ValueError("Tried to set read-only feature.")
if self.type == FeatureType.Number: # noqa: SIM102
if self.type == Feature.Type.Number: # noqa: SIM102
if value < self.minimum_value or value > self.maximum_value:
raise ValueError(
f"Value {value} out of range "
@ -117,7 +145,7 @@ class Feature:
)
container = self.container if self.container is not None else self.device
if self.type == FeatureType.Action:
if self.type == Feature.Type.Action:
return await getattr(container, self.attribute_setter)()
return await getattr(container, self.attribute_setter)(value)
@ -127,7 +155,7 @@ class Feature:
if self.unit is not None:
s += f" {self.unit}"
if self.type == FeatureType.Number:
if self.type == Feature.Type.Number:
s += f" (range: {self.minimum_value}-{self.maximum_value})"
return s

View File

@ -15,7 +15,7 @@ except ImportError:
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..feature import Feature, FeatureType
from ..feature import Feature
from ..protocol import BaseProtocol
from .iotdevice import IotDevice, KasaException, requires_update
from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage
@ -221,7 +221,7 @@ class IotBulb(IotDevice, Bulb):
attribute_setter="set_brightness",
minimum_value=1,
maximum_value=100,
type=FeatureType.Number,
type=Feature.Type.Number,
category=Feature.Category.Primary,
)
)

View File

@ -7,7 +7,7 @@ from typing import Any
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..feature import Feature, FeatureType
from ..feature import Feature
from ..protocol import BaseProtocol
from .iotdevice import KasaException, requires_update
from .iotplug import IotPlug
@ -96,7 +96,7 @@ class IotDimmer(IotPlug):
attribute_setter="set_brightness",
minimum_value=1,
maximum_value=100,
type=FeatureType.Number,
type=Feature.Type.Number,
)
)

View File

@ -6,7 +6,7 @@ import logging
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..feature import Feature, FeatureType
from ..feature import Feature
from ..protocol import BaseProtocol
from .iotdevice import IotDevice, requires_update
from .modules import Antitheft, Cloud, Schedule, Time, Usage
@ -69,7 +69,7 @@ class IotPlug(IotDevice):
icon="mdi:led-{state}",
attribute_getter="led",
attribute_setter="set_led",
type=FeatureType.Switch,
type=Feature.Type.Switch,
)
)

View File

@ -1,6 +1,6 @@
"""Implementation of the ambient light (LAS) module found in some dimmers."""
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..iotmodule import IotModule, merge
# TODO create tests and use the config reply there
@ -25,7 +25,7 @@ class AmbientLight(IotModule):
name="Ambient Light",
icon="mdi:brightness-percent",
attribute_getter="ambientlight_brightness",
type=FeatureType.Sensor,
type=Feature.Type.Sensor,
)
)

View File

@ -5,7 +5,7 @@ try:
except ImportError:
from pydantic import BaseModel
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..iotmodule import IotModule
@ -36,7 +36,7 @@ class Cloud(IotModule):
name="Cloud connection",
icon="mdi:cloud",
attribute_getter="is_connected",
type=FeatureType.BinarySensor,
type=Feature.Type.BinarySensor,
)
)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
@ -32,7 +32,7 @@ class AlarmModule(SmartModule):
container=self,
attribute_getter="active",
icon="mdi:bell",
type=FeatureType.BinarySensor,
type=Feature.Type.BinarySensor,
)
)
self._add_feature(
@ -60,7 +60,7 @@ class AlarmModule(SmartModule):
"Test alarm",
container=self,
attribute_setter="play",
type=FeatureType.Action,
type=Feature.Type.Action,
)
)
self._add_feature(
@ -69,7 +69,7 @@ class AlarmModule(SmartModule):
"Stop alarm",
container=self,
attribute_setter="stop",
type=FeatureType.Action,
type=Feature.Type.Action,
)
)

View File

@ -27,6 +27,7 @@ class AutoOffModule(SmartModule):
container=self,
attribute_getter="enabled",
attribute_setter="set_enabled",
type=Feature.Type.Switch,
)
)
self._add_feature(
@ -36,6 +37,7 @@ class AutoOffModule(SmartModule):
container=self,
attribute_getter="delay",
attribute_setter="set_delay",
type=Feature.Type.Number,
)
)
self._add_feature(

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
@ -35,7 +35,7 @@ class BatterySensor(SmartModule):
container=self,
attribute_getter="battery_low",
icon="mdi:alert",
type=FeatureType.BinarySensor,
type=Feature.Type.BinarySensor,
)
)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
@ -31,7 +31,7 @@ class Brightness(SmartModule):
attribute_setter="set_brightness",
minimum_value=BRIGHTNESS_MIN,
maximum_value=BRIGHTNESS_MAX,
type=FeatureType.Number,
type=Feature.Type.Number,
category=Feature.Category.Primary,
)
)

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ...exceptions import SmartErrorCode
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
@ -28,7 +28,7 @@ class CloudModule(SmartModule):
container=self,
attribute_getter="is_connected",
icon="mdi:cloud",
type=FeatureType.BinarySensor,
type=Feature.Type.BinarySensor,
)
)

View File

@ -25,8 +25,9 @@ class ColorModule(SmartModule):
"HSV",
container=self,
attribute_getter="hsv",
# TODO proper type for setting hsv
attribute_setter="set_hsv",
# TODO proper type for setting hsv
type=Feature.Type.Unknown,
)
)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
@ -27,7 +27,7 @@ class FanModule(SmartModule):
attribute_getter="fan_speed_level",
attribute_setter="set_fan_speed_level",
icon="mdi:fan",
type=FeatureType.Number,
type=Feature.Type.Number,
minimum_value=1,
maximum_value=4,
category=Feature.Category.Primary,
@ -41,7 +41,7 @@ class FanModule(SmartModule):
attribute_getter="sleep_mode",
attribute_setter="set_sleep_mode",
icon="mdi:sleep",
type=FeatureType.Switch,
type=Feature.Type.Switch,
)
)

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional
from ...exceptions import SmartErrorCode
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
try:
@ -59,7 +59,7 @@ class Firmware(SmartModule):
container=self,
attribute_getter="auto_update_enabled",
attribute_setter="set_auto_update_enabled",
type=FeatureType.Switch,
type=Feature.Type.Switch,
)
)
self._add_feature(
@ -68,7 +68,7 @@ class Firmware(SmartModule):
"Update available",
container=self,
attribute_getter="update_available",
type=FeatureType.BinarySensor,
type=Feature.Type.BinarySensor,
)
)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
@ -34,7 +34,7 @@ class HumiditySensor(SmartModule):
"Humidity warning",
container=self,
attribute_getter="humidity_warning",
type=FeatureType.BinarySensor,
type=Feature.Type.BinarySensor,
icon="mdi:alert",
)
)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
@ -27,7 +27,7 @@ class LedModule(SmartModule):
icon="mdi:led-{state}",
attribute_getter="led",
attribute_setter="set_led",
type=FeatureType.Switch,
type=Feature.Type.Switch,
category=Feature.Category.Config,
)
)

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ...exceptions import KasaException
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
@ -35,7 +35,7 @@ class LightTransitionModule(SmartModule):
icon=icon,
attribute_getter="enabled_v1",
attribute_setter="set_enabled_v1",
type=FeatureType.Switch,
type=Feature.Type.Switch,
)
)
elif self.supported_version >= 2:
@ -51,7 +51,7 @@ class LightTransitionModule(SmartModule):
attribute_getter="turn_on_transition",
attribute_setter="set_turn_on_transition",
icon=icon,
type=FeatureType.Number,
type=Feature.Type.Number,
maximum_value=self.MAXIMUM_DURATION,
)
) # self._turn_on_transition_max
@ -63,7 +63,7 @@ class LightTransitionModule(SmartModule):
attribute_getter="turn_off_transition",
attribute_setter="set_turn_off_transition",
icon=icon,
type=FeatureType.Number,
type=Feature.Type.Number,
maximum_value=self.MAXIMUM_DURATION,
)
) # self._turn_off_transition_max

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from ...feature import Feature, FeatureType
from ...feature import Feature
from ..smartmodule import SmartModule
if TYPE_CHECKING:
@ -35,7 +35,7 @@ class TemperatureSensor(SmartModule):
"Temperature warning",
container=self,
attribute_getter="temperature_warning",
type=FeatureType.BinarySensor,
type=Feature.Type.BinarySensor,
icon="mdi:alert",
)
)
@ -46,6 +46,7 @@ class TemperatureSensor(SmartModule):
container=self,
attribute_getter="temperature_unit",
attribute_setter="set_temperature_unit",
type=Feature.Type.Choice,
)
)
# TODO: use temperature_unit for feature creation

View File

@ -26,6 +26,7 @@ class TemperatureControl(SmartModule):
attribute_getter="target_temperature",
attribute_setter="set_target_temperature",
icon="mdi:thermometer",
type=Feature.Type.Number,
)
)
# TODO: this might belong into its own module, temperature_correction?
@ -38,6 +39,7 @@ class TemperatureControl(SmartModule):
attribute_setter="set_temperature_offset",
minimum_value=-10,
maximum_value=10,
type=Feature.Type.Number,
)
)

View File

@ -13,7 +13,7 @@ from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
from ..feature import Feature, FeatureType
from ..feature import Feature
from ..smartprotocol import SmartProtocol
from .modules import * # noqa: F403
@ -191,7 +191,7 @@ class SmartDevice(Device):
"State",
attribute_getter="is_on",
attribute_setter="set_state",
type=FeatureType.Switch,
type=Feature.Type.Switch,
category=Feature.Category.Primary,
)
)
@ -236,7 +236,7 @@ class SmartDevice(Device):
"Overheated",
attribute_getter=lambda x: x._info["overheated"],
icon="mdi:heat-wave",
type=FeatureType.BinarySensor,
type=Feature.Type.BinarySensor,
category=Feature.Category.Debug,
)
)

View File

@ -1,6 +1,6 @@
import pytest
from kasa import Feature, FeatureType
from kasa import Feature
class DummyDevice:
@ -18,7 +18,7 @@ def dummy_feature() -> Feature:
attribute_setter="dummysetter",
container=None,
icon="mdi:dummy",
type=FeatureType.BinarySensor,
type=Feature.Type.Switch,
unit="dummyunit",
)
return feat
@ -32,10 +32,21 @@ def test_feature_api(dummy_feature: Feature):
assert dummy_feature.attribute_setter == "dummysetter"
assert dummy_feature.container is None
assert dummy_feature.icon == "mdi:dummy"
assert dummy_feature.type == FeatureType.BinarySensor
assert dummy_feature.type == Feature.Type.Switch
assert dummy_feature.unit == "dummyunit"
def test_feature_missing_type():
"""Test that creating a feature with a setter but without type causes an error."""
with pytest.raises(ValueError):
Feature(
device=DummyDevice(), # type: ignore[arg-type]
name="dummy error",
attribute_getter="dummygetter",
attribute_setter="dummysetter",
)
def test_feature_value(dummy_feature: Feature):
"""Verify that property gets accessed on *value* access."""
dummy_feature.attribute_getter = "test_prop"
@ -91,7 +102,7 @@ async def test_feature_action(mocker):
attribute_setter="call_action",
container=None,
icon="mdi:dummy",
type=FeatureType.Action,
type=Feature.Type.Action,
)
mock_call_action = mocker.patch.object(feat.device, "call_action", create=True)
assert feat.value == "<Action>"