mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-01-09 22:37:08 +00:00
Add generic interface (descriptors) for obtaining device features
This commit is contained in:
parent
458949157a
commit
f0f1e478c4
@ -17,6 +17,7 @@ from warnings import warn
|
|||||||
|
|
||||||
from kasa.bulb import Bulb
|
from kasa.bulb import Bulb
|
||||||
from kasa.credentials import Credentials
|
from kasa.credentials import Credentials
|
||||||
|
from kasa.descriptors import Descriptor, DescriptorCategory, DescriptorType
|
||||||
from kasa.device import Device
|
from kasa.device import Device
|
||||||
from kasa.device_type import DeviceType
|
from kasa.device_type import DeviceType
|
||||||
from kasa.deviceconfig import (
|
from kasa.deviceconfig import (
|
||||||
@ -54,6 +55,9 @@ __all__ = [
|
|||||||
"TurnOnBehaviors",
|
"TurnOnBehaviors",
|
||||||
"TurnOnBehavior",
|
"TurnOnBehavior",
|
||||||
"DeviceType",
|
"DeviceType",
|
||||||
|
"Descriptor",
|
||||||
|
"DescriptorType",
|
||||||
|
"DescriptorCategory",
|
||||||
"EmeterStatus",
|
"EmeterStatus",
|
||||||
"Device",
|
"Device",
|
||||||
"Bulb",
|
"Bulb",
|
||||||
|
36
kasa/cli.py
36
kasa/cli.py
@ -565,6 +565,10 @@ async def state(ctx, dev: Device):
|
|||||||
else:
|
else:
|
||||||
echo(f"\t{info_name}: {info_data}")
|
echo(f"\t{info_name}: {info_data}")
|
||||||
|
|
||||||
|
echo("\n\t[bold]== Descriptors == [/bold]")
|
||||||
|
for id_, descriptor in dev.descriptors.items():
|
||||||
|
echo(f"\t{descriptor.name} ({id_}): {descriptor.value}")
|
||||||
|
|
||||||
if dev.has_emeter:
|
if dev.has_emeter:
|
||||||
echo("\n\t[bold]== Current State ==[/bold]")
|
echo("\n\t[bold]== Current State ==[/bold]")
|
||||||
emeter_status = dev.emeter_realtime
|
emeter_status = dev.emeter_realtime
|
||||||
@ -1102,5 +1106,37 @@ async def shell(dev: Device):
|
|||||||
loop.stop()
|
loop.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command(name="descriptor")
|
||||||
|
@click.argument("name", required=False)
|
||||||
|
@click.argument("value", required=False)
|
||||||
|
@pass_dev
|
||||||
|
async def descriptor(dev, name: str, value):
|
||||||
|
"""Access and modify descriptor values.
|
||||||
|
|
||||||
|
If no *name* is given, lists available descriptors and their values.
|
||||||
|
If only *name* is given, the value of named descriptor is returned.
|
||||||
|
If both *name* and *value* are set, the described setting is changed.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
echo("[bold]== Descriptors ==[/bold]")
|
||||||
|
for name, desc in dev.descriptors.items():
|
||||||
|
echo(f"{desc.name} ({name}): {desc.value}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if name not in dev.descriptors:
|
||||||
|
echo(f"No descriptor by name {name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
desc = dev.descriptors[name]
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
echo(f"{desc.name} ({name}): {desc.value}")
|
||||||
|
return desc.value
|
||||||
|
|
||||||
|
echo(f"Setting {name} to {value}")
|
||||||
|
value = ast.literal_eval(value)
|
||||||
|
return await dev.descriptors[name].set_value(value)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
54
kasa/descriptors.py
Normal file
54
kasa/descriptors.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""Generic interface for defining device features."""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptorCategory(Enum):
|
||||||
|
"""Descriptor category."""
|
||||||
|
|
||||||
|
# TODO: we could probably do better than using the scheme homeassistant is using
|
||||||
|
Config = auto()
|
||||||
|
Diagnostic = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptorType(Enum):
|
||||||
|
"""Type of the information defined by the descriptor."""
|
||||||
|
|
||||||
|
Sensor = auto()
|
||||||
|
BinarySensor = auto()
|
||||||
|
Switch = auto()
|
||||||
|
Button = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Descriptor:
|
||||||
|
"""Descriptor defines a generic interface for device features."""
|
||||||
|
|
||||||
|
device: Any # TODO: rename to something else, this can also be a module.
|
||||||
|
#: User-friendly short description
|
||||||
|
name: str
|
||||||
|
#: Name of the property that allows accessing the value
|
||||||
|
attribute_getter: str | Callable
|
||||||
|
#: Name of the method that allows changing the value
|
||||||
|
attribute_setter: str | None = None
|
||||||
|
#: Type of the information
|
||||||
|
icon: str | None = None
|
||||||
|
#: Unit of the descriptor
|
||||||
|
unit: str | None = None
|
||||||
|
#: Hint for homeassistant
|
||||||
|
#: TODO: Replace with a set of flags to allow homeassistant make its own decision?
|
||||||
|
show_in_hass: bool = True
|
||||||
|
category: DescriptorCategory = DescriptorCategory.Diagnostic
|
||||||
|
type: DescriptorType = DescriptorType.Sensor
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self):
|
||||||
|
"""Return the current value."""
|
||||||
|
if isinstance(self.attribute_getter, Callable):
|
||||||
|
return self.attribute_getter(self.device)
|
||||||
|
return getattr(self.device, self.attribute_getter)
|
||||||
|
|
||||||
|
async def set_value(self, value):
|
||||||
|
"""Set the value."""
|
||||||
|
return await getattr(self.device, self.attribute_setter)(value)
|
@ -6,6 +6,7 @@ from datetime import datetime
|
|||||||
from typing import Any, Dict, List, Optional, Sequence, Set, Union
|
from typing import Any, Dict, List, Optional, Sequence, Set, Union
|
||||||
|
|
||||||
from .credentials import Credentials
|
from .credentials import Credentials
|
||||||
|
from .descriptors import Descriptor
|
||||||
from .device_type import DeviceType
|
from .device_type import DeviceType
|
||||||
from .deviceconfig import DeviceConfig
|
from .deviceconfig import DeviceConfig
|
||||||
from .emeterstatus import EmeterStatus
|
from .emeterstatus import EmeterStatus
|
||||||
@ -69,6 +70,7 @@ class Device(ABC):
|
|||||||
self._discovery_info: Optional[Dict[str, Any]] = None
|
self._discovery_info: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
self.modules: Dict[str, Any] = {}
|
self.modules: Dict[str, Any] = {}
|
||||||
|
self._descriptors: Dict[str, Descriptor] = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def connect(
|
async def connect(
|
||||||
@ -343,6 +345,18 @@ class Device(ABC):
|
|||||||
async def set_alias(self, alias: str):
|
async def set_alias(self, alias: str):
|
||||||
"""Set the device name (alias)."""
|
"""Set the device name (alias)."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def descriptors(self) -> Dict[str, Descriptor]:
|
||||||
|
"""Return the list of descriptors."""
|
||||||
|
return self._descriptors
|
||||||
|
|
||||||
|
def add_descriptor(self, descriptor: "Descriptor"):
|
||||||
|
"""Add a new descriptor to the device."""
|
||||||
|
desc_name = descriptor.name.lower().replace(" ", "_")
|
||||||
|
if desc_name in self._descriptors:
|
||||||
|
raise SmartDeviceException("Duplicate descriptor name %s" % desc_name)
|
||||||
|
self._descriptors[desc_name] = descriptor
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if self._last_update is None:
|
if self._last_update is None:
|
||||||
return f"<{self._device_type} at {self.host} - update() needed>"
|
return f"<{self._device_type} at {self.host} - update() needed>"
|
||||||
|
@ -18,6 +18,7 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict, List, Optional, Sequence, Set
|
from typing import Any, Dict, List, Optional, Sequence, Set
|
||||||
|
|
||||||
|
from ..descriptors import Descriptor
|
||||||
from ..device import Device, WifiNetwork
|
from ..device import Device, WifiNetwork
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
from ..emeterstatus import EmeterStatus
|
from ..emeterstatus import EmeterStatus
|
||||||
@ -186,6 +187,7 @@ class IotDevice(Device):
|
|||||||
self._sys_info: Any = None # TODO: this is here to avoid changing tests
|
self._sys_info: Any = None # TODO: this is here to avoid changing tests
|
||||||
self._features: Set[str] = set()
|
self._features: Set[str] = set()
|
||||||
self._children: Sequence["IotDevice"] = []
|
self._children: Sequence["IotDevice"] = []
|
||||||
|
self._supported_modules: Optional[Dict[str, IotModule]] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def children(self) -> Sequence["IotDevice"]:
|
def children(self) -> Sequence["IotDevice"]:
|
||||||
@ -299,9 +301,33 @@ class IotDevice(Device):
|
|||||||
self._last_update = response
|
self._last_update = response
|
||||||
self._set_sys_info(response["system"]["get_sysinfo"])
|
self._set_sys_info(response["system"]["get_sysinfo"])
|
||||||
|
|
||||||
|
if not self._descriptors:
|
||||||
|
await self._initialize_descriptors()
|
||||||
|
|
||||||
await self._modular_update(req)
|
await self._modular_update(req)
|
||||||
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
|
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
|
||||||
|
|
||||||
|
async def _initialize_descriptors(self):
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
device=self, name="RSSI", attribute_getter="rssi", icon="mdi:signal"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
device=self, name="Time", attribute_getter="time", show_in_hass=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "on_time" in self._sys_info:
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
device=self,
|
||||||
|
name="On since",
|
||||||
|
attribute_getter="on_since",
|
||||||
|
icon="mdi:clock",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _modular_update(self, req: dict) -> None:
|
async def _modular_update(self, req: dict) -> None:
|
||||||
"""Execute an update query."""
|
"""Execute an update query."""
|
||||||
if self.has_emeter:
|
if self.has_emeter:
|
||||||
@ -310,6 +336,18 @@ class IotDevice(Device):
|
|||||||
)
|
)
|
||||||
self.add_module("emeter", Emeter(self, self.emeter_type))
|
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_desc in module._module_descriptors.items():
|
||||||
|
self.add_descriptor(module_desc)
|
||||||
|
|
||||||
|
self._supported_modules = supported
|
||||||
|
|
||||||
request_list = []
|
request_list = []
|
||||||
est_response_size = 1024 if "system" in req else 0
|
est_response_size = 1024 if "system" in req else 0
|
||||||
for module in self.modules.values():
|
for module in self.modules.values():
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from ..descriptors import Descriptor, DescriptorCategory, DescriptorType
|
||||||
from ..device_type import DeviceType
|
from ..device_type import DeviceType
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
from ..protocol import BaseProtocol
|
from ..protocol import BaseProtocol
|
||||||
@ -56,6 +57,18 @@ class IotPlug(IotDevice):
|
|||||||
self.add_module("time", Time(self, "time"))
|
self.add_module("time", Time(self, "time"))
|
||||||
self.add_module("cloud", Cloud(self, "cnCloud"))
|
self.add_module("cloud", Cloud(self, "cnCloud"))
|
||||||
|
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
device=self,
|
||||||
|
name="LED",
|
||||||
|
icon="mdi:led-{state}",
|
||||||
|
attribute_getter="led",
|
||||||
|
attribute_setter="set_led",
|
||||||
|
category=DescriptorCategory.Config,
|
||||||
|
type=DescriptorType.Switch,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@property # type: ignore
|
@property # type: ignore
|
||||||
@requires_update
|
@requires_update
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
@ -88,5 +101,4 @@ class IotPlug(IotDevice):
|
|||||||
@requires_update
|
@requires_update
|
||||||
def state_information(self) -> Dict[str, Any]:
|
def state_information(self) -> Dict[str, Any]:
|
||||||
"""Return switch-specific state information."""
|
"""Return switch-specific state information."""
|
||||||
info = {"LED state": self.led, "On since": self.on_since}
|
return {}
|
||||||
return info
|
|
||||||
|
@ -4,6 +4,7 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ...descriptors import Descriptor, DescriptorType
|
||||||
from .module import IotModule
|
from .module import IotModule
|
||||||
|
|
||||||
|
|
||||||
@ -25,6 +26,23 @@ class CloudInfo(BaseModel):
|
|||||||
class Cloud(IotModule):
|
class Cloud(IotModule):
|
||||||
"""Module implementing support for cloud services."""
|
"""Module implementing support for cloud services."""
|
||||||
|
|
||||||
|
def __init__(self, device, module):
|
||||||
|
super().__init__(device, module)
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
device=self,
|
||||||
|
name="Cloud Connection",
|
||||||
|
icon="mdi:cloud",
|
||||||
|
attribute_getter="is_connected",
|
||||||
|
type=DescriptorType.BinarySensor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Return true if device is connected to the cloud."""
|
||||||
|
return self.info.binded
|
||||||
|
|
||||||
def query(self):
|
def query(self):
|
||||||
"""Request cloud connectivity info."""
|
"""Request cloud connectivity info."""
|
||||||
return self.query_for_command("get_info")
|
return self.query_for_command("get_info")
|
||||||
|
@ -2,8 +2,9 @@
|
|||||||
import collections
|
import collections
|
||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Dict
|
||||||
|
|
||||||
|
from ...descriptors import Descriptor
|
||||||
from ...exceptions import SmartDeviceException
|
from ...exceptions import SmartDeviceException
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -34,6 +35,14 @@ class IotModule(ABC):
|
|||||||
def __init__(self, device: "IotDevice", module: str):
|
def __init__(self, device: "IotDevice", module: str):
|
||||||
self._device = device
|
self._device = device
|
||||||
self._module = module
|
self._module = module
|
||||||
|
self._module_descriptors: Dict[str, Descriptor] = {}
|
||||||
|
|
||||||
|
def add_descriptor(self, desc):
|
||||||
|
"""Add module descriptor."""
|
||||||
|
module_desc_name = f"{self._module}_{desc.name}"
|
||||||
|
if module_desc_name in self._module_descriptors:
|
||||||
|
raise Exception("Duplicate name detected %s" % module_desc_name)
|
||||||
|
self._module_descriptors[module_desc_name] = desc
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def query(self):
|
def query(self):
|
||||||
|
@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, cast
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, cast
|
||||||
|
|
||||||
from ..aestransport import AesTransport
|
from ..aestransport import AesTransport
|
||||||
|
from ..descriptors import Descriptor, DescriptorType
|
||||||
from ..device import Device, WifiNetwork
|
from ..device import Device, WifiNetwork
|
||||||
from ..device_type import DeviceType
|
from ..device_type import DeviceType
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
@ -118,6 +119,11 @@ class SmartDevice(Device):
|
|||||||
for info in child_info["child_device_list"]:
|
for info in child_info["child_device_list"]:
|
||||||
self._children[info["device_id"]].update_internal_state(info)
|
self._children[info["device_id"]].update_internal_state(info)
|
||||||
|
|
||||||
|
# We can first initialize the descriptors after the first update.
|
||||||
|
# We make here an assumption that every device has at least a single descriptor.
|
||||||
|
if not self._descriptors:
|
||||||
|
await self._initialize_descriptors()
|
||||||
|
|
||||||
_LOGGER.debug("Got an update: %s", self._last_update)
|
_LOGGER.debug("Got an update: %s", self._last_update)
|
||||||
|
|
||||||
async def _initialize_modules(self):
|
async def _initialize_modules(self):
|
||||||
@ -125,6 +131,50 @@ class SmartDevice(Device):
|
|||||||
if "energy_monitoring" in self._components:
|
if "energy_monitoring" in self._components:
|
||||||
self.emeter_type = "emeter"
|
self.emeter_type = "emeter"
|
||||||
|
|
||||||
|
async def _initialize_descriptors(self):
|
||||||
|
"""Initialize device descriptors."""
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
self,
|
||||||
|
"Signal Level",
|
||||||
|
attribute_getter=lambda x: x._info["signal_level"],
|
||||||
|
icon="mdi:signal",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
device=self, name="Time", attribute_getter="time", show_in_hass=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "overheated" in self._info:
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
self,
|
||||||
|
"Overheated",
|
||||||
|
attribute_getter=lambda x: x._info["overheated"],
|
||||||
|
icon="mdi:heat-wave",
|
||||||
|
type=DescriptorType.BinarySensor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# We check for the key available, and not for the property truthiness,
|
||||||
|
# as the value is falsy when the device is off.
|
||||||
|
if "on_since" in self._info:
|
||||||
|
self.add_descriptor(
|
||||||
|
Descriptor(
|
||||||
|
device=self,
|
||||||
|
name="On since",
|
||||||
|
attribute_getter="on_since",
|
||||||
|
icon="mdi:clock",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sys_info(self) -> Dict[str, Any]:
|
def sys_info(self) -> Dict[str, Any]:
|
||||||
"""Returns the device info."""
|
"""Returns the device info."""
|
||||||
@ -215,15 +265,19 @@ class SmartDevice(Device):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state_information(self) -> Dict[str, Any]:
|
def ssid(self) -> str:
|
||||||
"""Return the key state information."""
|
"""Return ssid of the connected wifi ap."""
|
||||||
ssid = self._info.get("ssid")
|
ssid = self._info.get("ssid")
|
||||||
ssid = base64.b64decode(ssid).decode() if ssid else "No SSID"
|
ssid = base64.b64decode(ssid).decode() if ssid else "No SSID"
|
||||||
|
return ssid
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_information(self) -> Dict[str, Any]:
|
||||||
|
"""Return the key state information."""
|
||||||
return {
|
return {
|
||||||
"overheated": self._info.get("overheated"),
|
"overheated": self._info.get("overheated"),
|
||||||
"signal_level": self._info.get("signal_level"),
|
"signal_level": self._info.get("signal_level"),
|
||||||
"SSID": ssid,
|
"SSID": self.ssid,
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
Loading…
Reference in New Issue
Block a user