From e81dcef7f39ede4f9ea08391f5b859175d71865f Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 23 Jan 2024 06:37:00 +0100 Subject: [PATCH 01/10] Initial implementation for modularised tapodevice --- kasa/cli.py | 3 + kasa/iot/iotdevice.py | 3 +- kasa/iot/{modules/module.py => iotmodule.py} | 64 ++------- kasa/iot/modules/__init__.py | 2 - kasa/iot/modules/ambientlight.py | 2 +- kasa/iot/modules/cloud.py | 2 +- kasa/iot/modules/motion.py | 2 +- kasa/iot/modules/rulemodule.py | 2 +- kasa/iot/modules/time.py | 2 +- kasa/iot/modules/usage.py | 2 +- kasa/module.py | 54 ++++++++ kasa/smart/modules/__init__.py | 5 + kasa/smart/modules/devicemodule.py | 16 +++ kasa/smart/modules/energymodule.py | 72 +++++++++++ kasa/smart/modules/timemodule.py | 42 ++++++ kasa/smart/smartdevice.py | 129 +++++++------------ kasa/smart/smartmodule.py | 71 ++++++++++ 17 files changed, 334 insertions(+), 139 deletions(-) rename kasa/iot/{modules/module.py => iotmodule.py} (54%) create mode 100644 kasa/module.py create mode 100644 kasa/smart/modules/__init__.py create mode 100644 kasa/smart/modules/devicemodule.py create mode 100644 kasa/smart/modules/energymodule.py create mode 100644 kasa/smart/modules/timemodule.py create mode 100644 kasa/smart/smartmodule.py diff --git a/kasa/cli.py b/kasa/cli.py index e922ec81c..e89c64695 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -592,6 +592,9 @@ async def state(ctx, dev: Device): for module in dev.modules.values(): if module.is_supported: echo(f"\t[green]+ {module}[/green]") + # TODO: fix this hack, probably with descriptors? + if pretty_print := getattr(module, "__cli_output__", None): + echo(f"\t\t{pretty_print()}") else: echo(f"\t[red]- {module}[/red]") diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 8ec7cd4bf..ac902af84 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -24,7 +24,8 @@ from ..exceptions import SmartDeviceException from ..feature import Feature from ..protocol import BaseProtocol -from .modules import Emeter, IotModule +from .iotmodule import IotModule +from .modules import Emeter _LOGGER = logging.getLogger(__name__) diff --git a/kasa/iot/modules/module.py b/kasa/iot/iotmodule.py similarity index 54% rename from kasa/iot/modules/module.py rename to kasa/iot/iotmodule.py index 57c245a06..8c82ea581 100644 --- a/kasa/iot/modules/module.py +++ b/kasa/iot/iotmodule.py @@ -1,20 +1,14 @@ -"""Base class for all module implementations.""" +"""Base class for IOT module implementations.""" import collections import logging -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Dict - -from ...exceptions import SmartDeviceException -from ...feature import Feature - -if TYPE_CHECKING: - from kasa.iot import IotDevice +from ..exceptions import SmartDeviceException +from ..module import Module _LOGGER = logging.getLogger(__name__) -# TODO: This is used for query construcing +# TODO: This is used for query constructing, check for a better place def merge(d, u): """Update dict recursively.""" for k, v in u.items(): @@ -25,32 +19,8 @@ def merge(d, u): return d -class IotModule(ABC): - """Base class implemention for all modules. - - The base classes should implement `query` to return the query they want to be - executed during the regular update cycle. - """ - - 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): - """Query to execute during the update cycle. - - The inheriting modules implement this to include their wanted - queries to the query that gets executed when Device.update() gets called. - """ +class IotModule(Module): + """Base class implemention for all IOT modules.""" @property def estimated_query_response_size(self): @@ -61,6 +31,14 @@ def estimated_query_response_size(self): """ return 256 # Estimate for modules that don't specify + def call(self, method, params=None): + """Call the given method with the given parameters.""" + return self._device._query_helper(self._module, method, params) + + def query_for_command(self, query, params=None): + """Create a request object for the given parameters.""" + return self._device._create_request(self._module, query, params) + @property def data(self): """Return the module specific raw data from the last update.""" @@ -80,17 +58,3 @@ def is_supported(self) -> bool: return True return "err_code" not in self.data - - def call(self, method, params=None): - """Call the given method with the given parameters.""" - return self._device._query_helper(self._module, method, params) - - def query_for_command(self, query, params=None): - """Create a request object for the given parameters.""" - return self._device._create_request(self._module, query, params) - - def __repr__(self) -> str: - return ( - f"" - ) diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 17a34b6e7..e4278b26c 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -4,7 +4,6 @@ from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter -from .module import IotModule from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,7 +16,6 @@ "Cloud", "Countdown", "Emeter", - "IotModule", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index 0a7663671..f1069448c 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -1,5 +1,5 @@ """Implementation of the ambient light (LAS) module found in some dimmers.""" -from .module import IotModule +from ..iotmodule import IotModule # TODO create tests and use the config reply there # [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450, diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py index 76d6fb1eb..b5c04d0b0 100644 --- a/kasa/iot/modules/cloud.py +++ b/kasa/iot/modules/cloud.py @@ -5,7 +5,7 @@ from pydantic import BaseModel from ...feature import Feature, FeatureType -from .module import IotModule +from ..iotmodule import IotModule class CloudInfo(BaseModel): diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py index cd79cba79..05edb2a53 100644 --- a/kasa/iot/modules/motion.py +++ b/kasa/iot/modules/motion.py @@ -3,7 +3,7 @@ from typing import Optional from ...exceptions import SmartDeviceException -from .module import IotModule +from ..iotmodule import IotModule class Range(Enum): diff --git a/kasa/iot/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py index f840f6725..81853793d 100644 --- a/kasa/iot/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -9,7 +9,7 @@ from pydantic import BaseModel -from .module import IotModule, merge +from ..iotmodule import IotModule, merge class Action(Enum): diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index 2099e22c4..568df1804 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -2,7 +2,7 @@ from datetime import datetime from ...exceptions import SmartDeviceException -from .module import IotModule, merge +from ..iotmodule import IotModule, merge class Time(IotModule): diff --git a/kasa/iot/modules/usage.py b/kasa/iot/modules/usage.py index 29dcd1727..f64baf79d 100644 --- a/kasa/iot/modules/usage.py +++ b/kasa/iot/modules/usage.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Dict -from .module import IotModule, merge +from ..iotmodule import IotModule, merge class Usage(IotModule): diff --git a/kasa/module.py b/kasa/module.py new file mode 100644 index 000000000..fab000600 --- /dev/null +++ b/kasa/module.py @@ -0,0 +1,54 @@ +"""Base class for all module implementations.""" +import logging +from abc import ABC, abstractmethod +from typing import Dict + +from .device import Device +from .exceptions import SmartDeviceException +from .feature import Feature + +_LOGGER = logging.getLogger(__name__) + + +class Module(ABC): + """Base class implemention for all modules. + + The base classes should implement `query` to return the query they want to be + executed during the regular update cycle. + """ + + def __init__(self, device: "Device", module: str): + self._device = device + self._module = module + self._module_features: Dict[str, Feature] = {} + + def _add_feature(self, feature: Feature): + """Add module descriptor.""" + feat_name = f"{self._module}_{feature.name}" + if feat_name in self._module_features: + raise SmartDeviceException("Duplicate name detected %s" % feat_name) + self._module_features[feat_name] = feature + + @abstractmethod + def query(self): + """Query to execute during the update cycle. + + The inheriting modules implement this to include their wanted + queries to the query that gets executed when Device.update() gets called. + """ + + @property + @abstractmethod + def data(self): + """Return the module specific raw data from the last update.""" + + @property + @abstractmethod + def is_supported(self) -> bool: + """Return whether the module is supported by the device.""" + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py new file mode 100644 index 000000000..07078016e --- /dev/null +++ b/kasa/smart/modules/__init__.py @@ -0,0 +1,5 @@ +from .timemodule import TimeModule +from .energymodule import EnergyModule +from .devicemodule import DeviceModule + +__all__ = ["TimeModule", "EnergyModule", "DeviceModule"] diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py new file mode 100644 index 000000000..a2b514b09 --- /dev/null +++ b/kasa/smart/modules/devicemodule.py @@ -0,0 +1,16 @@ +from typing import Dict +from ..smartmodule import SmartModule + + +class DeviceModule(SmartModule): + REQUIRED_COMPONENT = "device" + + def query(self) -> Dict: + query = { + "get_device_info": None, + } + # Device usage is not available on older firmware versions + if self._device._components[self.REQUIRED_COMPONENT] >= 2: + query["get_device_usage"] = None + + return query \ No newline at end of file diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energymodule.py new file mode 100644 index 000000000..cda3642df --- /dev/null +++ b/kasa/smart/modules/energymodule.py @@ -0,0 +1,72 @@ +from typing import Dict, Optional, TYPE_CHECKING + +from ..smartmodule import SmartModule +from ...feature import Feature +from ...emeterstatus import EmeterStatus + + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class EnergyModule(SmartModule): + REQUIRED_COMPONENT = "energy_monitoring" + + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + self._add_feature(Feature(device, name="Current consumption", attribute_getter="current_power", container=self)) # W or mW? + self._add_feature(Feature(device, name="Today's consumption", attribute_getter="emeter_today", container=self)) # Wh or kWh? + self._add_feature(Feature(device, name="This month's consumption", attribute_getter="emeter_this_month", container=self)) # Wh or kWH? + + def query(self) -> Dict: + return { + "get_energy_usage": None, + # The current_power in get_energy_usage is more precise (mw vs. w), + # making this rather useless, but maybe there are version differences? + "get_current_power": None, + } + + @property + def current_power(self): + """Current power.""" + return self.emeter_realtime.power + + @property + def energy(self): + """Return get_energy_usage results.""" + return self.data["get_energy_usage"] + + @property + def emeter_realtime(self): + """Get the emeter status.""" + # TODO: Perhaps we should get rid of emeterstatus altogether for smartdevices + return EmeterStatus( + { + "power_mw": self.energy.get("current_power"), + "total": self._convert_energy_data( + self.energy.get("today_energy"), 1 / 1000 + ), + } + ) + + @property + def emeter_this_month(self) -> Optional[float]: + """Get the emeter value for this month.""" + return self._convert_energy_data(self.energy.get("month_energy"), 1 / 1000) + + @property + def emeter_today(self) -> Optional[float]: + """Get the emeter value for today.""" + return self._convert_energy_data(self.energy.get("today_energy"), 1 / 1000) + + async def get_emeter_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + # TODO: maybe we should just have a generic `update()` or similar, + # to execute the query() and return the raw results? + resp = await self.call("get_energy_usage") + self._energy = resp["get_energy_usage"] + return self.emeter_realtime + + def _convert_energy_data(self, data, scale) -> Optional[float]: + """Return adjusted emeter information.""" + return data if not data else data * scale diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/timemodule.py new file mode 100644 index 000000000..e31ba5e2a --- /dev/null +++ b/kasa/smart/modules/timemodule.py @@ -0,0 +1,42 @@ +"""Implementation of device time module.""" +from datetime import datetime, timedelta, timezone +from time import mktime +from typing import TYPE_CHECKING, cast + +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class TimeModule(SmartModule): + """Implementation of device_local_time.""" + + REQUIRED_COMPONENT = "device_local_time" + QUERY_GETTER_NAME = "get_device_time" + + @property + def time(self) -> datetime: + """Return device's current datetime.""" + td = timedelta(minutes=cast(float, self.data.get("time_diff"))) + if self.data.get("region"): + tz = timezone(td, str(self.data.get("region"))) + else: + # in case the device returns a blank region this will result in the + # tzname being a UTC offset + tz = timezone(td) + return datetime.fromtimestamp( + cast(float, self.data.get("timestamp")), + tz=tz, + ) + + async def set_time(self, dt: datetime): + """Set device time.""" + unixtime = mktime(dt.timetuple()) + return await self.call( + "set_device_time", + {"timestamp": unixtime, "time_diff": dt.utcoffset(), "region": dt.tzname()}, + ) + + def __cli_output__(self): + return f"Time: {self.time}" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index d22594347..64d7f78f8 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -1,7 +1,7 @@ """Module for a SMART device.""" import base64 import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, cast from ..aestransport import AesTransport @@ -12,6 +12,8 @@ from ..exceptions import AuthenticationException, SmartDeviceException, SmartErrorCode from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol +from .modules import DeviceModule, TimeModule, EnergyModule # noqa: F403 +from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) @@ -37,9 +39,8 @@ def __init__( self._components_raw: Optional[Dict[str, Any]] = None self._components: Dict[str, int] = {} self._children: Dict[str, "SmartChildDevice"] = {} - self._energy: Dict[str, Any] = {} self._state_information: Dict[str, Any] = {} - self._time: Dict[str, Any] = {} + self.modules: Dict[str, SmartModule] = {} async def _initialize_children(self): """Initialize children for power strips.""" @@ -79,67 +80,43 @@ def _try_get_response(self, responses: dict, request: str, default=None) -> dict f"{request} not found in {responses} for device {self.host}" ) + async def _negotiate(self): + resp = await self.protocol.query("component_nego") + self._components_raw = resp["component_nego"] + self._components = { + comp["id"]: int(comp["ver_code"]) + for comp in self._components_raw["component_list"] + } + async def update(self, update_children: bool = True): """Update the device.""" if self.credentials is None and self.credentials_hash is None: raise AuthenticationException("Tapo plug requires authentication.") if self._components_raw is None: - resp = await self.protocol.query("component_nego") - self._components_raw = resp["component_nego"] - self._components = { - comp["id"]: int(comp["ver_code"]) - for comp in self._components_raw["component_list"] - } + await self._negotiate() await self._initialize_modules() - extra_reqs: Dict[str, Any] = {} - - if "child_device" in self._components: - extra_reqs = {**extra_reqs, "get_child_device_list": None} + req: Dict[str, Any] = {} - if "energy_monitoring" in self._components: - extra_reqs = { - **extra_reqs, - "get_energy_usage": None, - "get_current_power": None, - } - - if self._components.get("device", 0) >= 2: - extra_reqs = { - **extra_reqs, - "get_device_usage": None, - } - - req = { - "get_device_info": None, - "get_device_time": None, - **extra_reqs, - } + # TODO: this could be optimized by constructing the query only once + for module in self.modules.values(): + req.update(module.query()) resp = await self.protocol.query(req) self._info = self._try_get_response(resp, "get_device_info") - self._time = self._try_get_response(resp, "get_device_time", {}) - # Device usage is not available on older firmware versions - self._usage = self._try_get_response(resp, "get_device_usage", {}) - # Emeter is not always available, but we set them still for now. - self._energy = self._try_get_response(resp, "get_energy_usage", {}) - self._emeter = self._try_get_response(resp, "get_current_power", {}) self._last_update = { "components": self._components_raw, - "info": self._info, - "usage": self._usage, - "time": self._time, - "energy": self._energy, - "emeter": self._emeter, + **resp, "child_info": self._try_get_response(resp, "get_child_device_list", {}), } if child_info := self._last_update.get("child_info"): if not self.children: await self._initialize_children() + for info in child_info["child_device_list"]: self._children[info["device_id"]].update_internal_state(info) @@ -152,8 +129,18 @@ async def update(self, update_children: bool = True): async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" - if "energy_monitoring" in self._components: - self.emeter_type = "emeter" + from .smartmodule import SmartModule + + for mod in SmartModule.REGISTERED_MODULES.values(): + _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) + if mod.REQUIRED_COMPONENT in self._components: + _LOGGER.debug( + "Found required %s, adding %s to modules.", + mod.REQUIRED_COMPONENT, + mod.__name__, + ) + module = mod(self, mod.REQUIRED_COMPONENT) + self.modules[module.name] = module async def _initialize_features(self): """Initialize device features.""" @@ -200,6 +187,10 @@ async def _initialize_features(self): ) ) + for module in self.modules.values(): + for feat in module._module_features.values(): + self._add_feature(feat) + @property def sys_info(self) -> Dict[str, Any]: """Returns the device info.""" @@ -221,17 +212,7 @@ def alias(self) -> Optional[str]: @property def time(self) -> datetime: """Return the time.""" - td = timedelta(minutes=cast(float, self._time.get("time_diff"))) - if self._time.get("region"): - tz = timezone(td, str(self._time.get("region"))) - else: - # in case the device returns a blank region this will result in the - # tzname being a UTC offset - tz = timezone(td) - return datetime.fromtimestamp( - cast(float, self._time.get("timestamp")), - tz=tz, - ) + return self.modules["TimeModule"].time @property def timezone(self) -> Dict: @@ -308,7 +289,7 @@ def state_information(self) -> Dict[str, Any]: @property def has_emeter(self) -> bool: """Return if the device has emeter.""" - return "energy_monitoring" in self._components + return "EnergyModule" in self.modules @property def is_on(self) -> bool: @@ -330,43 +311,28 @@ def update_from_discover_info(self, info): async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" - self._verify_emeter() - resp = await self.protocol.query("get_energy_usage") - self._energy = resp["get_energy_usage"] - return self.emeter_realtime - - def _convert_energy_data(self, data, scale) -> Optional[float]: - """Return adjusted emeter information.""" - return data if not data else data * scale - - def _verify_emeter(self) -> None: - """Raise an exception if there is no emeter.""" + _LOGGER.warning("Deprecated. Use `emeter_realtime` property accessor to avoid I/O.") if not self.has_emeter: raise SmartDeviceException("Device has no emeter") - if self.emeter_type not in self._last_update: - raise SmartDeviceException("update() required prior accessing emeter") + await self.modules["EnergyModule"].get_emeter_realtime() + return self.emeter_realtime + @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - return EmeterStatus( - { - "power_mw": self._energy.get("current_power"), - "total": self._convert_energy_data( - self._energy.get("today_energy"), 1 / 1000 - ), - } - ) + return self.modules["EnergyModule"].emeter_realtime + @property def emeter_this_month(self) -> Optional[float]: """Get the emeter value for this month.""" - return self._convert_energy_data(self._energy.get("month_energy"), 1 / 1000) + return self.modules["EnergyModule"].emeter_this_month @property def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" - return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) + return self.modules["EnergyModule"].emeter_today @property def on_since(self) -> Optional[datetime]: @@ -377,7 +343,10 @@ def on_since(self) -> Optional[datetime]: ): return None on_time = cast(float, on_time) - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + if "TimeModule" in self.modules: + return self.modules["TimeModule"].time - timedelta(seconds=on_time) + else: # We have no device time, use current local time. + return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) async def wifi_scan(self) -> List[WifiNetwork]: """Scan for available wifi networks.""" diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py new file mode 100644 index 000000000..bc5f809b9 --- /dev/null +++ b/kasa/smart/smartmodule.py @@ -0,0 +1,71 @@ +"""Base implementation for SMART modules.""" +import logging +from typing import Dict, Type + +from ..exceptions import SmartDeviceException +from ..module import Module + +_LOGGER = logging.getLogger(__name__) + + +class SmartModule(Module): + """Base class for SMART modules.""" + + NAME: str + REQUIRED_COMPONENT: str + QUERY_GETTER_NAME: str + REGISTERED_MODULES: Dict[str, Type["SmartModule"]] = {} + + def __init_subclass__(cls, **kwargs): + assert cls.REQUIRED_COMPONENT is not None # noqa: S101 + + name = getattr(cls, "NAME", cls.__name__) + _LOGGER.debug("Registering %s" % cls) + cls.REGISTERED_MODULES[name] = cls + + @property + def name(self) -> str: + """Name of the module.""" + return getattr(self, "NAME", self.__class__.__name__) + + def query(self) -> Dict: + """Query to execute during the update cycle. + + Default implementation uses the raw query getter w/o parameters. + """ + return {self.QUERY_GETTER_NAME: None} + + def call(self, method, params=None): + """Call a method. + + Just a helper method. + """ + return self._device._query_helper(method, params) + + @property + def data(self): + """Return response data for the module. + + If module performs only a single query, the resulting response is unwrapped. + """ + q = self.query() + q_keys = list(q.keys()) + # TODO: hacky way to check if update has been called. + if q_keys[0] not in self._device._last_update: + raise SmartDeviceException( + f"You need to call update() prior accessing module data" + f" for '{self._module}'" + ) + + filtered_data = { + k: v for k, v in self._device._last_update.items() if k in q_keys + } + if len(filtered_data) == 1: + return next(iter(filtered_data.values())) + + return filtered_data + + @property + def is_supported(self) -> bool: + """Return True modules are initialized if they are seen in the negotiation.""" + return True From 4534bd1bdc991446ffdf8c110f8f87d5b556688d Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 16 Feb 2024 20:54:25 +0100 Subject: [PATCH 02/10] Adjust based on reviews, cleanup a bit, and make linters happy --- kasa/cli.py | 8 +----- kasa/iot/iotmodule.py | 16 +++++------ kasa/module.py | 17 ++++------- kasa/smart/modules/__init__.py | 5 ++-- kasa/smart/modules/devicemodule.py | 7 ++++- kasa/smart/modules/energymodule.py | 46 ++++++++++++++++++++---------- kasa/smart/modules/timemodule.py | 18 +++++++++--- kasa/smart/smartchilddevice.py | 3 -- kasa/smart/smartdevice.py | 24 +++++++++------- kasa/smart/smartmodule.py | 5 ++++ 10 files changed, 87 insertions(+), 62 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index e89c64695..4d3590d10 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -590,13 +590,7 @@ async def state(ctx, dev: Device): echo("\n\t[bold]== Modules ==[/bold]") for module in dev.modules.values(): - if module.is_supported: - echo(f"\t[green]+ {module}[/green]") - # TODO: fix this hack, probably with descriptors? - if pretty_print := getattr(module, "__cli_output__", None): - echo(f"\t\t{pretty_print()}") - else: - echo(f"\t[red]- {module}[/red]") + echo(f"\t[green]+ {module}[/green]") if verbose: echo("\n\t[bold]== Verbose information ==[/bold]") diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index 8c82ea581..ddff06b39 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -22,6 +22,14 @@ def merge(d, u): class IotModule(Module): """Base class implemention for all IOT modules.""" + def call(self, method, params=None): + """Call the given method with the given parameters.""" + return self._device._query_helper(self._module, method, params) + + def query_for_command(self, query, params=None): + """Create a request object for the given parameters.""" + return self._device._create_request(self._module, query, params) + @property def estimated_query_response_size(self): """Estimated maximum size of query response. @@ -31,14 +39,6 @@ def estimated_query_response_size(self): """ return 256 # Estimate for modules that don't specify - def call(self, method, params=None): - """Call the given method with the given parameters.""" - return self._device._query_helper(self._module, method, params) - - def query_for_command(self, query, params=None): - """Create a request object for the given parameters.""" - return self._device._create_request(self._module, query, params) - @property def data(self): """Return the module specific raw data from the last update.""" diff --git a/kasa/module.py b/kasa/module.py index fab000600..66a143dc7 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -22,13 +22,6 @@ def __init__(self, device: "Device", module: str): self._module = module self._module_features: Dict[str, Feature] = {} - def _add_feature(self, feature: Feature): - """Add module descriptor.""" - feat_name = f"{self._module}_{feature.name}" - if feat_name in self._module_features: - raise SmartDeviceException("Duplicate name detected %s" % feat_name) - self._module_features[feat_name] = feature - @abstractmethod def query(self): """Query to execute during the update cycle. @@ -42,10 +35,12 @@ def query(self): def data(self): """Return the module specific raw data from the last update.""" - @property - @abstractmethod - def is_supported(self) -> bool: - """Return whether the module is supported by the device.""" + def _add_feature(self, feature: Feature): + """Add module feature.""" + feat_name = f"{self._module}_{feature.name}" + if feat_name in self._module_features: + raise SmartDeviceException("Duplicate name detected %s" % feat_name) + self._module_features[feat_name] = feature def __repr__(self) -> str: return ( diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 07078016e..6aec95734 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,5 +1,6 @@ -from .timemodule import TimeModule -from .energymodule import EnergyModule +"""Modules for SMART devices.""" from .devicemodule import DeviceModule +from .energymodule import EnergyModule +from .timemodule import TimeModule __all__ = ["TimeModule", "EnergyModule", "DeviceModule"] diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index a2b514b09..80e7287f0 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -1,11 +1,16 @@ +"""Implementation of device module.""" from typing import Dict + from ..smartmodule import SmartModule class DeviceModule(SmartModule): + """Implementation of device module.""" + REQUIRED_COMPONENT = "device" def query(self) -> Dict: + """Query to execute during the update cycle.""" query = { "get_device_info": None, } @@ -13,4 +18,4 @@ def query(self) -> Dict: if self._device._components[self.REQUIRED_COMPONENT] >= 2: query["get_device_usage"] = None - return query \ No newline at end of file + return query diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energymodule.py index cda3642df..5782a23fd 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energymodule.py @@ -1,24 +1,48 @@ -from typing import Dict, Optional, TYPE_CHECKING +"""Implementation of energy monitoring module.""" +from typing import TYPE_CHECKING, Dict, Optional -from ..smartmodule import SmartModule -from ...feature import Feature from ...emeterstatus import EmeterStatus - +from ...feature import Feature +from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice class EnergyModule(SmartModule): + """Implementation of energy monitoring module.""" + REQUIRED_COMPONENT = "energy_monitoring" def __init__(self, device: "SmartDevice", module: str): super().__init__(device, module) - self._add_feature(Feature(device, name="Current consumption", attribute_getter="current_power", container=self)) # W or mW? - self._add_feature(Feature(device, name="Today's consumption", attribute_getter="emeter_today", container=self)) # Wh or kWh? - self._add_feature(Feature(device, name="This month's consumption", attribute_getter="emeter_this_month", container=self)) # Wh or kWH? + self._add_feature( + Feature( + device, + name="Current consumption", + attribute_getter="current_power", + container=self, + ) + ) # W or mW? + self._add_feature( + Feature( + device, + name="Today's consumption", + attribute_getter="emeter_today", + container=self, + ) + ) # Wh or kWh? + self._add_feature( + Feature( + device, + name="This month's consumption", + attribute_getter="emeter_this_month", + container=self, + ) + ) # Wh or kWH? def query(self) -> Dict: + """Query to execute during the update cycle.""" return { "get_energy_usage": None, # The current_power in get_energy_usage is more precise (mw vs. w), @@ -59,14 +83,6 @@ def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" return self._convert_energy_data(self.energy.get("today_energy"), 1 / 1000) - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - # TODO: maybe we should just have a generic `update()` or similar, - # to execute the query() and return the raw results? - resp = await self.call("get_energy_usage") - self._energy = resp["get_energy_usage"] - return self.emeter_realtime - def _convert_energy_data(self, data, scale) -> Optional[float]: """Return adjusted emeter information.""" return data if not data else data * scale diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/timemodule.py index e31ba5e2a..0880e1234 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/timemodule.py @@ -1,8 +1,9 @@ -"""Implementation of device time module.""" +"""Implementation of time module.""" from datetime import datetime, timedelta, timezone from time import mktime from typing import TYPE_CHECKING, cast +from ...feature import Feature from ..smartmodule import SmartModule if TYPE_CHECKING: @@ -15,6 +16,18 @@ class TimeModule(SmartModule): REQUIRED_COMPONENT = "device_local_time" QUERY_GETTER_NAME = "get_device_time" + def __init__(self, device: "SmartDevice", module: str): + super().__init__(device, module) + + self._add_feature( + Feature( + device=device, + name="Time", + attribute_getter="time", + container=self, + ) + ) + @property def time(self) -> datetime: """Return device's current datetime.""" @@ -37,6 +50,3 @@ async def set_time(self, dt: datetime): "set_device_time", {"timestamp": unixtime, "time_diff": dt.utcoffset(), "region": dt.tzname()}, ) - - def __cli_output__(self): - return f"Time: {self.time}" diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 69648d5e2..698982b67 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -24,9 +24,6 @@ def __init__( self._parent = parent self._id = child_id self.protocol = _ChildProtocolWrapper(child_id, parent.protocol) - # TODO: remove the assignment after modularization is done, - # currently required to allow accessing time-related properties - self._time = parent._time self._device_type = DeviceType.StripSocket async def update(self, update_children: bool = True): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 64d7f78f8..0c48480e7 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -12,7 +12,7 @@ from ..exceptions import AuthenticationException, SmartDeviceException, SmartErrorCode from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol -from .modules import DeviceModule, TimeModule, EnergyModule # noqa: F403 +from .modules import DeviceModule, EnergyModule, TimeModule # noqa: F401 from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) @@ -212,7 +212,8 @@ def alias(self) -> Optional[str]: @property def time(self) -> datetime: """Return the time.""" - return self.modules["TimeModule"].time + _timemod = cast(TimeModule, self.modules["TimeModule"]) + return _timemod.time @property def timezone(self) -> Dict: @@ -311,28 +312,28 @@ def update_from_discover_info(self, info): async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" - _LOGGER.warning("Deprecated. Use `emeter_realtime` property accessor to avoid I/O.") + _LOGGER.warning("Deprecated, use `emeter_realtime`.") if not self.has_emeter: raise SmartDeviceException("Device has no emeter") - await self.modules["EnergyModule"].get_emeter_realtime() return self.emeter_realtime - @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - return self.modules["EnergyModule"].emeter_realtime - + energy = cast(EnergyModule, self.modules["EnergyModule"]) + return energy.emeter_realtime @property def emeter_this_month(self) -> Optional[float]: """Get the emeter value for this month.""" - return self.modules["EnergyModule"].emeter_this_month + energy = cast(EnergyModule, self.modules["EnergyModule"]) + return energy.emeter_this_month @property def emeter_today(self) -> Optional[float]: """Get the emeter value for today.""" - return self.modules["EnergyModule"].emeter_today + energy = cast(EnergyModule, self.modules["EnergyModule"]) + return energy.emeter_today @property def on_since(self) -> Optional[datetime]: @@ -343,8 +344,9 @@ def on_since(self) -> Optional[datetime]: ): return None on_time = cast(float, on_time) - if "TimeModule" in self.modules: - return self.modules["TimeModule"].time - timedelta(seconds=on_time) + if (timemod := self.modules.get("TimeModule")) is not None: + timemod = cast(TimeModule, timemod) + return timemod.time - timedelta(seconds=on_time) else: # We have no device time, use current local time. return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index bc5f809b9..014119b20 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -4,6 +4,7 @@ from ..exceptions import SmartDeviceException from ..module import Module +from .smartdevice import SmartDevice _LOGGER = logging.getLogger(__name__) @@ -16,6 +17,10 @@ class SmartModule(Module): QUERY_GETTER_NAME: str REGISTERED_MODULES: Dict[str, Type["SmartModule"]] = {} + def __init__(self, device: "SmartDevice", module: str): + self._device: SmartDevice + super().__init__(device, module) + def __init_subclass__(cls, **kwargs): assert cls.REQUIRED_COMPONENT is not None # noqa: S101 From e91d58c29deb052b9169fbb0687c4f105be6c7a0 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 16 Feb 2024 23:05:39 +0100 Subject: [PATCH 03/10] Add childdevicemodule --- kasa/smart/modules/__init__.py | 3 ++- kasa/smart/modules/childdevicemodule.py | 8 ++++++++ kasa/smart/smartdevice.py | 2 +- kasa/smart/smartmodule.py | 11 ++++------- 4 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 kasa/smart/modules/childdevicemodule.py diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 6aec95734..49f99a12a 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -2,5 +2,6 @@ from .devicemodule import DeviceModule from .energymodule import EnergyModule from .timemodule import TimeModule +from .childdevicemodule import ChildDeviceModule -__all__ = ["TimeModule", "EnergyModule", "DeviceModule"] +__all__ = ["TimeModule", "EnergyModule", "DeviceModule", "ChildDeviceModule"] diff --git a/kasa/smart/modules/childdevicemodule.py b/kasa/smart/modules/childdevicemodule.py new file mode 100644 index 000000000..72bd9f2e3 --- /dev/null +++ b/kasa/smart/modules/childdevicemodule.py @@ -0,0 +1,8 @@ +"""Implementation for child devices.""" +from ..smartmodule import SmartModule + + +class ChildDeviceModule(SmartModule): + """Implementation for child devices.""" + REQUIRED_COMPONENT = "child_device" + QUERY_GETTER_NAME = "get_child_device_list" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 0c48480e7..c0c3b7fd3 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -12,7 +12,7 @@ from ..exceptions import AuthenticationException, SmartDeviceException, SmartErrorCode from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol -from .modules import DeviceModule, EnergyModule, TimeModule # noqa: F401 +from .modules import DeviceModule, EnergyModule, TimeModule, ChildDeviceModule # noqa: F401 from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 014119b20..52f8f7dd0 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -1,10 +1,12 @@ """Base implementation for SMART modules.""" import logging -from typing import Dict, Type +from typing import Dict, Type, TYPE_CHECKING from ..exceptions import SmartDeviceException from ..module import Module -from .smartdevice import SmartDevice + +if TYPE_CHECKING: + from .smartdevice import SmartDevice _LOGGER = logging.getLogger(__name__) @@ -69,8 +71,3 @@ def data(self): return next(iter(filtered_data.values())) return filtered_data - - @property - def is_supported(self) -> bool: - """Return True modules are initialized if they are seen in the negotiation.""" - return True From 29e3ed4c0802373e5b676be3507d5ec29b6df921 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 16 Feb 2024 23:29:12 +0100 Subject: [PATCH 04/10] get_device_time requires time, not device_local_time, seen on p100 1.3.7 --- kasa/smart/modules/timemodule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/timemodule.py index 0880e1234..778da5110 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/timemodule.py @@ -13,7 +13,7 @@ class TimeModule(SmartModule): """Implementation of device_local_time.""" - REQUIRED_COMPONENT = "device_local_time" + REQUIRED_COMPONENT = "time" QUERY_GETTER_NAME = "get_device_time" def __init__(self, device: "SmartDevice", module: str): From 45b3bfa8806e1b40623ce8985506a8303c8843a1 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 17 Feb 2024 00:02:44 +0100 Subject: [PATCH 05/10] Fix tests --- kasa/smart/smartdevice.py | 6 +++--- kasa/tests/test_childdevice.py | 5 +++++ kasa/tests/test_smartdevice.py | 5 ++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index c0c3b7fd3..dc0335745 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -410,7 +410,7 @@ async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): "password": base64.b64encode(password.encode()).decode(), "ssid": base64.b64encode(ssid.encode()).decode(), }, - "time": self.internal_state["time"], + "time": self.internal_state["get_device_time"], } # The device does not respond to the request but changes the settings @@ -429,13 +429,13 @@ async def update_credentials(self, username: str, password: str): This will replace the existing authentication credentials on the device. """ - t = self.internal_state["time"] + time_data = self.internal_state["get_device_time"] payload = { "account": { "username": base64.b64encode(username.encode()).decode(), "password": base64.b64encode(password.encode()).decode(), }, - "time": t, + "time": time_data, } return await self.protocol.query({"set_qs_info": payload}) diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 3247c9173..78863def3 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -60,6 +60,11 @@ def _test_property_getters(): ) for prop in properties: name, _ = prop + # Skip emeter and time properties + # TODO: needs API cleanup, emeter* should probably be removed in favor + # of access through features/modules, handling of time* needs decision. + if name.startswith("emeter_") or name.startswith("time"): + continue try: _ = getattr(first, name) except Exception as ex: diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 67f8fa84f..2bc6a7be1 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -313,13 +313,12 @@ async def test_modules_not_supported(dev: IotDevice): async def test_update_sub_errors(dev: SmartDevice, caplog): mock_response: dict = { "get_device_info": {}, - "get_device_usage": SmartErrorCode.PARAMS_ERROR, - "get_device_time": {}, + "get_child_device_list": SmartErrorCode.PARAMS_ERROR, } caplog.set_level(logging.DEBUG) with patch.object(dev.protocol, "query", return_value=mock_response): await dev.update() - msg = "Error PARAMS_ERROR(-1008) getting request get_device_usage for device 127.0.0.123" + msg = "Error PARAMS_ERROR(-1008) getting request get_child_device_list for device 127.0.0.123" assert msg in caplog.text From f7cfecaf2330973f7ea9cdf394b819b342892211 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 17 Feb 2024 00:13:29 +0100 Subject: [PATCH 06/10] Explicitly test _try_get_response --- kasa/tests/test_smartdevice.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 2bc6a7be1..487286dbb 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -310,15 +310,13 @@ async def test_modules_not_supported(dev: IotDevice): @device_smart -async def test_update_sub_errors(dev: SmartDevice, caplog): +async def test_try_get_response(dev: SmartDevice, caplog): mock_response: dict = { - "get_device_info": {}, - "get_child_device_list": SmartErrorCode.PARAMS_ERROR, + "get_device_info": SmartErrorCode.PARAMS_ERROR, } caplog.set_level(logging.DEBUG) - with patch.object(dev.protocol, "query", return_value=mock_response): - await dev.update() - msg = "Error PARAMS_ERROR(-1008) getting request get_child_device_list for device 127.0.0.123" + dev._try_get_response(mock_response, "get_device_info", {}) + msg = "Error PARAMS_ERROR(-1008) getting request get_device_info for device 127.0.0.123" assert msg in caplog.text From b912292b9e6e0427961a8746313321b8236004d7 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 17 Feb 2024 00:15:58 +0100 Subject: [PATCH 07/10] Fix linting --- kasa/smart/modules/__init__.py | 2 +- kasa/smart/modules/childdevicemodule.py | 1 + kasa/smart/smartdevice.py | 7 ++++++- kasa/smart/smartmodule.py | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 49f99a12a..564363222 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,7 +1,7 @@ """Modules for SMART devices.""" +from .childdevicemodule import ChildDeviceModule from .devicemodule import DeviceModule from .energymodule import EnergyModule from .timemodule import TimeModule -from .childdevicemodule import ChildDeviceModule __all__ = ["TimeModule", "EnergyModule", "DeviceModule", "ChildDeviceModule"] diff --git a/kasa/smart/modules/childdevicemodule.py b/kasa/smart/modules/childdevicemodule.py index 72bd9f2e3..991acc25b 100644 --- a/kasa/smart/modules/childdevicemodule.py +++ b/kasa/smart/modules/childdevicemodule.py @@ -4,5 +4,6 @@ class ChildDeviceModule(SmartModule): """Implementation for child devices.""" + REQUIRED_COMPONENT = "child_device" QUERY_GETTER_NAME = "get_child_device_list" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index dc0335745..60fff0a04 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -12,7 +12,12 @@ from ..exceptions import AuthenticationException, SmartDeviceException, SmartErrorCode from ..feature import Feature, FeatureType from ..smartprotocol import SmartProtocol -from .modules import DeviceModule, EnergyModule, TimeModule, ChildDeviceModule # noqa: F401 +from .modules import ( # noqa: F401 + ChildDeviceModule, + DeviceModule, + EnergyModule, + TimeModule, +) from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 52f8f7dd0..6f42f297a 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -1,6 +1,6 @@ """Base implementation for SMART modules.""" import logging -from typing import Dict, Type, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Type from ..exceptions import SmartDeviceException from ..module import Module From ea528fe6d10670f3e9367506b209205ae5e49e59 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Mon, 19 Feb 2024 00:56:07 +0100 Subject: [PATCH 08/10] Add state feature to turning device on/off --- kasa/smart/smartdevice.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 60fff0a04..872d77d71 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -149,6 +149,11 @@ async def _initialize_modules(self): async def _initialize_features(self): """Initialize device features.""" + if "device_on": + self._add_feature( + Feature(self, "State", attribute_getter="is_on", attribute_setter="set_state", type=FeatureType.Switch) + ) + self._add_feature( Feature( self, @@ -302,13 +307,20 @@ def is_on(self) -> bool: """Return true if the device is on.""" return bool(self._info.get("device_on")) + async def set_state(self, on: bool): # TODO: better name wanted. + """Set the device state. + + See :meth:`is_on`. + """ + return await self.protocol.query({"set_device_info": {"device_on": on}}) + async def turn_on(self, **kwargs): """Turn on the device.""" - await self.protocol.query({"set_device_info": {"device_on": True}}) + await self.set_state(True) async def turn_off(self, **kwargs): """Turn off the device.""" - await self.protocol.query({"set_device_info": {"device_on": False}}) + await self.set_state(False) def update_from_discover_info(self, info): """Update state from info from the discover call.""" From 2eeadf711be6bd2587c931d43be688e9305494c3 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Mon, 19 Feb 2024 01:08:01 +0100 Subject: [PATCH 09/10] Fix device_on check.. --- 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 872d77d71..034f750b6 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -149,7 +149,7 @@ async def _initialize_modules(self): async def _initialize_features(self): """Initialize device features.""" - if "device_on": + if "device_on" in self._info: self._add_feature( Feature(self, "State", attribute_getter="is_on", attribute_setter="set_state", type=FeatureType.Switch) ) From dc498e14954d4d2a0b4636b62b574b00575be0bc Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Mon, 19 Feb 2024 17:54:17 +0100 Subject: [PATCH 10/10] Fix linting --- kasa/smart/smartdevice.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 034f750b6..f5e41dc1b 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -151,7 +151,13 @@ async def _initialize_features(self): """Initialize device features.""" if "device_on" in self._info: self._add_feature( - Feature(self, "State", attribute_getter="is_on", attribute_setter="set_state", type=FeatureType.Switch) + Feature( + self, + "State", + attribute_getter="is_on", + attribute_setter="set_state", + type=FeatureType.Switch, + ) ) self._add_feature(