From b860c32d5f7f828b39cb718394d91530d1a10a61 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Apr 2024 19:20:12 +0200 Subject: [PATCH] Implement feature categories (#846) Initial implementation for feature categories to help downstreams and our cli tool to categorize the data for more user-friendly manner. As more and more information is being exposed through the generic features interface, it is necessary to give some hints to downstreams about how might want to present the information to users. This is not a 1:1 mapping to the homeassistant's mental model, and it will be necessary to fine-tune homeassistant-specific parameters by other means to polish the presentation. --- kasa/cli.py | 58 ++++++++++++++++++++++-------- kasa/device.py | 8 ++--- kasa/feature.py | 44 +++++++++++++++++++++++ kasa/iot/iotbulb.py | 2 ++ kasa/iot/iotdevice.py | 6 +++- kasa/smart/modules/brightness.py | 1 + kasa/smart/modules/colortemp.py | 1 + kasa/smart/modules/fanmodule.py | 1 + kasa/smart/modules/ledmodule.py | 1 + kasa/smart/modules/reportmodule.py | 1 + kasa/smart/modules/timemodule.py | 1 + kasa/smart/smartdevice.py | 20 +++++++++-- 12 files changed, 123 insertions(+), 21 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index b5babdbb..b527bef1 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -25,6 +25,7 @@ from kasa import ( DeviceFamilyType, Discover, EncryptType, + Feature, KasaException, UnsupportedDeviceError, ) @@ -583,6 +584,41 @@ async def sysinfo(dev): return dev.sys_info +def _echo_features( + features: dict[str, Feature], title: str, category: Feature.Category | None = None +): + """Print out a listing of features and their values.""" + if category is not None: + features = { + id_: feat for id_, feat in features.items() if feat.category == category + } + + if not features: + return + echo(f"[bold]{title}[/bold]") + for _, feat in features.items(): + try: + echo(f"\t{feat}") + except Exception as ex: + echo(f"\t{feat.name} ({feat.id}): got exception (%s)" % ex) + + +def _echo_all_features(features, 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 + ) + _echo_features( + features, title="\n\t== Information ==", category=Feature.Category.Info + ) + _echo_features( + features, title="\n\t== Configuration ==", category=Feature.Category.Config + ) + _echo_features(features, title="\n\t== Debug ==", category=Feature.Category.Debug) + + @cli.command() @pass_dev @click.pass_context @@ -595,15 +631,13 @@ async def state(ctx, dev: Device): echo(f"\tPort: {dev.port}") echo(f"\tDevice state: {dev.is_on}") if dev.children: - echo("\t[bold]== Children ==[/bold]") + echo("\t== Children ==") for child in dev.children: - echo(f"\t* {child.alias} ({child.model}, {child.device_type})") - for id_, feat in child.features.items(): - try: - unit = f" {feat.unit}" if feat.unit else "" - echo(f"\t\t{feat.name} ({id_}): {feat.value}{unit}") - except Exception as ex: - echo(f"\t\t{feat.name}: got exception (%s)" % ex) + _echo_all_features( + child.features, + title_prefix=f"{child.alias} ({child.model}, {child.device_type})", + ) + echo() echo("\t[bold]== Generic information ==[/bold]") @@ -613,19 +647,15 @@ async def state(ctx, dev: Device): echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") echo(f"\tLocation: {dev.location}") - echo("\n\t[bold]== Device-specific information == [/bold]") - for id_, feature in dev.features.items(): - unit = f" {feature.unit}" if feature.unit else "" - echo(f"\t{feature.name} ({id_}): {feature.value}{unit}") + _echo_all_features(dev.features) echo("\n\t[bold]== Modules ==[/bold]") for module in dev.modules.values(): echo(f"\t[green]+ {module}[/green]") if verbose: - echo("\n\t[bold]== Verbose information ==[/bold]") + echo("\n\t[bold]== Protocol information ==[/bold]") echo(f"\tCredentials hash: {dev.credentials_hash}") - echo(f"\tDevice ID: {dev.device_id}") echo() _echo_discovery_info(dev._discovery_info) return dev.internal_state diff --git a/kasa/device.py b/kasa/device.py index a4c2b5e3..dda7822f 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -318,10 +318,10 @@ class Device(ABC): def _add_feature(self, feature: Feature): """Add a new feature to the device.""" - desc_name = feature.name.lower().replace(" ", "_") - if desc_name in self._features: - raise KasaException("Duplicate feature name %s" % desc_name) - self._features[desc_name] = feature + if feature.id in self._features: + raise KasaException("Duplicate feature id %s" % feature.id) + assert feature.id is not None # TODO: hack for typing # noqa: S101 + self._features[feature.id] = feature @property @abstractmethod diff --git a/kasa/feature.py b/kasa/feature.py index 6add0091..c1bbc97b 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -10,6 +10,7 @@ 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.""" @@ -24,6 +25,22 @@ class FeatureType(Enum): class Feature: """Feature defines a generic interface for device features.""" + class Category(Enum): + """Category hint for downstreams.""" + + #: Primary features control the device state directly. + #: Examples including turning the device on, or adjust 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 #: User-friendly short description @@ -38,6 +55,8 @@ class Feature: icon: str | None = None #: Unit, if applicable unit: str | None = None + #: Category hint for downstreams + category: Feature.Category = Category.Unset #: Type of the feature type: FeatureType = FeatureType.Sensor @@ -50,14 +69,29 @@ class Feature: #: If set, this property will be used to set *minimum_value* and *maximum_value*. range_getter: str | None = None + #: Identifier + id: str | None = None + def __post_init__(self): """Handle late-binding of members.""" + # Set id, if unset + if self.id is None: + self.id = self.name.lower().replace(" ", "_") + + # Populate minimum & maximum values, if range_getter is given container = self.container if self.container is not None else self.device if self.range_getter is not None: self.minimum_value, self.maximum_value = getattr( container, self.range_getter ) + # 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 + @property def value(self): """Return the current value.""" @@ -79,3 +113,13 @@ class Feature: container = self.container if self.container is not None else self.device return await getattr(container, self.attribute_setter)(value) + + def __repr__(self): + s = f"{self.name} ({self.id}): {self.value}" + if self.unit is not None: + s += f" {self.unit}" + + if self.type == FeatureType.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 26f40f06..834c49b1 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -221,6 +221,7 @@ class IotBulb(IotDevice, Bulb): minimum_value=1, maximum_value=100, type=FeatureType.Number, + category=Feature.Category.Primary, ) ) @@ -233,6 +234,7 @@ class IotBulb(IotDevice, Bulb): attribute_getter="color_temp", attribute_setter="set_color_temp", range_getter="valid_temperature_range", + category=Feature.Category.Primary, ) ) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 32781a54..d4551d0d 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -306,7 +306,11 @@ class IotDevice(Device): async def _initialize_features(self): self._add_feature( Feature( - device=self, name="RSSI", attribute_getter="rssi", icon="mdi:signal" + device=self, + name="RSSI", + attribute_getter="rssi", + icon="mdi:signal", + category=Feature.Category.Debug, ) ) if "on_time" in self._sys_info: diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index 1f0b4d99..eaacf644 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -32,6 +32,7 @@ class Brightness(SmartModule): minimum_value=BRIGHTNESS_MIN, maximum_value=BRIGHTNESS_MAX, type=FeatureType.Number, + category=Feature.Category.Primary, ) ) diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemp.py index 2ecb09dd..e0bfec6a 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemp.py @@ -33,6 +33,7 @@ class ColorTemperatureModule(SmartModule): attribute_getter="color_temp", attribute_setter="set_color_temp", range_getter="valid_temperature_range", + category=Feature.Category.Primary, ) ) diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 1d79cdea..7c440434 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -30,6 +30,7 @@ class FanModule(SmartModule): type=FeatureType.Number, minimum_value=1, maximum_value=4, + category=Feature.Category.Primary, ) ) self._add_feature( diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index cac447b5..75f90425 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -28,6 +28,7 @@ class LedModule(SmartModule): attribute_getter="led", attribute_setter="set_led", type=FeatureType.Switch, + category=Feature.Category.Config, ) ) diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmodule.py index 0f3987bd..99d95fec 100644 --- a/kasa/smart/modules/reportmodule.py +++ b/kasa/smart/modules/reportmodule.py @@ -25,6 +25,7 @@ class ReportModule(SmartModule): "Report interval", container=self, attribute_getter="report_interval", + category=Feature.Category.Debug, ) ) diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/timemodule.py index 7a0eb51b..80f1308e 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/timemodule.py @@ -28,6 +28,7 @@ class TimeModule(SmartModule): name="Time", attribute_getter="time", container=self, + category=Feature.Category.Debug, ) ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6bd8774a..69e4fe87 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -176,7 +176,14 @@ class SmartDevice(Device): async def _initialize_features(self): """Initialize device features.""" - self._add_feature(Feature(self, "Device ID", attribute_getter="device_id")) + self._add_feature( + Feature( + self, + "Device ID", + attribute_getter="device_id", + category=Feature.Category.Debug, + ) + ) if "device_on" in self._info: self._add_feature( Feature( @@ -185,6 +192,7 @@ class SmartDevice(Device): attribute_getter="is_on", attribute_setter="set_state", type=FeatureType.Switch, + category=Feature.Category.Primary, ) ) @@ -195,6 +203,7 @@ class SmartDevice(Device): "Signal Level", attribute_getter=lambda x: x._info["signal_level"], icon="mdi:signal", + category=Feature.Category.Info, ) ) @@ -205,13 +214,18 @@ class SmartDevice(Device): "RSSI", attribute_getter=lambda x: x._info["rssi"], icon="mdi:signal", + category=Feature.Category.Debug, ) ) if "ssid" in self._info: self._add_feature( Feature( - device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi" + device=self, + name="SSID", + attribute_getter="ssid", + icon="mdi:wifi", + category=Feature.Category.Debug, ) ) @@ -223,6 +237,7 @@ class SmartDevice(Device): attribute_getter=lambda x: x._info["overheated"], icon="mdi:heat-wave", type=FeatureType.BinarySensor, + category=Feature.Category.Debug, ) ) @@ -235,6 +250,7 @@ class SmartDevice(Device): name="On since", attribute_getter="on_since", icon="mdi:clock", + category=Feature.Category.Debug, ) )