diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5f623b5..d5fd4de70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.7.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev5) (2024-06-17) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev4...0.7.0.dev5) + +**Implemented enhancements:** + +- Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) +- Add common energy module and deprecate device emeter attributes [\#976](https://github.com/python-kasa/python-kasa/pull/976) (@sdb9696) +- Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696) +- Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696) +- Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti) + +**Fixed bugs:** + +- Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti) +- Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) + +**Project maintenance:** + +- Add fixture for L920-5\(EU\) 1.0.7 [\#972](https://github.com/python-kasa/python-kasa/pull/972) (@rytilahti) + ## [0.7.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.7.0.dev4) (2024-06-10) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.dev3...0.7.0.dev4) diff --git a/SUPPORTED.md b/SUPPORTED.md index 9bc5b6b77..a644254a6 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -208,6 +208,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.0.17 - Hardware: 1.0 (EU) / Firmware: 1.1.0 - **L920-5** + - Hardware: 1.0 (EU) / Firmware: 1.0.7 - Hardware: 1.0 (US) / Firmware: 1.1.0 - Hardware: 1.0 (US) / Firmware: 1.1.3 - **L930-5** diff --git a/docs/source/cli.rst b/docs/source/cli.rst index dad754d25..7d4eb0806 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -58,6 +58,11 @@ As with all other commands, you can also pass ``--help`` to both ``join`` and `` However, note that communications with devices provisioned using this method will stop working when connected to the cloud. +.. note:: + + Some commands do not work if the device time is out-of-sync. + You can use ``kasa time sync`` command to set the device time from the system where the command is run. + .. warning:: At least some devices (e.g., Tapo lights L530 and L900) are known to have a watchdog that reboots them every 10 minutes if they are unable to connect to the cloud. diff --git a/kasa/cli.py b/kasa/cli.py index 39f6636fa..f7ff1dd34 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -9,6 +9,7 @@ import re import sys from contextlib import asynccontextmanager +from datetime import datetime from functools import singledispatch, wraps from pprint import pformat as pf from typing import Any, cast @@ -70,6 +71,12 @@ def wrapper(message=None, *args, **kwargs): echo = _do_echo +def error(msg: str): + """Print an error and exit.""" + echo(f"[bold red]{msg}[/bold red]") + sys.exit(1) + + TYPE_TO_CLASS = { "plug": IotPlug, "switch": IotWallSwitch, @@ -366,6 +373,9 @@ def _nop_echo(*args, **kwargs): credentials = None if host is None: + if ctx.invoked_subcommand and ctx.invoked_subcommand != "discover": + error("Only discover is available without --host or --alias") + echo("No host name given, trying discovery..") return await ctx.invoke(discover) @@ -763,7 +773,7 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): """ if index is not None or name is not None: if not dev.is_strip: - echo("Index and name are only for power strips!") + error("Index and name are only for power strips!") return if index is not None: @@ -773,11 +783,11 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): echo("[bold]== Emeter ==[/bold]") if not dev.has_emeter: - echo("Device has no emeter") + error("Device has no emeter") return if (year or month or erase) and not isinstance(dev, IotDevice): - echo("Device has no historical statistics") + error("Device has no historical statistics") return else: dev = cast(IotDevice, dev) @@ -864,7 +874,7 @@ async def usage(dev: Device, year, month, erase): async def brightness(dev: Device, brightness: int, transition: int): """Get or set brightness.""" if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: - echo("This device does not support brightness.") + error("This device does not support brightness.") return if brightness is None: @@ -884,7 +894,7 @@ async def brightness(dev: Device, brightness: int, transition: int): async def temperature(dev: Device, temperature: int, transition: int): """Get or set color temperature.""" if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: - echo("Device does not support color temperature") + error("Device does not support color temperature") return if temperature is None: @@ -910,7 +920,7 @@ async def temperature(dev: Device, temperature: int, transition: int): async def effect(dev: Device, ctx, effect): """Set an effect.""" if not (light_effect := dev.modules.get(Module.LightEffect)): - echo("Device does not support effects") + error("Device does not support effects") return if effect is None: echo( @@ -938,7 +948,7 @@ async def effect(dev: Device, ctx, effect): async def hsv(dev: Device, ctx, h, s, v, transition): """Get or set color in HSV.""" if not (light := dev.modules.get(Module.Light)) or not light.is_color: - echo("Device does not support colors") + error("Device does not support colors") return if h is None and s is None and v is None: @@ -957,7 +967,7 @@ async def hsv(dev: Device, ctx, h, s, v, transition): async def led(dev: Device, state): """Get or set (Plug's) led state.""" if not (led := dev.modules.get(Module.Led)): - echo("Device does not support led.") + error("Device does not support led.") return if state is not None: echo(f"Turning led to {state}") @@ -967,15 +977,43 @@ async def led(dev: Device, state): return led.led -@cli.command() +@cli.group(invoke_without_command=True) +@click.pass_context +async def time(ctx: click.Context): + """Get and set time.""" + if ctx.invoked_subcommand is None: + await ctx.invoke(time_get) + + +@time.command(name="get") @pass_dev -async def time(dev): +async def time_get(dev: Device): """Get the device time.""" res = dev.time echo(f"Current time: {res}") return res +@time.command(name="sync") +@pass_dev +async def time_sync(dev: SmartDevice): + """Set the device time to current time.""" + if not isinstance(dev, SmartDevice): + raise NotImplementedError("setting time currently only implemented on smart") + + if (time := dev.modules.get(Module.Time)) is None: + echo("Device does not have time module") + return + + echo("Old time: %s" % time.time) + + local_tz = datetime.now().astimezone().tzinfo + await time.set_time(datetime.now(tz=local_tz)) + + await dev.update() + echo("New time: %s" % time.time) + + @cli.command() @click.option("--index", type=int, required=False) @click.option("--name", type=str, required=False) @@ -985,7 +1023,7 @@ async def on(dev: Device, index: int, name: str, transition: int): """Turn the device on.""" if index is not None or name is not None: if not dev.children: - echo("Index and name are only for devices with children.") + error("Index and name are only for devices with children.") return if index is not None: @@ -1006,7 +1044,7 @@ async def off(dev: Device, index: int, name: str, transition: int): """Turn the device off.""" if index is not None or name is not None: if not dev.children: - echo("Index and name are only for devices with children.") + error("Index and name are only for devices with children.") return if index is not None: @@ -1027,7 +1065,7 @@ async def toggle(dev: Device, index: int, name: str, transition: int): """Toggle the device on/off.""" if index is not None or name is not None: if not dev.children: - echo("Index and name are only for devices with children.") + error("Index and name are only for devices with children.") return if index is not None: @@ -1067,7 +1105,7 @@ def _schedule_list(dev, type): for rule in sched.rules: print(rule) else: - echo(f"No rules of type {type}") + error(f"No rules of type {type}") return sched.rules @@ -1083,7 +1121,7 @@ async def delete_rule(dev, id): echo(f"Deleting rule id {id}") return await schedule.delete_rule(rule_to_delete) else: - echo(f"No rule with id {id} was found") + error(f"No rule with id {id} was found") @cli.group(invoke_without_command=True) @@ -1099,7 +1137,7 @@ async def presets(ctx): def presets_list(dev: IotBulb): """List presets.""" if not dev.is_bulb or not isinstance(dev, IotBulb): - echo("Presets only supported on iot bulbs") + error("Presets only supported on iot bulbs") return for preset in dev.presets: @@ -1121,7 +1159,7 @@ async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, tempe if preset.index == index: break else: - echo(f"No preset found for index {index}") + error(f"No preset found for index {index}") return if brightness is not None: @@ -1146,7 +1184,7 @@ async def presets_modify(dev: IotBulb, index, brightness, hue, saturation, tempe async def turn_on_behavior(dev: IotBulb, type, last, preset): """Modify bulb turn-on behavior.""" if not dev.is_bulb or not isinstance(dev, IotBulb): - echo("Presets only supported on iot bulbs") + error("Presets only supported on iot bulbs") return settings = await dev.get_turn_on_behavior() echo(f"Current turn on behavior: {settings}") @@ -1183,9 +1221,7 @@ async def turn_on_behavior(dev: IotBulb, type, last, preset): async def update_credentials(dev, username, password): """Update device credentials for authenticated devices.""" if not isinstance(dev, SmartDevice): - raise NotImplementedError( - "Credentials can only be updated on authenticated devices." - ) + error("Credentials can only be updated on authenticated devices.") click.confirm("Do you really want to replace the existing credentials?", abort=True) @@ -1242,7 +1278,7 @@ async def feature(dev: Device, child: str, name: str, value): return if name not in dev.features: - echo(f"No feature by name '{name}'") + error(f"No feature by name '{name}'") return feat = dev.features[name] diff --git a/kasa/device.py b/kasa/device.py index 10722f69b..53b71d859 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -19,7 +19,6 @@ DeviceEncryptionType, DeviceFamily, ) -from .emeterstatus import EmeterStatus from .exceptions import KasaException from .feature import Feature from .iotprotocol import IotProtocol @@ -323,27 +322,6 @@ def has_emeter(self) -> bool: def on_since(self) -> datetime | None: """Return the time that the device was turned on or None if turned off.""" - @abstractmethod - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - - @property - @abstractmethod - def emeter_realtime(self) -> EmeterStatus: - """Get the emeter status.""" - - @property - @abstractmethod - def emeter_this_month(self) -> float | None: - """Get the emeter value for this month.""" - - @property - @abstractmethod - def emeter_today(self) -> float | None | Any: - """Get the emeter value for today.""" - # Return type of Any ensures consumers being shielded from the return - # type by @update_required are not affected. - @abstractmethod async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" @@ -373,12 +351,15 @@ def __repr__(self): } def _get_replacing_attr(self, module_name: ModuleName, *attrs): - if module_name not in self.modules: + # If module name is None check self + if not module_name: + check = self + elif (check := self.modules.get(module_name)) is None: return None for attr in attrs: - if hasattr(self.modules[module_name], attr): - return getattr(self.modules[module_name], attr) + if hasattr(check, attr): + return attr return None @@ -411,6 +392,16 @@ def _get_replacing_attr(self, module_name: ModuleName, *attrs): # light preset attributes "presets": (Module.LightPreset, ["_deprecated_presets", "preset_states_list"]), "save_preset": (Module.LightPreset, ["_deprecated_save_preset"]), + # Emeter attribues + "get_emeter_realtime": (Module.Energy, ["get_status"]), + "emeter_realtime": (Module.Energy, ["status"]), + "emeter_today": (Module.Energy, ["consumption_today"]), + "emeter_this_month": (Module.Energy, ["consumption_this_month"]), + "current_consumption": (Module.Energy, ["current_consumption"]), + "get_emeter_daily": (Module.Energy, ["get_daily_stats"]), + "get_emeter_monthly": (Module.Energy, ["get_monthly_stats"]), + # Other attributes + "supported_modules": (None, ["modules"]), } def __getattr__(self, name): @@ -427,11 +418,10 @@ def __getattr__(self, name): (replacing_attr := self._get_replacing_attr(dep_attr[0], *dep_attr[1])) is not None ): - module_name = dep_attr[0] - msg = ( - f"{name} is deprecated, use: " - + f"Module.{module_name} in device.modules instead" - ) + mod = dep_attr[0] + dev_or_mod = self.modules[mod] if mod else self + replacing = f"Module.{mod} in device.modules" if mod else replacing_attr + msg = f"{name} is deprecated, use: {replacing} instead" warn(msg, DeprecationWarning, stacklevel=1) - return replacing_attr + return getattr(dev_or_mod, replacing_attr) raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/feature.py b/kasa/feature.py index 9863a39b5..b992789a5 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -10,7 +10,6 @@ if TYPE_CHECKING: from .device import Device - _LOGGER = logging.getLogger(__name__) @@ -140,22 +139,24 @@ def value(self): raise ValueError("Not an action and no attribute_getter set") container = self.container if self.container is not None else self.device - if isinstance(self.attribute_getter, Callable): + if callable(self.attribute_getter): return self.attribute_getter(container) return getattr(container, self.attribute_getter) - async def set_value(self, value): + async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: """Set the value.""" if self.attribute_setter is None: raise ValueError("Tried to set read-only feature.") if self.type == Feature.Type.Number: # noqa: SIM102 + if not isinstance(value, (int, float)): + raise ValueError("value must be a number") if value < self.minimum_value or value > self.maximum_value: raise ValueError( f"Value {value} out of range " f"[{self.minimum_value}, {self.maximum_value}]" ) elif self.type == Feature.Type.Choice: # noqa: SIM102 - if value not in self.choices: + if not self.choices or value not in self.choices: raise ValueError( f"Unexpected value for {self.name}: {value}" f" - allowed: {self.choices}" diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py index 31b9bc33d..6a12bc681 100644 --- a/kasa/interfaces/__init__.py +++ b/kasa/interfaces/__init__.py @@ -1,5 +1,6 @@ """Package for interfaces.""" +from .energy import Energy from .fan import Fan from .led import Led from .light import Light, LightState @@ -8,6 +9,7 @@ __all__ = [ "Fan", + "Energy", "Led", "Light", "LightEffect", diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py new file mode 100644 index 000000000..c1ce3a603 --- /dev/null +++ b/kasa/interfaces/energy.py @@ -0,0 +1,181 @@ +"""Module for base energy module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import IntFlag, auto +from warnings import warn + +from ..emeterstatus import EmeterStatus +from ..feature import Feature +from ..module import Module + + +class Energy(Module, ABC): + """Base interface to represent an Energy module.""" + + class ModuleFeature(IntFlag): + """Features supported by the device.""" + + #: Device reports :attr:`voltage` and :attr:`current` + VOLTAGE_CURRENT = auto() + #: Device reports :attr:`consumption_total` + CONSUMPTION_TOTAL = auto() + #: Device reports periodic stats via :meth:`get_daily_stats` + #: and :meth:`get_monthly_stats` + PERIODIC_STATS = auto() + + _supported: ModuleFeature = ModuleFeature(0) + + def supports(self, module_feature: ModuleFeature) -> bool: + """Return True if module supports the feature.""" + return module_feature in self._supported + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + name="Current consumption", + attribute_getter="current_consumption", + container=self, + unit="W", + id="current_consumption", + precision_hint=1, + category=Feature.Category.Primary, + ) + ) + self._add_feature( + Feature( + device, + name="Today's consumption", + attribute_getter="consumption_today", + container=self, + unit="kWh", + id="consumption_today", + precision_hint=3, + category=Feature.Category.Info, + ) + ) + self._add_feature( + Feature( + device, + id="consumption_this_month", + name="This month's consumption", + attribute_getter="consumption_this_month", + container=self, + unit="kWh", + precision_hint=3, + category=Feature.Category.Info, + ) + ) + if self.supports(self.ModuleFeature.CONSUMPTION_TOTAL): + self._add_feature( + Feature( + device, + name="Total consumption since reboot", + attribute_getter="consumption_total", + container=self, + unit="kWh", + id="consumption_total", + precision_hint=3, + category=Feature.Category.Info, + ) + ) + if self.supports(self.ModuleFeature.VOLTAGE_CURRENT): + self._add_feature( + Feature( + device, + name="Voltage", + attribute_getter="voltage", + container=self, + unit="V", + id="voltage", + precision_hint=1, + category=Feature.Category.Primary, + ) + ) + self._add_feature( + Feature( + device, + name="Current", + attribute_getter="current", + container=self, + unit="A", + id="current", + precision_hint=2, + category=Feature.Category.Primary, + ) + ) + + @property + @abstractmethod + def status(self) -> EmeterStatus: + """Return current energy readings.""" + + @property + @abstractmethod + def current_consumption(self) -> float | None: + """Get the current power consumption in Watt.""" + + @property + @abstractmethod + def consumption_today(self) -> float | None: + """Return today's energy consumption in kWh.""" + + @property + @abstractmethod + def consumption_this_month(self) -> float | None: + """Return this month's energy consumption in kWh.""" + + @property + @abstractmethod + def consumption_total(self) -> float | None: + """Return total consumption since last reboot in kWh.""" + + @property + @abstractmethod + def current(self) -> float | None: + """Return the current in A.""" + + @property + @abstractmethod + def voltage(self) -> float | None: + """Get the current voltage in V.""" + + @abstractmethod + async def get_status(self): + """Return real-time statistics.""" + + @abstractmethod + async def erase_stats(self): + """Erase all stats.""" + + @abstractmethod + async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: energy, ...}. + """ + + @abstractmethod + async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: + """Return monthly stats for the given year.""" + + _deprecated_attributes = { + "emeter_today": "consumption_today", + "emeter_this_month": "consumption_this_month", + "realtime": "status", + "get_realtime": "get_status", + "erase_emeter_stats": "erase_stats", + "get_daystat": "get_daily_stats", + "get_monthstat": "get_monthly_stats", + } + + def __getattr__(self, name): + if attr := self._deprecated_attributes.get(name): + msg = f"{name} is deprecated, use {attr} instead" + warn(msg, DeprecationWarning, stacklevel=1) + return getattr(self, attr) + raise AttributeError(f"Energy module has no attribute {name!r}") diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 362093609..26c73096a 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -220,7 +220,7 @@ async def _initialize_modules(self): Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft") ) self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting")) - self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) + self.add_module(Module.Energy, Emeter(self, self.emeter_type)) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) self.add_module(Module.Light, Light(self, self.LIGHT_SERVICE)) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index c7631763b..102d6a4dc 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -18,12 +18,11 @@ import functools import inspect import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig -from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature from ..module import Module @@ -188,7 +187,7 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self._sys_info: Any = None # TODO: this is here to avoid changing tests - self._supported_modules: dict[str, IotModule] | None = None + self._supported_modules: dict[str | ModuleName[Module], IotModule] | None = None self._legacy_features: set[str] = set() self._children: Mapping[str, IotDevice] = {} self._modules: dict[str | ModuleName[Module], IotModule] = {} @@ -199,15 +198,16 @@ def children(self) -> Sequence[IotDevice]: return list(self._children.values()) @property + @requires_update def modules(self) -> ModuleMapping[IotModule]: """Return the device modules.""" if TYPE_CHECKING: - return cast(ModuleMapping[IotModule], self._modules) - return self._modules + return cast(ModuleMapping[IotModule], self._supported_modules) + return self._supported_modules def add_module(self, name: str | ModuleName[Module], module: IotModule): """Register a module.""" - if name in self.modules: + if name in self._modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) return @@ -272,14 +272,6 @@ def features(self) -> dict[str, Feature]: """Return a set of features that the device supports.""" return self._features - @property # type: ignore - @requires_update - def supported_modules(self) -> list[str | ModuleName[Module]]: - """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: @@ -321,6 +313,11 @@ async def update(self, update_children: bool = True): async def _initialize_modules(self): """Initialize modules not added in init.""" + if self.has_emeter: + _LOGGER.debug( + "The device has emeter, querying its information along sysinfo" + ) + self.add_module(Module.Energy, Emeter(self, self.emeter_type)) async def _initialize_features(self): """Initialize common features.""" @@ -345,7 +342,8 @@ async def _initialize_features(self): category=Feature.Category.Debug, ) ) - if "on_time" in self._sys_info: + # iot strips calculate on_since from the children + if "on_time" in self._sys_info or self.device_type == Device.Type.Strip: self._add_feature( Feature( device=self, @@ -356,29 +354,13 @@ async def _initialize_features(self): ) ) - for module in self._modules.values(): + for module in self._supported_modules.values(): module._initialize_features() for module_feat in module._module_features.values(): self._add_feature(module_feat) 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" - ) - self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) - - # TODO: perhaps modules should not have unsupported modules, - # making separate handling for this unnecessary - if self._supported_modules is None: - supported = {} - for module in self._modules.values(): - if module.is_supported: - supported[module._module] = module - - self._supported_modules = supported - request_list = [] est_response_size = 1024 if "system" in req else 0 for module in self._modules.values(): @@ -410,6 +392,15 @@ async def _modular_update(self, req: dict) -> None: update = {**update, **response} self._last_update = update + # IOT modules are added as default but could be unsupported post first update + if self._supported_modules is None: + supported = {} + for module_name, module in self._modules.items(): + if module.is_supported: + supported[module_name] = module + + self._supported_modules = supported + def update_from_discover_info(self, info: dict[str, Any]) -> None: """Update state from info from the discover call.""" self._discovery_info = info @@ -556,74 +547,6 @@ async def set_mac(self, mac): """ return await self._query_helper("system", "set_mac_addr", {"mac": mac}) - @property - @requires_update - def emeter_realtime(self) -> EmeterStatus: - """Return current energy readings.""" - self._verify_emeter() - return EmeterStatus(self.modules[Module.IotEmeter].realtime) - - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - self._verify_emeter() - return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime()) - - @property - @requires_update - def emeter_today(self) -> float | None: - """Return today's energy consumption in kWh.""" - self._verify_emeter() - return self.modules[Module.IotEmeter].emeter_today - - @property - @requires_update - def emeter_this_month(self) -> float | None: - """Return this month's energy consumption in kWh.""" - self._verify_emeter() - return self.modules[Module.IotEmeter].emeter_this_month - - async def get_emeter_daily( - self, year: int | None = None, month: int | None = None, kwh: bool = True - ) -> dict: - """Retrieve daily statistics for a given month. - - :param year: year for which to retrieve statistics (default: this year) - :param month: month for which to retrieve statistics (default: this - month) - :param kwh: return usage in kWh (default: True) - :return: mapping of day of month to value - """ - self._verify_emeter() - return await self.modules[Module.IotEmeter].get_daystat( - year=year, month=month, kwh=kwh - ) - - @requires_update - async def get_emeter_monthly( - self, year: int | None = None, kwh: bool = True - ) -> dict: - """Retrieve monthly statistics for a given year. - - :param year: year for which to retrieve statistics (default: this year) - :param kwh: return usage in kWh (default: True) - :return: dict: mapping of month to value - """ - self._verify_emeter() - return await self.modules[Module.IotEmeter].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.modules[Module.IotEmeter].erase_stats() - - @requires_update - async def current_consumption(self) -> float: - """Get the current power consumption in Watt.""" - self._verify_emeter() - response = self.emeter_realtime - return float(response["power"]) - async def reboot(self, delay: int = 1) -> None: """Reboot the device. @@ -665,7 +588,9 @@ def on_since(self) -> datetime | None: on_time = self._sys_info["on_time"] - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + return datetime.now(timezone.utc).astimezone().replace( + microsecond=0 + ) - timedelta(seconds=on_time) @property # type: ignore @requires_update diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index dde57faaf..c2f2bb860 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -4,21 +4,22 @@ import logging from collections import defaultdict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig +from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature +from ..interfaces import Energy from ..module import Module from ..protocol import BaseProtocol from .iotdevice import ( - EmeterStatus, IotDevice, - merge, requires_update, ) +from .iotmodule import IotModule from .iotplug import IotPlug from .modules import Antitheft, Countdown, Schedule, Time, Usage @@ -97,11 +98,20 @@ def __init__( super().__init__(host=host, config=config, protocol=protocol) self.emeter_type = "emeter" self._device_type = DeviceType.Strip + + async def _initialize_modules(self): + """Initialize modules.""" + # Strip has different modules to plug so do not call super self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule")) self.add_module(Module.IotTime, Time(self, "time")) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) + if self.has_emeter: + _LOGGER.debug( + "The device has emeter, querying its information along sysinfo" + ) + self.add_module(Module.Energy, StripEmeter(self, self.emeter_type)) @property # type: ignore @requires_update @@ -114,10 +124,12 @@ async def update(self, update_children: bool = True): Needed for methods that are decorated with `requires_update`. """ + # Super initializes modules and features await super().update(update_children) + initialize_children = not self.children # Initialize the child devices during the first update. - if not self.children: + if initialize_children: children = self.sys_info["children"] _LOGGER.debug("Initializing %s child sockets", len(children)) self._children = { @@ -127,12 +139,22 @@ async def update(self, update_children: bool = True): for child in children } for child in self._children.values(): - await child._initialize_features() + await child._initialize_modules() - if update_children and self.has_emeter: + if update_children: for plug in self.children: await plug.update() + if not self.features: + await self._initialize_features() + + async def _initialize_features(self): + """Initialize common features.""" + # Do not initialize features until children are created + if not self.children: + return + await super()._initialize_features() + async def turn_on(self, **kwargs): """Turn the strip on.""" await self._query_helper("system", "set_relay_state", {"state": 1}) @@ -148,23 +170,45 @@ def on_since(self) -> datetime | None: if self.is_off: return None - return max(plug.on_since for plug in self.children if plug.on_since is not None) + return min(plug.on_since for plug in self.children if plug.on_since is not None) - async def current_consumption(self) -> float: + +class StripEmeter(IotModule, Energy): + """Energy module implementation to aggregate child modules.""" + + _supported = ( + Energy.ModuleFeature.CONSUMPTION_TOTAL + | Energy.ModuleFeature.PERIODIC_STATS + | Energy.ModuleFeature.VOLTAGE_CURRENT + ) + + def supports(self, module_feature: Energy.ModuleFeature) -> bool: + """Return True if module supports the feature.""" + return module_feature in self._supported + + def query(self): + """Return the base query.""" + return {} + + @property + def current_consumption(self) -> float | None: """Get the current power consumption in watts.""" - return sum([await plug.current_consumption() for plug in self.children]) + return sum( + v if (v := plug.modules[Module.Energy].current_consumption) else 0.0 + for plug in self._device.children + ) - @requires_update - async def get_emeter_realtime(self) -> EmeterStatus: + async def get_status(self) -> EmeterStatus: """Retrieve current energy readings.""" - emeter_rt = await self._async_get_emeter_sum("get_emeter_realtime", {}) + emeter_rt = await self._async_get_emeter_sum("get_status", {}) # Voltage is averaged since each read will result # in a slightly different voltage since they are not atomic - emeter_rt["voltage_mv"] = int(emeter_rt["voltage_mv"] / len(self.children)) + emeter_rt["voltage_mv"] = int( + emeter_rt["voltage_mv"] / len(self._device.children) + ) return EmeterStatus(emeter_rt) - @requires_update - async def get_emeter_daily( + async def get_daily_stats( self, year: int | None = None, month: int | None = None, kwh: bool = True ) -> dict: """Retrieve daily statistics for a given month. @@ -176,11 +220,10 @@ async def get_emeter_daily( :return: mapping of day of month to value """ return await self._async_get_emeter_sum( - "get_emeter_daily", {"year": year, "month": month, "kwh": kwh} + "get_daily_stats", {"year": year, "month": month, "kwh": kwh} ) - @requires_update - async def get_emeter_monthly( + async def get_monthly_stats( self, year: int | None = None, kwh: bool = True ) -> dict: """Retrieve monthly statistics for a given year. @@ -189,44 +232,68 @@ async def get_emeter_monthly( :param kwh: return usage in kWh (default: True) """ return await self._async_get_emeter_sum( - "get_emeter_monthly", {"year": year, "kwh": kwh} + "get_monthly_stats", {"year": year, "kwh": kwh} ) async def _async_get_emeter_sum(self, func: str, kwargs: dict[str, Any]) -> dict: - """Retreive emeter stats for a time period from children.""" - self._verify_emeter() + """Retrieve emeter stats for a time period from children.""" return merge_sums( - [await getattr(plug, func)(**kwargs) for plug in self.children] + [ + await getattr(plug.modules[Module.Energy], func)(**kwargs) + for plug in self._device.children + ] ) - @requires_update - async def erase_emeter_stats(self): + async def erase_stats(self): """Erase energy meter statistics for all plugs.""" - for plug in self.children: - await plug.erase_emeter_stats() + for plug in self._device.children: + await plug.modules[Module.Energy].erase_stats() @property # type: ignore - @requires_update - def emeter_this_month(self) -> float | None: + def consumption_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" - return sum(v if (v := plug.emeter_this_month) else 0 for plug in self.children) + return sum( + v if (v := plug.modules[Module.Energy].consumption_this_month) else 0.0 + for plug in self._device.children + ) @property # type: ignore - @requires_update - def emeter_today(self) -> float | None: + def consumption_today(self) -> float | None: """Return this month's energy consumption in kWh.""" - return sum(v if (v := plug.emeter_today) else 0 for plug in self.children) + return sum( + v if (v := plug.modules[Module.Energy].consumption_today) else 0.0 + for plug in self._device.children + ) @property # type: ignore - @requires_update - def emeter_realtime(self) -> EmeterStatus: + def consumption_total(self) -> float | None: + """Return total energy consumption since reboot in kWh.""" + return sum( + v if (v := plug.modules[Module.Energy].consumption_total) else 0.0 + for plug in self._device.children + ) + + @property # type: ignore + def status(self) -> EmeterStatus: """Return current energy readings.""" - emeter = merge_sums([plug.emeter_realtime for plug in self.children]) + emeter = merge_sums( + [plug.modules[Module.Energy].status for plug in self._device.children] + ) # Voltage is averaged since each read will result # in a slightly different voltage since they are not atomic - emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self.children)) + emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self._device.children)) return EmeterStatus(emeter) + @property + def current(self) -> float | None: + """Return the current in A.""" + return self.status.current + + @property + def voltage(self) -> float | None: + """Get the current voltage in V.""" + return self.status.voltage + class IotStripPlug(IotPlug): """Representation of a single socket in a power strip. @@ -275,9 +342,10 @@ async def _initialize_features(self): icon="mdi:clock", ) ) - # If the strip plug has it's own modules we should call initialize - # features for the modules here. However the _initialize_modules function - # above does not seem to be called. + for module in self._supported_modules.values(): + module._initialize_features() + for module_feat in module._module_features.values(): + self._add_feature(module_feat) async def update(self, update_children: bool = True): """Query the device to update the data. @@ -285,26 +353,8 @@ async def update(self, update_children: bool = True): Needed for properties that are decorated with `requires_update`. """ await self._modular_update({}) - - def _create_emeter_request(self, year: int | None = None, month: int | None = 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 + if not self._features: + await self._initialize_features() def _create_request( self, target: str, cmd: str, arg: dict | None = None, child_ids=None @@ -372,7 +422,9 @@ def on_since(self) -> datetime | None: info = self._get_child_info() on_time = info["on_time"] - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + return datetime.now(timezone.utc).astimezone().replace( + microsecond=0 + ) - timedelta(seconds=on_time) @property # type: ignore @requires_update diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 53fb20da5..7ae89e5b6 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -4,130 +4,71 @@ from datetime import datetime -from ... import Device from ...emeterstatus import EmeterStatus -from ...feature import Feature +from ...interfaces.energy import Energy as EnergyInterface from .usage import Usage -class Emeter(Usage): +class Emeter(Usage, EnergyInterface): """Emeter module.""" - def __init__(self, device: Device, module: str): - super().__init__(device, module) - self._add_feature( - Feature( - device, - name="Current consumption", - attribute_getter="current_consumption", - container=self, - unit="W", - id="current_power_w", # for homeassistant backwards compat - precision_hint=1, - category=Feature.Category.Primary, + def _post_update_hook(self) -> None: + self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS + if ( + "voltage_mv" in self.data["get_realtime"] + or "voltage" in self.data["get_realtime"] + ): + self._supported = ( + self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT ) - ) - self._add_feature( - Feature( - device, - name="Today's consumption", - attribute_getter="emeter_today", - container=self, - unit="kWh", - id="today_energy_kwh", # for homeassistant backwards compat - precision_hint=3, - category=Feature.Category.Info, + if ( + "total_wh" in self.data["get_realtime"] + or "total" in self.data["get_realtime"] + ): + self._supported = ( + self._supported | EnergyInterface.ModuleFeature.CONSUMPTION_TOTAL ) - ) - self._add_feature( - Feature( - device, - id="consumption_this_month", - name="This month's consumption", - attribute_getter="emeter_this_month", - container=self, - unit="kWh", - precision_hint=3, - category=Feature.Category.Info, - ) - ) - self._add_feature( - Feature( - device, - name="Total consumption since reboot", - attribute_getter="emeter_total", - container=self, - unit="kWh", - id="total_energy_kwh", # for homeassistant backwards compat - precision_hint=3, - category=Feature.Category.Info, - ) - ) - self._add_feature( - Feature( - device, - name="Voltage", - attribute_getter="voltage", - container=self, - unit="V", - id="voltage", # for homeassistant backwards compat - precision_hint=1, - category=Feature.Category.Primary, - ) - ) - self._add_feature( - Feature( - device, - name="Current", - attribute_getter="current", - container=self, - unit="A", - id="current_a", # for homeassistant backwards compat - precision_hint=2, - category=Feature.Category.Primary, - ) - ) @property # type: ignore - def realtime(self) -> EmeterStatus: + def status(self) -> EmeterStatus: """Return current energy readings.""" return EmeterStatus(self.data["get_realtime"]) @property - def emeter_today(self) -> float | None: + def consumption_today(self) -> float | None: """Return today's energy consumption in kWh.""" raw_data = self.daily_data today = datetime.now().day data = self._convert_stat_data(raw_data, entry_key="day", key=today) - return data.get(today) + return data.get(today, 0.0) @property - def emeter_this_month(self) -> float | None: + def consumption_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" raw_data = self.monthly_data current_month = datetime.now().month data = self._convert_stat_data(raw_data, entry_key="month", key=current_month) - return data.get(current_month) + return data.get(current_month, 0.0) @property def current_consumption(self) -> float | None: """Get the current power consumption in Watt.""" - return self.realtime.power + return self.status.power @property - def emeter_total(self) -> float | None: + def consumption_total(self) -> float | None: """Return total consumption since last reboot in kWh.""" - return self.realtime.total + return self.status.total @property def current(self) -> float | None: """Return the current in A.""" - return self.realtime.current + return self.status.current @property def voltage(self) -> float | None: """Get the current voltage in V.""" - return self.realtime.voltage + return self.status.voltage async def erase_stats(self): """Erase all stats. @@ -136,11 +77,11 @@ async def erase_stats(self): """ return await self.call("erase_emeter_stat") - async def get_realtime(self): + async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" - return await self.call("get_realtime") + return EmeterStatus(await self.call("get_realtime")) - async def get_daystat(self, *, year=None, month=None, kwh=True) -> dict: + async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: """Return daily stats for the given year & month. The return value is a dictionary of {day: energy, ...}. @@ -149,7 +90,7 @@ async def get_daystat(self, *, year=None, month=None, kwh=True) -> dict: data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) return data - async def get_monthstat(self, *, year=None, kwh=True) -> dict: + async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: """Return monthly stats for the given year. The return value is a dictionary of {month: energy, ...}. diff --git a/kasa/iot/modules/led.py b/kasa/iot/modules/led.py index 6c4ca02aa..48301f237 100644 --- a/kasa/iot/modules/led.py +++ b/kasa/iot/modules/led.py @@ -30,3 +30,8 @@ def led(self) -> bool: async def set_led(self, state: bool): """Set the state of the led (night mode).""" return await self.call("set_led_off", {"off": int(not state)}) + + @property + def is_supported(self) -> bool: + """Return whether the module is supported by the device.""" + return "led_off" in self.data diff --git a/kasa/module.py b/kasa/module.py index a2a9c931a..177c2baa1 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -33,6 +33,8 @@ class Module(ABC): """ # Common Modules + Energy: Final[ModuleName[interfaces.Energy]] = ModuleName("Energy") + Fan: Final[ModuleName[interfaces.Fan]] = ModuleName("Fan") LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") @@ -42,7 +44,6 @@ class Module(ABC): IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") - IotEmeter: Final[ModuleName[iot.Emeter]] = ModuleName("emeter") IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") @@ -62,8 +63,6 @@ class Module(ABC): ) ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") DeviceModule: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") - Energy: Final[ModuleName[smart.Energy]] = ModuleName("Energy") - Fan: Final[ModuleName[smart.Fan]] = ModuleName("Fan") Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware") FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName( "FrostProtection" diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 55b5088e7..3edbddb47 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -2,60 +2,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...emeterstatus import EmeterStatus -from ...feature import Feature +from ...exceptions import KasaException +from ...interfaces.energy import Energy as EnergyInterface from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - -class Energy(SmartModule): +class Energy(SmartModule, EnergyInterface): """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, - "consumption_current", - name="Current consumption", - attribute_getter="current_power", - container=self, - unit="W", - precision_hint=1, - category=Feature.Category.Primary, - ) - ) - self._add_feature( - Feature( - device, - "consumption_today", - name="Today's consumption", - attribute_getter="emeter_today", - container=self, - unit="Wh", - precision_hint=2, - category=Feature.Category.Info, - ) - ) - self._add_feature( - Feature( - device, - "consumption_this_month", - name="This month's consumption", - attribute_getter="emeter_this_month", - container=self, - unit="Wh", - precision_hint=2, - category=Feature.Category.Info, - ) - ) - def query(self) -> dict: """Query to execute during the update cycle.""" req = { @@ -66,9 +23,9 @@ def query(self) -> dict: return req @property - def current_power(self) -> float | None: + def current_consumption(self) -> float | None: """Current power in watts.""" - if power := self.energy.get("current_power"): + if (power := self.energy.get("current_power")) is not None: return power / 1_000 return None @@ -79,23 +36,64 @@ def energy(self): return en return self.data - @property - def emeter_realtime(self): - """Get the emeter status.""" - # TODO: Perhaps we should get rid of emeterstatus altogether for smartdevices + def _get_status_from_energy(self, energy) -> EmeterStatus: return EmeterStatus( { - "power_mw": self.energy.get("current_power"), - "total": self.energy.get("today_energy") / 1_000, + "power_mw": energy.get("current_power"), + "total": energy.get("today_energy") / 1_000, } ) @property - def emeter_this_month(self) -> float | None: - """Get the emeter value for this month.""" - return self.energy.get("month_energy") + def status(self): + """Get the emeter status.""" + return self._get_status_from_energy(self.energy) + + async def get_status(self): + """Return real-time statistics.""" + res = await self.call("get_energy_usage") + return self._get_status_from_energy(res["get_energy_usage"]) + + @property + def consumption_this_month(self) -> float | None: + """Get the emeter value for this month in kWh.""" + return self.energy.get("month_energy") / 1_000 + + @property + def consumption_today(self) -> float | None: + """Get the emeter value for today in kWh.""" + return self.energy.get("today_energy") / 1_000 + + @property + def consumption_total(self) -> float | None: + """Return total consumption since last reboot in kWh.""" + return None + + @property + def current(self) -> float | None: + """Return the current in A.""" + return None @property - def emeter_today(self) -> float | None: - """Get the emeter value for today.""" - return self.energy.get("today_energy") + def voltage(self) -> float | None: + """Get the current voltage in V.""" + return None + + async def _deprecated_get_realtime(self) -> EmeterStatus: + """Retrieve current energy readings.""" + return self.status + + async def erase_stats(self): + """Erase all stats.""" + raise KasaException("Device does not support periodic statistics") + + async def get_daily_stats(self, *, year=None, month=None, kwh=True) -> dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: energy, ...}. + """ + raise KasaException("Device does not support periodic statistics") + + async def get_monthly_stats(self, *, year=None, kwh=True) -> dict: + """Return monthly stats for the given year.""" + raise KasaException("Device does not support periodic statistics") diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index 854cf4813..c2f351881 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -2,16 +2,27 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from ...interfaces.lighteffect import LightEffect as LightEffectInterface from ..effects import EFFECT_MAPPING, EFFECT_NAMES from ..smartmodule import SmartModule +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + class LightStripEffect(SmartModule, LightEffectInterface): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_strip_lighting_effect" + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + effect_list = [self.LIGHT_EFFECTS_OFF] + effect_list.extend(EFFECT_NAMES) + self._effect_list = effect_list + @property def name(self) -> str: """Name of the module. @@ -37,7 +48,8 @@ def effect(self) -> str: """ eff = self.data["lighting_effect"] name = eff["name"] - if eff["enable"]: + # When devices are unpaired effect name is softAP which is not in our list + if eff["enable"] and name in self._effect_list: return name return self.LIGHT_EFFECTS_OFF @@ -48,9 +60,7 @@ def effect_list(self) -> list[str]: Example: ['Aurora', 'Bubbling Cauldron', ...] """ - effect_list = [self.LIGHT_EFFECTS_OFF] - effect_list.extend(EFFECT_NAMES) - return effect_list + return self._effect_list async def set_effect( self, diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index a11c7d95d..1e5ba0cf1 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -181,3 +181,13 @@ def query(self) -> dict: return {} else: return {self.QUERY_GETTER_NAME: None} + + async def _check_supported(self): + """Additional check to see if the module is supported by the device. + + Parent devices that report components of children such as ks240 will not have + the brightness value is sysinfo. + """ + # Look in _device.sys_info here because self.data is either sys_info or + # get_preset_rules depending on whether it's a child device or not. + return "brightness" in self._device.sys_info diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index 958cf9e21..3c2b96af3 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -51,7 +51,13 @@ def time(self) -> datetime: async def set_time(self, dt: datetime): """Set device time.""" unixtime = mktime(dt.timetuple()) + offset = cast(timedelta, dt.utcoffset()) + diff = offset / timedelta(minutes=1) return await self.call( "set_device_time", - {"timestamp": unixtime, "time_diff": dt.utcoffset(), "region": dt.tzname()}, + { + "timestamp": int(unixtime), + "time_diff": int(diff), + "region": dt.tzname(), + }, ) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 26bf1396d..5a2f99e59 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -4,14 +4,13 @@ import base64 import logging -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature from ..module import Module @@ -357,12 +356,25 @@ def alias(self) -> str | None: @property def time(self) -> datetime: """Return the time.""" - if self._parent and Module.Time in self._parent.modules: - _timemod = self._parent.modules[Module.Time] - else: - _timemod = self.modules[Module.Time] + if (self._parent and (time_mod := self._parent.modules.get(Module.Time))) or ( + time_mod := self.modules.get(Module.Time) + ): + return time_mod.time - return _timemod.time + # We have no device time, use current local time. + return datetime.now(timezone.utc).astimezone().replace(microsecond=0) + + @property + def on_since(self) -> datetime | None: + """Return the time that the device was turned on or None if turned off.""" + if ( + not self._info.get("device_on") + or (on_time := self._info.get("on_time")) is None + ): + return None + + on_time = cast(float, on_time) + return self.time - timedelta(seconds=on_time) @property def timezone(self) -> dict: @@ -464,45 +476,6 @@ def update_from_discover_info(self, info): self._discovery_info = info self._info = info - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - _LOGGER.warning("Deprecated, use `emeter_realtime`.") - if not self.has_emeter: - raise KasaException("Device has no emeter") - return self.emeter_realtime - - @property - def emeter_realtime(self) -> EmeterStatus: - """Get the emeter status.""" - energy = self.modules[Module.Energy] - return energy.emeter_realtime - - @property - def emeter_this_month(self) -> float | None: - """Get the emeter value for this month.""" - energy = self.modules[Module.Energy] - return energy.emeter_this_month - - @property - def emeter_today(self) -> float | None: - """Get the emeter value for today.""" - energy = self.modules[Module.Energy] - return energy.emeter_today - - @property - def on_since(self) -> datetime | None: - """Return the time that the device was turned on or None if turned off.""" - if ( - not self._info.get("device_on") - or (on_time := self._info.get("on_time")) is None - ): - return None - on_time = cast(float, on_time) - if (timemod := self.modules.get(Module.Time)) is not None: - 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) - async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" diff --git a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json new file mode 100644 index 000000000..a55707aeb --- /dev/null +++ b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json @@ -0,0 +1,350 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "music_rhythm", + "ver_code": 2 + }, + { + "id": "segment", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": true, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "1C-61-B4-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "obd_src": "tplink", + "owner": "" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "", + "brightness": 100, + "color_temp": 9000, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 220119 Rel.221439", + "has_set_location_info": false, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "lighting_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 1, + "id": "", + "name": "softAP" + }, + "longitude": 0, + "mac": "1C-61-B4-00-00-00", + "model": "L920", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "", + "rssi": -46, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "", + "time_diff": 0, + "timestamp": 946771372 + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_lighting_effect": { + "brightness": 0, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 1, + "expansion_strategy": 0, + "id": "", + "name": "softAP", + "repeat_times": 0, + "segment_length": 1, + "sequence": [ + [ + 30, + 100, + 0 + ], + [ + 30, + 100, + 50 + ], + [ + 30, + 100, + 0 + ], + [ + 120, + 100, + 0 + ], + [ + 120, + 100, + 50 + ], + [ + 120, + 100, + 0 + ] + ], + "spread": 8, + "transition": 400, + "type": "sequence" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 24, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 1, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "L920", + "device_type": "SMART.TAPOBULB" + } + } +} diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 2104de050..e30685fe4 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -31,6 +31,7 @@ state, sysinfo, temperature, + time, toggle, update_credentials, wifi, @@ -260,6 +261,37 @@ async def test_update_credentials(dev, runner): ) +async def test_time_get(dev, runner): + """Test time get command.""" + res = await runner.invoke( + time, + obj=dev, + ) + assert res.exit_code == 0 + assert "Current time: " in res.output + + +@device_smart +async def test_time_sync(dev, mocker, runner): + """Test time sync command. + + Currently implemented only for SMART. + """ + update = mocker.patch.object(dev, "update") + set_time_mock = mocker.spy(dev.modules[Module.Time], "set_time") + res = await runner.invoke( + time, + ["sync"], + obj=dev, + ) + set_time_mock.assert_called() + update.assert_called() + + assert res.exit_code == 0 + assert "Old time: " in res.output + assert "New time: " in res.output + + async def test_emeter(dev: Device, mocker, runner): res = await runner.invoke(emeter, obj=dev) if not dev.has_emeter: @@ -429,12 +461,12 @@ async def test_led(dev: Device, runner: CliRunner): async def test_json_output(dev: Device, mocker, runner): """Test that the json output produces correct output.""" - mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) - # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.Discover.discover_single", return_value=dev) + # These will mock the features to avoid accessing non-existing ones mocker.patch("kasa.device.Device.features", return_value={}) mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) - res = await runner.invoke(cli, ["--json", "state"], obj=dev) + res = await runner.invoke(cli, ["--host", "127.0.0.1", "--json", "state"], obj=dev) assert res.exit_code == 0 assert json.loads(res.output) == dev.internal_state @@ -757,7 +789,7 @@ async def test_errors(mocker, runner): ) assert res.exit_code == 1 assert ( - "Raised error: Managed to invoke callback without a context object of type 'Device' existing." + "Only discover is available without --host or --alias" in res.output.replace("\n", "") # Remove newlines from rich formatting ) assert isinstance(res.exception, SystemExit) @@ -828,7 +860,7 @@ async def test_feature_missing(mocker, runner): ) assert "No feature by name 'missing'" in res.output assert "== Features ==" not in res.output - assert res.exit_code == 0 + assert res.exit_code == 1 async def test_feature_set(mocker, runner): diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index c6d412c73..07e764cbf 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -163,12 +163,7 @@ async def _test_attribute( if is_expected and will_raise: ctx = pytest.raises(will_raise) elif is_expected: - ctx = pytest.deprecated_call( - match=( - f"{attribute_name} is deprecated, use: Module." - + f"{module_name} in device.modules instead" - ) - ) + ctx = pytest.deprecated_call(match=(f"{attribute_name} is deprecated, use:")) else: ctx = pytest.raises( AttributeError, match=f"Device has no attribute '{attribute_name}'" @@ -239,6 +234,19 @@ async def test_deprecated_other_attributes(dev: Device): await _test_attribute(dev, "led", bool(led_module), "Led") await _test_attribute(dev, "set_led", bool(led_module), "Led", True) + await _test_attribute(dev, "supported_modules", True, None) + + +async def test_deprecated_emeter_attributes(dev: Device): + energy_module = dev.modules.get(Module.Energy) + + await _test_attribute(dev, "get_emeter_realtime", bool(energy_module), "Energy") + await _test_attribute(dev, "emeter_realtime", bool(energy_module), "Energy") + await _test_attribute(dev, "emeter_today", bool(energy_module), "Energy") + await _test_attribute(dev, "emeter_this_month", bool(energy_module), "Energy") + await _test_attribute(dev, "current_consumption", bool(energy_module), "Energy") + await _test_attribute(dev, "get_emeter_daily", bool(energy_module), "Energy") + await _test_attribute(dev, "get_emeter_monthly", bool(energy_module), "Energy") async def test_deprecated_light_preset_attributes(dev: Device): diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py index a8fe75edd..b710ec73f 100644 --- a/kasa/tests/test_emeter.py +++ b/kasa/tests/test_emeter.py @@ -10,8 +10,9 @@ Schema, ) -from kasa import EmeterStatus, KasaException -from kasa.iot import IotDevice +from kasa import Device, EmeterStatus, Module +from kasa.interfaces.energy import Energy +from kasa.iot import IotDevice, IotStrip from kasa.iot.modules.emeter import Emeter from .conftest import has_emeter, has_emeter_iot, no_emeter @@ -38,16 +39,16 @@ async def test_no_emeter(dev): assert not dev.has_emeter - with pytest.raises(KasaException): + with pytest.raises(AttributeError): await dev.get_emeter_realtime() # Only iot devices support the historical stats so other # devices will not implement the methods below if isinstance(dev, IotDevice): - with pytest.raises(KasaException): + with pytest.raises(AttributeError): await dev.get_emeter_daily() - with pytest.raises(KasaException): + with pytest.raises(AttributeError): await dev.get_emeter_monthly() - with pytest.raises(KasaException): + with pytest.raises(AttributeError): await dev.erase_emeter_stats() @@ -128,11 +129,11 @@ async def test_erase_emeter_stats(dev): @has_emeter_iot async def test_current_consumption(dev): if dev.has_emeter: - x = await dev.current_consumption() + x = dev.current_consumption assert isinstance(x, float) assert x >= 0.0 else: - assert await dev.current_consumption() is None + assert dev.current_consumption is None async def test_emeterstatus_missing_current(): @@ -173,3 +174,30 @@ def data(self): {"day": now.day, "energy_wh": 500, "month": now.month, "year": now.year} ) assert emeter.emeter_today == 0.500 + + +@has_emeter +async def test_supported(dev: Device): + energy_module = dev.modules.get(Module.Energy) + assert energy_module + if isinstance(dev, IotDevice): + info = ( + dev._last_update + if not isinstance(dev, IotStrip) + else dev.children[0].internal_state + ) + emeter = info[energy_module._module]["get_realtime"] + has_total = "total" in emeter or "total_wh" in emeter + has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter + assert ( + energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total + ) + assert ( + energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) + is has_voltage_current + ) + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True + else: + assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False + assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False + assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index d5c76192b..f43258e45 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -116,9 +116,16 @@ async def test_initial_update_no_emeter(dev, mocker): dev._legacy_features = set() spy = mocker.spy(dev.protocol, "query") await dev.update() - # 2 calls are necessary as some devices crash on unexpected modules + # child calls will happen if a child has a module with a query (e.g. schedule) + child_calls = 0 + for child in dev.children: + for module in child.modules.values(): + if module.query(): + child_calls += 1 + break + # 2 parent are necessary as some devices crash on unexpected modules # See #105, #120, #161 - assert spy.call_count == 2 + assert spy.call_count == 2 + child_calls @device_iot diff --git a/poetry.lock b/poetry.lock index c2f9c7240..9d5e069fa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -622,18 +622,18 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.14.0" +version = "3.15.1" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"}, - {file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"}, + {file = "filelock-3.15.1-py3-none-any.whl", hash = "sha256:71b3102950e91dfc1bb4209b64be4dc8854f40e5f534428d8684f953ac847fac"}, + {file = "filelock-3.15.1.tar.gz", hash = "sha256:58a2549afdf9e02e10720eaa4d4470f56386d7a6f72edd7d0596337af8ed7ad8"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] typing = ["typing-extensions (>=4.8)"] [[package]] @@ -1135,57 +1135,57 @@ files = [ [[package]] name = "orjson" -version = "3.10.3" +version = "3.10.5" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = true python-versions = ">=3.8" files = [ - {file = "orjson-3.10.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9fb6c3f9f5490a3eb4ddd46fc1b6eadb0d6fc16fb3f07320149c3286a1409dd8"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:252124b198662eee80428f1af8c63f7ff077c88723fe206a25df8dc57a57b1fa"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f3e87733823089a338ef9bbf363ef4de45e5c599a9bf50a7a9b82e86d0228da"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8334c0d87103bb9fbbe59b78129f1f40d1d1e8355bbed2ca71853af15fa4ed3"}, - {file = "orjson-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1952c03439e4dce23482ac846e7961f9d4ec62086eb98ae76d97bd41d72644d7"}, - {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0403ed9c706dcd2809f1600ed18f4aae50be263bd7112e54b50e2c2bc3ebd6d"}, - {file = "orjson-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:382e52aa4270a037d41f325e7d1dfa395b7de0c367800b6f337d8157367bf3a7"}, - {file = "orjson-3.10.3-cp310-none-win32.whl", hash = "sha256:be2aab54313752c04f2cbaab4515291ef5af8c2256ce22abc007f89f42f49109"}, - {file = "orjson-3.10.3-cp310-none-win_amd64.whl", hash = "sha256:416b195f78ae461601893f482287cee1e3059ec49b4f99479aedf22a20b1098b"}, - {file = "orjson-3.10.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:73100d9abbbe730331f2242c1fc0bcb46a3ea3b4ae3348847e5a141265479700"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:544a12eee96e3ab828dbfcb4d5a0023aa971b27143a1d35dc214c176fdfb29b3"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:520de5e2ef0b4ae546bea25129d6c7c74edb43fc6cf5213f511a927f2b28148b"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccaa0a401fc02e8828a5bedfd80f8cd389d24f65e5ca3954d72c6582495b4bcf"}, - {file = "orjson-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7bc9e8bc11bac40f905640acd41cbeaa87209e7e1f57ade386da658092dc16"}, - {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3582b34b70543a1ed6944aca75e219e1192661a63da4d039d088a09c67543b08"}, - {file = "orjson-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c23dfa91481de880890d17aa7b91d586a4746a4c2aa9a145bebdbaf233768d5"}, - {file = "orjson-3.10.3-cp311-none-win32.whl", hash = "sha256:1770e2a0eae728b050705206d84eda8b074b65ee835e7f85c919f5705b006c9b"}, - {file = "orjson-3.10.3-cp311-none-win_amd64.whl", hash = "sha256:93433b3c1f852660eb5abdc1f4dd0ced2be031ba30900433223b28ee0140cde5"}, - {file = "orjson-3.10.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a39aa73e53bec8d410875683bfa3a8edf61e5a1c7bb4014f65f81d36467ea098"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0943a96b3fa09bee1afdfccc2cb236c9c64715afa375b2af296c73d91c23eab2"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e852baafceff8da3c9defae29414cc8513a1586ad93e45f27b89a639c68e8176"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18566beb5acd76f3769c1d1a7ec06cdb81edc4d55d2765fb677e3eaa10fa99e0"}, - {file = "orjson-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bd2218d5a3aa43060efe649ec564ebedec8ce6ae0a43654b81376216d5ebd42"}, - {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf20465e74c6e17a104ecf01bf8cd3b7b252565b4ccee4548f18b012ff2f8069"}, - {file = "orjson-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba7f67aa7f983c4345eeda16054a4677289011a478ca947cd69c0a86ea45e534"}, - {file = "orjson-3.10.3-cp312-none-win32.whl", hash = "sha256:17e0713fc159abc261eea0f4feda611d32eabc35708b74bef6ad44f6c78d5ea0"}, - {file = "orjson-3.10.3-cp312-none-win_amd64.whl", hash = "sha256:4c895383b1ec42b017dd2c75ae8a5b862fc489006afde06f14afbdd0309b2af0"}, - {file = "orjson-3.10.3-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:be2719e5041e9fb76c8c2c06b9600fe8e8584e6980061ff88dcbc2691a16d20d"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0175a5798bdc878956099f5c54b9837cb62cfbf5d0b86ba6d77e43861bcec2"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978be58a68ade24f1af7758626806e13cff7748a677faf95fbb298359aa1e20d"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16bda83b5c61586f6f788333d3cf3ed19015e3b9019188c56983b5a299210eb5"}, - {file = "orjson-3.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ad1f26bea425041e0a1adad34630c4825a9e3adec49079b1fb6ac8d36f8b754"}, - {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9e253498bee561fe85d6325ba55ff2ff08fb5e7184cd6a4d7754133bd19c9195"}, - {file = "orjson-3.10.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0a62f9968bab8a676a164263e485f30a0b748255ee2f4ae49a0224be95f4532b"}, - {file = "orjson-3.10.3-cp38-none-win32.whl", hash = "sha256:8d0b84403d287d4bfa9bf7d1dc298d5c1c5d9f444f3737929a66f2fe4fb8f134"}, - {file = "orjson-3.10.3-cp38-none-win_amd64.whl", hash = "sha256:8bc7a4df90da5d535e18157220d7915780d07198b54f4de0110eca6b6c11e290"}, - {file = "orjson-3.10.3-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9059d15c30e675a58fdcd6f95465c1522b8426e092de9fff20edebfdc15e1cb0"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d40c7f7938c9c2b934b297412c067936d0b54e4b8ab916fd1a9eb8f54c02294"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a654ec1de8fdaae1d80d55cee65893cb06494e124681ab335218be6a0691e7"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:831c6ef73f9aa53c5f40ae8f949ff7681b38eaddb6904aab89dca4d85099cb78"}, - {file = "orjson-3.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99b880d7e34542db89f48d14ddecbd26f06838b12427d5a25d71baceb5ba119d"}, - {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2e5e176c994ce4bd434d7aafb9ecc893c15f347d3d2bbd8e7ce0b63071c52e25"}, - {file = "orjson-3.10.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b69a58a37dab856491bf2d3bbf259775fdce262b727f96aafbda359cb1d114d8"}, - {file = "orjson-3.10.3-cp39-none-win32.whl", hash = "sha256:b8d4d1a6868cde356f1402c8faeb50d62cee765a1f7ffcfd6de732ab0581e063"}, - {file = "orjson-3.10.3-cp39-none-win_amd64.whl", hash = "sha256:5102f50c5fc46d94f2033fe00d392588564378260d64377aec702f21a7a22912"}, - {file = "orjson-3.10.3.tar.gz", hash = "sha256:2b166507acae7ba2f7c315dcf185a9111ad5e992ac81f2d507aac39193c2c818"}, + {file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c13ca5e2ddded0ce6a927ea5a9f27cae77eee4c75547b4297252cb20c4d30e6"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6c8e30adfa52c025f042a87f450a6b9ea29649d828e0fec4858ed5e6caecf63"}, + {file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338fd4f071b242f26e9ca802f443edc588fa4ab60bfa81f38beaedf42eda226c"}, + {file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6970ed7a3126cfed873c5d21ece1cd5d6f83ca6c9afb71bbae21a0b034588d96"}, + {file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:235dadefb793ad12f7fa11e98a480db1f7c6469ff9e3da5e73c7809c700d746b"}, + {file = "orjson-3.10.5-cp310-none-win32.whl", hash = "sha256:be79e2393679eda6a590638abda16d167754393f5d0850dcbca2d0c3735cebe2"}, + {file = "orjson-3.10.5-cp310-none-win_amd64.whl", hash = "sha256:c4a65310ccb5c9910c47b078ba78e2787cb3878cdded1702ac3d0da71ddc5228"}, + {file = "orjson-3.10.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cdf7365063e80899ae3a697def1277c17a7df7ccfc979990a403dfe77bb54d40"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b68742c469745d0e6ca5724506858f75e2f1e5b59a4315861f9e2b1df77775a"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d10cc1b594951522e35a3463da19e899abe6ca95f3c84c69e9e901e0bd93d38"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcbe82b35d1ac43b0d84072408330fd3295c2896973112d495e7234f7e3da2e1"}, + {file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c0eb7e0c75e1e486c7563fe231b40fdd658a035ae125c6ba651ca3b07936f5"}, + {file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53ed1c879b10de56f35daf06dbc4a0d9a5db98f6ee853c2dbd3ee9d13e6f302f"}, + {file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:099e81a5975237fda3100f918839af95f42f981447ba8f47adb7b6a3cdb078fa"}, + {file = "orjson-3.10.5-cp311-none-win32.whl", hash = "sha256:1146bf85ea37ac421594107195db8bc77104f74bc83e8ee21a2e58596bfb2f04"}, + {file = "orjson-3.10.5-cp311-none-win_amd64.whl", hash = "sha256:36a10f43c5f3a55c2f680efe07aa93ef4a342d2960dd2b1b7ea2dd764fe4a37c"}, + {file = "orjson-3.10.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:68f85ecae7af14a585a563ac741b0547a3f291de81cd1e20903e79f25170458f"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28afa96f496474ce60d3340fe8d9a263aa93ea01201cd2bad844c45cd21f5268"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cd684927af3e11b6e754df80b9ffafd9fb6adcaa9d3e8fdd5891be5a5cad51e"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d21b9983da032505f7050795e98b5d9eee0df903258951566ecc358f6696969"}, + {file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ad1de7fef79736dde8c3554e75361ec351158a906d747bd901a52a5c9c8d24b"}, + {file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d97531cdfe9bdd76d492e69800afd97e5930cb0da6a825646667b2c6c6c0211"}, + {file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d69858c32f09c3e1ce44b617b3ebba1aba030e777000ebdf72b0d8e365d0b2b3"}, + {file = "orjson-3.10.5-cp312-none-win32.whl", hash = "sha256:64c9cc089f127e5875901ac05e5c25aa13cfa5dbbbd9602bda51e5c611d6e3e2"}, + {file = "orjson-3.10.5-cp312-none-win_amd64.whl", hash = "sha256:b2efbd67feff8c1f7728937c0d7f6ca8c25ec81373dc8db4ef394c1d93d13dc5"}, + {file = "orjson-3.10.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:03b565c3b93f5d6e001db48b747d31ea3819b89abf041ee10ac6988886d18e01"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584c902ec19ab7928fd5add1783c909094cc53f31ac7acfada817b0847975f26"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a35455cc0b0b3a1eaf67224035f5388591ec72b9b6136d66b49a553ce9eb1e6"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1670fe88b116c2745a3a30b0f099b699a02bb3482c2591514baf5433819e4f4d"}, + {file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185c394ef45b18b9a7d8e8f333606e2e8194a50c6e3c664215aae8cf42c5385e"}, + {file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ca0b3a94ac8d3886c9581b9f9de3ce858263865fdaa383fbc31c310b9eac07c9"}, + {file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dfc91d4720d48e2a709e9c368d5125b4b5899dced34b5400c3837dadc7d6271b"}, + {file = "orjson-3.10.5-cp38-none-win32.whl", hash = "sha256:c05f16701ab2a4ca146d0bca950af254cb7c02f3c01fca8efbbad82d23b3d9d4"}, + {file = "orjson-3.10.5-cp38-none-win_amd64.whl", hash = "sha256:8a11d459338f96a9aa7f232ba95679fc0c7cedbd1b990d736467894210205c09"}, + {file = "orjson-3.10.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:85c89131d7b3218db1b24c4abecea92fd6c7f9fab87441cfc342d3acc725d807"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66215277a230c456f9038d5e2d84778141643207f85336ef8d2a9da26bd7ca"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bbcdea96cdefa4a9b4461e690c75ad4e33796530d182bdd5c38980202c134a"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbead71dbe65f959b7bd8cf91e0e11d5338033eba34c114f69078d59827ee139"}, + {file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df58d206e78c40da118a8c14fc189207fffdcb1f21b3b4c9c0c18e839b5a214"}, + {file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c4057c3b511bb8aef605616bd3f1f002a697c7e4da6adf095ca5b84c0fd43595"}, + {file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b39e006b00c57125ab974362e740c14a0c6a66ff695bff44615dcf4a70ce2b86"}, + {file = "orjson-3.10.5-cp39-none-win32.whl", hash = "sha256:eded5138cc565a9d618e111c6d5c2547bbdd951114eb822f7f6309e04db0fb47"}, + {file = "orjson-3.10.5-cp39-none-win_amd64.whl", hash = "sha256:cc28e90a7cae7fcba2493953cff61da5a52950e78dc2dacfe931a317ee3d8de7"}, + {file = "orjson-3.10.5.tar.gz", hash = "sha256:7a5baef8a4284405d96c90c7c62b755e9ef1ada84c2406c24a9ebec86b89f46d"}, ] [[package]] @@ -1311,13 +1311,13 @@ files = [ [[package]] name = "pydantic" -version = "2.7.3" +version = "2.7.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.3-py3-none-any.whl", hash = "sha256:ea91b002777bf643bb20dd717c028ec43216b24a6001a280f83877fd2655d0b4"}, - {file = "pydantic-2.7.3.tar.gz", hash = "sha256:c46c76a40bb1296728d7a8b99aa73dd70a48c3510111ff290034f860c99c419e"}, + {file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"}, + {file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index d6fdb8cb3..d7ac0f632 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.dev4" +version = "0.7.0.dev5" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"]