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.
This commit is contained in:
Teemu R 2024-04-23 19:20:12 +02:00 committed by GitHub
parent aa969ef020
commit b860c32d5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 123 additions and 21 deletions

View File

@ -25,6 +25,7 @@ from kasa import (
DeviceFamilyType, DeviceFamilyType,
Discover, Discover,
EncryptType, EncryptType,
Feature,
KasaException, KasaException,
UnsupportedDeviceError, UnsupportedDeviceError,
) )
@ -583,6 +584,41 @@ async def sysinfo(dev):
return dev.sys_info 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() @cli.command()
@pass_dev @pass_dev
@click.pass_context @click.pass_context
@ -595,15 +631,13 @@ async def state(ctx, dev: Device):
echo(f"\tPort: {dev.port}") echo(f"\tPort: {dev.port}")
echo(f"\tDevice state: {dev.is_on}") echo(f"\tDevice state: {dev.is_on}")
if dev.children: if dev.children:
echo("\t[bold]== Children ==[/bold]") echo("\t== Children ==")
for child in dev.children: for child in dev.children:
echo(f"\t* {child.alias} ({child.model}, {child.device_type})") _echo_all_features(
for id_, feat in child.features.items(): child.features,
try: title_prefix=f"{child.alias} ({child.model}, {child.device_type})",
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() echo()
echo("\t[bold]== Generic information ==[/bold]") 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"\tMAC (rssi): {dev.mac} ({dev.rssi})")
echo(f"\tLocation: {dev.location}") echo(f"\tLocation: {dev.location}")
echo("\n\t[bold]== Device-specific information == [/bold]") _echo_all_features(dev.features)
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("\n\t[bold]== Modules ==[/bold]") echo("\n\t[bold]== Modules ==[/bold]")
for module in dev.modules.values(): for module in dev.modules.values():
echo(f"\t[green]+ {module}[/green]") echo(f"\t[green]+ {module}[/green]")
if verbose: 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"\tCredentials hash: {dev.credentials_hash}")
echo(f"\tDevice ID: {dev.device_id}")
echo() echo()
_echo_discovery_info(dev._discovery_info) _echo_discovery_info(dev._discovery_info)
return dev.internal_state return dev.internal_state

View File

@ -318,10 +318,10 @@ class Device(ABC):
def _add_feature(self, feature: Feature): def _add_feature(self, feature: Feature):
"""Add a new feature to the device.""" """Add a new feature to the device."""
desc_name = feature.name.lower().replace(" ", "_") if feature.id in self._features:
if desc_name in self._features: raise KasaException("Duplicate feature id %s" % feature.id)
raise KasaException("Duplicate feature name %s" % desc_name) assert feature.id is not None # TODO: hack for typing # noqa: S101
self._features[desc_name] = feature self._features[feature.id] = feature
@property @property
@abstractmethod @abstractmethod

View File

@ -10,6 +10,7 @@ if TYPE_CHECKING:
from .device import Device from .device import Device
# TODO: This is only useful for Feature, so maybe move to Feature.Type?
class FeatureType(Enum): class FeatureType(Enum):
"""Type to help decide how to present the feature.""" """Type to help decide how to present the feature."""
@ -24,6 +25,22 @@ class FeatureType(Enum):
class Feature: class Feature:
"""Feature defines a generic interface for device features.""" """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 instance required for getting and setting values
device: Device device: Device
#: User-friendly short description #: User-friendly short description
@ -38,6 +55,8 @@ class Feature:
icon: str | None = None icon: str | None = None
#: Unit, if applicable #: Unit, if applicable
unit: str | None = None unit: str | None = None
#: Category hint for downstreams
category: Feature.Category = Category.Unset
#: Type of the feature #: Type of the feature
type: FeatureType = FeatureType.Sensor type: FeatureType = FeatureType.Sensor
@ -50,14 +69,29 @@ class Feature:
#: If set, this property will be used to set *minimum_value* and *maximum_value*. #: If set, this property will be used to set *minimum_value* and *maximum_value*.
range_getter: str | None = None range_getter: str | None = None
#: Identifier
id: str | None = None
def __post_init__(self): def __post_init__(self):
"""Handle late-binding of members.""" """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 container = self.container if self.container is not None else self.device
if self.range_getter is not None: if self.range_getter is not None:
self.minimum_value, self.maximum_value = getattr( self.minimum_value, self.maximum_value = getattr(
container, self.range_getter 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 @property
def value(self): def value(self):
"""Return the current value.""" """Return the current value."""
@ -79,3 +113,13 @@ class Feature:
container = self.container if self.container is not None else self.device container = self.container if self.container is not None else self.device
return await getattr(container, self.attribute_setter)(value) 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

View File

@ -221,6 +221,7 @@ class IotBulb(IotDevice, Bulb):
minimum_value=1, minimum_value=1,
maximum_value=100, maximum_value=100,
type=FeatureType.Number, type=FeatureType.Number,
category=Feature.Category.Primary,
) )
) )
@ -233,6 +234,7 @@ class IotBulb(IotDevice, Bulb):
attribute_getter="color_temp", attribute_getter="color_temp",
attribute_setter="set_color_temp", attribute_setter="set_color_temp",
range_getter="valid_temperature_range", range_getter="valid_temperature_range",
category=Feature.Category.Primary,
) )
) )

View File

@ -306,7 +306,11 @@ class IotDevice(Device):
async def _initialize_features(self): async def _initialize_features(self):
self._add_feature( self._add_feature(
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: if "on_time" in self._sys_info:

View File

@ -32,6 +32,7 @@ class Brightness(SmartModule):
minimum_value=BRIGHTNESS_MIN, minimum_value=BRIGHTNESS_MIN,
maximum_value=BRIGHTNESS_MAX, maximum_value=BRIGHTNESS_MAX,
type=FeatureType.Number, type=FeatureType.Number,
category=Feature.Category.Primary,
) )
) )

View File

@ -33,6 +33,7 @@ class ColorTemperatureModule(SmartModule):
attribute_getter="color_temp", attribute_getter="color_temp",
attribute_setter="set_color_temp", attribute_setter="set_color_temp",
range_getter="valid_temperature_range", range_getter="valid_temperature_range",
category=Feature.Category.Primary,
) )
) )

View File

@ -30,6 +30,7 @@ class FanModule(SmartModule):
type=FeatureType.Number, type=FeatureType.Number,
minimum_value=1, minimum_value=1,
maximum_value=4, maximum_value=4,
category=Feature.Category.Primary,
) )
) )
self._add_feature( self._add_feature(

View File

@ -28,6 +28,7 @@ class LedModule(SmartModule):
attribute_getter="led", attribute_getter="led",
attribute_setter="set_led", attribute_setter="set_led",
type=FeatureType.Switch, type=FeatureType.Switch,
category=Feature.Category.Config,
) )
) )

View File

@ -25,6 +25,7 @@ class ReportModule(SmartModule):
"Report interval", "Report interval",
container=self, container=self,
attribute_getter="report_interval", attribute_getter="report_interval",
category=Feature.Category.Debug,
) )
) )

View File

@ -28,6 +28,7 @@ class TimeModule(SmartModule):
name="Time", name="Time",
attribute_getter="time", attribute_getter="time",
container=self, container=self,
category=Feature.Category.Debug,
) )
) )

View File

@ -176,7 +176,14 @@ class SmartDevice(Device):
async def _initialize_features(self): async def _initialize_features(self):
"""Initialize device features.""" """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: if "device_on" in self._info:
self._add_feature( self._add_feature(
Feature( Feature(
@ -185,6 +192,7 @@ class SmartDevice(Device):
attribute_getter="is_on", attribute_getter="is_on",
attribute_setter="set_state", attribute_setter="set_state",
type=FeatureType.Switch, type=FeatureType.Switch,
category=Feature.Category.Primary,
) )
) )
@ -195,6 +203,7 @@ class SmartDevice(Device):
"Signal Level", "Signal Level",
attribute_getter=lambda x: x._info["signal_level"], attribute_getter=lambda x: x._info["signal_level"],
icon="mdi:signal", icon="mdi:signal",
category=Feature.Category.Info,
) )
) )
@ -205,13 +214,18 @@ class SmartDevice(Device):
"RSSI", "RSSI",
attribute_getter=lambda x: x._info["rssi"], attribute_getter=lambda x: x._info["rssi"],
icon="mdi:signal", icon="mdi:signal",
category=Feature.Category.Debug,
) )
) )
if "ssid" in self._info: if "ssid" in self._info:
self._add_feature( self._add_feature(
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"], attribute_getter=lambda x: x._info["overheated"],
icon="mdi:heat-wave", icon="mdi:heat-wave",
type=FeatureType.BinarySensor, type=FeatureType.BinarySensor,
category=Feature.Category.Debug,
) )
) )
@ -235,6 +250,7 @@ class SmartDevice(Device):
name="On since", name="On since",
attribute_getter="on_since", attribute_getter="on_since",
icon="mdi:clock", icon="mdi:clock",
category=Feature.Category.Debug,
) )
) )