From f0be672cf51feaaeb97de75271a9c6ec1cb0db48 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 11 Jun 2024 15:46:36 +0100 Subject: [PATCH 1/9] Add supported check to light transition module (#971) Adds an implementation of `_check_supported` to the light transition module so it is not added to a parent device that reports it but doesn't support it, i.e. ks240. --- kasa/smart/modules/lighttransition.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From 5d5c353422a77f42ab420574847d19ebeb591586 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 11 Jun 2024 20:22:32 +0200 Subject: [PATCH 2/9] Add fixture for L920-5(EU) 1.0.7 (#972) When not paired, the effect is softAP: `Light effect (light_effect): invalid value 'softAP' not in ['Off', 'Aurora', ...]` --- SUPPORTED.md | 1 + .../fixtures/smart/L920-5(EU)_1.0_1.0.7.json | 350 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json 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/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" + } + } +} From 7f24408c326db8f1c0753c3874f57e457cad3965 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 11 Jun 2024 19:23:06 +0100 Subject: [PATCH 3/9] Handle unknown light effect names and only calculate effect list once (#973) Fixes issue with unpaired devices reporting light effect as `softAP` reported in https://github.com/python-kasa/python-kasa/pull/972. I don't think we need to handle that effect properly so just reports as off. --- kasa/smart/modules/lightstripeffect.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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, From 4cf395483f7a8a51e47bc7ece150d2da26de57fe Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Jun 2024 20:58:21 +0100 Subject: [PATCH 4/9] Add type hints to feature set_value (#974) To prevent untyped call mypy errors in consumers --- kasa/feature.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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}" From 6cdbbefb908ff1df1683e1fac4fb3ce3ee77f496 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 14 Jun 2024 22:04:20 +0100 Subject: [PATCH 5/9] Add timezone to on_since attributes (#978) This allows them to displayed in HA without errors. --- kasa/iot/iotdevice.py | 9 ++++++--- kasa/iot/iotstrip.py | 8 +++++--- kasa/smart/smartdevice.py | 39 +++++++++++++++++++-------------------- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index c7631763b..1048034db 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -18,7 +18,7 @@ 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 @@ -345,7 +345,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, @@ -665,7 +666,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..1ad1bdb86 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -4,7 +4,7 @@ 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 @@ -148,7 +148,7 @@ 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: """Get the current power consumption in watts.""" @@ -372,7 +372,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/smart/smartdevice.py b/kasa/smart/smartdevice.py index 26bf1396d..f4e3eb587 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -4,7 +4,7 @@ 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 @@ -357,12 +357,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: @@ -489,20 +502,6 @@ def emeter_today(self) -> float | None: 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.""" From 867b7b88309c6ccf39d661aae92afb25c487b24f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 17 Jun 2024 10:37:08 +0200 Subject: [PATCH 6/9] Add time sync command (#951) Allows setting the device time (on SMART devices) to the current time. Fixes also setting the time which was previously broken. --- docs/source/cli.rst | 5 +++++ kasa/cli.py | 33 +++++++++++++++++++++++++++++++-- kasa/smart/modules/time.py | 8 +++++++- kasa/tests/test_cli.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) 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..a8d8b6ece 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 @@ -967,15 +968,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) 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/tests/test_cli.py b/kasa/tests/test_cli.py index 2104de050..41b1e1ad9 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: From 51a972542f7b2c665dddf7db476c1d613d0bd02e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 17 Jun 2024 11:04:46 +0200 Subject: [PATCH 7/9] Disallow non-targeted device commands (#982) Prevent the cli from allowing sub commands unless host or alias is specified. It is unwise to allow commands to be run on an arbitrary set of discovered devices so this PR shows an error if attempted. Also consolidates other invalid cli operations to use a single error function to display the error to the user. --- kasa/cli.py | 47 ++++++++++++++++++++++++------------------ kasa/tests/test_cli.py | 10 ++++----- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index a8d8b6ece..f7ff1dd34 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -71,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, @@ -367,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) @@ -764,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: @@ -774,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) @@ -865,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: @@ -885,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: @@ -911,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( @@ -939,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: @@ -958,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}") @@ -1014,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: @@ -1035,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: @@ -1056,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: @@ -1096,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 @@ -1112,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) @@ -1128,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: @@ -1150,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: @@ -1175,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}") @@ -1212,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) @@ -1271,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/tests/test_cli.py b/kasa/tests/test_cli.py index 41b1e1ad9..e30685fe4 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -461,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 @@ -789,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) @@ -860,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): From b4a6df2b5cef00066d1d8279be019329b5e680a2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 17 Jun 2024 11:22:05 +0100 Subject: [PATCH 8/9] Add common energy module and deprecate device emeter attributes (#976) Consolidates logic for energy monitoring across smart and iot devices. Deprecates emeter attributes in favour of common names. --- kasa/device.py | 52 ++++------ kasa/interfaces/__init__.py | 2 + kasa/interfaces/energy.py | 181 +++++++++++++++++++++++++++++++++++ kasa/iot/iotbulb.py | 2 +- kasa/iot/iotdevice.py | 118 ++++------------------- kasa/iot/iotstrip.py | 164 ++++++++++++++++++++----------- kasa/iot/modules/emeter.py | 119 ++++++----------------- kasa/iot/modules/led.py | 5 + kasa/module.py | 5 +- kasa/smart/modules/energy.py | 118 +++++++++++------------ kasa/smart/smartdevice.py | 26 ----- kasa/tests/test_device.py | 20 ++-- kasa/tests/test_emeter.py | 44 +++++++-- kasa/tests/test_iotdevice.py | 11 ++- 14 files changed, 486 insertions(+), 381 deletions(-) create mode 100644 kasa/interfaces/energy.py 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/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 1048034db..102d6a4dc 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -23,7 +23,6 @@ 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.""" @@ -357,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(): @@ -411,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 @@ -557,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. diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 1ad1bdb86..c2f2bb860 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -9,16 +9,17 @@ 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}) @@ -150,21 +172,43 @@ def on_since(self) -> datetime | 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 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/smartdevice.py b/kasa/smart/smartdevice.py index f4e3eb587..5a2f99e59 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -11,7 +11,6 @@ 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 @@ -477,31 +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 - async def wifi_scan(self) -> list[WifiNetwork]: """Scan for available wifi networks.""" 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 From 6b46773609fbdecc026b770439908ae2ecb4f760 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 17 Jun 2024 12:19:04 +0100 Subject: [PATCH 9/9] Prepare 0.7.0.dev5 (#984) ## [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) --- CHANGELOG.md | 21 ++++++++++ poetry.lock | 108 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 3 files changed, 76 insertions(+), 55 deletions(-) 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/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"]