diff --git a/kasa/cli.py b/kasa/cli.py index 696dd9aab..c9cab4b50 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -221,7 +221,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})") @@ -236,6 +236,13 @@ async def state(ctx, dev: SmartDevice): emeter_status = dev.emeter_realtime click.echo(f"\t{emeter_status}") + 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() @pass_dev @@ -309,7 +316,6 @@ async def emeter(dev: SmartDevice, year, month, erase): usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns - usage_data = {} emeter_status = dev.emeter_realtime click.echo("Current: %s A" % emeter_status["current"]) @@ -327,6 +333,44 @@ async def emeter(dev: SmartDevice, year, month, erase): click.echo(f"{index}, {usage}") +@cli.command() +@pass_dev +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +async def usage(dev: SmartDevice, year, month, erase): + """Query usage for historical consumption. + + Daily and monthly data provided in CSV format. + """ + click.echo(click.style("== Usage ==", bold=True)) + usage = dev.modules["usage"] + + if erase: + click.echo("Erasing usage statistics..") + click.echo(await usage.erase_stats()) + return + + if year: + click.echo(f"== For year {year.year} ==") + click.echo("Month, usage (minutes)") + usage_data = await usage.get_monthstat(year.year) + elif month: + click.echo(f"== For month {month.month} of {month.year} ==") + click.echo("Day, usage (minutes)") + usage_data = await usage.get_daystat(year=month.year, month=month.month) + else: + # Call with no argument outputs summary data and returns + click.echo("Today: %s minutes" % usage.usage_today) + click.echo("This month: %s minutes" % usage.usage_this_month) + + return + + # output any detailed usage data + for index, usage in usage_data.items(): + click.echo(f"{index}, {usage}") + + @cli.command() @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @@ -430,7 +474,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 @@ -488,5 +532,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 +@click.argument("type", default="schedule") +def _schedule_list(dev, type): + """Return the list of schedule actions for the given type.""" + 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..e5cb83d66 --- /dev/null +++ b/kasa/modules/__init__.py @@ -0,0 +1,12 @@ +# flake8: noqa +from .ambientlight import AmbientLight +from .antitheft import Antitheft +from .cloud import Cloud +from .countdown import Countdown +from .emeter import Emeter +from .module import Module +from .motion import Motion +from .rulemodule import Rule, RuleModule +from .schedule import Schedule +from .time import Time +from .usage import Usage diff --git a/kasa/modules/ambientlight.py b/kasa/modules/ambientlight.py new file mode 100644 index 000000000..963c73a3f --- /dev/null +++ b/kasa/modules/ambientlight.py @@ -0,0 +1,47 @@ +"""Implementation of the ambient light (LAS) module found in some dimmers.""" +from .module import Module + +# TODO create tests and use the config reply there +# [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450, +# "level_array":[{"name":"cloudy","adc":490,"value":20}, +# {"name":"overcast","adc":294,"value":12}, +# {"name":"dawn","adc":222,"value":9}, +# {"name":"twilight","adc":222,"value":9}, +# {"name":"total darkness","adc":111,"value":4}, +# {"name":"custom","adc":2400,"value":97}]}] + + +class AmbientLight(Module): + """Implements ambient light controls for the motion sensor.""" + + def query(self): + """Request configuration.""" + return self.query_for_command("get_config") + + @property + def presets(self) -> dict: + """Return device-defined presets for brightness setting.""" + return self.data["level_array"] + + @property + def enabled(self) -> bool: + """Return True if the module is enabled.""" + return bool(self.data["enable"]) + + async def set_enabled(self, state: bool): + """Enable/disable LAS.""" + return await self.call("set_enable", {"enable": int(state)}) + + async def current_brightness(self) -> int: + """Return current brightness. + + Return value units. + """ + return await self.call("get_current_brt") + + async def set_brightness_limit(self, value: int): + """Set the limit when the motion sensor is inactive. + + See `presets` for preset values. Custom values are also likely allowed. + """ + return await self.call("set_brt_level", {"index": 0, "value": value}) diff --git a/kasa/modules/antitheft.py b/kasa/modules/antitheft.py new file mode 100644 index 000000000..c885a70c2 --- /dev/null +++ b/kasa/modules/antitheft.py @@ -0,0 +1,9 @@ +"""Implementation of the antitheft module.""" +from .rulemodule import 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..9f3e59c16 --- /dev/null +++ b/kasa/modules/countdown.py @@ -0,0 +1,6 @@ +"""Implementation for the countdown timer.""" +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..cd92c3cce --- /dev/null +++ b/kasa/modules/emeter.py @@ -0,0 +1,73 @@ +"""Implementation of the emeter module.""" +from datetime import datetime +from typing import Dict, Optional + +from ..emeterstatus import EmeterStatus +from .usage import Usage + + +class Emeter(Usage): + """Emeter module.""" + + @property # type: ignore + def realtime(self) -> EmeterStatus: + """Return current energy readings.""" + return EmeterStatus(self.data["get_realtime"]) + + @property + def emeter_today(self) -> Optional[float]: + """Return today's energy consumption in kWh.""" + raw_data = self.daily_data + today = datetime.now().day + data = self._emeter_convert_emeter_data(raw_data) + + return data.get(today) + + @property + def emeter_this_month(self) -> Optional[float]: + """Return this month's energy consumption in kWh.""" + raw_data = self.monthly_data + current_month = datetime.now().month + data = self._emeter_convert_emeter_data(raw_data) + + return data.get(current_month) + + async def erase_stats(self): + """Erase all stats. + + Uses different query than usage meter. + """ + return await self.call("erase_emeter_stat") + + async def get_realtime(self): + """Return real-time statistics.""" + return await self.call("get_realtime") + + async def get_daystat(self, *, year, month, kwh=True): + """Return daily stats for the given year & month.""" + raw_data = await super().get_daystat(year=year, month=month) + return self._emeter_convert_emeter_data(raw_data["day_list"], kwh) + + async def get_monthstat(self, *, year, kwh=True): + """Return monthly stats for the given year.""" + raw_data = await super().get_monthstat(year=year) + return self._emeter_convert_emeter_data(raw_data["month_list"], kwh) + + def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict: + """Return emeter information keyed with the day/month..""" + response = [EmeterStatus(**x) for x in data] + + if not response: + return {} + + energy_key = "energy_wh" + if kwh: + energy_key = "energy" + + entry_key = "month" + if "day" in response[0]: + entry_key = "day" + + data = {entry[entry_key]: entry[energy_key] for entry in response} + + return data diff --git a/kasa/modules/module.py b/kasa/modules/module.py new file mode 100644 index 000000000..7340d7e11 --- /dev/null +++ b/kasa/modules/module.py @@ -0,0 +1,74 @@ +"""Base class for all module implementations.""" +import collections +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from ..exceptions import SmartDeviceException + +if TYPE_CHECKING: + from kasa import SmartDevice + + +_LOGGER = logging.getLogger(__name__) + + +# 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.""" + if self._module not in self._device._last_update: + raise SmartDeviceException( + f"You need to call update() prior accessing module data for '{self._module}'" + ) + + return self._device._last_update[self._module] + + @property + def is_supported(self) -> bool: + """Return whether the module is supported by the device.""" + if self._module not in self._device._last_update: + _LOGGER.debug("Initial update, so consider supported: %s", self._module) + 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/modules/motion.py b/kasa/modules/motion.py new file mode 100644 index 000000000..45e272bed --- /dev/null +++ b/kasa/modules/motion.py @@ -0,0 +1,61 @@ +"""Implementation of the motion detection (PIR) module found in some dimmers.""" +from enum import Enum +from typing import Optional + +from ..exceptions import SmartDeviceException +from .module import Module + + +class Range(Enum): + """Range for motion detection.""" + + Far = 0 + Mid = 1 + Near = 2 + Custom = 3 + + +# TODO: use the config reply in tests +# {"enable":0,"version":"1.0","trigger_index":2,"cold_time":60000, +# "min_adc":0,"max_adc":4095,"array":[80,50,20,0],"err_code":0}}} + + +class Motion(Module): + """Implements the motion detection (PIR) module.""" + + def query(self): + """Request PIR configuration.""" + return self.query_for_command("get_config") + + @property + def range(self) -> Range: + """Return motion detection range.""" + return Range(self.data["trigger_index"]) + + @property + def enabled(self) -> bool: + """Return True if module is enabled.""" + return bool(self.data["enable"]) + + async def set_enabled(self, state: bool): + """Enable/disable PIR.""" + return await self.call("set_enable", {"enable": int(state)}) + + async def set_range( + self, *, range: Optional[Range] = None, custom_range: Optional[int] = None + ): + """Set the range for the sensor. + + :param range: for using standard ranges + :param custom_range: range in decimeters, overrides the range parameter + """ + if custom_range is not None: + payload = {"index": Range.Custom.value, "value": custom_range} + elif range is not None: + payload = {"index": range.value} + else: + raise SmartDeviceException( + "Either range or custom_range need to be defined" + ) + + return await self.call("set_trigger_sens", payload) 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..62371692b --- /dev/null +++ b/kasa/modules/schedule.py @@ -0,0 +1,6 @@ +"""Schedule module implementation.""" +from .rulemodule import RuleModule + + +class Schedule(RuleModule): + """Implements the scheduling interface.""" diff --git a/kasa/modules/time.py b/kasa/modules/time.py new file mode 100644 index 000000000..d72e2d600 --- /dev/null +++ b/kasa/modules/time.py @@ -0,0 +1,54 @@ +"""Provides the current time and timezone information.""" +from datetime import datetime + +from ..exceptions import SmartDeviceException +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 + + async def get_time(self): + """Return current device time.""" + try: + res = await self.call("get_time") + return datetime( + res["year"], + res["month"], + res["mday"], + res["hour"], + res["min"], + res["sec"], + ) + except SmartDeviceException: + return None + + async def get_timezone(self): + """Request timezone information from the device.""" + return await self.call("get_timezone") diff --git a/kasa/modules/usage.py b/kasa/modules/usage.py new file mode 100644 index 000000000..5aecb9a75 --- /dev/null +++ b/kasa/modules/usage.py @@ -0,0 +1,71 @@ +"""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("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})) + + return req + + @property + def daily_data(self): + """Return statistics on daily basis.""" + return self.data["get_daystat"]["day_list"] + + @property + def monthly_data(self): + """Return statistics on monthly basis.""" + return self.data["get_monthstat"]["month_list"] + + @property + def usage_today(self): + """Return today's usage in minutes.""" + today = datetime.now().day + converted = [x["time"] for x in self.daily_data if x["day"] == today] + if not converted: + return None + + return converted.pop() + + @property + def usage_this_month(self): + """Return usage in this month in minutes.""" + this_month = datetime.now().month + converted = [x["time"] for x in self.monthly_data if x["month"] == this_month] + if not converted: + return None + + return converted.pop() + + async def get_daystat(self, *, year=None, month=None): + """Return daily stats for the given year & month.""" + 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=None): + """Return monthly stats for the given year.""" + 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/protocol.py b/kasa/protocol.py index e2f946269..24c2cd056 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -70,20 +70,13 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: async with self.query_lock: return await self._query(request, retry_count, timeout) - async def _connect(self, timeout: int) -> bool: + async def _connect(self, timeout: int) -> None: """Try to connect or reconnect to the device.""" if self.writer: - return True - - with contextlib.suppress(Exception): - self.reader = self.writer = None - task = asyncio.open_connection( - self.host, TPLinkSmartHomeProtocol.DEFAULT_PORT - ) - self.reader, self.writer = await asyncio.wait_for(task, timeout=timeout) - return True - - return False + return + self.reader = self.writer = None + task = asyncio.open_connection(self.host, TPLinkSmartHomeProtocol.DEFAULT_PORT) + self.reader, self.writer = await asyncio.wait_for(task, timeout=timeout) async def _execute_query(self, request: str) -> Dict: """Execute a query on the device and wait for the response.""" @@ -123,12 +116,14 @@ def _reset(self) -> None: async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: """Try to query a device.""" for retry in range(retry_count + 1): - if not await self._connect(timeout): + try: + await self._connect(timeout) + except Exception as ex: await self.close() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self.host, retry) raise SmartDeviceException( - f"Unable to connect to the device: {self.host}" + f"Unable to connect to the device: {self.host}: {ex}" ) continue diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index c2d095395..f941dcf1f 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, Usage from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -109,13 +110,19 @@ 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("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)) + 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 7a66a864d..93e01758c 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__) @@ -185,7 +186,7 @@ class SmartDevice: """ - TIME_SERVICE = "time" + emeter_type = "emeter" def __init__(self, host: str) -> None: """Create a new SmartDevice instance. @@ -195,7 +196,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 +203,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 ): @@ -268,6 +280,14 @@ def features(self) -> Set[str]: _LOGGER.debug("Device does not have feature information") return set() + @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: @@ -293,20 +313,27 @@ async def update(self, update_children: bool = True): _LOGGER.debug("Performing the initial update to obtain sysinfo") self._last_update = await self.protocol.query(req) self._sys_info = self._last_update["system"]["get_sysinfo"] - # If the device has no emeter, we are done for the initial update - # Otherwise we will follow the regular code path to also query - # the emeter data also during the initial update - if not self.has_emeter: - return + await self._modular_update(req) + self._sys_info = self._last_update["system"]["get_sysinfo"] + + async def _modular_update(self, req: dict) -> None: + """Execute an update query.""" if self.has_emeter: _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(): + if not module.is_supported: + _LOGGER.debug("Module %s not supported, skipping" % module) + continue + q = module.query() + _LOGGER.debug("Adding query for %s: %s", module, q) + req = merge(req, q) self._last_update = await self.protocol.query(req) - self._sys_info = self._last_update["system"]["get_sysinfo"] def update_from_discover_info(self, info): """Update state from info from the discover call.""" @@ -337,24 +364,31 @@ 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: - res = await self._query_helper(self.TIME_SERVICE, "get_time") - return datetime( - res["year"], - res["month"], - res["mday"], - res["hour"], - res["min"], - res["sec"], - ) - except SmartDeviceException: - return None + _LOGGER.warning( + "Use `time` property instead, this call will be removed in the future." + ) + return await self.modules["time"].get_time() async def get_timezone(self) -> Dict: """Return timezone information.""" - return await self._query_helper(self.TIME_SERVICE, "get_timezone") + _LOGGER.warning( + "Use `timezone` property instead, this call will be removed in the future." + ) + return await self.modules["time"].get_timezone() @property # type: ignore @requires_update @@ -392,7 +426,7 @@ def location(self) -> Dict: loc["latitude"] = sys_info["latitude_i"] / 10000 loc["longitude"] = sys_info["longitude_i"] / 10000 else: - _LOGGER.warning("Unsupported device location.") + _LOGGER.debug("Unsupported device location.") return loc @@ -435,61 +469,26 @@ 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.""" self._verify_emeter() - return EmeterStatus(await self._query_helper(self.emeter_type, "get_realtime")) - - def _create_emeter_request(self, year: int = None, month: int = None): - """Create a Internal method for building a request for all emeter statistics at once.""" - if year is None: - year = datetime.now().year - if month is None: - month = datetime.now().month - - req: Dict[str, Any] = {} - merge(req, self._create_request(self.emeter_type, "get_realtime")) - merge( - req, self._create_request(self.emeter_type, "get_monthstat", {"year": year}) - ) - merge( - req, - self._create_request( - self.emeter_type, "get_daystat", {"month": month, "year": year} - ), - ) - - return req + return EmeterStatus(await self.modules["emeter"].get_realtime()) @property # type: ignore @requires_update def emeter_today(self) -> Optional[float]: """Return today's energy consumption in kWh.""" self._verify_emeter() - raw_data = self._last_update[self.emeter_type]["get_daystat"]["day_list"] - data = self._emeter_convert_emeter_data(raw_data) - today = datetime.now().day - - if today in data: - return data[today] - - return None + return self.modules["emeter"].emeter_today @property # type: ignore @requires_update def emeter_this_month(self) -> Optional[float]: """Return this month's energy consumption in kWh.""" self._verify_emeter() - raw_data = self._last_update[self.emeter_type]["get_monthstat"]["month_list"] - data = self._emeter_convert_emeter_data(raw_data) - current_month = datetime.now().month - - if current_month in data: - return data[current_month] - - return None + return self.modules["emeter"].emeter_this_month def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict: """Return emeter information keyed with the day/month..""" @@ -522,16 +521,7 @@ async def get_emeter_daily( :return: mapping of day of month to value """ self._verify_emeter() - if year is None: - year = datetime.now().year - if month is None: - month = datetime.now().month - - response = await self._query_helper( - self.emeter_type, "get_daystat", {"month": month, "year": year} - ) - - return self._emeter_convert_emeter_data(response["day_list"], kwh) + return await self.modules["emeter"].get_daystat(year=year, month=month, kwh=kwh) @requires_update async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: @@ -542,26 +532,19 @@ async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: :return: dict: mapping of month to value """ self._verify_emeter() - if year is None: - year = datetime.now().year - - response = await self._query_helper( - self.emeter_type, "get_monthstat", {"year": year} - ) - - return self._emeter_convert_emeter_data(response["month_list"], kwh) + return await self.modules["emeter"].get_monthstat(year=year, kwh=kwh) @requires_update 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: """Get the current power consumption in Watt.""" self._verify_emeter() - response = EmeterStatus(await self.get_emeter_realtime()) + response = self.emeter_realtime return float(response["power"]) async def reboot(self, delay: int = 1) -> None: diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index 8e5cb1527..5c06b8b94 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -1,6 +1,7 @@ """Module for dimmers (currently only HS220).""" from typing import Any, Dict +from kasa.modules import AmbientLight, Motion from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update from kasa.smartplug import SmartPlug @@ -40,6 +41,10 @@ class SmartDimmer(SmartPlug): def __init__(self, host: str) -> None: super().__init__(host) self._device_type = DeviceType.Dimmer + # TODO: need to be verified if it's okay to call these on HS220 w/o these + # TODO: need to be figured out what's the best approach to detect support for these + self.add_module("motion", Motion(self, "smartlife.iot.PIR")) + self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) @property # type: ignore @requires_update diff --git a/kasa/smartplug.py b/kasa/smartplug.py index d23bc9396..b636c3e11 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, Usage from kasa.smartdevice import DeviceType, SmartDevice, requires_update _LOGGER = logging.getLogger(__name__) @@ -38,8 +39,12 @@ class SmartPlug(SmartDevice): 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("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")) @property # type: ignore @requires_update diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 391381bcc..ba863d059 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -9,10 +9,13 @@ EmeterStatus, SmartDevice, SmartDeviceException, + merge, requires_update, ) from kasa.smartplug import SmartPlug +from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage + _LOGGER = logging.getLogger(__name__) @@ -80,6 +83,12 @@ 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("usage", Usage(self, "schedule")) + self.add_module("time", Time(self, "time")) + self.add_module("countdown", Countdown(self, "countdown")) + self.add_module("emeter", Emeter(self, "emeter")) @property # type: ignore @requires_update @@ -242,16 +251,37 @@ def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: self._last_update = parent._last_update self._sys_info = parent._sys_info self._device_type = DeviceType.StripSocket + self.modules = {} + self.protocol = parent.protocol # Must use the same connection as the parent + self.add_module("time", Time(self, "time")) async def update(self, update_children: bool = True): """Query the device to update the data. Needed for properties that are decorated with `requires_update`. """ - self._last_update = await self.parent.protocol.query( - self._create_emeter_request() + await self._modular_update({}) + + def _create_emeter_request(self, year: int = None, month: int = None): + """Create a request for requesting all emeter statistics at once.""" + if year is None: + year = datetime.now().year + if month is None: + month = datetime.now().month + + req: Dict[str, Any] = {} + + merge(req, self._create_request("emeter", "get_realtime")) + merge(req, self._create_request("emeter", "get_monthstat", {"year": year})) + merge( + req, + self._create_request( + "emeter", "get_daystat", {"month": month, "year": year} + ), ) + return req + def _create_request( self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None ): @@ -318,7 +348,7 @@ def on_since(self) -> Optional[datetime]: info = self._get_child_info() on_time = info["on_time"] - return datetime.now() - timedelta(seconds=on_time) + return self.time - timedelta(seconds=on_time) @property # type: ignore @requires_update diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index d977daeb3..9138a7e5c 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -36,7 +36,9 @@ async def test_initial_update_no_emeter(dev, mocker): dev._last_update = None spy = mocker.spy(dev.protocol, "query") await dev.update() - assert spy.call_count == 1 + # 2 calls are necessary as some devices crash on unexpected modules + # See #105, #120, #161 + assert spy.call_count == 2 async def test_query_helper(dev): diff --git a/poetry.lock b/poetry.lock index d3af30c3f..aa1231d90 100644 --- a/poetry.lock +++ b/poetry.lock @@ -212,11 +212,11 @@ python-versions = "*" [[package]] name = "jinja2" -version = "3.0.3" +version = "3.1.1" description = "A very fast and expressive template engine." category = "main" optional = true -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] MarkupSafe = ">=2.0" @@ -300,11 +300,11 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.17.0" +version = "2.18.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.7" [package.dependencies] cfgv = ">=2.0.0" @@ -323,6 +323,21 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "pydantic" +version = "1.9.0" +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.11.2" @@ -366,7 +381,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2. [[package]] name = "pytest-asyncio" -version = "0.18.2" +version = "0.18.3" description = "Pytest support for asyncio" category = "dev" optional = false @@ -377,7 +392,7 @@ pytest = ">=6.1.0" typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] -testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)"] +testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)", "pytest-trio (>=0.7.0)"] [[package]] name = "pytest-cov" @@ -678,7 +693,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.13.4" +version = "20.14.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -697,7 +712,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", [[package]] name = "voluptuous" -version = "0.12.2" +version = "0.13.0" description = "" category = "dev" optional = false @@ -723,15 +738,15 @@ tests = ["codecov", "scikit-build", "cmake", "ninja", "pybind11", "pytest", "pyt [[package]] name = "zipp" -version = "3.7.0" +version = "3.8.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] [extras] docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programoutput"] @@ -739,7 +754,7 @@ docs = ["sphinx", "sphinx_rtd_theme", "m2r", "mistune", "sphinxcontrib-programou [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "9c4aaea750c8c2cb4ed6d37c53ec3884a10d698ceb77716c33b04eed12a08506" +content-hash = "cbc8eb721e3b498c25eef73c95b2aa309419fa075b878c18cac0b148113c25f9" [metadata.files] alabaster = [ @@ -862,8 +877,8 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ - {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, - {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, + {file = "Jinja2-3.1.1-py3-none-any.whl", hash = "sha256:539835f51a74a69f41b848a9645dbdc35b4f20a3b601e2d9a7e22947b15ff119"}, + {file = "Jinja2-3.1.1.tar.gz", hash = "sha256:640bed4bb501cbd17194b3cace1dc2126f5b619cf068a726b98192a0fde74ae9"}, ] m2r = [ {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, @@ -931,13 +946,50 @@ pluggy = [ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"}, - {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"}, + {file = "pre_commit-2.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"}, + {file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"}, ] py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] +pydantic = [ + {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, + {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, + {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, + {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, + {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, + {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, + {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, + {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, + {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, + {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, +] pygments = [ {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, @@ -951,8 +1003,9 @@ pytest = [ {file = "pytest-7.1.1.tar.gz", hash = "sha256:841132caef6b1ad17a9afde46dc4f6cfa59a05f9555aae5151f73bdf2820ca63"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.18.2.tar.gz", hash = "sha256:fc8e4190f33fee7797cc7f1829f46a82c213f088af5d1bb5d4e454fe87e6cdc2"}, - {file = "pytest_asyncio-0.18.2-py3-none-any.whl", hash = "sha256:20db0bdd3d7581b2e11f5858a5d9541f2db9cd8c5853786f94ad273d466c8c6d"}, + {file = "pytest-asyncio-0.18.3.tar.gz", hash = "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91"}, + {file = "pytest_asyncio-0.18.3-1-py3-none-any.whl", hash = "sha256:16cf40bdf2b4fb7fc8e4b82bd05ce3fbcd454cbf7b92afc445fe299dabb88213"}, + {file = "pytest_asyncio-0.18.3-py3-none-any.whl", hash = "sha256:8fafa6c52161addfd41ee7ab35f11836c5a16ec208f93ee388f752bea3493a84"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -1080,17 +1133,18 @@ urllib3 = [ {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, ] virtualenv = [ - {file = "virtualenv-20.13.4-py2.py3-none-any.whl", hash = "sha256:c3e01300fb8495bc00ed70741f5271fc95fed067eb7106297be73d30879af60c"}, - {file = "virtualenv-20.13.4.tar.gz", hash = "sha256:ce8901d3bbf3b90393498187f2d56797a8a452fb2d0d7efc6fd837554d6f679c"}, + {file = "virtualenv-20.14.0-py2.py3-none-any.whl", hash = "sha256:1e8588f35e8b42c6ec6841a13c5e88239de1e6e4e4cedfd3916b306dc826ec66"}, + {file = "virtualenv-20.14.0.tar.gz", hash = "sha256:8e5b402037287126e81ccde9432b95a8be5b19d36584f64957060a3488c11ca8"}, ] voluptuous = [ - {file = "voluptuous-0.12.2.tar.gz", hash = "sha256:4db1ac5079db9249820d49c891cb4660a6f8cae350491210abce741fabf56513"}, + {file = "voluptuous-0.13.0-py3-none-any.whl", hash = "sha256:e3b5f6cb68fcb0230701b5c756db4caa6766223fc0eaf613931fdba51025981b"}, + {file = "voluptuous-0.13.0.tar.gz", hash = "sha256:cae6a4526b434b642816b34a00e1186d5a5f5e0c948ab94d2a918e01e5874066"}, ] xdoctest = [ {file = "xdoctest-0.15.10-py3-none-any.whl", hash = "sha256:7666bd0511df59275dfe94ef94b0fde9654afd14f00bf88902fdc9bcee77d527"}, {file = "xdoctest-0.15.10.tar.gz", hash = "sha256:5f16438f2b203860e75ec594dbc38020df7524db0b41bb88467ea0a6030e6685"}, ] zipp = [ - {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, - {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, + {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, + {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, ] diff --git a/pyproject.toml b/pyproject.toml index 9d7e084af..43e9f6ab0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.4.3" +version = "0.5.0.dev0" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["Your Name "] @@ -19,6 +19,7 @@ python = "^3.7" anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 importlib-metadata = "*" asyncclick = ">=8" +pydantic = "^1" # required only for docs sphinx = { version = "^3", optional = true }