From e70d161d666e888597ded94cbb9789a9bed83672 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 20 Oct 2021 23:14:03 +0200 Subject: [PATCH 1/9] Add module support & modularize existing query This creates a base to expose more features on the supported devices. At the moment, the most visible change is that each update cycle gets information from all available modules: * Basic system info * Cloud (new) * Countdown (new) * Antitheft (new) * Schedule (new) * Time (existing, implements the time/timezone handling) * Emeter (existing, partially separated from smartdevice) --- kasa/cli.py | 22 +++++++++- kasa/modules/__init__.py | 9 +++++ kasa/modules/antitheft.py | 10 +++++ kasa/modules/cloud.py | 50 +++++++++++++++++++++++ kasa/modules/countdown.py | 5 +++ kasa/modules/emeter.py | 20 +++++++++ kasa/modules/module.py | 52 ++++++++++++++++++++++++ kasa/modules/rulemodule.py | 83 ++++++++++++++++++++++++++++++++++++++ kasa/modules/schedule.py | 11 +++++ kasa/modules/time.py | 34 ++++++++++++++++ kasa/modules/usage.py | 40 ++++++++++++++++++ kasa/smartbulb.py | 10 ++++- kasa/smartdevice.py | 37 +++++++++++++++-- kasa/smartplug.py | 5 +++ kasa/smartstrip.py | 10 ++++- poetry.lock | 41 ++++++++++++++++++- pyproject.toml | 1 + 17 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 kasa/modules/__init__.py create mode 100644 kasa/modules/antitheft.py create mode 100644 kasa/modules/cloud.py create mode 100644 kasa/modules/countdown.py create mode 100644 kasa/modules/emeter.py create mode 100644 kasa/modules/module.py create mode 100644 kasa/modules/rulemodule.py create mode 100644 kasa/modules/schedule.py create mode 100644 kasa/modules/time.py create mode 100644 kasa/modules/usage.py diff --git a/kasa/cli.py b/kasa/cli.py index c23019ecf..57f4fdec9 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -199,7 +199,7 @@ async def state(ctx, dev: SmartDevice): click.echo() click.echo(click.style("\t== Generic information ==", bold=True)) - click.echo(f"\tTime: {await dev.get_time()}") + click.echo(f"\tTime: {dev.time} (tz: {dev.timezone}") click.echo(f"\tHardware: {dev.hw_info['hw_ver']}") click.echo(f"\tSoftware: {dev.hw_info['sw_ver']}") click.echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") @@ -388,7 +388,7 @@ async def led(dev, state): @pass_dev async def time(dev): """Get the device time.""" - res = await dev.get_time() + res = dev.time click.echo(f"Current time: {res}") return res @@ -446,5 +446,23 @@ async def reboot(plug, delay): return await plug.reboot(delay) +@cli.group() +@pass_dev +async def schedule(dev): + """Scheduling commands.""" + + +@schedule.command(name="list") +@pass_dev +def _schedule_list(dev): + """Return the list of schedule actions for the given type.""" + type_ = "schedule" + sched = dev.modules[type_] + for rule in sched.rules: + print(rule) + else: + click.echo(f"No rules of type {type_}") + + if __name__ == "__main__": cli() diff --git a/kasa/modules/__init__.py b/kasa/modules/__init__.py new file mode 100644 index 000000000..bed006373 --- /dev/null +++ b/kasa/modules/__init__.py @@ -0,0 +1,9 @@ +# flake8: noqa +from .antitheft import Antitheft +from .cloud import Cloud +from .countdown import Countdown +from .emeter import Emeter +from .module import Module +from .rulemodule import Rule, RuleModule +from .schedule import Schedule +from .time import Time diff --git a/kasa/modules/antitheft.py b/kasa/modules/antitheft.py new file mode 100644 index 000000000..51e74c380 --- /dev/null +++ b/kasa/modules/antitheft.py @@ -0,0 +1,10 @@ +from typing import Dict, List + +from .rulemodule import Rule, RuleModule + + +class Antitheft(RuleModule): + """Implementation of the antitheft module. + + This shares the functionality among other rule-based modules. + """ diff --git a/kasa/modules/cloud.py b/kasa/modules/cloud.py new file mode 100644 index 000000000..32d3a26d0 --- /dev/null +++ b/kasa/modules/cloud.py @@ -0,0 +1,50 @@ +"""Cloud module implementation.""" +from pydantic import BaseModel + +from .module import Module + + +class CloudInfo(BaseModel): + """Container for cloud settings.""" + + binded: bool + cld_connection: int + fwDlPage: str + fwNotifyType: int + illegalType: int + server: str + stopConnect: int + tcspInfo: str + tcspStatus: int + username: str + + +class Cloud(Module): + """Module implementing support for cloud services.""" + + def query(self): + """Request cloud connectivity info.""" + return self.query_for_command("get_info") + + @property + def info(self) -> CloudInfo: + """Return information about the cloud connectivity.""" + return CloudInfo.parse_obj(self.data["get_info"]) + + def get_available_firmwares(self): + """Return list of available firmwares.""" + return self.query_for_command("get_intl_fw_list") + + def set_server(self, url: str): + """Set the update server URL.""" + return self.query_for_command("set_server_url", {"server": url}) + + def connect(self, username: str, password: str): + """Login to the cloud using given information.""" + return self.query_for_command( + "bind", {"username": username, "password": password} + ) + + def disconnect(self): + """Disconnect from the cloud.""" + return self.query_for_command("unbind") diff --git a/kasa/modules/countdown.py b/kasa/modules/countdown.py new file mode 100644 index 000000000..2458ce6f4 --- /dev/null +++ b/kasa/modules/countdown.py @@ -0,0 +1,5 @@ +from .rulemodule import RuleModule + + +class Countdown(RuleModule): + """Implementation of countdown module.""" diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py new file mode 100644 index 000000000..5d20cdac1 --- /dev/null +++ b/kasa/modules/emeter.py @@ -0,0 +1,20 @@ +"""Implementation of the emeter module.""" +from ..emeterstatus import EmeterStatus +from .usagemodule import Usage + + +class Emeter(Usage): + """Emeter module.""" + + def query(self): + """Prepare query for emeter data.""" + return self._device._create_emeter_request() + + @property # type: ignore + def realtime(self) -> EmeterStatus: + """Return current energy readings.""" + return EmeterStatus(self.data["get_realtime"]) + + async def erase_stats(self): + """Erase all stats.""" + return await self.call("erase_emeter_stat") diff --git a/kasa/modules/module.py b/kasa/modules/module.py new file mode 100644 index 000000000..d46b40c84 --- /dev/null +++ b/kasa/modules/module.py @@ -0,0 +1,52 @@ +"""Base class for all module implementations.""" +import collections +from abc import ABC, abstractmethod + +from kasa import SmartDevice + + +# TODO: This is used for query construcing +def merge(d, u): + """Update dict recursively.""" + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = merge(d.get(k, {}), v) + else: + d[k] = v + return d + + +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: "SmartDevice", module: str): + self._device: "SmartDevice" = device + self._module = module + + @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 + def data(self): + """Return the module specific raw data from the last update.""" + return self._device._last_update[self._module] + + 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/modules/rulemodule.py b/kasa/modules/rulemodule.py new file mode 100644 index 000000000..e73b2d03e --- /dev/null +++ b/kasa/modules/rulemodule.py @@ -0,0 +1,83 @@ +"""Base implementation for all rule-based modules.""" +import logging +from enum import Enum +from typing import Dict, List, Optional + +from pydantic import BaseModel + +from .module import Module, merge + + +class Action(Enum): + """Action to perform.""" + + Disabled = -1 + TurnOff = 0 + TurnOn = 1 + Unknown = 2 + + +class TimeOption(Enum): + """Time when the action is executed.""" + + Disabled = -1 + Enabled = 0 + AtSunrise = 1 + AtSunset = 2 + + +class Rule(BaseModel): + """Representation of a rule.""" + + id: str + name: str + enable: bool + wday: List[int] + repeat: bool + + # start action + sact: Optional[Action] + stime_opt: TimeOption + smin: int + + eact: Optional[Action] + etime_opt: TimeOption + emin: int + + # Only on bulbs + s_light: Optional[Dict] + + +_LOGGER = logging.getLogger(__name__) + + +class RuleModule(Module): + """Base class for rule-based modules, such as countdown and antitheft.""" + + def query(self): + """Prepare the query for rules.""" + q = self.query_for_command("get_rules") + return merge(q, self.query_for_command("get_next_action")) + + @property + def rules(self) -> List[Rule]: + """Return the list of rules for the service.""" + try: + return [ + Rule.parse_obj(rule) for rule in self.data["get_rules"]["rule_list"] + ] + except Exception as ex: + _LOGGER.error("Unable to read rule list: %s (data: %s)", ex, self.data) + return [] + + async def set_enabled(self, state: bool): + """Enable or disable the service.""" + return await self.call("set_overall_enable", state) + + async def delete_rule(self, rule: Rule): + """Delete the given rule.""" + return await self.call("delete_rule", {"id": rule.id}) + + async def delete_all_rules(self): + """Delete all rules.""" + return await self.call("delete_all_rules") diff --git a/kasa/modules/schedule.py b/kasa/modules/schedule.py new file mode 100644 index 000000000..bbf1363f4 --- /dev/null +++ b/kasa/modules/schedule.py @@ -0,0 +1,11 @@ +"""Schedule module implementation.""" +from .rulemodule import RuleModule +from .usage import Usage + + +class Schedule(Usage, RuleModule): + """Implements the scheduling interface & usage statistics. + + Some devices do not support emeter, but may still keep track about their on/off state. + This module implements the interface to access that usage data. + """ diff --git a/kasa/modules/time.py b/kasa/modules/time.py new file mode 100644 index 000000000..0bd3f1714 --- /dev/null +++ b/kasa/modules/time.py @@ -0,0 +1,34 @@ +"""Provides the current time and timezone information.""" +from datetime import datetime + +from .module import Module, merge + + +class Time(Module): + """Implements the timezone settings.""" + + def query(self): + """Request time and timezone.""" + q = self.query_for_command("get_time") + + merge(q, self.query_for_command("get_timezone")) + return q + + @property + def time(self) -> datetime: + """Return current device time.""" + res = self.data["get_time"] + return datetime( + res["year"], + res["month"], + res["mday"], + res["hour"], + res["min"], + res["sec"], + ) + + @property + def timezone(self): + """Return current timezone.""" + res = self.data["get_timezone"] + return res diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py new file mode 100644 index 000000000..60db6d910 --- /dev/null +++ b/kasa/modules/usage.py @@ -0,0 +1,40 @@ +"""Implementation of the usage interface.""" +from datetime import datetime + +from .module import Module, merge + + +class Usage(Module): + """Baseclass for emeter/usage interfaces.""" + + def query(self): + """Return the base query.""" + year = datetime.now().year + month = datetime.now().month + + req = self.query_for_command(self._module, "get_realtime") + req = merge( + req, self.query_for_command("get_daystat", {"year": year, "month": month}) + ) + req = merge(req, self.query_for_command("get_monthstat", {"year": year})) + req = merge(req, self.query_for_command("get_next_action")) + + return req + + async def get_daystat(self, year, month): + """Return stats for the current day.""" + if year is None: + year = datetime.now().year + if month is None: + month = datetime.now().month + return await self.call("get_daystat", {"year": year, "month": month}) + + async def get_monthstat(self, year): + """Return stats for the current month.""" + if year is None: + year = datetime.now().year + return await self.call("get_monthstat", {"year": year}) + + async def erase_stats(self): + """Erase all stats.""" + return await self.call("erase_runtime_stat") diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index aad2ce8ce..e0102b736 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -3,6 +3,7 @@ import re from typing import Any, Dict, NamedTuple, cast +from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -105,13 +106,18 @@ class SmartBulb(SmartDevice): """ LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" - TIME_SERVICE = "smartlife.iot.common.timesetting" SET_LIGHT_METHOD = "transition_light_state" + emeter_type = "smartlife.iot.common.emeter" def __init__(self, host: str) -> None: super().__init__(host=host) - self.emeter_type = "smartlife.iot.common.emeter" self._device_type = DeviceType.Bulb + self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) + self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft")) + self.add_module("time", Time(self, "smartlife.iot.common.timesetting")) + self.add_module("emeter", Emeter(self, self.emeter_type)) + self.add_module("countdown", Countdown(self, "countdown")) + self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud")) @property # type: ignore @requires_update diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index fabf26b32..d65777679 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -22,6 +22,7 @@ from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException +from .modules import Emeter, Module from .protocol import TPLinkSmartHomeProtocol _LOGGER = logging.getLogger(__name__) @@ -186,6 +187,7 @@ class SmartDevice: """ TIME_SERVICE = "time" + emeter_type = "emeter" def __init__(self, host: str) -> None: """Create a new SmartDevice instance. @@ -195,7 +197,6 @@ def __init__(self, host: str) -> None: self.host = host self.protocol = TPLinkSmartHomeProtocol(host) - self.emeter_type = "emeter" _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) self._device_type = DeviceType.Unknown # TODO: typing Any is just as using Optional[Dict] would require separate checks in @@ -203,9 +204,21 @@ def __init__(self, host: str) -> None: # are not accessed incorrectly. self._last_update: Any = None self._sys_info: Any = None # TODO: this is here to avoid changing tests + self.modules: Dict[str, Any] = {} self.children: List["SmartDevice"] = [] + def add_module(self, name: str, module: Module): + """Register a module.""" + if name in self.modules: + _LOGGER.debug("Module %s already registered, ignoring..." % name) + return + + assert name not in self.modules + + _LOGGER.debug("Adding module %s", module) + self.modules[name] = module + def _create_request( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None ): @@ -297,7 +310,11 @@ async def update(self, update_children: bool = True): _LOGGER.debug( "The device has emeter, querying its information along sysinfo" ) - req.update(self._create_emeter_request()) + self.add_module("emeter", Emeter(self, self.emeter_type)) + + for module in self.modules.values(): + _LOGGER.debug("Adding query from %s", module) + req.update(module.query()) self._last_update = await self.protocol.query(req) self._sys_info = self._last_update["system"]["get_sysinfo"] @@ -331,6 +348,18 @@ async def set_alias(self, alias: str) -> None: """Set the device name (alias).""" return await self._query_helper("system", "set_dev_alias", {"alias": alias}) + @property # type: ignore + @requires_update + def time(self) -> datetime: + """Return current time from the device.""" + return self.modules["time"].time + + @property # type: ignore + @requires_update + def timezone(self) -> Dict: + """Return the current timezone.""" + return self.modules["time"].timezone + async def get_time(self) -> Optional[datetime]: """Return current time from the device, if available.""" try: @@ -429,7 +458,7 @@ async def set_mac(self, mac): def emeter_realtime(self) -> EmeterStatus: """Return current energy readings.""" self._verify_emeter() - return EmeterStatus(self._last_update[self.emeter_type]["get_realtime"]) + return EmeterStatus(self.modules["emeter"].realtime) async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" @@ -549,7 +578,7 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: async def erase_emeter_stats(self) -> Dict: """Erase energy meter statistics.""" self._verify_emeter() - return await self._query_helper(self.emeter_type, "erase_emeter_stat", None) + return await self.modules["emeter"].erase_stats() @requires_update async def current_consumption(self) -> float: diff --git a/kasa/smartplug.py b/kasa/smartplug.py index d23bc9396..06885f3aa 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -2,6 +2,7 @@ import logging from typing import Any, Dict +from kasa.modules import Antitheft, Cloud, Schedule, Time from kasa.smartdevice import DeviceType, SmartDevice, requires_update _LOGGER = logging.getLogger(__name__) @@ -40,6 +41,10 @@ def __init__(self, host: str) -> None: super().__init__(host) self.emeter_type = "emeter" self._device_type = DeviceType.Plug + self.add_module("schedule", Schedule(self, "schedule")) + self.add_module("antitheft", Antitheft(self, "anti_theft")) + self.add_module("time", Time(self, "time")) + self.add_module("cloud", Cloud(self, "cnCloud")) @property # type: ignore @requires_update diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 71373a7a9..47be1a534 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -13,6 +13,8 @@ ) from kasa.smartplug import SmartPlug +from .modules import Antitheft, Countdown, Schedule, Time + _LOGGER = logging.getLogger(__name__) @@ -80,6 +82,10 @@ def __init__(self, host: str) -> None: super().__init__(host=host) self.emeter_type = "emeter" self._device_type = DeviceType.Strip + self.add_module("antitheft", Antitheft(self, "anti_theft")) + self.add_module("schedule", Schedule(self, "schedule")) + self.add_module("time", Time(self, "time")) + self.add_module("countdown", Countdown(self, "countdown")) @property # type: ignore @requires_update @@ -205,13 +211,13 @@ async def erase_emeter_stats(self): @requires_update def emeter_this_month(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" - return sum([plug.emeter_this_month for plug in self.children]) + return sum(plug.emeter_this_month for plug in self.children) @property # type: ignore @requires_update def emeter_today(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" - return sum([plug.emeter_today for plug in self.children]) + return sum(plug.emeter_today for plug in self.children) @property # type: ignore @requires_update diff --git a/poetry.lock b/poetry.lock index 63bbc2c22..1ef917112 100644 --- a/poetry.lock +++ b/poetry.lock @@ -333,6 +333,21 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pydantic" +version = "1.8.2" +description = "Data validation and settings management using python 3.6 type hinting" +category = "main" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +typing-extensions = ">=3.7.4.3" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] + [[package]] name = "pygments" version = "2.10.0" @@ -747,7 +762,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "e388fa366e9423e60697bfa77c37151094ca0367eb3ed441d61bb8cc7f055675" +content-hash = "9b67c28fae544c487b54137b9953d86b359113c2de58020f4c4981361afbcf5a" [metadata.files] alabaster = [ @@ -955,6 +970,30 @@ py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, ] +pydantic = [ + {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, + {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, + {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, + {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, + {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, + {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, + {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, + {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, + {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, + {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, +] pygments = [ {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, diff --git a/pyproject.toml b/pyproject.toml index 0671d41b9..3c4705e1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ sphinx = { version = "^3", optional = true } m2r = { version = "^0", optional = true } sphinx_rtd_theme = { version = "^0", optional = true } sphinxcontrib-programoutput = { version = "^0", optional = true } +pydantic = "^1" [tool.poetry.dev-dependencies] pytest = "^5" From b31c71d39b8e1134bd9c30ef7409e3c401ae30d2 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 28 Oct 2021 00:46:04 +0200 Subject: [PATCH 2/9] Fix imports --- kasa/modules/emeter.py | 2 +- kasa/modules/module.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py index 5d20cdac1..f8144e39e 100644 --- a/kasa/modules/emeter.py +++ b/kasa/modules/emeter.py @@ -1,6 +1,6 @@ """Implementation of the emeter module.""" from ..emeterstatus import EmeterStatus -from .usagemodule import Usage +from .usage import Usage class Emeter(Usage): diff --git a/kasa/modules/module.py b/kasa/modules/module.py index d46b40c84..f56f12bd2 100644 --- a/kasa/modules/module.py +++ b/kasa/modules/module.py @@ -1,8 +1,10 @@ """Base class for all module implementations.""" import collections from abc import ABC, abstractmethod +from typing import TYPE_CHECKING -from kasa import SmartDevice +if TYPE_CHECKING: + from kasa import SmartDevice # TODO: This is used for query construcing From 22484e398dc9ec0b4bbb0bfb0d67f293ff3e9f2a Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Thu, 28 Oct 2021 00:51:39 +0200 Subject: [PATCH 3/9] Fix linting --- kasa/modules/antitheft.py | 5 ++--- kasa/modules/countdown.py | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/kasa/modules/antitheft.py b/kasa/modules/antitheft.py index 51e74c380..c885a70c2 100644 --- a/kasa/modules/antitheft.py +++ b/kasa/modules/antitheft.py @@ -1,6 +1,5 @@ -from typing import Dict, List - -from .rulemodule import Rule, RuleModule +"""Implementation of the antitheft module.""" +from .rulemodule import RuleModule class Antitheft(RuleModule): diff --git a/kasa/modules/countdown.py b/kasa/modules/countdown.py index 2458ce6f4..c2b47777e 100644 --- a/kasa/modules/countdown.py +++ b/kasa/modules/countdown.py @@ -1,3 +1,4 @@ +"""Implmention for the countdown timer.""" from .rulemodule import RuleModule From 0d810b620eec6845a8838691f1ea033678da1a8e Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 2 Nov 2021 14:31:12 +0100 Subject: [PATCH 4/9] Use device host instead of alias in module repr --- kasa/modules/module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/modules/module.py b/kasa/modules/module.py index f56f12bd2..aa81b64b3 100644 --- a/kasa/modules/module.py +++ b/kasa/modules/module.py @@ -51,4 +51,4 @@ def query_for_command(self, query, params=None): return self._device._create_request(self._module, query, params) def __repr__(self) -> str: - return f"" + return f"" From a37a2445d52c736781d2502ec8528183a246d65f Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 2 Nov 2021 14:53:00 +0100 Subject: [PATCH 5/9] Add property to list available modules, print them in cli state report --- kasa/cli.py | 4 ++++ kasa/smartdevice.py | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/kasa/cli.py b/kasa/cli.py index 57f4fdec9..5630aa3a3 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -215,6 +215,10 @@ async def state(ctx, dev: SmartDevice): emeter_status = dev.emeter_realtime click.echo(f"\t{emeter_status}") + click.echo(click.style("\n\t== Supported modules ==", bold=True)) + for module in dev.supported_modules: + click.echo(f"\t- {module}") + @cli.command() @pass_dev diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index d65777679..4cf3b39f5 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -275,6 +275,14 @@ def features(self) -> Set[str]: """Return a set of features that the device supports.""" return set(self.sys_info["feature"].split(":")) + @property # type: ignore + @requires_update + def supported_modules(self) -> List[str]: + """Return a set of modules supported by the device.""" + # TODO: this should rather be called `features`, but we don't want to break + # the API now. Maybe just deprecate it and point the users to use this? + return list(self.modules.keys()) + @property # type: ignore @requires_update def has_emeter(self) -> bool: From ef5462e50b7b2fd180e314b0e96a7064f59e1472 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 2 Nov 2021 16:24:43 +0100 Subject: [PATCH 6/9] usage: fix the get_realtime query --- kasa/modules/usage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py index 60db6d910..2a5b6dc0b 100644 --- a/kasa/modules/usage.py +++ b/kasa/modules/usage.py @@ -12,7 +12,7 @@ def query(self): year = datetime.now().year month = datetime.now().month - req = self.query_for_command(self._module, "get_realtime") + req = self.query_for_command("get_realtime") req = merge( req, self.query_for_command("get_daystat", {"year": year, "month": month}) ) From 120297f63047639cc63690a6aa945b98b1c1a7ef Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 2 Nov 2021 16:25:27 +0100 Subject: [PATCH 7/9] separate usage from schedule to avoid multi-inheritance --- kasa/modules/__init__.py | 1 + kasa/modules/countdown.py | 2 +- kasa/modules/schedule.py | 9 ++------- kasa/smartbulb.py | 3 ++- kasa/smartdevice.py | 5 +++-- kasa/smartplug.py | 3 ++- kasa/smartstrip.py | 3 ++- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/kasa/modules/__init__.py b/kasa/modules/__init__.py index bed006373..dd9d1072f 100644 --- a/kasa/modules/__init__.py +++ b/kasa/modules/__init__.py @@ -7,3 +7,4 @@ from .rulemodule import Rule, RuleModule from .schedule import Schedule from .time import Time +from .usage import Usage diff --git a/kasa/modules/countdown.py b/kasa/modules/countdown.py index c2b47777e..9f3e59c16 100644 --- a/kasa/modules/countdown.py +++ b/kasa/modules/countdown.py @@ -1,4 +1,4 @@ -"""Implmention for the countdown timer.""" +"""Implementation for the countdown timer.""" from .rulemodule import RuleModule diff --git a/kasa/modules/schedule.py b/kasa/modules/schedule.py index bbf1363f4..62371692b 100644 --- a/kasa/modules/schedule.py +++ b/kasa/modules/schedule.py @@ -1,11 +1,6 @@ """Schedule module implementation.""" from .rulemodule import RuleModule -from .usage import Usage -class Schedule(Usage, RuleModule): - """Implements the scheduling interface & usage statistics. - - Some devices do not support emeter, but may still keep track about their on/off state. - This module implements the interface to access that usage data. - """ +class Schedule(RuleModule): + """Implements the scheduling interface.""" diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index e0102b736..b5352c3c3 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -3,7 +3,7 @@ import re from typing import Any, Dict, NamedTuple, cast -from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time +from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -113,6 +113,7 @@ def __init__(self, host: str) -> None: super().__init__(host=host) self._device_type = DeviceType.Bulb self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) + self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft")) self.add_module("time", Time(self, "smartlife.iot.common.timesetting")) self.add_module("emeter", Emeter(self, self.emeter_type)) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 4cf3b39f5..876a64382 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -321,8 +321,9 @@ async def update(self, update_children: bool = True): self.add_module("emeter", Emeter(self, self.emeter_type)) for module in self.modules.values(): - _LOGGER.debug("Adding query from %s", module) - req.update(module.query()) + q = module.query() + _LOGGER.debug("Adding query for %s: %s", module, q) + req = merge(req, module.query(q)) self._last_update = await self.protocol.query(req) self._sys_info = self._last_update["system"]["get_sysinfo"] diff --git a/kasa/smartplug.py b/kasa/smartplug.py index 06885f3aa..58144b58a 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -2,7 +2,7 @@ import logging from typing import Any, Dict -from kasa.modules import Antitheft, Cloud, Schedule, Time +from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage from kasa.smartdevice import DeviceType, SmartDevice, requires_update _LOGGER = logging.getLogger(__name__) @@ -42,6 +42,7 @@ def __init__(self, host: str) -> None: self.emeter_type = "emeter" self._device_type = DeviceType.Plug self.add_module("schedule", Schedule(self, "schedule")) + self.add_module("usage", Usage(self, "schedule")) self.add_module("antitheft", Antitheft(self, "anti_theft")) self.add_module("time", Time(self, "time")) self.add_module("cloud", Cloud(self, "cnCloud")) diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 47be1a534..bbdf2a3fb 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -13,7 +13,7 @@ ) from kasa.smartplug import SmartPlug -from .modules import Antitheft, Countdown, Schedule, Time +from .modules import Antitheft, Countdown, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -84,6 +84,7 @@ def __init__(self, host: str) -> None: self._device_type = DeviceType.Strip self.add_module("antitheft", Antitheft(self, "anti_theft")) self.add_module("schedule", Schedule(self, "schedule")) + self.add_module("usage", Usage(self, "schedule")) self.add_module("time", Time(self, "time")) self.add_module("countdown", Countdown(self, "countdown")) From c54d71fab7ebd567938fd5c34fdf3cef4ffe7e9d Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 2 Nov 2021 16:37:52 +0100 Subject: [PATCH 8/9] Fix module querying --- kasa/smartdevice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 876a64382..c9b9171f5 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -323,7 +323,7 @@ async def update(self, update_children: bool = True): for module in self.modules.values(): q = module.query() _LOGGER.debug("Adding query for %s: %s", module, q) - req = merge(req, module.query(q)) + req = merge(req, module.query()) self._last_update = await self.protocol.query(req) self._sys_info = self._last_update["system"]["get_sysinfo"] From 8d8511a3390ede8ca6dcba07f9d03c1cd92db1ec Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 2 Nov 2021 16:38:28 +0100 Subject: [PATCH 9/9] Add is_supported property to modules --- kasa/cli.py | 17 ++++++++++------- kasa/modules/module.py | 5 +++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 5630aa3a3..5d56e19b4 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -215,9 +215,12 @@ async def state(ctx, dev: SmartDevice): emeter_status = dev.emeter_realtime click.echo(f"\t{emeter_status}") - click.echo(click.style("\n\t== Supported modules ==", bold=True)) - for module in dev.supported_modules: - click.echo(f"\t- {module}") + click.echo(click.style("\n\t== Modules ==", bold=True)) + for module in dev.modules.values(): + if module.is_supported: + click.echo(click.style(f"\t+ {module}", fg="green")) + else: + click.echo(click.style(f"\t- {module}", fg="red")) @cli.command() @@ -458,14 +461,14 @@ async def schedule(dev): @schedule.command(name="list") @pass_dev -def _schedule_list(dev): +@click.argument("type", default="schedule") +def _schedule_list(dev, type): """Return the list of schedule actions for the given type.""" - type_ = "schedule" - sched = dev.modules[type_] + sched = dev.modules[type] for rule in sched.rules: print(rule) else: - click.echo(f"No rules of type {type_}") + click.echo(f"No rules of type {type}") if __name__ == "__main__": diff --git a/kasa/modules/module.py b/kasa/modules/module.py index aa81b64b3..1f7f3829f 100644 --- a/kasa/modules/module.py +++ b/kasa/modules/module.py @@ -42,6 +42,11 @@ def data(self): """Return the module specific raw data from the last update.""" return self._device._last_update[self._module] + @property + def is_supported(self) -> bool: + """Return whether the module is supported by the device.""" + 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)