From f0f1e478c420e47ba6c470f7710cd0a90e27d5cf Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 3 Feb 2024 19:42:08 +0100 Subject: [PATCH 01/10] Add generic interface (descriptors) for obtaining device features --- kasa/__init__.py | 4 +++ kasa/cli.py | 36 +++++++++++++++++++++++ kasa/descriptors.py | 54 ++++++++++++++++++++++++++++++++++ kasa/device.py | 14 +++++++++ kasa/iot/iotdevice.py | 38 ++++++++++++++++++++++++ kasa/iot/iotplug.py | 16 ++++++++-- kasa/iot/modules/cloud.py | 18 ++++++++++++ kasa/iot/modules/module.py | 11 ++++++- kasa/smart/smartdevice.py | 60 ++++++++++++++++++++++++++++++++++++-- 9 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 kasa/descriptors.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 0d9e0c3eb..ace09e2db 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -17,6 +17,7 @@ from kasa.bulb import Bulb from kasa.credentials import Credentials +from kasa.descriptors import Descriptor, DescriptorCategory, DescriptorType from kasa.device import Device from kasa.device_type import DeviceType from kasa.deviceconfig import ( @@ -54,6 +55,9 @@ "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", + "Descriptor", + "DescriptorType", + "DescriptorCategory", "EmeterStatus", "Device", "Bulb", diff --git a/kasa/cli.py b/kasa/cli.py index ab65c448b..ebec92e7d 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -565,6 +565,10 @@ async def state(ctx, dev: Device): else: 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: echo("\n\t[bold]== Current State ==[/bold]") emeter_status = dev.emeter_realtime @@ -1102,5 +1106,37 @@ async def shell(dev: Device): 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__": cli() diff --git a/kasa/descriptors.py b/kasa/descriptors.py new file mode 100644 index 000000000..a039c0536 --- /dev/null +++ b/kasa/descriptors.py @@ -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) diff --git a/kasa/device.py b/kasa/device.py index 48537ff56..74c3c4e37 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional, Sequence, Set, Union from .credentials import Credentials +from .descriptors import Descriptor from .device_type import DeviceType from .deviceconfig import DeviceConfig from .emeterstatus import EmeterStatus @@ -69,6 +70,7 @@ def __init__( self._discovery_info: Optional[Dict[str, Any]] = None self.modules: Dict[str, Any] = {} + self._descriptors: Dict[str, Descriptor] = {} @staticmethod async def connect( @@ -343,6 +345,18 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): async def set_alias(self, alias: str): """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): if self._last_update is None: return f"<{self._device_type} at {self.host} - update() needed>" diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 8e51cac65..c92a13412 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -18,6 +18,7 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Sequence, Set +from ..descriptors import Descriptor from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus @@ -186,6 +187,7 @@ def __init__( 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 @property def children(self) -> Sequence["IotDevice"]: @@ -299,9 +301,33 @@ async def update(self, update_children: bool = True): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) + if not self._descriptors: + await self._initialize_descriptors() + await self._modular_update(req) 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: """Execute an update query.""" if self.has_emeter: @@ -310,6 +336,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_desc in module._module_descriptors.items(): + self.add_descriptor(module_desc) + + self._supported_modules = supported + request_list = [] est_response_size = 1024 if "system" in req else 0 for module in self.modules.values(): diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 72cba7c31..cb871db8b 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -2,6 +2,7 @@ import logging from typing import Any, Dict, Optional +from ..descriptors import Descriptor, DescriptorCategory, DescriptorType from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..protocol import BaseProtocol @@ -56,6 +57,18 @@ def __init__( self.add_module("time", Time(self, "time")) 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 @requires_update def is_on(self) -> bool: @@ -88,5 +101,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 {} diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 28cf2d1eb..53f68152b 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -4,6 +4,7 @@ except ImportError: from pydantic import BaseModel +from ...descriptors import Descriptor, DescriptorType from .module import IotModule @@ -25,6 +26,23 @@ class CloudInfo(BaseModel): class Cloud(IotModule): """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): """Request cloud connectivity info.""" return self.query_for_command("get_info") diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index 51d4b350d..519c598ad 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -2,8 +2,9 @@ import collections import logging from abc import ABC, abstractmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict +from ...descriptors import Descriptor from ...exceptions import SmartDeviceException if TYPE_CHECKING: @@ -34,6 +35,14 @@ class IotModule(ABC): def __init__(self, device: "IotDevice", module: str): self._device = device 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 def query(self): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index ca9ed63be..841c23511 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Set, cast from ..aestransport import AesTransport +from ..descriptors import Descriptor, DescriptorType from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig @@ -118,6 +119,11 @@ async def update(self, update_children: bool = True): for info in child_info["child_device_list"]: 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) async def _initialize_modules(self): @@ -125,6 +131,50 @@ async def _initialize_modules(self): if "energy_monitoring" in self._components: 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 def sys_info(self) -> Dict[str, Any]: """Returns the device info.""" @@ -215,15 +265,19 @@ async def _query_helper( return res @property - def state_information(self) -> Dict[str, Any]: - """Return the key state information.""" + def ssid(self) -> str: + """Return ssid of the connected wifi ap.""" ssid = self._info.get("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 { "overheated": self._info.get("overheated"), "signal_level": self._info.get("signal_level"), - "SSID": ssid, + "SSID": self.ssid, } @property From 72372fb254e9be5d2cb66a462a8d957d120fb8ba Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 8 Feb 2024 15:33:39 +0100 Subject: [PATCH 02/10] Add rssi for smartdevices --- kasa/smart/smartdevice.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 841c23511..433b99495 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -141,6 +141,14 @@ async def _initialize_descriptors(self): icon="mdi:signal", ) ) + self.add_descriptor( + Descriptor( + self, + "RSSI", + attribute_getter=lambda x: x._info["rssi"], + icon="mdi:signal", + ) + ) self.add_descriptor( Descriptor( device=self, name="Time", attribute_getter="time", show_in_hass=False From 288b5cacce31844280770ff90af930f198bdfe08 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 9 Feb 2024 00:17:47 +0100 Subject: [PATCH 03/10] Use correct key for chjecking if on since should be added --- kasa/smart/smartdevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 433b99495..8554dced1 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -173,7 +173,7 @@ async def _initialize_descriptors(self): # 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: + if "on_time" in self._info: self.add_descriptor( Descriptor( device=self, From 5dc190837c2071ffe9ae8c80ed2103c70453495e Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 14 Feb 2024 18:59:11 +0100 Subject: [PATCH 04/10] Rename descriptor to feature --- kasa/__init__.py | 8 +++--- kasa/cli.py | 29 ++++++++++----------- kasa/device.py | 29 +++++++++------------ kasa/{descriptors.py => feature.py} | 33 +++++++++++++++--------- kasa/iot/iotdevice.py | 30 ++++++++++------------ kasa/iot/iotplug.py | 10 ++++---- kasa/iot/modules/cloud.py | 13 +++++----- kasa/iot/modules/module.py | 14 +++++----- kasa/smart/smartdevice.py | 40 ++++++++++++----------------- 9 files changed, 100 insertions(+), 106 deletions(-) rename kasa/{descriptors.py => feature.py} (55%) diff --git a/kasa/__init__.py b/kasa/__init__.py index ace09e2db..d16fcb0b3 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -17,7 +17,6 @@ from kasa.bulb import Bulb from kasa.credentials import Credentials -from kasa.descriptors import Descriptor, DescriptorCategory, DescriptorType from kasa.device import Device from kasa.device_type import DeviceType from kasa.deviceconfig import ( @@ -34,6 +33,7 @@ TimeoutException, UnsupportedDeviceException, ) +from kasa.feature import Feature, FeatureCategory, FeatureType from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, @@ -55,9 +55,9 @@ "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", - "Descriptor", - "DescriptorType", - "DescriptorCategory", + "Feature", + "FeatureType", + "FeatureCategory", "EmeterStatus", "Device", "Bulb", diff --git a/kasa/cli.py b/kasa/cli.py index ebec92e7d..5068ab461 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -101,6 +101,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): @@ -565,9 +566,9 @@ async def state(ctx, dev: Device): else: 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}") + 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]") @@ -585,8 +586,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 @@ -1106,11 +1105,11 @@ async def shell(dev: Device): loop.stop() -@cli.command(name="descriptor") +@cli.command(name="feature") @click.argument("name", required=False) @click.argument("value", required=False) @pass_dev -async def descriptor(dev, name: str, value): +async def feature(dev, name: str, value): """Access and modify descriptor values. If no *name* is given, lists available descriptors and their values. @@ -1118,24 +1117,24 @@ async def descriptor(dev, name: str, value): 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}") + echo("[bold]== Feature ==[/bold]") + for name, feat in dev.features.items(): + echo(f"{feat.name} ({name}): {feat.value}") return - if name not in dev.descriptors: + if name not in dev.features: echo(f"No descriptor by name {name}") return - desc = dev.descriptors[name] + feat = dev.features[name] if value is None: - echo(f"{desc.name} ({name}): {desc.value}") - return desc.value + echo(f"{feat.name} ({name}): {feat.value}") + return feat.value echo(f"Setting {name} to {value}") value = ast.literal_eval(value) - return await dev.descriptors[name].set_value(value) + return await dev.features[name].set_value(value) if __name__ == "__main__": diff --git a/kasa/device.py b/kasa/device.py index 74c3c4e37..3cc21381e 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -3,14 +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 .descriptors import Descriptor 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 @@ -70,7 +70,7 @@ def __init__( self._discovery_info: Optional[Dict[str, Any]] = None self.modules: Dict[str, Any] = {} - self._descriptors: Dict[str, Descriptor] = {} + self._features: Dict[str, Feature] = {} @staticmethod async def connect( @@ -298,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 @@ -345,18 +352,6 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): async def set_alias(self, alias: str): """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): if self._last_update is None: return f"<{self._device_type} at {self.host} - update() needed>" diff --git a/kasa/descriptors.py b/kasa/feature.py similarity index 55% rename from kasa/descriptors.py rename to kasa/feature.py index a039c0536..14e9a6433 100644 --- a/kasa/descriptors.py +++ b/kasa/feature.py @@ -1,10 +1,13 @@ """Generic interface for defining device features.""" from dataclasses import dataclass from enum import Enum, auto -from typing import Any, Callable +from typing import TYPE_CHECKING, Any, Callable +if TYPE_CHECKING: + from .device import Device -class DescriptorCategory(Enum): + +class FeatureCategory(Enum): """Descriptor category.""" # TODO: we could probably do better than using the scheme homeassistant is using @@ -12,8 +15,8 @@ class DescriptorCategory(Enum): Diagnostic = auto() -class DescriptorType(Enum): - """Type of the information defined by the descriptor.""" +class FeatureType(Enum): + """Type to help decide how to present the feature.""" Sensor = auto() BinarySensor = auto() @@ -22,33 +25,39 @@ class DescriptorType(Enum): @dataclass -class Descriptor: - """Descriptor defines a generic interface for device features.""" +class Feature: + """Feature defines a generic interface for device features.""" - device: Any # TODO: rename to something else, this can also be a module. + #: 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: str | Callable #: Name of the method that allows changing the value attribute_setter: str | None = None - #: Type of the information + #: Container storing the data, this overrides 'device' for getters + container: Any = None + #: Icon suggestion 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 + category: FeatureCategory = FeatureCategory.Diagnostic + 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(self.device) - return getattr(self.device, self.attribute_getter) + 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) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index c92a13412..67337ad44 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -18,11 +18,11 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Sequence, Set -from ..descriptors import Descriptor from ..device import Device, WifiNetwork 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 @@ -185,9 +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"]: @@ -262,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 @@ -278,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.""" @@ -301,26 +301,26 @@ async def update(self, update_children: bool = True): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) - if not self._descriptors: + if not self._features: await self._initialize_descriptors() await self._modular_update(req) self._set_sys_info(self._last_update["system"]["get_sysinfo"]) async def _initialize_descriptors(self): - self.add_descriptor( - Descriptor( + self.add_feature( + Feature( device=self, name="RSSI", attribute_getter="rssi", icon="mdi:signal" ) ) - self.add_descriptor( - Descriptor( + self.add_feature( + Feature( device=self, name="Time", attribute_getter="time", show_in_hass=False ) ) if "on_time" in self._sys_info: - self.add_descriptor( - Descriptor( + self.add_feature( + Feature( device=self, name="On since", attribute_getter="on_since", @@ -343,8 +343,8 @@ async def _modular_update(self, req: dict) -> None: 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) + for module_feat in module._module_features.values(): + self.add_feature(module_feat) self._supported_modules = supported @@ -395,9 +395,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 diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index cb871db8b..17b30a2ac 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -2,9 +2,9 @@ import logging from typing import Any, Dict, Optional -from ..descriptors import Descriptor, DescriptorCategory, DescriptorType from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..feature import Feature, FeatureCategory, FeatureType from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update from .modules import Antitheft, Cloud, Schedule, Time, Usage @@ -57,15 +57,15 @@ def __init__( self.add_module("time", Time(self, "time")) self.add_module("cloud", Cloud(self, "cnCloud")) - self.add_descriptor( - Descriptor( + self.add_feature( + Feature( device=self, name="LED", icon="mdi:led-{state}", attribute_getter="led", attribute_setter="set_led", - category=DescriptorCategory.Config, - type=DescriptorType.Switch, + category=FeatureCategory.Config, + type=FeatureType.Switch, ) ) diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 53f68152b..7caa1530e 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -4,7 +4,7 @@ except ImportError: from pydantic import BaseModel -from ...descriptors import Descriptor, DescriptorType +from ...feature import Feature, FeatureType from .module import IotModule @@ -28,13 +28,14 @@ class Cloud(IotModule): def __init__(self, device, module): super().__init__(device, module) - self.add_descriptor( - Descriptor( - device=self, - name="Cloud Connection", + self.add_feature( + Feature( + device=device, + container=self, + name="Cloud connection", icon="mdi:cloud", attribute_getter="is_connected", - type=DescriptorType.BinarySensor, + type=FeatureType.BinarySensor, ) ) diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index 519c598ad..07a4f0ddf 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -4,8 +4,8 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Dict -from ...descriptors import Descriptor from ...exceptions import SmartDeviceException +from ...feature import Feature if TYPE_CHECKING: from kasa.iot import IotDevice @@ -35,14 +35,14 @@ class IotModule(ABC): def __init__(self, device: "IotDevice", module: str): self._device = device self._module = module - self._module_descriptors: Dict[str, Descriptor] = {} + self._module_features: Dict[str, Feature] = {} - def add_descriptor(self, desc): + def add_feature(self, feature: Feature): """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 + 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): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 8554dced1..49a3a2ea3 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -2,15 +2,15 @@ import base64 import logging 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, cast from ..aestransport import AesTransport -from ..descriptors import Descriptor, DescriptorType from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationException, SmartDeviceException +from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol _LOGGER = logging.getLogger(__name__) @@ -121,7 +121,7 @@ async def update(self, update_children: bool = True): # 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: + if not self._features: await self._initialize_descriptors() _LOGGER.debug("Got an update: %s", self._last_update) @@ -133,49 +133,47 @@ async def _initialize_modules(self): async def _initialize_descriptors(self): """Initialize device descriptors.""" - self.add_descriptor( - Descriptor( + self.add_feature( + Feature( self, "Signal Level", attribute_getter=lambda x: x._info["signal_level"], icon="mdi:signal", ) ) - self.add_descriptor( - Descriptor( + self.add_feature( + Feature( self, "RSSI", attribute_getter=lambda x: x._info["rssi"], icon="mdi:signal", ) ) - self.add_descriptor( - Descriptor( + self.add_feature( + Feature( 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" - ) + self.add_feature( + Feature(device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi") ) if "overheated" in self._info: - self.add_descriptor( - Descriptor( + self.add_feature( + Feature( self, "Overheated", attribute_getter=lambda x: x._info["overheated"], icon="mdi:heat-wave", - type=DescriptorType.BinarySensor, + type=FeatureType.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_time" in self._info: - self.add_descriptor( - Descriptor( + self.add_feature( + Feature( device=self, name="On since", attribute_getter="on_since", @@ -288,12 +286,6 @@ def state_information(self) -> Dict[str, Any]: "SSID": self.ssid, } - @property - def features(self) -> Set[str]: - """Return the list of supported features.""" - # TODO: - return set() - @property def has_emeter(self) -> bool: """Return if the device has emeter.""" From fc29d2456e66c614511a18a3ad8751bad1bad615 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 14 Feb 2024 19:14:35 +0100 Subject: [PATCH 05/10] Remove last remnants from 'descriptor' --- kasa/cli.py | 8 ++++---- kasa/feature.py | 4 +--- kasa/iot/iotdevice.py | 4 ++-- kasa/iot/modules/module.py | 2 +- kasa/smart/smartdevice.py | 10 +++++----- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 5068ab461..037abae0c 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1110,10 +1110,10 @@ async def shell(dev: Device): @click.argument("value", required=False) @pass_dev async def feature(dev, name: str, value): - """Access and modify descriptor values. + """Access and modify features. - If no *name* is given, lists available descriptors and their values. - If only *name* is given, the value of named descriptor is returned. + 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: @@ -1123,7 +1123,7 @@ async def feature(dev, name: str, value): return if name not in dev.features: - echo(f"No descriptor by name {name}") + echo(f"No feature by name {name}") return feat = dev.features[name] diff --git a/kasa/feature.py b/kasa/feature.py index 14e9a6433..11444e8d1 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -8,7 +8,7 @@ class FeatureCategory(Enum): - """Descriptor category.""" + """Feature category.""" # TODO: we could probably do better than using the scheme homeassistant is using Config = auto() @@ -40,8 +40,6 @@ class Feature: container: Any = None #: Icon suggestion 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 diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 67337ad44..3d7b3ac1e 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -302,12 +302,12 @@ async def update(self, update_children: bool = True): self._set_sys_info(response["system"]["get_sysinfo"]) if not self._features: - await self._initialize_descriptors() + await self._initialize_features() await self._modular_update(req) self._set_sys_info(self._last_update["system"]["get_sysinfo"]) - async def _initialize_descriptors(self): + async def _initialize_features(self): self.add_feature( Feature( device=self, name="RSSI", attribute_getter="rssi", icon="mdi:signal" diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index 07a4f0ddf..73808241b 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -38,7 +38,7 @@ def __init__(self, device: "IotDevice", module: str): self._module_features: Dict[str, Feature] = {} def add_feature(self, feature: Feature): - """Add module descriptor.""" + """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) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 49a3a2ea3..9bb75350b 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -119,10 +119,10 @@ async def update(self, update_children: bool = True): for info in child_info["child_device_list"]: 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. + # We can first initialize the features after the first update. + # We make here an assumption that every device has at least a single feature. if not self._features: - await self._initialize_descriptors() + await self._initialize_features() _LOGGER.debug("Got an update: %s", self._last_update) @@ -131,8 +131,8 @@ async def _initialize_modules(self): if "energy_monitoring" in self._components: self.emeter_type = "emeter" - async def _initialize_descriptors(self): - """Initialize device descriptors.""" + async def _initialize_features(self): + """Initialize device features.""" self.add_feature( Feature( self, From 231a3e4dc51ba4a8083078dc5c7622eeadb4bf87 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 14 Feb 2024 20:00:37 +0100 Subject: [PATCH 06/10] Make add_feature private --- kasa/device.py | 2 +- kasa/iot/iotdevice.py | 8 ++++---- kasa/iot/iotplug.py | 2 +- kasa/smart/smartdevice.py | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 3cc21381e..3c38b5446 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -302,7 +302,7 @@ def features(self) -> Dict[str, Feature]: """Return the list of supported features.""" return self._features - def add_feature(self, feature: Feature): + 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: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 3d7b3ac1e..85d95653c 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -308,18 +308,18 @@ async def update(self, update_children: bool = True): self._set_sys_info(self._last_update["system"]["get_sysinfo"]) async def _initialize_features(self): - self.add_feature( + self._add_feature( Feature( device=self, name="RSSI", attribute_getter="rssi", icon="mdi:signal" ) ) - self.add_feature( + self._add_feature( Feature( device=self, name="Time", attribute_getter="time", show_in_hass=False ) ) if "on_time" in self._sys_info: - self.add_feature( + self._add_feature( Feature( device=self, name="On since", @@ -344,7 +344,7 @@ async def _modular_update(self, req: dict) -> None: if module.is_supported: supported[module._module] = module for module_feat in module._module_features.values(): - self.add_feature(module_feat) + self._add_feature(module_feat) self._supported_modules = supported diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 17b30a2ac..5182b10c8 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -57,7 +57,7 @@ def __init__( self.add_module("time", Time(self, "time")) self.add_module("cloud", Cloud(self, "cnCloud")) - self.add_feature( + self._add_feature( Feature( device=self, name="LED", diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 9bb75350b..c9358df60 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -133,7 +133,7 @@ async def _initialize_modules(self): async def _initialize_features(self): """Initialize device features.""" - self.add_feature( + self._add_feature( Feature( self, "Signal Level", @@ -141,7 +141,7 @@ async def _initialize_features(self): icon="mdi:signal", ) ) - self.add_feature( + self._add_feature( Feature( self, "RSSI", @@ -149,17 +149,17 @@ async def _initialize_features(self): icon="mdi:signal", ) ) - self.add_feature( + self._add_feature( Feature( device=self, name="Time", attribute_getter="time", show_in_hass=False ) ) - self.add_feature( + self._add_feature( Feature(device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi") ) if "overheated" in self._info: - self.add_feature( + self._add_feature( Feature( self, "Overheated", @@ -172,7 +172,7 @@ async def _initialize_features(self): # We check for the key available, and not for the property truthiness, # as the value is falsy when the device is off. if "on_time" in self._info: - self.add_feature( + self._add_feature( Feature( device=self, name="On since", From 69108cea7d4874d283ed25864bd1df1392bf82fd Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 14 Feb 2024 20:18:01 +0100 Subject: [PATCH 07/10] Simplify impl by removing featurecategory and show_in_hass --- kasa/__init__.py | 3 +-- kasa/feature.py | 13 +------------ kasa/iot/iotdevice.py | 5 ----- kasa/iot/iotplug.py | 3 +-- kasa/smart/smartdevice.py | 5 ----- 5 files changed, 3 insertions(+), 26 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index d16fcb0b3..7dac1170d 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -33,7 +33,7 @@ TimeoutException, UnsupportedDeviceException, ) -from kasa.feature import Feature, FeatureCategory, FeatureType +from kasa.feature import Feature, FeatureType from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, @@ -57,7 +57,6 @@ "DeviceType", "Feature", "FeatureType", - "FeatureCategory", "EmeterStatus", "Device", "Bulb", diff --git a/kasa/feature.py b/kasa/feature.py index 11444e8d1..cf868dd0b 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -7,14 +7,6 @@ from .device import Device -class FeatureCategory(Enum): - """Feature category.""" - - # TODO: we could probably do better than using the scheme homeassistant is using - Config = auto() - Diagnostic = auto() - - class FeatureType(Enum): """Type to help decide how to present the feature.""" @@ -40,10 +32,7 @@ class Feature: container: Any = None #: Icon suggestion icon: 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: FeatureCategory = FeatureCategory.Diagnostic + #: Type of the feature type: FeatureType = FeatureType.Sensor @property diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 85d95653c..8ec7cd4bf 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -313,11 +313,6 @@ async def _initialize_features(self): device=self, name="RSSI", attribute_getter="rssi", icon="mdi:signal" ) ) - self._add_feature( - Feature( - device=self, name="Time", attribute_getter="time", show_in_hass=False - ) - ) if "on_time" in self._sys_info: self._add_feature( Feature( diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 5182b10c8..c72489660 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -4,7 +4,7 @@ from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature, FeatureCategory, FeatureType +from ..feature import Feature, FeatureType from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update from .modules import Antitheft, Cloud, Schedule, Time, Usage @@ -64,7 +64,6 @@ def __init__( icon="mdi:led-{state}", attribute_getter="led", attribute_setter="set_led", - category=FeatureCategory.Config, type=FeatureType.Switch, ) ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index c9358df60..04b84c38e 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -149,11 +149,6 @@ async def _initialize_features(self): icon="mdi:signal", ) ) - self._add_feature( - Feature( - device=self, name="Time", attribute_getter="time", show_in_hass=False - ) - ) self._add_feature( Feature(device=self, name="SSID", attribute_getter="ssid", icon="mdi:wifi") ) From 20ec2f5c90cd519770ca650ed80624d669e3955d Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 14 Feb 2024 20:40:25 +0100 Subject: [PATCH 08/10] Make iotmodule's _add_feature private, too --- kasa/iot/modules/cloud.py | 2 +- kasa/iot/modules/module.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 7caa1530e..76d6fb1eb 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -28,7 +28,7 @@ class Cloud(IotModule): def __init__(self, device, module): super().__init__(device, module) - self.add_feature( + self._add_feature( Feature( device=device, container=self, diff --git a/kasa/iot/modules/module.py b/kasa/iot/modules/module.py index 73808241b..57c245a06 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/modules/module.py @@ -37,7 +37,7 @@ def __init__(self, device: "IotDevice", module: str): self._module = module self._module_features: Dict[str, Feature] = {} - def add_feature(self, feature: Feature): + def _add_feature(self, feature: Feature): """Add module feature.""" feature_name = f"{self._module}_{feature.name}" if feature_name in self._module_features: From 3e25c79f3965b1504aa5ba950e0ffb5455179981 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 15 Feb 2024 15:12:51 +0100 Subject: [PATCH 09/10] Fix tests --- kasa/cli.py | 2 +- kasa/tests/test_cli.py | 22 ++++++++++ kasa/tests/test_feature.py | 79 ++++++++++++++++++++++++++++++++++ kasa/tests/test_smartdevice.py | 8 ++-- 4 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 kasa/tests/test_feature.py diff --git a/kasa/cli.py b/kasa/cli.py index 037abae0c..9d4991aaa 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1117,7 +1117,7 @@ async def feature(dev, name: str, value): If both *name* and *value* are set, the described setting is changed. """ if not name: - echo("[bold]== Feature ==[/bold]") + echo("[bold]== Features ==[/bold]") for name, feat in dev.features.items(): echo(f"{feat.name} ({name}): {feat.value}") return diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 84f016c02..f44a924a9 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -36,6 +36,11 @@ async def test_update_called_by_cli(dev, mocker): """Test that device update is called on main.""" runner = CliRunner() update = mocker.patch.object(dev, "update") + + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) res = await runner.invoke( @@ -48,6 +53,7 @@ async def test_update_called_by_cli(dev, mocker): "--password", "bar", ], + catch_exceptions=False, ) assert res.exit_code == 0 update.assert_called() @@ -291,6 +297,10 @@ async def test_brightness(dev): async def test_json_output(dev: Device, mocker): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + runner = CliRunner() res = await runner.invoke(cli, ["--json", "state"], obj=dev) assert res.exit_code == 0 @@ -342,6 +352,10 @@ async def test_without_device_type(dev, mocker): """Test connecting without the device type.""" runner = CliRunner() mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + res = await runner.invoke( cli, [ @@ -398,6 +412,10 @@ async def test_duplicate_target_device(): async def test_discover(discovery_mock, mocker): """Test discovery output.""" + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + runner = CliRunner() res = await runner.invoke( cli, @@ -417,6 +435,10 @@ async def test_discover(discovery_mock, mocker): async def test_discover_host(discovery_mock, mocker): """Test discovery output.""" + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + runner = CliRunner() res = await runner.invoke( cli, diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py new file mode 100644 index 000000000..549f4266e --- /dev/null +++ b/kasa/tests/test_feature.py @@ -0,0 +1,79 @@ +import pytest + +from kasa import Feature, FeatureType + + +@pytest.fixture +def dummy_feature() -> Feature: + # create_autospec for device slows tests way too much, so we use a dummy here + class DummyDevice: + pass + + feat = Feature( + device=DummyDevice(), # type: ignore[arg-type] + name="dummy_feature", + attribute_getter="dummygetter", + attribute_setter="dummysetter", + container=None, + icon="mdi:dummy", + type=FeatureType.BinarySensor, + ) + return feat + + +def test_feature_api(dummy_feature: Feature): + """Test all properties of a dummy feature.""" + assert dummy_feature.device is not None + assert dummy_feature.name == "dummy_feature" + assert dummy_feature.attribute_getter == "dummygetter" + assert dummy_feature.attribute_setter == "dummysetter" + assert dummy_feature.container is None + assert dummy_feature.icon == "mdi:dummy" + assert dummy_feature.type == FeatureType.BinarySensor + + +def test_feature_value(dummy_feature: Feature): + """Verify that property gets accessed on *value* access.""" + dummy_feature.attribute_getter = "test_prop" + dummy_feature.device.test_prop = "dummy" # type: ignore[attr-defined] + assert dummy_feature.value == "dummy" + + +def test_feature_value_container(mocker, dummy_feature: Feature): + """Test that container's attribute is accessed when expected.""" + + class DummyContainer: + @property + def test_prop(self): + return "dummy" + + dummy_feature.container = DummyContainer() + dummy_feature.attribute_getter = "test_prop" + + mock_dev_prop = mocker.patch.object( + dummy_feature, "test_prop", new_callable=mocker.PropertyMock, create=True + ) + + assert dummy_feature.value == "dummy" + mock_dev_prop.assert_not_called() + + +def test_feature_value_callable(dev, dummy_feature: Feature): + """Verify that callables work as *attribute_getter*.""" + dummy_feature.attribute_getter = lambda x: "dummy value" + assert dummy_feature.value == "dummy value" + + +async def test_feature_setter(dev, mocker, dummy_feature: Feature): + """Verify that *set_value* calls the defined method.""" + mock_set_dummy = mocker.patch.object(dummy_feature.device, "set_dummy", create=True) + dummy_feature.attribute_setter = "set_dummy" + await dummy_feature.set_value("dummy value") + mock_set_dummy.assert_called_with("dummy value") + + +async def test_feature_setter_read_only(dummy_feature): + """Verify that read-only feature raises an exception when trying to change it.""" + dummy_feature.attribute_setter = None + with pytest.raises(ValueError): + await dummy_feature.set_value("value for read only feature") diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index ba5ebc4fe..efe6995ba 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -67,7 +67,7 @@ async def test_invalid_connection(dev): async def test_initial_update_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" dev._last_update = None - dev._features = set() + dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() # Devices with small buffers may require 3 queries @@ -79,7 +79,7 @@ async def test_initial_update_emeter(dev, mocker): async def test_initial_update_no_emeter(dev, mocker): """Test that the initial update performs second query if emeter is available.""" dev._last_update = None - dev._features = set() + dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() # 2 calls are necessary as some devices crash on unexpected modules @@ -218,9 +218,9 @@ async def test_features(dev): """Make sure features is always accessible.""" sysinfo = dev._last_update["system"]["get_sysinfo"] if "feature" in sysinfo: - assert dev.features == set(sysinfo["feature"].split(":")) + assert dev._legacy_features == set(sysinfo["feature"].split(":")) else: - assert dev.features == set() + assert dev._legacy_features == set() @device_iot From ff7be987d8a687ea64a4a31d18f2440397fd3b96 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 15 Feb 2024 15:24:30 +0100 Subject: [PATCH 10/10] Fix type hints for older python versions --- kasa/feature.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index cf868dd0b..c0c14b06c 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -1,7 +1,7 @@ """Generic interface for defining device features.""" from dataclasses import dataclass from enum import Enum, auto -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Optional, Union if TYPE_CHECKING: from .device import Device @@ -25,13 +25,13 @@ class Feature: #: User-friendly short description name: str #: Name of the property that allows accessing the value - attribute_getter: str | Callable + attribute_getter: Union[str, Callable] #: Name of the method that allows changing the value - attribute_setter: str | None = None + attribute_setter: Optional[str] = None #: Container storing the data, this overrides 'device' for getters container: Any = None #: Icon suggestion - icon: str | None = None + icon: Optional[str] = None #: Type of the feature type: FeatureType = FeatureType.Sensor