diff --git a/kasa/__init__.py b/kasa/__init__.py index 68dbb0c1..ceaf7520 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -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", diff --git a/kasa/cli.py b/kasa/cli.py index b527bef1..66eb8936 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -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(): diff --git a/kasa/feature.py b/kasa/feature.py index ffa3df44..fc6e4c60 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -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 "" 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 diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index f0ecaada..4d6e49d2 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -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, ) ) diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index 9c8c8f55..672b2265 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -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, ) ) diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index c584131d..ecf73e03 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -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, ) ) diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index 44885b82..2d7d679b 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -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, ) ) diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 316617fd..5e552116 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -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, ) ) diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarmmodule.py index 30e432f4..5f6cd3ee 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarmmodule.py @@ -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, ) ) diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooffmodule.py index 1d31bfb9..019d4235 100644 --- a/kasa/smart/modules/autooffmodule.py +++ b/kasa/smart/modules/autooffmodule.py @@ -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( diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/battery.py index 982f9c6a..20bca34b 100644 --- a/kasa/smart/modules/battery.py +++ b/kasa/smart/modules/battery.py @@ -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, ) ) diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index eaacf644..af3026f6 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -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, ) ) diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloudmodule.py index 951ff789..55338f26 100644 --- a/kasa/smart/modules/cloudmodule.py +++ b/kasa/smart/modules/cloudmodule.py @@ -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, ) ) diff --git a/kasa/smart/modules/colormodule.py b/kasa/smart/modules/colormodule.py index 234acc74..3adf0b4e 100644 --- a/kasa/smart/modules/colormodule.py +++ b/kasa/smart/modules/colormodule.py @@ -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, ) ) diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 7c440434..083f025c 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -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, ) ) diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index eacfd702..c5540044 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -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, ) ) diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humidity.py index 8f829b26..26fca25a 100644 --- a/kasa/smart/modules/humidity.py +++ b/kasa/smart/modules/humidity.py @@ -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", ) ) diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index 75f90425..6fd0d637 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -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, ) ) diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index 229dea57..ebcb093c 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -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 diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperature.py index 3cec427b..7b83c42c 100644 --- a/kasa/smart/modules/temperature.py +++ b/kasa/smart/modules/temperature.py @@ -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 diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 9106a56f..1c190f67 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -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, ) ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 69e4fe87..6393c61c 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -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, ) ) diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index db2d27a8..d100fef0 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -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 == ""