Skip to content

Commit

Permalink
Add generic interface for accessing device features (#741)
Browse files Browse the repository at this point in the history
This adds a generic interface for all device classes to introspect available device features,
that is necessary to make it easier to support a wide variety of supported devices with different set of features.
This will allow constructing generic interfaces (e.g., in homeassistant) that fetch and change these features without hard-coding the API calls.

`Device.features()` now returns a mapping of `<identifier, Feature>` where the `Feature` contains all necessary information (like the name, the icon, a way to get and change the setting) to present and change the defined feature through its interface.
  • Loading branch information
rytilahti committed Feb 15, 2024
1 parent 5783527 commit 64da736
Show file tree
Hide file tree
Showing 12 changed files with 345 additions and 28 deletions.
3 changes: 3 additions & 0 deletions kasa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
TimeoutException,
UnsupportedDeviceException,
)
from kasa.feature import Feature, FeatureType
from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.iotprotocol import (
IotProtocol,
Expand All @@ -54,6 +55,8 @@
"TurnOnBehaviors",
"TurnOnBehavior",
"DeviceType",
"Feature",
"FeatureType",
"EmeterStatus",
"Device",
"Bulb",
Expand Down
39 changes: 37 additions & 2 deletions kasa/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def __call__(self, *args, **kwargs):
asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs))
except Exception as ex:
echo(f"Got error: {ex!r}")
raise


def json_formatter_cb(result, **kwargs):
Expand Down Expand Up @@ -578,6 +579,10 @@ async def state(ctx, dev: Device):
else:
echo(f"\t{info_name}: {info_data}")

echo("\n\t[bold]== Features == [/bold]")
for id_, feature in dev.features.items():
echo(f"\t{feature.name} ({id_}): {feature.value}")

if dev.has_emeter:
echo("\n\t[bold]== Current State ==[/bold]")
emeter_status = dev.emeter_realtime
Expand All @@ -594,8 +599,6 @@ async def state(ctx, dev: Device):
echo("\n\t[bold]== Verbose information ==[/bold]")
echo(f"\tCredentials hash: {dev.credentials_hash}")
echo(f"\tDevice ID: {dev.device_id}")
for feature in dev.features:
echo(f"\tFeature: {feature}")
echo()
_echo_discovery_info(dev._discovery_info)
return dev.internal_state
Expand Down Expand Up @@ -1115,5 +1118,37 @@ async def shell(dev: Device):
loop.stop()


@cli.command(name="feature")
@click.argument("name", required=False)
@click.argument("value", required=False)
@pass_dev
async def feature(dev, name: str, value):
"""Access and modify features.
If no *name* is given, lists available features and their values.
If only *name* is given, the value of named feature is returned.
If both *name* and *value* are set, the described setting is changed.
"""
if not name:
echo("[bold]== Features ==[/bold]")
for name, feat in dev.features.items():
echo(f"{feat.name} ({name}): {feat.value}")
return

if name not in dev.features:
echo(f"No feature by name {name}")
return

feat = dev.features[name]

if value is None:
echo(f"{feat.name} ({name}): {feat.value}")
return feat.value

echo(f"Setting {name} to {value}")
value = ast.literal_eval(value)
return await dev.features[name].set_value(value)


if __name__ == "__main__":
cli()
15 changes: 12 additions & 3 deletions kasa/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional, Sequence, Set, Union
from typing import Any, Dict, List, Optional, Sequence, Union

from .credentials import Credentials
from .device_type import DeviceType
from .deviceconfig import DeviceConfig
from .emeterstatus import EmeterStatus
from .exceptions import SmartDeviceException
from .feature import Feature
from .iotprotocol import IotProtocol
from .protocol import BaseProtocol
from .xortransport import XorTransport
Expand Down Expand Up @@ -69,6 +70,7 @@ def __init__(
self._discovery_info: Optional[Dict[str, Any]] = None

self.modules: Dict[str, Any] = {}
self._features: Dict[str, Feature] = {}

@staticmethod
async def connect(
Expand Down Expand Up @@ -296,9 +298,16 @@ def state_information(self) -> Dict[str, Any]:
"""Return the key state information."""

@property
@abstractmethod
def features(self) -> Set[str]:
def features(self) -> Dict[str, Feature]:
"""Return the list of supported features."""
return self._features

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 SmartDeviceException("Duplicate feature name %s" % desc_name)
self._features[desc_name] = feature

@property
@abstractmethod
Expand Down
50 changes: 50 additions & 0 deletions kasa/feature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Generic interface for defining device features."""
from dataclasses import dataclass
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, Callable, Optional, Union

if TYPE_CHECKING:
from .device import Device


class FeatureType(Enum):
"""Type to help decide how to present the feature."""

Sensor = auto()
BinarySensor = auto()
Switch = auto()
Button = auto()


@dataclass
class Feature:
"""Feature defines a generic interface for device features."""

#: Device instance required for getting and setting values
device: "Device"
#: User-friendly short description
name: str
#: Name of the property that allows accessing the value
attribute_getter: Union[str, Callable]
#: Name of the method that allows changing the value
attribute_setter: Optional[str] = None
#: Container storing the data, this overrides 'device' for getters
container: Any = None
#: Icon suggestion
icon: Optional[str] = None
#: Type of the feature
type: FeatureType = FeatureType.Sensor

@property
def value(self):
"""Return the current value."""
container = self.container if self.container is not None else self.device
if isinstance(self.attribute_getter, Callable):
return self.attribute_getter(container)
return getattr(container, self.attribute_getter)

async def set_value(self, value):
"""Set the value."""
if self.attribute_setter is None:
raise ValueError("Tried to set read-only feature.")
return await getattr(self.device, self.attribute_setter)(value)
43 changes: 37 additions & 6 deletions kasa/iot/iotdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ..deviceconfig import DeviceConfig
from ..emeterstatus import EmeterStatus
from ..exceptions import SmartDeviceException
from ..feature import Feature
from ..protocol import BaseProtocol
from .modules import Emeter, IotModule

Expand Down Expand Up @@ -184,8 +185,9 @@ def __init__(
super().__init__(host=host, config=config, protocol=protocol)

self._sys_info: Any = None # TODO: this is here to avoid changing tests
self._features: Set[str] = set()
self._children: Sequence["IotDevice"] = []
self._supported_modules: Optional[Dict[str, IotModule]] = None
self._legacy_features: Set[str] = set()

@property
def children(self) -> Sequence["IotDevice"]:
Expand Down Expand Up @@ -260,7 +262,7 @@ async def _query_helper(

@property # type: ignore
@requires_update
def features(self) -> Set[str]:
def features(self) -> Dict[str, Feature]:
"""Return a set of features that the device supports."""
return self._features

Expand All @@ -276,7 +278,7 @@ def supported_modules(self) -> List[str]:
@requires_update
def has_emeter(self) -> bool:
"""Return True if device has an energy meter."""
return "ENE" in self.features
return "ENE" in self._legacy_features

async def get_sys_info(self) -> Dict[str, Any]:
"""Retrieve system information."""
Expand All @@ -299,9 +301,28 @@ async def update(self, update_children: bool = True):
self._last_update = response
self._set_sys_info(response["system"]["get_sysinfo"])

if not self._features:
await self._initialize_features()

await self._modular_update(req)
self._set_sys_info(self._last_update["system"]["get_sysinfo"])

async def _initialize_features(self):
self._add_feature(
Feature(
device=self, name="RSSI", attribute_getter="rssi", icon="mdi:signal"
)
)
if "on_time" in self._sys_info:
self._add_feature(
Feature(
device=self,
name="On since",
attribute_getter="on_since",
icon="mdi:clock",
)
)

async def _modular_update(self, req: dict) -> None:
"""Execute an update query."""
if self.has_emeter:
Expand All @@ -310,6 +331,18 @@ async def _modular_update(self, req: dict) -> None:
)
self.add_module("emeter", Emeter(self, self.emeter_type))

# TODO: perhaps modules should not have unsupported modules,
# making separate handling for this unnecessary
if self._supported_modules is None:
supported = {}
for module in self.modules.values():
if module.is_supported:
supported[module._module] = module
for module_feat in module._module_features.values():
self._add_feature(module_feat)

self._supported_modules = supported

request_list = []
est_response_size = 1024 if "system" in req else 0
for module in self.modules.values():
Expand Down Expand Up @@ -357,9 +390,7 @@ def _set_sys_info(self, sys_info: Dict[str, Any]) -> None:
"""Set sys_info."""
self._sys_info = sys_info
if features := sys_info.get("feature"):
self._features = _parse_features(features)
else:
self._features = set()
self._legacy_features = _parse_features(features)

@property # type: ignore
@requires_update
Expand Down
15 changes: 13 additions & 2 deletions kasa/iot/iotplug.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..feature import Feature, FeatureType
from ..protocol import BaseProtocol
from .iotdevice import IotDevice, requires_update
from .modules import Antitheft, Cloud, Schedule, Time, Usage
Expand Down Expand Up @@ -56,6 +57,17 @@ def __init__(
self.add_module("time", Time(self, "time"))
self.add_module("cloud", Cloud(self, "cnCloud"))

self._add_feature(
Feature(
device=self,
name="LED",
icon="mdi:led-{state}",
attribute_getter="led",
attribute_setter="set_led",
type=FeatureType.Switch,
)
)

@property # type: ignore
@requires_update
def is_on(self) -> bool:
Expand Down Expand Up @@ -88,5 +100,4 @@ async def set_led(self, state: bool):
@requires_update
def state_information(self) -> Dict[str, Any]:
"""Return switch-specific state information."""
info = {"LED state": self.led, "On since": self.on_since}
return info
return {}
19 changes: 19 additions & 0 deletions kasa/iot/modules/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
except ImportError:
from pydantic import BaseModel

from ...feature import Feature, FeatureType
from .module import IotModule


Expand All @@ -25,6 +26,24 @@ class CloudInfo(BaseModel):
class Cloud(IotModule):
"""Module implementing support for cloud services."""

def __init__(self, device, module):
super().__init__(device, module)
self._add_feature(
Feature(
device=device,
container=self,
name="Cloud connection",
icon="mdi:cloud",
attribute_getter="is_connected",
type=FeatureType.BinarySensor,
)
)

@property
def is_connected(self) -> bool:
"""Return true if device is connected to the cloud."""
return self.info.binded

def query(self):
"""Request cloud connectivity info."""
return self.query_for_command("get_info")
Expand Down
11 changes: 10 additions & 1 deletion kasa/iot/modules/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import collections
import logging
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Dict

from ...exceptions import SmartDeviceException
from ...feature import Feature

if TYPE_CHECKING:
from kasa.iot import IotDevice
Expand Down Expand Up @@ -34,6 +35,14 @@ class IotModule(ABC):
def __init__(self, device: "IotDevice", module: str):
self._device = device
self._module = module
self._module_features: Dict[str, Feature] = {}

def _add_feature(self, feature: Feature):
"""Add module feature."""
feature_name = f"{self._module}_{feature.name}"
if feature_name in self._module_features:
raise SmartDeviceException("Duplicate name detected %s" % feature_name)
self._module_features[feature_name] = feature

@abstractmethod
def query(self):
Expand Down

0 comments on commit 64da736

Please sign in to comment.