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, ) )