From 1bf6d80b2a389ea30dc0a59667f649bc6b605395 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jul 2024 12:30:43 +0100 Subject: [PATCH 01/38] Update changelog generator config (#1030) Move the static command line options into the config file for consistency and remove `--no-issues` in favour of `issues-wo-labels=false` to fix the problem where `release-summary` issues are being excluded from the changelog. --- .github_changelog_generator | 10 ++++++++-- RELEASING.md | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github_changelog_generator b/.github_changelog_generator index 9a0c0af9d..f72e740cd 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -1,5 +1,11 @@ -breaking_labels=breaking change -add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} +output=CHANGELOG.md +base=HISTORY.md +user=python-kasa +project=python-kasa +since-tag=0.3.5 release_branch=master usernames-as-github-logins=true +breaking_labels=breaking change +add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} exclude-labels=duplicate,question,invalid,wontfix,release-prep +issues-wo-labels=false diff --git a/RELEASING.md b/RELEASING.md index 476e9de59..e42e1c871 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -73,6 +73,8 @@ gh issue close ISSUE_NUMBER ## Generate changelog +Configuration settings are in `.github_changelog_generator` + ### For pre-release EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags. @@ -82,13 +84,13 @@ Regex should be something like this `^((?!0\.7\.0)(.*dev\d))+`. The first match ```bash EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+" echo "$EXCLUDE_TAGS" -github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex "$EXCLUDE_TAGS" +github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex "$EXCLUDE_TAGS" ``` ### For production ```bash -github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex 'dev\d$' +github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex 'dev\d$' ``` You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master: From e5b959e4a9dc612c419424ed7e4c1d5d25a6eca3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 2 Jul 2024 14:36:57 +0200 Subject: [PATCH 02/38] Add L920(EU) v1.1.3 fixture (#1031) --- SUPPORTED.md | 1 + .../fixtures/smart/L920-5(EU)_1.0_1.1.3.json | 436 ++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json diff --git a/SUPPORTED.md b/SUPPORTED.md index a644254a6..08ae8ada3 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -209,6 +209,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.1.0 - **L920-5** - Hardware: 1.0 (EU) / Firmware: 1.0.7 + - Hardware: 1.0 (EU) / Firmware: 1.1.3 - 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.1.3.json b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json new file mode 100644 index 000000000..0e7679e2b --- /dev/null +++ b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json @@ -0,0 +1,436 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "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": 3 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(EU)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "1C-61-B4-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": false, + "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": "light_strip", + "brightness": 65, + "color_temp": 0, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 65, + "color_temp": 0, + "hue": 9, + "saturation": 67 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 231229 Rel.164316", + "has_set_location_info": false, + "hue": 9, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "lighting_effect": { + "brightness": 65, + "custom": 0, + "display_colors": [ + [ + 136, + 98, + 100 + ], + [ + 350, + 97, + 100 + ] + ], + "enable": 0, + "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", + "name": "Christmas" + }, + "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": "Europe/Berlin", + "rssi": -56, + "saturation": 67, + "segment_effect": { + "brightness": 97, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "Lightning" + }, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1719920893 + }, + "get_device_usage": { + "power_usage": { + "past30": 20, + "past7": 20, + "today": 0 + }, + "saved_power": { + "past30": 319, + "past7": 319, + "today": 0 + }, + "time_usage": { + "past30": 339, + "past7": 339, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_lighting_effect": { + "backgrounds": [ + [ + 136, + 98, + 75 + ], + [ + 136, + 0, + 0 + ], + [ + 350, + 0, + 100 + ], + [ + 350, + 97, + 94 + ] + ], + "brightness": 65, + "brightness_range": [ + 50, + 100 + ], + "custom": 0, + "display_colors": [ + [ + 136, + 98, + 100 + ], + [ + 350, + 97, + 100 + ] + ], + "duration": 5000, + "enable": 0, + "expansion_strategy": 1, + "fadeoff": 2000, + "hue_range": [ + 136, + 146 + ], + "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", + "init_states": [ + [ + 136, + 0, + 100 + ] + ], + "name": "Christmas", + "random_seed": 100, + "saturation_range": [ + 90, + 100 + ], + "segments": [ + 0 + ], + "transition": 0, + "type": "random" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": true + }, + "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_segment_effect_rule": { + "brightness": 97, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "Lightning" + }, + "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 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L920", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From b8a87f1c571aee48aa2f168aefdca9509ac53915 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:43:37 +0100 Subject: [PATCH 03/38] Fix credential hash to return None on empty credentials (#1029) If discovery is triggered without credentials and discovers devices requiring authentication, blank credentials are used to initialise the protocols and no connection is actually made. In this instance we should not return the credentials_hash for blank credentials as it will be invalid. --- kasa/aestransport.py | 4 ++- kasa/klaptransport.py | 4 ++- kasa/protocol.py | 2 +- kasa/tests/fakeprotocol_iot.py | 4 +-- kasa/tests/test_protocol.py | 64 +++++++++++++++++++++++++++++++++- kasa/xortransport.py | 4 +-- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index cc373b190..c9cb83bd3 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -117,8 +117,10 @@ def default_port(self) -> int: return self.DEFAULT_PORT @property - def credentials_hash(self) -> str: + def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" + if self._credentials == Credentials(): + return None return base64.b64encode(json_dumps(self._login_params).encode()).decode() def _get_login_params(self, credentials: Credentials) -> dict[str, str]: diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 3a1eb3367..dd90ffd28 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -132,8 +132,10 @@ def default_port(self): return self.DEFAULT_PORT @property - def credentials_hash(self) -> str: + def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" + if self._credentials == Credentials(): + return None return base64.b64encode(self._local_auth_hash).decode() async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: diff --git a/kasa/protocol.py b/kasa/protocol.py index c7d505b8a..7d717c5ed 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -59,7 +59,7 @@ def default_port(self) -> int: @property @abstractmethod - def credentials_hash(self) -> str: + def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" @abstractmethod diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 523205989..9c5f655c4 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -234,8 +234,8 @@ def default_port(self) -> int: return 9999 @property - def credentials_hash(self) -> str: - return "" + def credentials_hash(self) -> None: + return None def set_alias(self, x, child_ids=None): if child_ids is None: diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index e0ddbbb43..1aeeedb27 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -13,6 +13,7 @@ from ..aestransport import AesTransport from ..credentials import Credentials +from ..device import Device from ..deviceconfig import DeviceConfig from ..exceptions import KasaException from ..iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol @@ -512,11 +513,72 @@ def test_transport_init_signature(class_name_obj): ) +@pytest.mark.parametrize( + ("transport_class", "login_version", "expected_hash"), + [ + pytest.param( + AesTransport, + 1, + "eyJwYXNzd29yZCI6IlFtRnkiLCJ1c2VybmFtZSI6Ik1qQXhZVFppTXpBMU0yTmpNVFF5TW1ReVl6TTJOekJpTmpJMk1UWXlNakZrTWpJNU1Ea3lPUT09In0=", + id="aes-lv-1", + ), + pytest.param( + AesTransport, + 2, + "eyJwYXNzd29yZDIiOiJaVFE1Tm1aa01qQXhNelprTkdKaU56Z3lPR1ZpWWpCaFlqa3lOV0l4WW1RNU56Y3lNRGhsTkE9PSIsInVzZXJuYW1lIjoiTWpBeFlUWmlNekExTTJOak1UUXlNbVF5WXpNMk56QmlOakkyTVRZeU1qRmtNakk1TURreU9RPT0ifQ==", + id="aes-lv-2", + ), + pytest.param(KlapTransport, 1, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-1"), + pytest.param(KlapTransport, 2, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-2"), + pytest.param( + KlapTransportV2, + 1, + "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", + id="klapv2-lv-1", + ), + pytest.param( + KlapTransportV2, + 2, + "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", + id="klapv2-lv-2", + ), + pytest.param(XorTransport, None, None, id="xor"), + ], +) +@pytest.mark.parametrize( + ("credentials", "expected_blank"), + [ + pytest.param(Credentials("Foo", "Bar"), False, id="credentials"), + pytest.param(None, True, id="no-credentials"), + pytest.param(Credentials(None, "Bar"), True, id="no-username"), # type: ignore[arg-type] + ], +) +async def test_transport_credentials_hash( + mocker, transport_class, login_version, expected_hash, credentials, expected_blank +): + """Test that the actual hashing doesn't break and empty credential returns an empty hash.""" + host = "127.0.0.1" + + params = Device.ConnectionParameters( + device_family=Device.Family.SmartTapoPlug, + encryption_type=Device.EncryptionType.Xor, + login_version=login_version, + ) + config = DeviceConfig(host, credentials=credentials, connection_type=params) + transport = transport_class(config=config) + + credentials_hash = transport.credentials_hash + + expected = None if expected_blank else expected_hash + assert credentials_hash == expected + + @pytest.mark.parametrize( "transport_class", [AesTransport, KlapTransport, KlapTransportV2, XorTransport, XorTransport], ) -async def test_transport_credentials_hash(mocker, transport_class): +async def test_transport_credentials_hash_from_config(mocker, transport_class): + """Test that credentials_hash provided via config sets correctly.""" host = "127.0.0.1" credentials = Credentials("Foo", "Bar") diff --git a/kasa/xortransport.py b/kasa/xortransport.py index e96864533..52fba3d3e 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -54,9 +54,9 @@ def default_port(self): return self.DEFAULT_PORT @property - def credentials_hash(self) -> str: + def credentials_hash(self) -> str | None: """The hashed credentials used by the transport.""" - return "" + return None async def _connect(self, timeout: int) -> None: """Try to connect or reconnect to the device.""" From 9cffbe9e485c004f2b6b4f685d2524f26fc29d0d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:11:19 +0100 Subject: [PATCH 04/38] Support child devices in all applicable cli commands (#1020) Adds a new decorator that adds child options to a command and gets the child device if the options are set. - Single definition of options and error handling - Adds options automatically to command - Backwards compatible with `--index` and `--name` - `--child` allows for id and alias for ease of use - Omitting a value for `--child` gives an interactive prompt Implements private `_update` to allow the CLI to patch a child `update` method to call the parent device `update`. Example help output: ``` $ kasa brightness --help Usage: kasa brightness [OPTIONS] [BRIGHTNESS] Get or set brightness. Options: --transition INTEGER --child, --name TEXT Child ID or alias for controlling sub- devices. If no value provided will show an interactive prompt allowing you to select a child. --child-index, --index INTEGER Child index controlling sub-devices --help Show this message and exit. ``` Fixes #769 --- kasa/cli.py | 316 ++++++++++++++++++++------------- kasa/device.py | 12 +- kasa/iot/iotstrip.py | 10 +- kasa/smart/smartchilddevice.py | 8 + kasa/smart/smartdevice.py | 2 +- kasa/tests/test_cli.py | 119 ++++++++++++- 6 files changed, 333 insertions(+), 134 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 4d0a1db5e..10c422978 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -8,11 +8,11 @@ import logging import re import sys -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager from datetime import datetime -from functools import singledispatch, wraps +from functools import singledispatch, update_wrapper, wraps from pprint import pformat as pf -from typing import Any, cast +from typing import Any, Final, cast import asyncclick as click from pydantic.v1 import ValidationError @@ -41,6 +41,7 @@ IotStrip, IotWallSwitch, ) +from kasa.iot.iotstrip import IotStripPlug from kasa.iot.modules import Usage from kasa.smart import SmartDevice @@ -77,6 +78,9 @@ def error(msg: str): sys.exit(1) +# Value for optional options if passed without a value +OPTIONAL_VALUE_FLAG: Final = "_FLAG_" + TYPE_TO_CLASS = { "plug": IotPlug, "switch": IotWallSwitch, @@ -169,6 +173,112 @@ def _device_to_serializable(val: Device): print(json_content) +def pass_dev_or_child(wrapped_function): + """Pass the device or child to the click command based on the child options.""" + child_help = ( + "Child ID or alias for controlling sub-devices. " + "If no value provided will show an interactive prompt allowing you to " + "select a child." + ) + child_index_help = "Child index controlling sub-devices" + + @contextmanager + def patched_device_update(parent: Device, child: Device): + try: + orig_update = child.update + # patch child update method. Can be removed once update can be called + # directly on child devices + child.update = parent.update # type: ignore[method-assign] + yield child + finally: + child.update = orig_update # type: ignore[method-assign] + + @click.pass_obj + @click.pass_context + @click.option( + "--child", + "--name", + is_flag=False, + flag_value=OPTIONAL_VALUE_FLAG, + default=None, + required=False, + type=click.STRING, + help=child_help, + ) + @click.option( + "--child-index", + "--index", + required=False, + default=None, + type=click.INT, + help=child_index_help, + ) + async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs): + if child := await _get_child_device(dev, child, child_index, ctx.info_name): + ctx.obj = ctx.with_resource(patched_device_update(dev, child)) + dev = child + return await ctx.invoke(wrapped_function, dev, *args, **kwargs) + + # Update wrapper function to look like wrapped function + return update_wrapper(wrapper, wrapped_function) + + +async def _get_child_device( + device: Device, child_option, child_index_option, info_command +) -> Device | None: + def _list_children(): + return "\n".join( + [ + f"{idx}: {child.device_id} ({child.alias})" + for idx, child in enumerate(device.children) + ] + ) + + if child_option is None and child_index_option is None: + return None + + if info_command in SKIP_UPDATE_COMMANDS: + # The device hasn't had update called (e.g. for cmd_command) + # The way child devices are accessed requires a ChildDevice to + # wrap the communications. Doing this properly would require creating + # a common interfaces for both IOT and SMART child devices. + # As a stop-gap solution, we perform an update instead. + await device.update() + + if not device.children: + error(f"Device: {device.host} does not have children") + + if child_option is not None and child_index_option is not None: + raise click.BadOptionUsage( + "child", "Use either --child or --child-index, not both." + ) + + if child_option is not None: + if child_option is OPTIONAL_VALUE_FLAG: + msg = _list_children() + child_index_option = click.prompt( + f"\n{msg}\nEnter the index number of the child device", + type=click.IntRange(0, len(device.children) - 1), + ) + elif child := device.get_child_device(child_option): + echo(f"Targeting child device {child.alias}") + return child + else: + error( + "No child device found with device_id or name: " + f"{child_option} children are:\n{_list_children()}" + ) + + if child_index_option + 1 > len(device.children) or child_index_option < 0: + error( + f"Invalid index {child_index_option}, " + f"device has {len(device.children)} children" + ) + child_by_index = device.children[child_index_option] + echo(f"Targeting child device {child_by_index.alias}") + return child_by_index + + @click.group( invoke_without_command=True, cls=CatchAllExceptions(click.Group), @@ -232,6 +342,7 @@ def _device_to_serializable(val: Device): help="Output raw device response as JSON.", ) @click.option( + "-e", "--encrypt-type", envvar="KASA_ENCRYPT_TYPE", default=None, @@ -240,13 +351,14 @@ def _device_to_serializable(val: Device): @click.option( "--device-family", envvar="KASA_DEVICE_FAMILY", - default=None, + default="SMART.TAPOPLUG", type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False), ) @click.option( + "-lv", "--login-version", envvar="KASA_LOGIN_VERSION", - default=None, + default=2, type=int, ) @click.option( @@ -379,7 +491,8 @@ def _nop_echo(*args, **kwargs): device_updated = False if type is not None: - dev = TYPE_TO_CLASS[type](host) + config = DeviceConfig(host=host, port_override=port, timeout=timeout) + dev = TYPE_TO_CLASS[type](host, config=config) elif device_family and encrypt_type: ctype = DeviceConnectionParameters( DeviceFamily(device_family), @@ -397,12 +510,6 @@ def _nop_echo(*args, **kwargs): dev = await Device.connect(config=config) device_updated = True else: - if device_family or encrypt_type: - echo( - "--device-family and --encrypt-type options must both be " - "provided or they are ignored\n" - f"discovering for {discovery_timeout} seconds.." - ) dev = await Discover.discover_single( host, port=port, @@ -587,7 +694,7 @@ async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attem @cli.command() -@pass_dev +@pass_dev_or_child async def sysinfo(dev): """Print out full system information.""" echo("== System info ==") @@ -624,6 +731,7 @@ def _echo_all_features(features, *, verbose=False, title_prefix=None, indent="") """Print out all features by category.""" if title_prefix is not None: echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]") + echo() _echo_features( features, title="== Primary features ==", @@ -658,7 +766,7 @@ def _echo_all_features(features, *, verbose=False, title_prefix=None, indent="") @cli.command() -@pass_dev +@pass_dev_or_child @click.pass_context async def state(ctx, dev: Device): """Print out device state and versions.""" @@ -676,11 +784,16 @@ async def state(ctx, dev: Device): if verbose: echo(f"Location: {dev.location}") - _echo_all_features(dev.features, verbose=verbose) echo() + _echo_all_features(dev.features, verbose=verbose) + + if verbose: + echo("\n[bold]== Modules ==[/bold]") + for module in dev.modules.values(): + echo(f"[green]+ {module}[/green]") if dev.children: - echo("[bold]== Children ==[/bold]") + echo("\n[bold]== Children ==[/bold]") for child in dev.children: _echo_all_features( child.features, @@ -688,14 +801,13 @@ async def state(ctx, dev: Device): verbose=verbose, indent="\t", ) - + if verbose: + echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]") + for module in child.modules.values(): + echo(f"\t[green]+ {module}[/green]") echo() if verbose: - echo("\n\t[bold]== Modules ==[/bold]") - for module in dev.modules.values(): - echo(f"\t[green]+ {module}[/green]") - echo("\n\t[bold]== Protocol information ==[/bold]") echo(f"\tCredentials hash: {dev.credentials_hash}") echo() @@ -705,24 +817,19 @@ async def state(ctx, dev: Device): @cli.command() -@pass_dev @click.argument("new_alias", required=False, default=None) -@click.option("--index", type=int) -async def alias(dev, new_alias, index): +@pass_dev_or_child +async def alias(dev, new_alias): """Get or set the device (or plug) alias.""" - if index is not None: - if not dev.is_strip: - echo("Index can only used for power strips!") - return - dev = dev.get_plug_by_index(index) - if new_alias is not None: echo(f"Setting alias to {new_alias}") res = await dev.set_alias(new_alias) + await dev.update() + echo(f"Alias set to: {dev.alias}") return res echo(f"Alias: {dev.alias}") - if dev.is_strip: + if dev.children: for plug in dev.children: echo(f" * {plug.alias}") @@ -730,36 +837,26 @@ async def alias(dev, new_alias, index): @cli.command() -@pass_dev @click.pass_context @click.argument("module") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def raw_command(ctx, dev: Device, module, command, parameters): +async def raw_command(ctx, module, command, parameters): """Run a raw command on the device.""" logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command) return await ctx.forward(cmd_command) @cli.command(name="command") -@pass_dev @click.option("--module", required=False, help="Module for IOT protocol.") -@click.option("--child", required=False, help="Child ID for controlling sub-devices") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def cmd_command(dev: Device, module, child, command, parameters): +@pass_dev_or_child +async def cmd_command(dev: Device, module, command, parameters): """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) - if child: - # The way child devices are accessed requires a ChildDevice to - # wrap the communications. Doing this properly would require creating - # a common interfaces for both IOT and SMART child devices. - # As a stop-gap solution, we perform an update instead. - await dev.update() - dev = dev.get_child_device(child) - if isinstance(dev, IotDevice): res = await dev._query_helper(module, command, parameters) elif isinstance(dev, SmartDevice): @@ -771,27 +868,30 @@ async def cmd_command(dev: Device, module, child, command, parameters): @cli.command() -@pass_dev @click.option("--index", type=int, required=False) @click.option("--name", type=str, required=False) @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--erase", is_flag=True) -async def emeter(dev: Device, index: int, name: str, year, month, erase): - """Query emeter for historical consumption. +@click.pass_context +async def emeter(ctx: click.Context, index, name, year, month, erase): + """Query emeter for historical consumption.""" + logging.warning("Deprecated, use 'kasa energy'") + return await ctx.invoke( + energy, child_index=index, child=name, year=year, month=month, erase=erase + ) - Daily and monthly data provided in CSV format. - """ - if index is not None or name is not None: - if not dev.is_strip: - error("Index and name are only for power strips!") - return - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) +@cli.command() +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@pass_dev_or_child +async def energy(dev: Device, year, month, erase): + """Query energy module for historical consumption. + Daily and monthly data provided in CSV format. + """ echo("[bold]== Emeter ==[/bold]") if not dev.has_emeter: error("Device has no emeter") @@ -817,7 +917,7 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) else: # Call with no argument outputs summary data and returns - if index is not None or name is not None: + if isinstance(dev, IotStripPlug): emeter_status = await dev.get_emeter_realtime() else: emeter_status = dev.emeter_realtime @@ -840,10 +940,10 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase): @cli.command() -@pass_dev @click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) @click.option("--erase", is_flag=True) +@pass_dev_or_child async def usage(dev: Device, year, month, erase): """Query usage for historical consumption. @@ -881,7 +981,7 @@ async def usage(dev: Device, year, month, erase): @cli.command() @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) -@pass_dev +@pass_dev_or_child 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: @@ -901,7 +1001,7 @@ async def brightness(dev: Device, brightness: int, transition: int): "temperature", type=click.IntRange(2500, 9000), default=None, required=False ) @click.option("--transition", type=int, required=False) -@pass_dev +@pass_dev_or_child 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: @@ -927,7 +1027,7 @@ async def temperature(dev: Device, temperature: int, transition: int): @cli.command() @click.argument("effect", type=click.STRING, default=None, required=False) @click.pass_context -@pass_dev +@pass_dev_or_child async def effect(dev: Device, ctx, effect): """Set an effect.""" if not (light_effect := dev.modules.get(Module.LightEffect)): @@ -955,7 +1055,7 @@ async def effect(dev: Device, ctx, effect): @click.argument("v", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @click.pass_context -@pass_dev +@pass_dev_or_child 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: @@ -974,7 +1074,7 @@ async def hsv(dev: Device, ctx, h, s, v, transition): @cli.command() @click.argument("state", type=bool, required=False) -@pass_dev +@pass_dev_or_child async def led(dev: Device, state): """Get or set (Plug's) led state.""" if not (led := dev.modules.get(Module.Led)): @@ -1026,64 +1126,28 @@ async def time_sync(dev: Device): @cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) -@pass_dev -async def on(dev: Device, index: int, name: str, transition: int): +@pass_dev_or_child +async def on(dev: Device, transition: int): """Turn the device on.""" - if index is not None or name is not None: - if not dev.children: - error("Index and name are only for devices with children.") - return - - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) - echo(f"Turning on {dev.alias}") return await dev.turn_on(transition=transition) -@cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) +@cli.command @click.option("--transition", type=int, required=False) -@pass_dev -async def off(dev: Device, index: int, name: str, transition: int): +@pass_dev_or_child +async def off(dev: Device, transition: int): """Turn the device off.""" - if index is not None or name is not None: - if not dev.children: - error("Index and name are only for devices with children.") - return - - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) - echo(f"Turning off {dev.alias}") return await dev.turn_off(transition=transition) @cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) @click.option("--transition", type=int, required=False) -@pass_dev -async def toggle(dev: Device, index: int, name: str, transition: int): +@pass_dev_or_child +async def toggle(dev: Device, transition: int): """Toggle the device on/off.""" - if index is not None or name is not None: - if not dev.children: - error("Index and name are only for devices with children.") - return - - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) - if dev.is_on: echo(f"Turning off {dev.alias}") return await dev.turn_off(transition=transition) @@ -1108,9 +1172,9 @@ async def schedule(dev): @schedule.command(name="list") -@pass_dev +@pass_dev_or_child @click.argument("type", default="schedule") -def _schedule_list(dev, type): +async def _schedule_list(dev, type): """Return the list of schedule actions for the given type.""" sched = dev.modules[type] for rule in sched.rules: @@ -1122,7 +1186,7 @@ def _schedule_list(dev, type): @schedule.command(name="delete") -@pass_dev +@pass_dev_or_child @click.option("--id", type=str, required=True) async def delete_rule(dev, id): """Delete rule from device.""" @@ -1136,25 +1200,26 @@ async def delete_rule(dev, id): @cli.group(invoke_without_command=True) +@pass_dev_or_child @click.pass_context -async def presets(ctx): +async def presets(ctx, dev): """List and modify bulb setting presets.""" if ctx.invoked_subcommand is None: return await ctx.invoke(presets_list) @presets.command(name="list") -@pass_dev +@pass_dev_or_child def presets_list(dev: Device): """List presets.""" - if not dev.is_bulb or not isinstance(dev, IotBulb): - error("Presets only supported on iot bulbs") + if not (light_preset := dev.modules.get(Module.LightPreset)): + error("Presets not supported on device") return - for preset in dev.presets: + for preset in light_preset.preset_states_list: echo(preset) - return dev.presets + return light_preset.preset_states_list @presets.command(name="modify") @@ -1163,7 +1228,7 @@ def presets_list(dev: Device): @click.option("--hue", type=int) @click.option("--saturation", type=int) @click.option("--temperature", type=int) -@pass_dev +@pass_dev_or_child async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): """Modify a preset.""" for preset in dev.presets: @@ -1188,7 +1253,7 @@ async def presets_modify(dev: Device, index, brightness, hue, saturation, temper @cli.command() -@pass_dev +@pass_dev_or_child @click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) @click.option("--last", is_flag=True) @click.option("--preset", type=int) @@ -1240,7 +1305,7 @@ async def update_credentials(dev, username, password): @cli.command() -@pass_dev +@pass_dev_or_child async def shell(dev: Device): """Open interactive shell.""" echo("Opening shell for %s" % dev) @@ -1263,10 +1328,14 @@ async def shell(dev: Device): @cli.command(name="feature") @click.argument("name", required=False) @click.argument("value", required=False) -@click.option("--child", required=False) -@pass_dev +@pass_dev_or_child @click.pass_context -async def feature(ctx: click.Context, dev: Device, child: str, name: str, value): +async def feature( + ctx: click.Context, + dev: Device, + name: str, + value, +): """Access and modify features. If no *name* is given, lists available features and their values. @@ -1275,9 +1344,6 @@ async def feature(ctx: click.Context, dev: Device, child: str, name: str, value) """ verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False - if child is not None: - echo(f"Targeting child device {child}") - dev = dev.get_child_device(child) if not name: _echo_all_features(dev.features, verbose=verbose, indent="") diff --git a/kasa/device.py b/kasa/device.py index ac23fdb24..69b7370b0 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -338,9 +338,15 @@ def children(self) -> Sequence[Device]: """Returns the child devices.""" return list(self._children.values()) - def get_child_device(self, id_: str) -> Device: - """Return child device by its ID.""" - return self._children[id_] + def get_child_device(self, name_or_id: str) -> Device | None: + """Return child device by its device_id or alias.""" + if name_or_id in self._children: + return self._children[name_or_id] + name_lower = name_or_id.lower() + for child in self.children: + if child.alias and child.alias.lower() == name_lower: + return child + return None @property @abstractmethod diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 3a1406aa6..61017228d 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -145,7 +145,7 @@ async def update(self, update_children: bool = True): if update_children: for plug in self.children: - await plug.update() + await plug._update() if not self.features: await self._initialize_features() @@ -362,6 +362,14 @@ async def update(self, update_children: bool = True): Needed for properties that are decorated with `requires_update`. """ + await self._update(update_children) + + async def _update(self, update_children: bool = True): + """Query the device to update the data. + + Internal implementation to allow patching of public update in the cli + or test framework. + """ await self._modular_update({}) for module in self._modules.values(): module._post_update_hook() diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index c6596b969..98145f6c9 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -40,6 +40,14 @@ async def update(self, update_children: bool = True): The parent updates our internal info so just update modules with their own queries. """ + await self._update(update_children) + + async def _update(self, update_children: bool = True): + """Update child module info. + + Internal implementation to allow patching of public update in the cli + or test framework. + """ req: dict[str, Any] = {} for module in self.modules.values(): if mod_query := module.query(): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index a5b64e527..408ba0278 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -171,7 +171,7 @@ async def update(self, update_children: bool = False): # devices will always update children to prevent errors on module access. if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): - await child.update() + await child._update() if child_info := self._try_get_response(resp, "get_child_device_list", {}): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 4f8157025..06a7d37ae 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -5,6 +5,7 @@ import asyncclick as click import pytest from asyncclick.testing import CliRunner +from pytest_mock import MockerFixture from kasa import ( AuthenticationError, @@ -24,6 +25,7 @@ cmd_command, effect, emeter, + energy, hsv, led, raw_command, @@ -62,7 +64,6 @@ def runner(): [ pytest.param(None, None, id="No connect params"), pytest.param("SMART.TAPOPLUG", None, id="Only device_family"), - pytest.param(None, "KLAP", id="Only encrypt_type"), ], ) async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_type): @@ -171,13 +172,16 @@ async def test_command_with_child(dev, mocker, runner): class DummyDevice(dev.__class__): def __init__(self): super().__init__("127.0.0.1") + # device_type and _info initialised for repr + self._device_type = Device.Type.StripSocket + self._info = {} async def _query_helper(*_, **__): return {"dummy": "response"} dummy_child = DummyDevice() - mocker.patch.object(dev, "_children", {"XYZ": dummy_child}) + mocker.patch.object(dev, "_children", {"XYZ": [dummy_child]}) mocker.patch.object(dev, "get_child_device", return_value=dummy_child) res = await runner.invoke( @@ -314,9 +318,9 @@ async def test_emeter(dev: Device, mocker, runner): if not dev.is_strip: res = await runner.invoke(emeter, ["--index", "0"], obj=dev) - assert "Index and name are only for power strips!" in res.output + assert f"Device: {dev.host} does not have children" in res.output res = await runner.invoke(emeter, ["--name", "mock"], obj=dev) - assert "Index and name are only for power strips!" in res.output + assert f"Device: {dev.host} does not have children" in res.output if dev.is_strip and len(dev.children) > 0: realtime_emeter = mocker.patch.object(dev.children[0], "get_emeter_realtime") @@ -930,3 +934,110 @@ async def test_feature_set_child(mocker, runner): assert f"Targeting child device {child_id}" assert "Changing state from False to True" in res.output assert res.exit_code == 0 + + +async def test_cli_child_commands( + dev: Device, runner: CliRunner, mocker: MockerFixture +): + if not dev.children: + res = await runner.invoke(alias, ["--child-index", "0"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--index", "0"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--child", "Plug 2"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--name", "Plug 2"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + if dev.children: + child_alias = dev.children[0].alias + assert child_alias + child_device_id = dev.children[0].device_id + child_count = len(dev.children) + child_update_method = dev.children[0].update + + # Test child retrieval + res = await runner.invoke(alias, ["--child-index", "0"], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--index", "0"], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--child", child_alias], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--name", child_alias], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--child", child_device_id], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--name", child_device_id], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + # Test invalid name and index + res = await runner.invoke(alias, ["--child-index", "-1"], obj=dev) + assert f"Invalid index -1, device has {child_count} children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--child-index", str(child_count)], obj=dev) + assert ( + f"Invalid index {child_count}, device has {child_count} children" + in res.output + ) + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--child", "foobar"], obj=dev) + assert "No child device found with device_id or name: foobar" in res.output + assert res.exit_code == 1 + + # Test using both options: + + res = await runner.invoke( + alias, ["--child", child_alias, "--child-index", "0"], obj=dev + ) + assert "Use either --child or --child-index, not both." in res.output + assert res.exit_code == 2 + + # Test child with no parameter interactive prompt + + res = await runner.invoke(alias, ["--child"], obj=dev, input="0\n") + assert "Enter the index number of the child device:" in res.output + assert f"Alias: {child_alias}" in res.output + assert res.exit_code == 0 + + # Test values and updates + + res = await runner.invoke(alias, ["foo", "--child", child_device_id], obj=dev) + assert "Alias set to: foo" in res.output + assert res.exit_code == 0 + + # Test help has command options plus child options + + res = await runner.invoke(energy, ["--help"], obj=dev) + assert "--year" in res.output + assert "--child" in res.output + assert "--child-index" in res.output + assert res.exit_code == 0 + + # Test child update patching calls parent and is undone on exit + + parent_update_spy = mocker.spy(dev, "update") + res = await runner.invoke(alias, ["bar", "--child", child_device_id], obj=dev) + assert "Alias set to: bar" in res.output + assert res.exit_code == 0 + parent_update_spy.assert_called_once() + assert dev.children[0].update == child_update_method From 905a14895d6111a7a0b50b773bbbd89ca955e5d4 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 08:02:50 +0100 Subject: [PATCH 05/38] Handle module errors more robustly and add query params to light preset and transition (#1036) Ensures that all modules try to access their data in `_post_update_hook` in a safe manner and disable themselves if there's an error. Also adds parameters to get_preset_rules and get_on_off_gradually_info to fix issues with recent firmware updates. [#1033](https://github.com/python-kasa/python-kasa/issues/1033) --- devtools/helpers/smartrequests.py | 11 ++- kasa/smart/modules/autooff.py | 6 -- kasa/smart/modules/batterysensor.py | 4 ++ kasa/smart/modules/cloud.py | 10 ++- kasa/smart/modules/devicemodule.py | 7 ++ kasa/smart/modules/firmware.py | 12 +++- kasa/smart/modules/frostprotection.py | 4 ++ kasa/smart/modules/humiditysensor.py | 4 ++ kasa/smart/modules/lightpreset.py | 2 +- kasa/smart/modules/lighttransition.py | 2 +- kasa/smart/modules/reportmode.py | 4 ++ kasa/smart/modules/temperaturesensor.py | 4 ++ kasa/smart/smartdevice.py | 30 ++++++-- kasa/smart/smartmodule.py | 22 +++++- kasa/smartprotocol.py | 4 ++ kasa/tests/test_smartdevice.py | 94 ++++++++++++++++++++++--- kasa/tests/test_smartprotocol.py | 16 +++++ 17 files changed, 206 insertions(+), 30 deletions(-) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 881488b5e..4db1f7a1c 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -284,6 +284,15 @@ def get_preset_rules(params: GetRulesParams | None = None) -> SmartRequest: """Get preset rules.""" return SmartRequest("get_preset_rules", params or SmartRequest.GetRulesParams()) + @staticmethod + def get_on_off_gradually_info( + params: SmartRequestParams | None = None, + ) -> SmartRequest: + """Get preset rules.""" + return SmartRequest( + "get_on_off_gradually_info", params or SmartRequest.SmartRequestParams() + ) + @staticmethod def get_auto_light_info() -> SmartRequest: """Get auto light info.""" @@ -382,7 +391,7 @@ def get_component_requests(component_id, ver_code): "auto_light": [SmartRequest.get_auto_light_info()], "light_effect": [SmartRequest.get_dynamic_light_effect_rules()], "bulb_quick_control": [], - "on_off_gradually": [SmartRequest.get_raw_request("get_on_off_gradually_info")], + "on_off_gradually": [SmartRequest.get_on_off_gradually_info()], "light_strip": [], "light_strip_lighting_effect": [ SmartRequest.get_raw_request("get_lighting_effect") diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index 0004aec43..5e4b100f8 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -19,12 +19,6 @@ class AutoOff(SmartModule): def _initialize_features(self): """Initialize features after the initial update.""" - if not isinstance(self.data, dict): - _LOGGER.warning( - "No data available for module, skipping %s: %s", self, self.data - ) - return - self._add_feature( Feature( self._device, diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py index 415e47d1e..7ff7df2d8 100644 --- a/kasa/smart/modules/batterysensor.py +++ b/kasa/smart/modules/batterysensor.py @@ -43,6 +43,10 @@ def _initialize_features(self): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def battery(self): """Return battery level.""" diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index 1b64f090a..8346af57a 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING -from ...exceptions import SmartErrorCode from ...feature import Feature from ..smartmodule import SmartModule @@ -18,6 +17,13 @@ class Cloud(SmartModule): QUERY_GETTER_NAME = "get_connect_cloud_state" REQUIRED_COMPONENT = "cloud_connect" + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because the logic here is to treat that as not connected. + """ + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -37,6 +43,6 @@ def __init__(self, device: SmartDevice, module: str): @property def is_connected(self): """Return True if device is connected to the cloud.""" - if isinstance(self.data, SmartErrorCode): + if self._has_data_error(): return False return self.data["status"] == 0 diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 6a846d542..3203e82fa 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -10,6 +10,13 @@ class DeviceModule(SmartModule): REQUIRED_COMPONENT = "device" + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because this module is critical. + """ + def query(self) -> dict: """Query to execute during the update cycle.""" query = { diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 3dcaddd66..10a6b8245 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -13,7 +13,6 @@ from async_timeout import timeout as asyncio_timeout from pydantic.v1 import BaseModel, Field, validator -from ...exceptions import SmartErrorCode from ...feature import Feature from ..smartmodule import SmartModule @@ -123,6 +122,13 @@ def query(self) -> dict: req["get_auto_update_info"] = None return req + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because some of the module still functions. + """ + @property def current_firmware(self) -> str: """Return the current firmware version.""" @@ -136,11 +142,11 @@ def latest_firmware(self) -> str: @property def firmware_update_info(self): """Return latest firmware information.""" - fw = self.data.get("get_latest_fw") or self.data - if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): + if not self._device.is_cloud_connected or self._has_data_error(): # Error in response, probably disconnected from the cloud. return UpdateInfo(type=0, need_to_upgrade=False) + fw = self.data.get("get_latest_fw") or self.data return UpdateInfo.parse_obj(fw) @property diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index f1811012f..440e1ed1b 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -14,6 +14,10 @@ class FrostProtection(SmartModule): REQUIRED_COMPONENT = "frost_protection" QUERY_GETTER_NAME = "get_frost_protection" + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def enabled(self) -> bool: """Return True if frost protection is on.""" diff --git a/kasa/smart/modules/humiditysensor.py b/kasa/smart/modules/humiditysensor.py index f0dcc18a4..b137736ff 100644 --- a/kasa/smart/modules/humiditysensor.py +++ b/kasa/smart/modules/humiditysensor.py @@ -45,6 +45,10 @@ def __init__(self, device: SmartDevice, module: str): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def humidity(self): """Return current humidity in percentage.""" diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 8e5cae209..7635a5f86 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -140,7 +140,7 @@ def query(self) -> dict: """Query to execute during the update cycle.""" if self._state_in_sysinfo: # Child lights can have states in the child info return {} - return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {"start_index": 0}} async def _check_supported(self): """Additional check to see if the module is supported by the device. diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 29a4bb055..ca0eca867 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -230,7 +230,7 @@ def query(self) -> dict: if self._state_in_sysinfo: return {} else: - return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {}} async def _check_supported(self): """Additional check to see if the module is supported by the device.""" diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index 79c8ae621..8d210a5b3 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -32,6 +32,10 @@ def __init__(self, device: SmartDevice, module: str): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def report_interval(self): """Reporting interval of a sensor device.""" diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index d98501508..a61859cdc 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -58,6 +58,10 @@ def __init__(self, device: SmartDevice, module: str): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def temperature(self): """Return current humidity in percentage.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 408ba0278..5bf2400b7 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -177,11 +177,20 @@ async def update(self, update_children: bool = False): self._children[info["device_id"]]._update_internal_state(info) # Call handle update for modules that want to update internal data - for module in self._modules.values(): - module._post_update_hook() + errors = [] + for module_name, module in self._modules.items(): + if not self._handle_module_post_update_hook(module): + errors.append(module_name) + for error in errors: + self._modules.pop(error) + for child in self._children.values(): - for child_module in child._modules.values(): - child_module._post_update_hook() + errors = [] + for child_module_name, child_module in child._modules.items(): + if not self._handle_module_post_update_hook(child_module): + errors.append(child_module_name) + for error in errors: + child._modules.pop(error) # We can first initialize the features after the first update. # We make here an assumption that every device has at least a single feature. @@ -190,6 +199,19 @@ async def update(self, update_children: bool = False): _LOGGER.debug("Got an update: %s", self._last_update) + def _handle_module_post_update_hook(self, module: SmartModule) -> bool: + try: + module._post_update_hook() + return True + except Exception as ex: + _LOGGER.error( + "Error processing %s for device %s, module will be unavailable: %s", + module.name, + self.host, + ex, + ) + return False + async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index e78f43933..fb946a8b3 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING -from ..exceptions import KasaException +from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..module import Module if TYPE_CHECKING: @@ -41,6 +41,14 @@ def name(self) -> str: """Name of the module.""" return getattr(self, "NAME", self.__class__.__name__) + def _post_update_hook(self): # noqa: B027 + """Perform actions after a device update. + + Any modules overriding this should ensure that self.data is + accessed unless the module should remain active despite errors. + """ + assert self.data # noqa: S101 + def query(self) -> dict: """Query to execute during the update cycle. @@ -87,6 +95,11 @@ def data(self): filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys} + for data_item in filtered_data: + if isinstance(filtered_data[data_item], SmartErrorCode): + raise DeviceError( + f"{data_item} for {self.name}", error_code=filtered_data[data_item] + ) if len(filtered_data) == 1: return next(iter(filtered_data.values())) @@ -110,3 +123,10 @@ async def _check_supported(self) -> bool: color_temp_range but only supports one value. """ return True + + def _has_data_error(self) -> bool: + try: + assert self.data # noqa: S101 + return False + except DeviceError: + return True diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index e6741bc47..3085714c4 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -416,6 +416,10 @@ def _get_method_and_params_for_request(self, request): return smart_method, smart_params async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside control_child envelope.""" + return await self._query(request, retry_count) + + async def _query(self, request: str | dict, retry_count: int = 3) -> dict: """Wrap request inside control_child envelope.""" method, params = self._get_method_and_params_for_request(request) request_data = { diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 48475a900..44fabc715 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from unittest.mock import patch import pytest @@ -132,6 +132,78 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): spies[device].assert_not_called() +@device_smart +async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): + """Test that modules that error are disabled / removed.""" + # We need to have some modules initialized by now + assert dev._modules + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + not_disabling_modules = {Module.Firmware, Module.Cloud} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + + module_queries = { + modname: q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + child_module_queries = { + modname: q + for child in dev.children + for modname, module in child._modules.items() + if (q := module.query()) and modname not in critical_modules + } + all_queries_names = { + key for mod_query in module_queries.values() for key in mod_query + } + all_child_queries_names = { + key for mod_query in child_module_queries.values() for key in mod_query + } + + async def _query(request, *args, **kwargs): + responses = await dev.protocol._query(request, *args, **kwargs) + for k in responses: + if k in all_queries_names: + responses[k] = SmartErrorCode.PARAMS_ERROR + return responses + + async def _child_query(self, request, *args, **kwargs): + responses = await child_protocols[self._device_id]._query( + request, *args, **kwargs + ) + for k in responses: + if k in all_child_queries_names: + responses[k] = SmartErrorCode.PARAMS_ERROR + return responses + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + + from kasa.smartprotocol import _ChildProtocolWrapper + + child_protocols = { + cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol + for child in dev.children + } + # children not created yet so cannot patch.object + mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) + + await new_dev.update() + for modname in module_queries: + no_disable = modname in not_disabling_modules + mod_present = modname in new_dev._modules + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + + for modname in child_module_queries: + no_disable = modname in not_disabling_modules + mod_present = any(modname in child._modules for child in new_dev.children) + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + + async def test_get_modules(): """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( @@ -181,6 +253,9 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt assert dev.is_cloud_connected == is_connected last_update = dev._last_update + for child in dev.children: + mocker.patch.object(child.protocol, "query", return_value=child._last_update) + last_update["get_connect_cloud_state"] = {"status": 0} with patch.object(dev.protocol, "query", return_value=last_update): await dev.update() @@ -207,21 +282,18 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt "get_connect_cloud_state": last_update["get_connect_cloud_state"], "get_device_info": last_update["get_device_info"], } - # Child component list is not stored on the device - if "get_child_device_list" in last_update: - child_component_list = await dev.protocol.query( - "get_child_device_component_list" - ) - last_update["get_child_device_component_list"] = child_component_list[ - "get_child_device_component_list" - ] + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) first_call = True - def side_effect_func(*_, **__): + async def side_effect_func(*args, **kwargs): nonlocal first_call - resp = initial_response if first_call else last_update + resp = ( + initial_response + if first_call + else await new_dev.protocol._query(*args, **kwargs) + ) first_call = False return resp diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index d362fd00a..71125ca83 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,6 +1,7 @@ import logging import pytest +import pytest_mock from ..exceptions import ( SMART_RETRYABLE_ERRORS, @@ -19,6 +20,21 @@ ERRORS = [e for e in SmartErrorCode if e != 0] +async def test_smart_queries(dummy_protocol, mocker: pytest_mock.MockerFixture): + mock_response = {"result": {"great": "success"}, "error_code": 0} + + mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) + # test sending a method name as a string + resp = await dummy_protocol.query("foobar") + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + # test sending a method name as a dict + resp = await dummy_protocol.query(DUMMY_QUERY) + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) async def test_smart_device_errors(dummy_protocol, mocker, error_code): mock_response = {"result": {"great": "success"}, "error_code": error_code.value} From fd4d084839c2b33f4e74a157b5fc32eb7b63af6c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:48:18 +0100 Subject: [PATCH 06/38] Add KS225(US) v1.1.0 fixture (#1046) --- SUPPORTED.md | 1 + .../fixtures/smart/KS225(US)_1.0_1.1.0.json | 332 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 08ae8ada3..7a78c1cc7 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -88,6 +88,7 @@ Some newer Kasa devices require authentication. These are marked with *\* + - Hardware: 1.0 (US) / Firmware: 1.1.0\* - **KS230** - Hardware: 1.0 (US) / Firmware: 1.0.14 - **KS240** diff --git a/kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json new file mode 100644 index 000000000..798642d3e --- /dev/null +++ b/kasa/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json @@ -0,0 +1,332 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "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": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "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": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_s500d", + "brightness": 5, + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240411 Rel.150716", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "KS225", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 88, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -48, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1720036002 + }, + "get_device_usage": { + "time_usage": { + "past30": 1371, + "past7": 659, + "today": 58 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.0 Build 240411 Rel.150716", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 350, + "night_mode_type": "sunrise_sunset", + "start_time": 1266, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "get_preset_rules": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 1, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 5, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS225", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} From 88df7f9ba699ee4c85c5ce428a08260bf5103bd1 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:02:47 +0100 Subject: [PATCH 07/38] Add KS200M(US) v1.0.11 fixture (#1047) --- SUPPORTED.md | 1 + .../tests/fixtures/KS200M(US)_1.0_1.0.11.json | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 7a78c1cc7..ef57c80b5 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -81,6 +81,7 @@ Some newer Kasa devices require authentication. These are marked with *\* diff --git a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json new file mode 100644 index 000000000..3eb480c3a --- /dev/null +++ b/kasa/tests/fixtures/KS200M(US)_1.0_1.0.11.json @@ -0,0 +1,96 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 0, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 222, + "name": "twilight", + "value": 9 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 60000, + "enable": 0, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 1, + "version": "1.0" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Light Switch with PIR", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "3C:52:A1:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200M(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -40, + "status": "new", + "sw_ver": "1.0.11 Build 230113 Rel.151038", + "updating": 0 + } + } +} From 7427a885700b62ffb935a0f3f84947bfc97d139d Mon Sep 17 00:00:00 2001 From: gimpy88 <64541114+gimpy88@users.noreply.github.com> Date: Thu, 4 Jul 2024 07:21:03 -0400 Subject: [PATCH 08/38] Add KP400 v1.0.3 fixture (#1037) --- SUPPORTED.md | 1 + kasa/tests/fixtures/KP400(US)_3.0_1.0.3.json | 45 ++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 kasa/tests/fixtures/KP400(US)_3.0_1.0.3.json diff --git a/SUPPORTED.md b/SUPPORTED.md index ef57c80b5..e3ffcd1ec 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -65,6 +65,7 @@ Some newer Kasa devices require authentication. These are marked with * Date: Thu, 4 Jul 2024 13:52:01 +0100 Subject: [PATCH 09/38] Structure cli into a package (#1038) PR with just the initial structural changes for the cli to be a package. Subsequent PR will break out `main.py` into modules. Doing it in two stages ensure that the commit history will be continuous for `cli.py` > `cli/main.py` --- devtools/parse_pcap.py | 2 +- kasa/cli/__init__.py | 1 + kasa/cli/__main__.py | 5 +++++ kasa/{cli.py => cli/main.py} | 0 kasa/tests/test_cli.py | 6 +++--- pyproject.toml | 2 +- 6 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 kasa/cli/__init__.py create mode 100644 kasa/cli/__main__.py rename kasa/{cli.py => cli/main.py} (100%) diff --git a/devtools/parse_pcap.py b/devtools/parse_pcap.py index 7a55bf545..02d3911c5 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -8,7 +8,7 @@ import dpkt from dpkt.ethernet import ETH_TYPE_IP, Ethernet -from kasa.cli import echo +from kasa.cli.main import echo from kasa.xortransport import XorEncryption diff --git a/kasa/cli/__init__.py b/kasa/cli/__init__.py new file mode 100644 index 000000000..1d4991659 --- /dev/null +++ b/kasa/cli/__init__.py @@ -0,0 +1 @@ +"""Package for the cli.""" diff --git a/kasa/cli/__main__.py b/kasa/cli/__main__.py new file mode 100644 index 000000000..5d4ca6a05 --- /dev/null +++ b/kasa/cli/__main__.py @@ -0,0 +1,5 @@ +"""Main module.""" + +from kasa.cli.main import cli + +cli() diff --git a/kasa/cli.py b/kasa/cli/main.py similarity index 100% rename from kasa/cli.py rename to kasa/cli/main.py diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 06a7d37ae..e6b96cd73 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -17,7 +17,7 @@ Module, UnsupportedDeviceError, ) -from kasa.cli import ( +from kasa.cli.main import ( TYPE_TO_CLASS, alias, brightness, @@ -500,7 +500,7 @@ async def _state(dev: Device): f"Username:{dev.credentials.username} Password:{dev.credentials.password}" ) - mocker.patch("kasa.cli.state", new=_state) + mocker.patch("kasa.cli.main.state", new=_state) dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) res = await runner.invoke( @@ -746,7 +746,7 @@ async def _state(dev: Device): nonlocal result_device result_device = dev - mocker.patch("kasa.cli.state", new=_state) + mocker.patch("kasa.cli.main.state", new=_state) expected_type = TYPE_TO_CLASS[device_type] mocker.patch.object(expected_type, "update") res = await runner.invoke( diff --git a/pyproject.toml b/pyproject.toml index 45350aefd..bfa044774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ include = [ "Documentation" = "https://python-kasa.readthedocs.io" [tool.poetry.scripts] -kasa = "kasa.cli:cli" +kasa = "kasa.cli:__main__" [tool.poetry.dependencies] python = "^3.9" From 7888f4904a09765ee94f329857da5cd1a3987caa Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:22:47 +0100 Subject: [PATCH 10/38] Fix light preset module when list contains lighting effects (#1048) Fixes the residual issues with the light preset module not handling unexpected `lighting_effect` items in the presets list. Completes the fixes started with PR https://github.com/python-kasa/python-kasa/pull/1043 to fix https://github.com/python-kasa/python-kasa/issues/1040, [HA #121115](https://github.com/home-assistant/core/issues/121115) and [HA #121119](https://github.com/home-assistant/core/issues/121119) With this PR affected devices will no longer have the light preset functionality disabled. As this is a new feature this does not warrant a hotfix so will go into the next release. Updated fixture for testing thanks to @szssamuel, many thanks! --- kasa/smart/modules/lightpreset.py | 11 + .../fixtures/smart/L920-5(EU)_1.0_1.1.3.json | 863 +++++++++++++++--- 2 files changed, 767 insertions(+), 107 deletions(-) diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 7635a5f86..589060412 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Sequence from dataclasses import asdict from typing import TYPE_CHECKING @@ -13,6 +14,8 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + class LightPreset(SmartModule, LightPresetInterface): """Implementation of light presets.""" @@ -38,6 +41,14 @@ def _post_update_hook(self): state_key = "states" if not self._state_in_sysinfo else self.SYS_INFO_STATE_KEY if preset_states := self.data.get(state_key): for preset_state in preset_states: + if "brightness" not in preset_state: + # Some devices can store effects as a preset. These will be ignored + # and handled in the effects module + if "lighting_effect" not in preset_state: + _LOGGER.info( + "Unexpected keys %s in preset", list(preset_state.keys()) + ) + continue color_temp = preset_state.get("color_temp") hue = preset_state.get("hue") saturation = preset_state.get("saturation") diff --git a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json index 0e7679e2b..5f03b5b64 100644 --- a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json +++ b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json @@ -122,7 +122,7 @@ "factory_default": false, "ip": "127.0.0.123", "is_support_iot_cloud": true, - "mac": "1C-61-B4-00-00-00", + "mac": "B4-B0-24-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "KLAP", "http_port": 80, @@ -138,12 +138,12 @@ "rule_list": [] }, "get_auto_update_info": { - "enable": false, + "enable": true, "random_range": 120, "time": 180 }, "get_connect_cloud_state": { - "status": 1 + "status": 0 }, "get_countdown_rules": { "countdown_rule_max_count": 1, @@ -152,7 +152,7 @@ }, "get_device_info": { "avatar": "light_strip", - "brightness": 65, + "brightness": 100, "color_temp": 0, "color_temp_range": [ 9000, @@ -160,10 +160,10 @@ ], "default_states": { "state": { - "brightness": 65, + "brightness": 100, "color_temp": 0, - "hue": 9, - "saturation": 67 + "hue": 30, + "saturation": 0 }, "type": "last_states" }, @@ -171,78 +171,85 @@ "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.1.3 Build 231229 Rel.164316", - "has_set_location_info": false, - "hue": 9, + "has_set_location_info": true, + "hue": 30, "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", "ip": "127.0.0.123", - "lang": "de_DE", + "lang": "en_US", + "latitude": 0, "lighting_effect": { - "brightness": 65, + "brightness": 100, "custom": 0, "display_colors": [ [ - 136, - 98, + 30, + 0, + 100 + ], + [ + 30, + 95, 100 ], [ - 350, - 97, + 0, + 100, 100 ] ], "enable": 0, - "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", - "name": "Christmas" + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise" }, - "mac": "1C-61-B4-00-00-00", + "longitude": 0, + "mac": "B4-B0-24-00-00-00", "model": "L920", "music_rhythm_enable": false, "music_rhythm_mode": "single_lamp", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "overheated": false, - "region": "Europe/Berlin", - "rssi": -56, - "saturation": 67, + "region": "Europe/Bucharest", + "rssi": -57, + "saturation": 0, "segment_effect": { "brightness": 97, "custom": 0, "display_colors": [], "enable": 0, "id": "", - "name": "Lightning" + "name": "Warm Aurora" }, "signal_level": 2, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", - "time_diff": 60, + "time_diff": 120, "type": "SMART.TAPOBULB" }, "get_device_segment": { "segment": 50 }, "get_device_time": { - "region": "Europe/Berlin", - "time_diff": 60, - "timestamp": 1719920893 + "region": "Europe/Bucharest", + "time_diff": 120, + "timestamp": 1720089009 }, "get_device_usage": { "power_usage": { - "past30": 20, - "past7": 20, - "today": 0 + "past30": 1211, + "past7": 183, + "today": 7 }, "saved_power": { - "past30": 319, - "past7": 319, - "today": 0 + "past30": 6124, + "past7": 1204, + "today": 30 }, "time_usage": { - "past30": 339, - "past7": 339, - "today": 0 + "past30": 7335, + "past7": 1387, + "today": 37 } }, "get_fw_download_state": { @@ -253,74 +260,133 @@ "upgrade_time": 5 }, "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.3 Build 231229 Rel.164316", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, "get_lighting_effect": { - "backgrounds": [ - [ - 136, - 98, - 75 - ], - [ - 136, - 0, - 0 - ], + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ [ - 350, + 30, 0, 100 ], [ - 350, - 97, - 94 - ] - ], - "brightness": 65, - "brightness_range": [ - 50, - 100 - ], - "custom": 0, - "display_colors": [ - [ - 136, - 98, + 30, + 95, 100 ], [ - 350, - 97, + 0, + 100, 100 ] ], - "duration": 5000, + "duration": 600, "enable": 0, - "expansion_strategy": 1, - "fadeoff": 2000, - "hue_range": [ - 136, - 146 + "expansion_strategy": 2, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 ], - "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", - "init_states": [ + "sequence": [ [ - 136, + 0, + 100, + 5 + ], + [ + 0, + 100, + 5 + ], + [ + 10, + 100, + 6 + ], + [ + 15, + 100, + 7 + ], + [ + 20, + 100, + 8 + ], + [ + 20, + 100, + 10 + ], + [ + 30, + 100, + 12 + ], + [ + 30, + 95, + 15 + ], + [ + 30, + 90, + 20 + ], + [ + 30, + 80, + 25 + ], + [ + 30, + 75, + 30 + ], + [ + 30, + 70, + 40 + ], + [ + 30, + 60, + 50 + ], + [ + 30, + 50, + 60 + ], + [ + 30, + 20, + 70 + ], + [ + 30, 0, 100 ] ], - "name": "Christmas", - "random_seed": 100, - "saturation_range": [ - 90, - 100 - ], - "segments": [ - 0 - ], - "transition": 0, - "type": "random" + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" }, "get_next_event": {}, "get_on_off_gradually_info": { @@ -336,39 +402,438 @@ "saturation": 100 }, { - "brightness": 100, - "color_temp": 0, - "hue": 240, - "saturation": 100 + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 100, + 100 + ] + ], + "duration": 600, + "enable": 1, + "expansion_strategy": 2, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 0, + 100, + 5 + ], + [ + 0, + 100, + 5 + ], + [ + 10, + 100, + 6 + ], + [ + 15, + 100, + 7 + ], + [ + 20, + 100, + 8 + ], + [ + 20, + 100, + 10 + ], + [ + 30, + 100, + 12 + ], + [ + 30, + 95, + 15 + ], + [ + 30, + 90, + 20 + ], + [ + 30, + 80, + 25 + ], + [ + 30, + 75, + 30 + ], + [ + 30, + 70, + 40 + ], + [ + 30, + 60, + 50 + ], + [ + 30, + 50, + 60 + ], + [ + 30, + 20, + 70 + ], + [ + 30, + 0, + 100 + ] + ], + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" + } }, { - "brightness": 100, - "color_temp": 0, - "hue": 0, - "saturation": 100 + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 30, + 0, + 100 + ] + ], + "duration": 600, + "enable": 1, + "expansion_strategy": 2, + "id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL", + "name": "Sunset", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 20, + 100 + ], + [ + 30, + 50, + 99 + ], + [ + 30, + 60, + 98 + ], + [ + 30, + 70, + 97 + ], + [ + 30, + 75, + 95 + ], + [ + 30, + 80, + 93 + ], + [ + 30, + 90, + 90 + ], + [ + 30, + 95, + 85 + ], + [ + 30, + 100, + 80 + ], + [ + 20, + 100, + 70 + ], + [ + 20, + 100, + 60 + ], + [ + 15, + 100, + 50 + ], + [ + 10, + 100, + 40 + ], + [ + 0, + 100, + 30 + ], + [ + 0, + 100, + 0 + ] + ], + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" + } }, { - "brightness": 100, - "color_temp": 0, - "hue": 120, - "saturation": 100 + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 100, + 100, + 100 + ], + [ + 200, + 100, + 100 + ], + [ + 300, + 100, + 100 + ] + ], + "duration": 0, + "enable": 1, + "expansion_strategy": 1, + "id": "TapoStrip_7CC5y4lsL8pETYvmz7UOpQ", + "name": "Rainbow", + "repeat_times": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 0, + 100, + 100 + ], + [ + 100, + 100, + 100 + ], + [ + 200, + 100, + 100 + ], + [ + 300, + 100, + 100 + ] + ], + "spread": 12, + "transition": 1500, + "type": "sequence" + } }, { - "brightness": 100, - "color_temp": 0, - "hue": 277, - "saturation": 86 + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 4, + "display_colors": [ + [ + 120, + 100, + 100 + ], + [ + 240, + 100, + 100 + ], + [ + 260, + 100, + 100 + ], + [ + 280, + 100, + 100 + ] + ], + "duration": 0, + "enable": 1, + "expansion_strategy": 1, + "id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP", + "name": "Aurora", + "repeat_times": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 120, + 100, + 100 + ], + [ + 240, + 100, + 100 + ], + [ + 260, + 100, + 100 + ], + [ + 280, + 100, + 100 + ] + ], + "spread": 7, + "transition": 1500, + "type": "sequence" + } }, { - "brightness": 100, - "color_temp": 0, - "hue": 60, - "saturation": 100 + "lighting_effect": { + "brightness": 100, + "custom": 1, + "direction": 4, + "display_colors": [ + [ + 103, + 100, + 100 + ], + [ + 73, + 100, + 100 + ], + [ + 16, + 100, + 100 + ], + [ + 44, + 100, + 100 + ] + ], + "duration": 0, + "enable": 1, + "expansion_strategy": 1, + "id": "TapoStrip_639hjRuGECd1gsSbFAINNn", + "name": "Warm Aurora", + "repeat_times": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 103, + 100, + 100 + ], + [ + 73, + 100, + 100 + ], + [ + 16, + 100, + 100 + ], + [ + 44, + 100, + 100 + ] + ], + "spread": 7, + "transition": 5000, + "type": "sequence" + } }, { "brightness": 100, "color_temp": 0, - "hue": 300, + "hue": 0, "saturation": 100 } ], @@ -387,7 +852,7 @@ "display_colors": [], "enable": 0, "id": "", - "name": "Lightning" + "name": "Warm Aurora" }, "get_wireless_scan_info": { "ap_list": [ @@ -398,10 +863,194 @@ "key_type": "wpa2_psk", "signal_level": 2, "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" } ], "start_index": 0, - "sum": 1, + "sum": 24, "wep_supported": false }, "qs_component_nego": { From 6e0bbd872011fca2f5802e529a1707426179036c Mon Sep 17 00:00:00 2001 From: gimpy88 <64541114+gimpy88@users.noreply.github.com> Date: Sun, 7 Jul 2024 04:16:07 -0400 Subject: [PATCH 11/38] Add KS205(US) v1.1.0 fixture (#1049) --- SUPPORTED.md | 1 + .../fixtures/smart/KS205(US)_1.0_1.1.0.json | 298 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index e3ffcd1ec..e2db7bc7e 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -86,6 +86,7 @@ Some newer Kasa devices require authentication. These are marked with *\* + - Hardware: 1.0 (US) / Firmware: 1.1.0\* - **KS220M** - Hardware: 1.0 (US) / Firmware: 1.0.4 - **KS225** diff --git a/kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json new file mode 100644 index 000000000..f9ac5af95 --- /dev/null +++ b/kasa/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json @@ -0,0 +1,298 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "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": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS205(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "40-ED-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "switch_s500", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240411 Rel.144632", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "40-ED-00-00-00-00", + "model": "KS205", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -57, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1720146765 + }, + "get_device_usage": { + "time_usage": { + "past30": 10601, + "past7": 966, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.0 Build 240411 Rel.144632", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 351, + "night_mode_type": "sunrise_sunset", + "start_time": 1266, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000-0000.00.000" + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "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==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 1, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 6, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS205", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} From 4b77db31d020c71adc6cf566c5745d58a3dd5469 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sun, 7 Jul 2024 13:22:43 +0100 Subject: [PATCH 12/38] Add new HS220 kasa aes fixture (#1050) Many thanks to @pjarbit for making the device available for a fixture! --- README.md | 2 +- SUPPORTED.md | 1 + kasa/tests/device_fixtures.py | 2 +- .../fixtures/smart/HS220(US)_3.26_1.0.1.json | 371 ++++++++++++++++++ 4 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json diff --git a/README.md b/README.md index 2dfde360f..fcc28190d 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 - **Power Strips**: EP40, HS107, HS300, KP200, KP303, KP400 -- **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230, KS240\* +- **Wall Switches**: ES20M, HS200, HS210, HS220\*\*, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230, KS240\* - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 - **Hubs**: KH100\* diff --git a/SUPPORTED.md b/SUPPORTED.md index e2db7bc7e..40330d0a2 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -79,6 +79,7 @@ Some newer Kasa devices require authentication. These are marked with *\* - **KP405** - Hardware: 1.0 (US) / Firmware: 1.0.5 - **KS200M** diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 0d6fbd488..1eb3e829b 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -110,7 +110,7 @@ STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {"KS225", "S500D", "P135"} +DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} DIMMERS = { *DIMMERS_IOT, *DIMMERS_SMART, diff --git a/kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json b/kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json new file mode 100644 index 000000000..63ec680b4 --- /dev/null +++ b/kasa/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json @@ -0,0 +1,371 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "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": "led", + "ver_code": 3 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "smart_switch", + "ver_code": 1 + }, + { + "id": "dimmer_custom_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "owner": "00000000000000000000000000000000", + "device_type": "SMART.KASASWITCH", + "device_model": "HS220(US)", + "ip": "127.0.0.123", + "mac": "24-2F-D0-00-00-00", + "is_support_iot_cloud": true, + "obd_src": "tplink", + "factory_default": false, + "mgt_encrypt_schm": { + "is_support_https": false, + "encrypt_type": "AES", + "http_port": 80, + "lv": 2 + } + }, + "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": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "table_lamp_5", + "brightness": 51, + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.1 Build 230829 Rel.160220", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.26", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "24-2F-D0-00-00-00", + "model": "HS220", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Detroit", + "rssi": -62, + "signal_level": 2, + "smart_switch_state": false, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Detroit", + "time_diff": -300, + "timestamp": 1720201570 + }, + "get_device_usage": { + "time_usage": { + "past30": 30, + "past7": 30, + "today": 11 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.1 Build 230829 Rel.160220", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "toggle", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + }, + "on_state": { + "duration": 1, + "enable": true, + "max_duration": 60 + } + }, + "get_preset_rules": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 11, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "HS220", + "device_type": "SMART.KASASWITCH", + "is_klap": false + } + } +} From abb5d0d412dc509c4d497b5e0bd6f496078d2f36 Mon Sep 17 00:00:00 2001 From: gimpy88 <64541114+gimpy88@users.noreply.github.com> Date: Sun, 7 Jul 2024 08:23:24 -0400 Subject: [PATCH 13/38] Add KP400(US) v1.0.4 fixture (#1051) --- SUPPORTED.md | 1 + kasa/tests/fixtures/KP400(US)_3.0_1.0.4.json | 46 ++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 kasa/tests/fixtures/KP400(US)_3.0_1.0.4.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 40330d0a2..1781442a0 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -66,6 +66,7 @@ Some newer Kasa devices require authentication. These are marked with * Date: Thu, 11 Jul 2024 13:26:33 +0100 Subject: [PATCH 14/38] Bump project version to 0.7.0.3 (#1053) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bfa044774..ad43f3bd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.2" +version = "0.7.0.3" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From a0440635260abe2d8b6719ff2260510d2fed9b3f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 11 Jul 2024 15:11:50 +0200 Subject: [PATCH 15/38] Use first known thermostat state as main state (#1054) Instead of trying to use the first state when multiple are reported, iterate over the known states and pick the first matching. This will fix an issue where the device reports extra states (like `low_battery`) while having a known mode active. Related to https://github.com/home-assistant/core/issues/121335 --- kasa/smart/modules/temperaturecontrol.py | 43 ++++++++++--------- .../smart/modules/test_temperaturecontrol.py | 10 ++++- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index dcd0da725..00afe5b53 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -4,15 +4,10 @@ import logging from enum import Enum -from typing import TYPE_CHECKING from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - _LOGGER = logging.getLogger(__name__) @@ -31,11 +26,11 @@ class TemperatureControl(SmartModule): REQUIRED_COMPONENT = "temp_control" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="target_temperature", name="Target temperature", container=self, @@ -50,7 +45,7 @@ def __init__(self, device: SmartDevice, module: str): # TODO: this might belong into its own module, temperature_correction? self._add_feature( Feature( - device, + self._device, id="temperature_offset", name="Temperature offset", container=self, @@ -65,7 +60,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( - device, + self._device, id="state", name="State", container=self, @@ -78,7 +73,7 @@ def __init__(self, device: SmartDevice, module: str): self._add_feature( Feature( - device, + self._device, id="thermostat_mode", name="Thermostat mode", container=self, @@ -109,23 +104,24 @@ def mode(self) -> ThermostatState: if self._device.sys_info.get("frost_protection_on", False): return ThermostatState.Off - states = self._device.sys_info["trv_states"] + states = self.states # If the states is empty, the device is idling if not states: return ThermostatState.Idle + # Discard known extra states, and report on unknown extra states + states.discard("low_battery") if len(states) > 1: - _LOGGER.warning( - "Got multiple states (%s), using the first one: %s", states, states[0] - ) + _LOGGER.warning("Got multiple states: %s", states) - state = states[0] - try: - return ThermostatState(state) - except: # noqa: E722 - _LOGGER.warning("Got unknown state: %s", state) - return ThermostatState.Unknown + # Return the first known state + for state in ThermostatState: + if state.value in states: + return state + + _LOGGER.warning("Got unknown state: %s", states) + return ThermostatState.Unknown @property def allowed_temperature_range(self) -> tuple[int, int]: @@ -147,6 +143,11 @@ def target_temperature(self) -> float: """Return target temperature.""" return self._device.sys_info["target_temp"] + @property + def states(self) -> set: + """Return thermostat states.""" + return set(self._device.sys_info["trv_states"]) + async def set_target_temperature(self, target: float): """Set target temperature.""" if ( diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 16e01ed2b..90f91216f 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -94,7 +94,7 @@ async def test_temperature_offset(dev): ), pytest.param( ThermostatState.Heating, - [ThermostatState.Heating], + ["heating"], False, id="heating is heating", ), @@ -135,3 +135,11 @@ async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog): temp_module.data["trv_states"] = states assert temp_module.mode is mode assert msg in caplog.text + + +@thermostats_smart +async def test_thermostat_heating_with_low_battery(dev): + """Test that mode is reported correctly with extra states.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module.data["trv_states"] = ["low_battery", "heating"] + assert temp_module.mode is ThermostatState.Heating From 7fd5c213e6a73e4aca709098211bed347ea42b07 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jul 2024 16:21:59 +0100 Subject: [PATCH 16/38] Defer module updates for less volatile modules (#1052) Addresses stability issues on older hw device versions - Handles module timeout errors better by querying modules individually on errors and disabling problematic modules like Firmware that go out to the internet to get updates. - Addresses an issue with the Led module on P100 hardware version 1.0 which appears to have a memory leak and will cause the device to crash after approximately 500 calls. - Delays updates of modules that do not have regular changes like LightPreset and LightEffect and enables them to be updated on the next update cycle only if required values have changed. --- kasa/aestransport.py | 14 ++- kasa/exceptions.py | 2 + kasa/httpclient.py | 23 +++- kasa/smart/modules/cloud.py | 1 + kasa/smart/modules/firmware.py | 12 +-- kasa/smart/modules/led.py | 5 +- kasa/smart/modules/lighteffect.py | 5 +- kasa/smart/modules/lightpreset.py | 4 +- kasa/smart/modules/lightstripeffect.py | 4 +- kasa/smart/modules/lighttransition.py | 6 +- kasa/smart/smartchilddevice.py | 2 + kasa/smart/smartdevice.py | 141 ++++++++++++++++++++----- kasa/smart/smartmodule.py | 29 ++++- kasa/smartprotocol.py | 44 ++++++-- kasa/tests/test_smartdevice.py | 126 +++++++++++++++++++++- kasa/tests/test_smartprotocol.py | 2 +- 16 files changed, 364 insertions(+), 56 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index c9cb83bd3..cd0f24b38 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -146,7 +146,9 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: try: error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: - _LOGGER.warning("Received unknown error code: %s", error_code_raw) + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR if error_code is SmartErrorCode.SUCCESS: return @@ -216,10 +218,18 @@ async def perform_login(self): """Login to the device.""" try: await self.try_login(self._login_params) + _LOGGER.debug( + "%s: logged in with provided credentials", + self._host, + ) except AuthenticationError as aex: try: if aex.error_code is not SmartErrorCode.LOGIN_ERROR: raise aex + _LOGGER.debug( + "%s: trying login with default TAPO credentials", + self._host, + ) if self._default_credentials is None: self._default_credentials = get_default_credentials( DEFAULT_CREDENTIALS["TAPO"] @@ -227,7 +237,7 @@ async def perform_login(self): await self.perform_handshake() await self.try_login(self._get_login_params(self._default_credentials)) _LOGGER.debug( - "%s: logged in with default credentials", + "%s: logged in with default TAPO credentials", self._host, ) except (AuthenticationError, _ConnectionError, TimeoutError): diff --git a/kasa/exceptions.py b/kasa/exceptions.py index f5c26ff04..3f7f301ba 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -128,6 +128,8 @@ def from_int(value: int) -> SmartErrorCode: # Library internal for unknown error codes INTERNAL_UNKNOWN_ERROR = -100_000 + # Library internal for query errors + INTERNAL_QUERY_ERROR = -100_001 SMART_RETRYABLE_ERRORS = [ diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 02e697821..1c8c46e27 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -75,13 +75,21 @@ async def post( now = time.time() gap = now - self._last_request_time if gap < self._wait_between_requests: - await asyncio.sleep(self._wait_between_requests - gap) + sleep = self._wait_between_requests - gap + _LOGGER.debug( + "Device %s waiting %s seconds to send request", + self._config.host, + sleep, + ) + await asyncio.sleep(sleep) _LOGGER.debug("Posting to %s", url) response_data = None self._last_url = url self.client.cookie_jar.clear() return_json = bool(json) + client_timeout = aiohttp.ClientTimeout(total=self._config.timeout) + # If json is not a dict send as data. # This allows the json parameter to be used to pass other # types of data such as async_generator and still have json @@ -95,9 +103,10 @@ async def post( params=params, data=data, json=json, - timeout=self._config.timeout, + timeout=client_timeout, cookies=cookies_dict, headers=headers, + ssl=False, ) async with resp: if resp.status == 200: @@ -106,9 +115,15 @@ async def post( response_data = json_loads(response_data.decode()) except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: - if isinstance(ex, aiohttp.ClientOSError): + if not self._wait_between_requests: + _LOGGER.debug( + "Device %s received an os error, " + "enabling sequential request delay: %s", + self._config.host, + ex, + ) self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR - self._last_request_time = time.time() + self._last_request_time = time.time() raise _ConnectionError( f"Device connection error: {self._config.host}: {ex}", ex ) from ex diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index 8346af57a..e7513a562 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -16,6 +16,7 @@ class Cloud(SmartModule): QUERY_GETTER_NAME = "get_connect_cloud_state" REQUIRED_COMPONENT = "cloud_connect" + MINIMUM_UPDATE_INTERVAL_SECS = 60 def _post_update_hook(self): """Perform actions after a device update. diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 10a6b8245..dc0483e71 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -14,7 +14,7 @@ from pydantic.v1 import BaseModel, Field, validator from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -66,6 +66,7 @@ class Firmware(SmartModule): """Implementation of firmware module.""" REQUIRED_COMPONENT = "firmware" + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -122,13 +123,6 @@ def query(self) -> dict: req["get_auto_update_info"] = None return req - def _post_update_hook(self): - """Perform actions after a device update. - - Overrides the default behaviour to disable a module if the query returns - an error because some of the module still functions. - """ - @property def current_firmware(self) -> str: """Return the current firmware version.""" @@ -162,6 +156,7 @@ async def get_update_state(self) -> DownloadState: state = resp["get_fw_download_state"] return DownloadState(**state) + @allow_update_after async def update( self, progress_cb: Callable[[DownloadState], Coroutine] | None = None ): @@ -219,6 +214,7 @@ def auto_update_enabled(self): and self.data["get_auto_update_info"]["enable"] ) + @allow_update_after async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" data = {**self.data["get_auto_update_info"], "enable": enabled} diff --git a/kasa/smart/modules/led.py b/kasa/smart/modules/led.py index 2d0a354c0..bbfe3579b 100644 --- a/kasa/smart/modules/led.py +++ b/kasa/smart/modules/led.py @@ -3,7 +3,7 @@ from __future__ import annotations from ...interfaces.led import Led as LedInterface -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after class Led(SmartModule, LedInterface): @@ -11,6 +11,8 @@ class Led(SmartModule, LedInterface): REQUIRED_COMPONENT = "led" QUERY_GETTER_NAME = "get_led_info" + # Led queries can cause device to crash on P100 + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 def query(self) -> dict: """Query to execute during the update cycle.""" @@ -29,6 +31,7 @@ def led(self): """Return current led status.""" return self.data["led_rule"] != "never" + @allow_update_after async def set_led(self, enable: bool): """Set led. diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 07f6aece9..5f589d6dd 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -9,7 +9,7 @@ from typing import Any from ..effects import SmartLightEffect -from ..smartmodule import Module, SmartModule +from ..smartmodule import Module, SmartModule, allow_update_after class LightEffect(SmartModule, SmartLightEffect): @@ -17,6 +17,7 @@ class LightEffect(SmartModule, SmartLightEffect): REQUIRED_COMPONENT = "light_effect" QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 AVAILABLE_BULB_EFFECTS = { "L1": "Party", "L2": "Relax", @@ -130,6 +131,7 @@ def brightness(self) -> int: return brightness + @allow_update_after async def set_brightness( self, brightness: int, @@ -156,6 +158,7 @@ def _replace_brightness(data, new_brightness): return await self.call("edit_dynamic_light_effect_rule", new_effect) + @allow_update_after async def set_custom_effect( self, effect_dict: dict, diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 589060412..6bb2fb3fa 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -9,7 +9,7 @@ from ...interfaces import LightPreset as LightPresetInterface from ...interfaces import LightState -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -22,6 +22,7 @@ class LightPreset(SmartModule, LightPresetInterface): REQUIRED_COMPONENT = "preset" QUERY_GETTER_NAME = "get_preset_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 SYS_INFO_STATE_KEY = "preset_state" @@ -124,6 +125,7 @@ async def set_preset( raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") await self._device.modules[SmartModule.Light].set_state(preset) + @allow_update_after async def save_preset( self, preset_name: str, diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index a80c20f3c..f75620686 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect -from ..smartmodule import Module, SmartModule +from ..smartmodule import Module, SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -84,6 +84,7 @@ def effect_list(self) -> list[str]: """ return self._effect_list + @allow_update_after async def set_effect( self, effect: str, @@ -126,6 +127,7 @@ async def set_effect( await self.set_custom_effect(effect_dict) + @allow_update_after async def set_custom_effect( self, effect_dict: dict, diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index ca0eca867..3a5897d12 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -6,7 +6,7 @@ from ...exceptions import KasaException from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -23,6 +23,7 @@ class LightTransition(SmartModule): REQUIRED_COMPONENT = "on_off_gradually" QUERY_GETTER_NAME = "get_on_off_gradually_info" + MINIMUM_UPDATE_INTERVAL_SECS = 60 MAXIMUM_DURATION = 60 # Key in sysinfo that indicates state can be retrieved from there. @@ -136,6 +137,7 @@ def _post_update_hook(self) -> None: "max_duration": off_max, } + @allow_update_after async def set_enabled(self, enable: bool): """Enable gradual on/off.""" if not self._supports_on_and_off: @@ -168,6 +170,7 @@ def _turn_on_transition_max(self) -> int: # v3 added max_duration, we default to 60 when it's not available return self._on_state["max_duration"] + @allow_update_after async def set_turn_on_transition(self, seconds: int): """Set turn on transition in seconds. @@ -203,6 +206,7 @@ def _turn_off_transition_max(self) -> int: # v3 added max_duration, we default to 60 when it's not available return self._off_state["max_duration"] + @allow_update_after async def set_turn_off_transition(self, seconds: int): """Set turn on transition in seconds. diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 98145f6c9..679692baf 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from typing import Any from ..device_type import DeviceType @@ -54,6 +55,7 @@ async def _update(self, update_children: bool = True): req.update(mod_query) if req: self._last_update = await self.protocol.query(req) + self._last_update_time = time.time() @classmethod async def create(cls, parent: SmartDevice, child_info, child_components): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 5bf2400b7..8cdd2013a 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -4,6 +4,7 @@ import base64 import logging +import time from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from typing import Any, cast @@ -18,6 +19,7 @@ from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( + ChildDevice, Cloud, DeviceModule, Firmware, @@ -35,6 +37,9 @@ # same issue, homekit perhaps? NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] +# Modules that are called as part of the init procedure on first update +FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud} + # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. @@ -60,6 +65,7 @@ def __init__( self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} self._last_update = {} + self._last_update_time: float | None = None async def _initialize_children(self): """Initialize children for power strips.""" @@ -152,19 +158,15 @@ async def update(self, update_children: bool = False): if self.credentials is None and self.credentials_hash is None: raise AuthenticationError("Tapo plug requires authentication.") - if self._components_raw is None: + first_update = self._last_update_time is None + now = time.time() + self._last_update_time = now + + if first_update: await self._negotiate() await self._initialize_modules() - req: dict[str, Any] = {} - - # TODO: this could be optimized by constructing the query only once - for module in self._modules.values(): - req.update(module.query()) - - self._last_update = resp = await self.protocol.query(req) - - self._info = self._try_get_response(resp, "get_device_info") + resp = await self._modular_update(first_update, now) # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other @@ -172,18 +174,12 @@ async def update(self, update_children: bool = False): if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): await child._update() - if child_info := self._try_get_response(resp, "get_child_device_list", {}): + if child_info := self._try_get_response( + self._last_update, "get_child_device_list", {} + ): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) - # Call handle update for modules that want to update internal data - errors = [] - for module_name, module in self._modules.items(): - if not self._handle_module_post_update_hook(module): - errors.append(module_name) - for error in errors: - self._modules.pop(error) - for child in self._children.values(): errors = [] for child_module_name, child_module in child._modules.items(): @@ -197,14 +193,18 @@ async def update(self, update_children: bool = False): if not self._features: await self._initialize_features() - _LOGGER.debug("Got an update: %s", self._last_update) + _LOGGER.debug( + "Update completed %s: %s", + self.host, + self._last_update if first_update else resp, + ) def _handle_module_post_update_hook(self, module: SmartModule) -> bool: try: module._post_update_hook() return True except Exception as ex: - _LOGGER.error( + _LOGGER.warning( "Error processing %s for device %s, module will be unavailable: %s", module.name, self.host, @@ -212,6 +212,100 @@ def _handle_module_post_update_hook(self, module: SmartModule) -> bool: ) return False + async def _modular_update( + self, first_update: bool, update_time: float + ) -> dict[str, Any]: + """Update the device with via the module queries.""" + req: dict[str, Any] = {} + # Keep a track of actual module queries so we can track the time for + # modules that do not need to be updated frequently + module_queries: list[SmartModule] = [] + mq = { + module: query + for module in self._modules.values() + if (query := module.query()) + } + for module, query in mq.items(): + if first_update and module.__class__ in FIRST_UPDATE_MODULES: + module._last_update_time = update_time + continue + if ( + not module.MINIMUM_UPDATE_INTERVAL_SECS + or not module._last_update_time + or (update_time - module._last_update_time) + >= module.MINIMUM_UPDATE_INTERVAL_SECS + ): + module_queries.append(module) + req.update(query) + + _LOGGER.debug( + "Querying %s for modules: %s", + self.host, + ", ".join(mod.name for mod in module_queries), + ) + + try: + resp = await self.protocol.query(req) + except Exception as ex: + resp = await self._handle_modular_update_error( + ex, first_update, ", ".join(mod.name for mod in module_queries), req + ) + + info_resp = self._last_update if first_update else resp + self._last_update.update(**resp) + self._info = self._try_get_response(info_resp, "get_device_info") + + # Call handle update for modules that want to update internal data + errors = [] + for module_name, module in self._modules.items(): + if not self._handle_module_post_update_hook(module): + errors.append(module_name) + for error in errors: + self._modules.pop(error) + + # Set the last update time for modules that had queries made. + for module in module_queries: + module._last_update_time = update_time + + return resp + + async def _handle_modular_update_error( + self, + ex: Exception, + first_update: bool, + module_names: str, + requests: dict[str, Any], + ) -> dict[str, Any]: + """Handle an error on calling module update. + + Will try to call all modules individually + and any errors such as timeouts will be set as a SmartErrorCode. + """ + msg_part = "on first update" if first_update else "after first update" + + _LOGGER.error( + "Error querying %s for modules '%s' %s: %s", + self.host, + module_names, + msg_part, + ex, + ) + responses = {} + for meth, params in requests.items(): + try: + resp = await self.protocol.query({meth: params}) + responses[meth] = resp[meth] + except Exception as iex: + _LOGGER.error( + "Error querying %s individually for module query '%s' %s: %s", + self.host, + meth, + msg_part, + iex, + ) + responses[meth] = SmartErrorCode.INTERNAL_QUERY_ERROR + return responses + async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule @@ -229,8 +323,6 @@ async def _initialize_modules(self): skip_parent_only_modules = True for mod in SmartModule.REGISTERED_MODULES.values(): - _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) - if ( skip_parent_only_modules and mod in NON_HUB_PARENT_ONLY_MODULES ) or mod.__name__ in child_modules_to_skip: @@ -240,7 +332,8 @@ async def _initialize_modules(self): or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None ): _LOGGER.debug( - "Found required %s, adding %s to modules.", + "Device %s, found required %s, adding %s to modules.", + self.host, mod.REQUIRED_COMPONENT, mod.__name__, ) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index fb946a8b3..f5f2c212a 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -3,7 +3,10 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from collections.abc import Awaitable, Callable, Coroutine +from typing import TYPE_CHECKING, Any + +from typing_extensions import Concatenate, ParamSpec, TypeVar from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..module import Module @@ -13,6 +16,27 @@ _LOGGER = logging.getLogger(__name__) +_T = TypeVar("_T", bound="SmartModule") +_P = ParamSpec("_P") + + +def allow_update_after( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Define a wrapper to set _last_update_time to None. + + This will ensure that a module is updated in the next update cycle after + a value has been changed. + """ + + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + finally: + self._last_update_time = None + + return _async_wrap + class SmartModule(Module): """Base class for SMART modules.""" @@ -27,9 +51,12 @@ class SmartModule(Module): REGISTERED_MODULES: dict[str, type[SmartModule]] = {} + MINIMUM_UPDATE_INTERVAL_SECS = 0 + def __init__(self, device: SmartDevice, module: str): self._device: SmartDevice super().__init__(device, module) + self._last_update_time: float | None = None def __init_subclass__(cls, **kwargs): name = getattr(cls, "NAME", cls.__name__) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 3085714c4..0c95325a5 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -73,18 +73,32 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: return await self._execute_query( request, retry_count=retry, iterate_list_pages=True ) - except _ConnectionError as sdex: + except _ConnectionError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a connection error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise sdex + raise ex continue - except AuthenticationError as auex: + except AuthenticationError as ex: await self._transport.reset() _LOGGER.debug( - "Unable to authenticate with %s, not retrying", self._host + "Unable to authenticate with %s, not retrying: %s", self._host, ex ) - raise auex + raise ex except _RetryableError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a retryable error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) @@ -92,6 +106,13 @@ async def _query(self, request: str | dict, retry_count: int = 3) -> dict: await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) continue except TimeoutError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a timeout error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) @@ -130,20 +151,21 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic self._handle_response_error_code(resp, method, raise_on_error=False) multi_result[method] = resp["result"] return multi_result - for i in range(0, end, step): + + for batch_num, i in enumerate(range(0, end, step)): requests_step = multi_requests[i : i + step] smart_params = {"requests": requests_step} smart_request = self.get_smart_request(smart_method, smart_params) + batch_name = f"multi-request-batch-{batch_num+1}-of-{int(end/step)+1}" if debug_enabled: _LOGGER.debug( - "%s multi-request-batch-%s >> %s", + "%s %s >> %s", self._host, - i + 1, + batch_name, pf(smart_request), ) response_step = await self._transport.send(smart_request) - batch_name = f"multi-request-batch-{i+1}" if debug_enabled: _LOGGER.debug( "%s %s << %s", @@ -271,7 +293,9 @@ def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=Tr try: error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: - _LOGGER.warning("Received unknown error code: %s", error_code_raw) + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR if error_code is SmartErrorCode.SUCCESS: diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 44fabc715..99e2ddb9e 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -3,10 +3,12 @@ from __future__ import annotations import logging +import time from typing import Any, cast from unittest.mock import patch import pytest +from freezegun.api import FrozenDateTimeFactory from pytest_mock import MockerFixture from kasa import Device, KasaException, Module @@ -54,6 +56,8 @@ async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): dev._modules = {} dev._features = {} dev._children = {} + dev._last_update = {} + dev._last_update_time = None negotiate = mocker.spy(dev, "_negotiate") initialize_modules = mocker.spy(dev, "_initialize_modules") @@ -109,6 +113,9 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): """Test that the regular update uses queries from all supported modules.""" # We need to have some modules initialized by now assert dev._modules + # Reset last update so all modules will query + for mod in dev._modules.values(): + mod._last_update_time = None device_queries: dict[SmartDevice, dict[str, Any]] = {} for mod in dev._modules.values(): @@ -139,7 +146,7 @@ async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): assert dev._modules critical_modules = {Module.DeviceModule, Module.ChildDevice} - not_disabling_modules = {Module.Firmware, Module.Cloud} + not_disabling_modules = {Module.Cloud} new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) @@ -204,6 +211,123 @@ async def _child_query(self, request, *args, **kwargs): ), f"{modname} present {mod_present} when no_disable {no_disable}" +@device_smart +async def test_update_module_update_delays( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +): + """Test that modules that disabled / removed on query failures.""" + # We need to have some modules initialized by now + assert dev._modules + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + await new_dev.update() + first_update_time = time.time() + assert new_dev._last_update_time == first_update_time + for module in new_dev.modules.values(): + if module.query(): + assert module._last_update_time == first_update_time + + seconds = 0 + tick = 30 + while seconds <= 180: + seconds += tick + freezer.tick(tick) + + now = time.time() + await new_dev.update() + for module in new_dev.modules.values(): + mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS + if module.query(): + expected_update_time = ( + now if mod_delay == 0 else now - (seconds % mod_delay) + ) + + assert ( + module._last_update_time == expected_update_time + ), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" + + +@pytest.mark.parametrize( + ("first_update"), + [ + pytest.param(True, id="First update true"), + pytest.param(False, id="First update false"), + ], +) +@device_smart +async def test_update_module_query_errors( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, + first_update, +): + """Test that modules that disabled / removed on query failures.""" + # We need to have some modules initialized by now + assert dev._modules + + first_update_queries = {"get_device_info", "get_connect_cloud_state"} + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + not_disabling_modules = {Module.Cloud} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + if not first_update: + await new_dev.update() + freezer.tick( + max(module.MINIMUM_UPDATE_INTERVAL_SECS for module in dev._modules.values()) + ) + + module_queries = { + modname: q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + + async def _query(request, *args, **kwargs): + if ( + "component_nego" in request + or "get_child_device_component_list" in request + or "control_child" in request + ): + return await dev.protocol._query(request, *args, **kwargs) + if len(request) == 1 and "get_device_info" in request: + return await dev.protocol._query(request, *args, **kwargs) + + raise TimeoutError("Dummy timeout") + + from kasa.smartprotocol import _ChildProtocolWrapper + + child_protocols = { + cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol + for child in dev.children + } + + async def _child_query(self, request, *args, **kwargs): + return await child_protocols[self._device_id]._query(request, *args, **kwargs) + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + # children not created yet so cannot patch.object + mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) + + await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" + assert msg in caplog.text + for modname in module_queries: + no_disable = modname in not_disabling_modules + mod_present = modname in new_dev._modules + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + for mod_query in module_queries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + + async def test_get_modules(): """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 71125ca83..204d0c7f2 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -66,7 +66,7 @@ async def test_smart_device_unknown_errors( assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR send_mock.assert_called_once() - assert f"Received unknown error code: {error_code}" in caplog.text + assert f"received unknown error code: {error_code}" in caplog.text @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) From 84192a0d77ad25d26ff130b264ff1fa439c85108 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:45:37 +0100 Subject: [PATCH 17/38] Bump version to 0.7.0.4 (#1060) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ad43f3bd4..b9e8c5784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.3" +version = "0.7.0.4" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From a2b7daa0692ba43bfec99c308c6f4efc645d76f8 Mon Sep 17 00:00:00 2001 From: daleye Date: Sun, 14 Jul 2024 10:31:31 -0400 Subject: [PATCH 18/38] Add fixture file for KP405 fw 1.0.6 (#1063) --- SUPPORTED.md | 1 + kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json | 65 ++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json diff --git a/SUPPORTED.md b/SUPPORTED.md index 1781442a0..a0d301b32 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -83,6 +83,7 @@ Some newer Kasa devices require authentication. These are marked with *\* - **KP405** - Hardware: 1.0 (US) / Firmware: 1.0.5 + - Hardware: 1.0 (US) / Firmware: 1.0.6 - **KS200M** - Hardware: 1.0 (US) / Firmware: 1.0.11 - Hardware: 1.0 (US) / Firmware: 1.0.8 diff --git a/kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json b/kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json new file mode 100644 index 000000000..d2431bfd5 --- /dev/null +++ b/kasa/tests/fixtures/KP405(US)_1.0_1.0.6.json @@ -0,0 +1,65 @@ +{ + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "calibration_type": 0, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 1, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dev_name": "Kasa Smart Wi-Fi Outdoor Plug-In Dimmer", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "50:91:E3:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP405(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "preferred_state": [ + { + "brightness": 100, + "index": 0 + }, + { + "brightness": 75, + "index": 1 + }, + { + "brightness": 50, + "index": 2 + }, + { + "brightness": 25, + "index": 3 + } + ], + "relay_state": 0, + "rssi": -66, + "status": "new", + "sw_ver": "1.0.6 Build 240229 Rel.174151", + "updating": 0 + } + } +} From 7e9b1687d0fcc724779d2432d59f09a81537b923 Mon Sep 17 00:00:00 2001 From: Carter Strickland <50763236+clstrickland@users.noreply.github.com> Date: Mon, 15 Jul 2024 07:18:43 -0500 Subject: [PATCH 19/38] Decrypt KLAP data from PCAP files (#1041) Allows for decryption of pcap files capturing klap communication with devices. --- devtools/README.md | 27 ++++ devtools/parse_pcap_klap.py | 307 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 335 insertions(+) create mode 100755 devtools/parse_pcap_klap.py diff --git a/devtools/README.md b/devtools/README.md index 99d5ec5a0..f59ea374c 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -99,3 +99,30 @@ id New parser, parsing 100000 messages took 0.6339647499989951 seconds Old parser, parsing 100000 messages took 9.473990250000497 seconds ``` + + +## parse_pcap_klap + +* A tool to allow KLAP data to be exported, in JSON, from a PCAP file of encrypted requests. + +* NOTE: must install pyshark (`pip install pyshark`). +* pyshark requires Wireshark or tshark to be installed on windows and tshark to be installed +on linux (`apt get tshark`) + +```shell +Usage: parse_pcap_klap.py [OPTIONS] + + Export KLAP data in JSON format from a PCAP file. + +Options: + --host TEXT the IP of the smart device as it appears in the pcap + file. [required] + --username TEXT Username/email address to authenticate to device. + [required] + --password TEXT Password to use to authenticate to device. + [required] + --pcap-file-path TEXT The path to the pcap file to parse. [required] + -o, --output TEXT The name of the output file, relative to the current + directory. + --help Show this message and exit. +``` diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py new file mode 100755 index 000000000..d8be6573c --- /dev/null +++ b/devtools/parse_pcap_klap.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python +""" +This code allow for the decryption of KlapV2 data from a pcap file. + +It will output the decrypted data to a file. +This was designed and tested with a Tapo light strip setup using a cloud account. +""" + +import codecs +import json +import re +from threading import Thread + +import asyncclick as click +import pyshark +from cryptography.hazmat.primitives import padding + +from kasa.credentials import Credentials +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) +from kasa.klaptransport import KlapEncryptionSession, KlapTransportV2 + + +class MyEncryptionSession(KlapEncryptionSession): + """A custom KlapEncryptionSession class that allows for decryption.""" + + def decrypt(self, msg): + """Decrypt the data.""" + decryptor = self._cipher.decryptor() + dp = decryptor.update(msg[32:]) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + plaintextbytes = unpadder.update(dp) + unpadder.finalize() + + return plaintextbytes.decode("utf-8", "bad_chars_replacement") + + +class Operator: + """A class that handles the data decryption, and the encryption session updating.""" + + def __init__(self, klap, creds): + self._local_seed: bytes = None + self._remote_seed: bytes = None + self._session: MyEncryptionSession = None + self._creds = creds + self._klap: KlapTransportV2 = klap + self._auth_hash = self._klap.generate_auth_hash(self._creds) + self._local_auth_hash = None + self._remote_auth_hash = None + self._seq = 0 + + def update_encryption_session(self): + """Update the encryption session used for decrypting data. + + It is called whenever the local_seed, remote_seed, + or remote_auth_hash is updated. + + It checks if the seeds are set and, if they are, creates a new session. + + Raises: + ValueError: If the auth hashes do not match. + """ + if self._local_seed is None or self._remote_seed is None: + self._session = None + else: + self._local_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed, self._remote_seed, self._auth_hash + ) + if (self._remote_auth_hash is not None) and ( + self._local_auth_hash != self._remote_auth_hash + ): + raise ValueError( + "Local and remote auth hashes do not match.\ +This could mean an incorrect username and/or password." + ) + self._session = MyEncryptionSession( + self._local_seed, self._remote_seed, self._auth_hash + ) + self._session._seq = self._seq + self._session._generate_cipher() + + @property + def seq(self) -> int: + """Get the sequence number.""" + return self._seq + + @seq.setter + def seq(self, value: int): + if not isinstance(value, int): + raise ValueError("seq must be an integer") + self._seq = value + self.update_encryption_session() + + @property + def local_seed(self) -> bytes: + """Get the local seed.""" + return self._local_seed + + @local_seed.setter + def local_seed(self, value: bytes): + if not isinstance(value, bytes): + raise ValueError("local_seed must be bytes") + elif len(value) != 16: + raise ValueError("local_seed must be 16 bytes") + else: + self._local_seed = value + self.update_encryption_session() + + @property + def remote_auth_hash(self) -> bytes: + """Get the remote auth hash.""" + return self._remote_auth_hash + + @remote_auth_hash.setter + def remote_auth_hash(self, value: bytes): + print("setting remote_auth_hash") + if not isinstance(value, bytes): + raise ValueError("remote_auth_hash must be bytes") + elif len(value) != 32: + raise ValueError("remote_auth_hash must be 32 bytes") + else: + self._remote_auth_hash = value + self.update_encryption_session() + + @property + def remote_seed(self) -> bytes: + """Get the remote seed.""" + return self._remote_seed + + @remote_seed.setter + def remote_seed(self, value: bytes): + print("setting remote_seed") + if not isinstance(value, bytes): + raise ValueError("remote_seed must be bytes") + elif len(value) != 16: + raise ValueError("remote_seed must be 16 bytes") + else: + self._remote_seed = value + self.update_encryption_session() + + # This function decrypts the data using the encryption session. + def decrypt(self, *args, **kwargs): + """Decrypt the data using the encryption session.""" + if self._session is None: + raise ValueError("No session available") + return self._session.decrypt(*args, **kwargs) + + +# This is a custom error handler that replaces bad characters with '*', +# in case something goes wrong in decryption. +# Without this, the decryption could yield an error. +def bad_chars_replacement(exception): + """Replace bad characters with '*'.""" + return ("*", exception.start + 1) + + +codecs.register_error("bad_chars_replacement", bad_chars_replacement) + + +def main(username, password, device_ip, pcap_file_path, output_json_name=None): + """Run the main function.""" + capture = pyshark.FileCapture(pcap_file_path, display_filter="http") + + # In an effort to keep this code tied into the original code + # (so that this can hopefully leverage any future codebase updates inheriently), + # some weird initialization is done here + creds = Credentials(username, password) + + fake_connection = DeviceConnectionParameters( + DeviceFamily.SmartTapoBulb, DeviceEncryptionType.Klap + ) + fake_device = DeviceConfig( + device_ip, connection_type=fake_connection, credentials=creds + ) + + operator = Operator(KlapTransportV2(config=fake_device), creds) + + packets = [] + + # pyshark is a little weird in how it handles iteration, + # so this is a workaround to allow for (advanced) iteration over the packets. + while True: + try: + packet = capture.next() + # packet_number = capture._current_packet + # we only care about http packets + if hasattr( + packet, "http" + ): # this is redundant, as pyshark is set to only load http packets + if hasattr(packet.http, "request_uri_path"): + uri = packet.http.get("request_uri_path") + elif hasattr(packet.http, "request_uri"): + uri = packet.http.get("request_uri") + else: + uri = None + if hasattr(packet.http, "request_uri_query"): + query = packet.http.get("request_uri_query") + # use regex to get: seq=(\d+) + seq = re.search(r"seq=(\d+)", query) + if seq is not None: + operator.seq = int( + seq.group(1) + ) # grab the sequence number from the query + data = ( + # Windows and linux file_data attribute returns different + # pretty format so get the raw field value. + packet.http.get_field_value("file_data", raw=True) + if hasattr(packet.http, "file_data") + else None + ) + match uri: + case "/app/request": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data) + try: + plaintext = operator.decrypt(message) + payload = json.loads(plaintext) + print(json.dumps(payload, indent=2)) + packets.append(payload) + except ValueError: + print("Insufficient data to decrypt thus far") + + case "/app/handshake1": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data) + operator.local_seed = message + response = None + while ( + True + ): # we are going to now look for the response to this request + response = capture.next() + if ( + hasattr(response, "http") + and hasattr(response.http, "response_for_uri") + and ( + response.http.response_for_uri + == packet.http.request_full_uri + ) + ): + break + data = response.http.get_field_value("file_data", raw=True) + message = bytes.fromhex(data) + operator.remote_seed = message[0:16] + operator.remote_auth_hash = message[16:] + + case "/app/handshake2": + continue # we don't care about this + case _: + continue + except StopIteration: + break + + # save the final array to a file + if output_json_name is not None: + with open(output_json_name, "w") as f: + f.write(json.dumps(packets, indent=2)) + f.write("\n" * 1) + f.close() + + +@click.command() +@click.option( + "--host", + required=True, + help="the IP of the smart device as it appears in the pcap file.", +) +@click.option( + "--username", + required=True, + envvar="KASA_USERNAME", + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + required=True, + envvar="KASA_PASSWORD", + help="Password to use to authenticate to device.", +) +@click.option( + "--pcap-file-path", + required=True, + help="The path to the pcap file to parse.", +) +@click.option( + "-o", + "--output", + required=False, + help="The name of the output file, relative to the current directory.", +) +def cli(username, password, host, pcap_file_path, output): + """Export KLAP data in JSON format from a PCAP file.""" + # pyshark does not work within a running event loop and we don't want to + # install click as well as asyncclick so run in a new thread. + thread = Thread( + target=main, args=[username, password, host, pcap_file_path, output] + ) + thread.start() + thread.join() + + +if __name__ == "__main__": + cli() diff --git a/pyproject.toml b/pyproject.toml index b9e8c5784..91317f489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ disable_error_code = "annotation-unchecked" module = [ "devtools.bench.benchmark", "devtools.parse_pcap", + "devtools.parse_pcap_klap", "devtools.perftest", "devtools.create_module_fixtures" ] From b220beb8118660387b539db0c432d0fc6f514197 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 16 Jul 2024 13:25:32 +0100 Subject: [PATCH 20/38] Use monotonic time for query timing (#1070) To fix intermittent issues with [windows CI](https://github.com/python-kasa/python-kasa/actions/runs/9952477932/job/27493918272?pr=1068). Probably better to use monotonic here anyway. ``` FAILED kasa/tests/test_smartdevice.py::test_update_module_update_delays[L530E(EU)_3.0_1.1.6.json-SMART] - ValueError: Clock moved backwards. Refusing to generate ID. ``` --- kasa/httpclient.py | 6 +++--- kasa/klaptransport.py | 6 ++++-- kasa/smart/smartdevice.py | 2 +- kasa/smartprotocol.py | 2 +- kasa/tests/test_smartdevice.py | 4 ++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 1c8c46e27..ec80ad616 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -72,7 +72,7 @@ async def post( # Once we know a device needs a wait between sequential queries always wait # first rather than keep erroring then waiting. if self._wait_between_requests: - now = time.time() + now = time.monotonic() gap = now - self._last_request_time if gap < self._wait_between_requests: sleep = self._wait_between_requests - gap @@ -123,7 +123,7 @@ async def post( ex, ) self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR - self._last_request_time = time.time() + self._last_request_time = time.monotonic() raise _ConnectionError( f"Device connection error: {self._config.host}: {ex}", ex ) from ex @@ -140,7 +140,7 @@ async def post( # For performance only request system time if waiting is enabled if self._wait_between_requests: - self._last_request_time = time.time() + self._last_request_time = time.monotonic() return resp.status, response_data diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index dd90ffd28..c138ba38e 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -300,7 +300,9 @@ async def perform_handshake(self) -> Any: # There is a 24 hour timeout on the session cookie # but the clock on the device is not always accurate # so we set the expiry to 24 hours from now minus a buffer - self._session_expire_at = time.time() + timeout - SESSION_EXPIRE_BUFFER_SECONDS + self._session_expire_at = ( + time.monotonic() + timeout - SESSION_EXPIRE_BUFFER_SECONDS + ) self._encryption_session = await self.perform_handshake2( local_seed, remote_seed, auth_hash ) @@ -312,7 +314,7 @@ def _handshake_session_expired(self): """Return true if session has expired.""" return ( self._session_expire_at is None - or self._session_expire_at - time.time() <= 0 + or self._session_expire_at - time.monotonic() <= 0 ) async def send(self, request: str): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 8cdd2013a..21e46cc34 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -159,7 +159,7 @@ async def update(self, update_children: bool = False): raise AuthenticationError("Tapo plug requires authentication.") first_update = self._last_update_time is None - now = time.time() + now = time.monotonic() self._last_update_time = now if first_update: diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 0c95325a5..c0dfb31e4 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -392,7 +392,7 @@ def generate_id(self): ) def _current_millis(self): - return round(time.time() * 1000) + return round(time.monotonic() * 1000) def _wait_next_millis(self, last_timestamp): timestamp = self._current_millis() diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 99e2ddb9e..4e6706444 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -224,7 +224,7 @@ async def test_update_module_update_delays( new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) await new_dev.update() - first_update_time = time.time() + first_update_time = time.monotonic() assert new_dev._last_update_time == first_update_time for module in new_dev.modules.values(): if module.query(): @@ -236,7 +236,7 @@ async def test_update_module_update_delays( seconds += tick freezer.tick(tick) - now = time.time() + now = time.monotonic() await new_dev.update() for module in new_dev.modules.values(): mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS From e17ca21a8391a037e5d312407a07d1d5d66c329d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 08:28:11 +0100 Subject: [PATCH 21/38] Only refresh smart LightEffect module daily (#1064) Fixes an issue with L530 bulbs on HW version 1.0 whereby the light effect query causes the device to crash with JSON_ENCODE_FAIL_ERROR after approximately 60 calls. --- kasa/smart/modules/lighteffect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 5f589d6dd..699c679b3 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -17,7 +17,7 @@ class LightEffect(SmartModule, SmartLightEffect): REQUIRED_COMPONENT = "light_effect" QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" - MINIMUM_UPDATE_INTERVAL_SECS = 60 + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 AVAILABLE_BULB_EFFECTS = { "L1": "Party", "L2": "Relax", @@ -74,6 +74,7 @@ def effect(self) -> str: """Return effect name.""" return self._effect + @allow_update_after async def set_effect( self, effect: str, From c19389f23640f891f6941ea057b81b24fb819217 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 08:34:12 +0100 Subject: [PATCH 22/38] Fix parse_pcap_klap on windows and support default credentials (#1068) - Fixes issue running pyshark on new thread in windows - Fixes bug if handshake repeated during capture - Tries the default tplink hardcoded credentials as per the library --- devtools/parse_pcap_klap.py | 95 +++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 26 deletions(-) diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py index d8be6573c..36384631b 100755 --- a/devtools/parse_pcap_klap.py +++ b/devtools/parse_pcap_klap.py @@ -6,6 +6,9 @@ This was designed and tested with a Tapo light strip setup using a cloud account. """ +from __future__ import annotations + +import asyncio import codecs import json import re @@ -23,6 +26,7 @@ DeviceFamily, ) from kasa.klaptransport import KlapEncryptionSession, KlapTransportV2 +from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials class MyEncryptionSession(KlapEncryptionSession): @@ -42,16 +46,34 @@ class Operator: """A class that handles the data decryption, and the encryption session updating.""" def __init__(self, klap, creds): - self._local_seed: bytes = None - self._remote_seed: bytes = None - self._session: MyEncryptionSession = None + self._local_seed: bytes | None = None + self._remote_seed: bytes | None = None + self._session: MyEncryptionSession | None = None self._creds = creds self._klap: KlapTransportV2 = klap self._auth_hash = self._klap.generate_auth_hash(self._creds) - self._local_auth_hash = None - self._remote_auth_hash = None + self._local_seed_auth_hash = None + self._remote_seed_auth_hash = None self._seq = 0 + def check_default_credentials(self): + """Check whether default credentials were used. + + Devices sometimes randomly accept the hardcoded default credentials + and the library handles that. + """ + for value in DEFAULT_CREDENTIALS.values(): + default_credentials = get_default_credentials(value) + default_auth_hash = self._klap.generate_auth_hash(default_credentials) + default_credentials_seed_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed, + self._remote_seed, + default_auth_hash, # type: ignore + ) + if self._remote_seed_auth_hash == default_credentials_seed_auth_hash: + return default_auth_hash + return None + def update_encryption_session(self): """Update the encryption session used for decrypting data. @@ -66,21 +88,25 @@ def update_encryption_session(self): if self._local_seed is None or self._remote_seed is None: self._session = None else: - self._local_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed_auth_hash = self._klap.handshake1_seed_auth_hash( self._local_seed, self._remote_seed, self._auth_hash ) - if (self._remote_auth_hash is not None) and ( - self._local_auth_hash != self._remote_auth_hash - ): - raise ValueError( - "Local and remote auth hashes do not match.\ -This could mean an incorrect username and/or password." + auth_hash = None + if self._remote_seed_auth_hash is not None: + if self._local_seed_auth_hash == self._remote_seed_auth_hash: + auth_hash = self._auth_hash + else: + auth_hash = self.check_default_credentials() + if not auth_hash: + raise ValueError( + "Local and remote auth hashes do not match. " + "This could mean an incorrect username and/or password." + ) + self._session = MyEncryptionSession( + self._local_seed, self._remote_seed, auth_hash ) - self._session = MyEncryptionSession( - self._local_seed, self._remote_seed, self._auth_hash - ) - self._session._seq = self._seq - self._session._generate_cipher() + self._session._seq = self._seq + self._session._generate_cipher() @property def seq(self) -> int: @@ -95,24 +121,27 @@ def seq(self, value: int): self.update_encryption_session() @property - def local_seed(self) -> bytes: + def local_seed(self) -> bytes | None: """Get the local seed.""" return self._local_seed @local_seed.setter def local_seed(self, value: bytes): + print("setting local_seed") if not isinstance(value, bytes): raise ValueError("local_seed must be bytes") elif len(value) != 16: raise ValueError("local_seed must be 16 bytes") else: self._local_seed = value + self._remote_seed_auth_hash = None + self._remote_seed = None self.update_encryption_session() @property - def remote_auth_hash(self) -> bytes: + def remote_auth_hash(self) -> bytes | None: """Get the remote auth hash.""" - return self._remote_auth_hash + return self._remote_seed_auth_hash @remote_auth_hash.setter def remote_auth_hash(self, value: bytes): @@ -122,11 +151,11 @@ def remote_auth_hash(self, value: bytes): elif len(value) != 32: raise ValueError("remote_auth_hash must be 32 bytes") else: - self._remote_auth_hash = value + self._remote_seed_auth_hash = value self.update_encryption_session() @property - def remote_seed(self) -> bytes: + def remote_seed(self) -> bytes | None: """Get the remote seed.""" return self._remote_seed @@ -160,9 +189,17 @@ def bad_chars_replacement(exception): codecs.register_error("bad_chars_replacement", bad_chars_replacement) -def main(username, password, device_ip, pcap_file_path, output_json_name=None): +def main( + loop: asyncio.AbstractEventLoop, + username, + password, + device_ip, + pcap_file_path, + output_json_name=None, +): """Run the main function.""" - capture = pyshark.FileCapture(pcap_file_path, display_filter="http") + asyncio.set_event_loop(loop) + capture = pyshark.FileCapture(pcap_file_path, display_filter="http", eventloop=loop) # In an effort to keep this code tied into the original code # (so that this can hopefully leverage any future codebase updates inheriently), @@ -262,6 +299,9 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): f.write("\n" * 1) f.close() + # Call close method which cleans up event loop + capture.close() + @click.command() @click.option( @@ -292,12 +332,15 @@ def main(username, password, device_ip, pcap_file_path, output_json_name=None): required=False, help="The name of the output file, relative to the current directory.", ) -def cli(username, password, host, pcap_file_path, output): +async def cli(username, password, host, pcap_file_path, output): """Export KLAP data in JSON format from a PCAP file.""" # pyshark does not work within a running event loop and we don't want to # install click as well as asyncclick so run in a new thread. + loop = asyncio.new_event_loop() thread = Thread( - target=main, args=[username, password, host, pcap_file_path, output] + target=main, + args=[loop, username, password, host, pcap_file_path, output], + daemon=True, ) thread.start() thread.join() From c4f015a2fbc5f0f3c2f3e90afda4190319c49338 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:57:09 +0100 Subject: [PATCH 23/38] Redact sensitive info from debug logs (#1069) Redacts sensitive data when debug logging device responses such as mac, location and usernames --- kasa/discover.py | 40 ++++++++++++++++--- kasa/iotprotocol.py | 39 ++++++++++++++++++- kasa/klaptransport.py | 9 +---- kasa/protocol.py | 41 +++++++++++++++++++ kasa/smart/smartdevice.py | 8 ++-- kasa/smartprotocol.py | 32 +++++++++++++-- kasa/tests/discovery_fixtures.py | 22 ++++++----- kasa/tests/test_discovery.py | 36 +++++++++++++++++ kasa/tests/test_protocol.py | 67 ++++++++++++++++++++++++++++++++ kasa/tests/test_smartprotocol.py | 35 +++++++++++++++++ kasa/xortransport.py | 10 ++--- 11 files changed, 300 insertions(+), 39 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index b9e34ee2a..c69933a95 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -87,7 +87,8 @@ import logging import socket from collections.abc import Awaitable -from typing import Callable, Dict, Optional, Type, cast +from pprint import pformat as pf +from typing import Any, Callable, Dict, Optional, Type, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -112,8 +113,10 @@ UnsupportedDeviceError, ) from kasa.iot.iotdevice import IotDevice +from kasa.iotprotocol import REDACTORS as IOT_REDACTORS from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads +from kasa.protocol import mask_mac, redact_data from kasa.xortransport import XorEncryption _LOGGER = logging.getLogger(__name__) @@ -123,6 +126,12 @@ OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Awaitable[None]] DeviceDict = Dict[str, Device] +NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": lambda x: "REDACTED_" + x[9::], + "mac": mask_mac, +} + class _DiscoverProtocol(asyncio.DatagramProtocol): """Implementation of the discovery protocol handler. @@ -293,6 +302,8 @@ class Discover: DISCOVERY_PORT_2 = 20002 DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") + _redact_data = True + @staticmethod async def discover( *, @@ -484,7 +495,9 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: f"Unable to read response from device: {config.host}: {ex}" ) from ex - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + if _LOGGER.isEnabledFor(logging.DEBUG): + data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device_class = cast(Type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) @@ -504,6 +517,7 @@ def _get_device_instance( config: DeviceConfig, ) -> Device: """Get SmartDevice from the new 20002 response.""" + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) try: info = json_loads(data[16:]) except Exception as ex: @@ -514,9 +528,17 @@ def _get_device_instance( try: discovery_result = DiscoveryResult(**info["result"]) except ValidationError as ex: - _LOGGER.debug( - "Unable to parse discovery from device %s: %s", config.host, info - ) + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug( + "Unable to parse discovery from device %s: %s", + config.host, + pf(data), + ) raise UnsupportedDeviceError( f"Unable to parse discovery from device: {config.host}: {ex}" ) from ex @@ -551,7 +573,13 @@ def _get_device_instance( discovery_result=discovery_result.get_dict(), ) - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device = device_class(config.host, protocol=protocol) di = discovery_result.get_dict() diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 1795566e2..91edb0329 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -4,6 +4,8 @@ import asyncio import logging +from pprint import pformat as pf +from typing import Any, Callable from .deviceconfig import DeviceConfig from .exceptions import ( @@ -14,11 +16,26 @@ _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport +from .protocol import BaseProtocol, BaseTransport, mask_mac, redact_data from .xortransport import XorEncryption, XorTransport _LOGGER = logging.getLogger(__name__) +REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "latitude": lambda x: 0, + "longitude": lambda x: 0, + "latitude_i": lambda x: 0, + "longitude_i": lambda x: 0, + "deviceId": lambda x: "REDACTED_" + x[9::], + "id": lambda x: "REDACTED_" + x[9::], + "alias": lambda x: "#MASKED_NAME#" if x else "", + "mac": mask_mac, + "mic_mac": mask_mac, + "ssid": lambda x: "#MASKED_SSID#" if x else "", + "oemId": lambda x: "REDACTED_" + x[9::], + "username": lambda _: "user@example.com", # cnCloud +} + class IotProtocol(BaseProtocol): """Class for the legacy TPLink IOT KASA Protocol.""" @@ -34,6 +51,7 @@ def __init__( super().__init__(transport=transport) self._query_lock = asyncio.Lock() + self._redact_data = True async def query(self, request: str | dict, retry_count: int = 3) -> dict: """Query the device retrying for retry_count on failure.""" @@ -85,7 +103,24 @@ async def _query(self, request: str, retry_count: int = 3) -> dict: raise KasaException("Query reached somehow to unreachable") async def _execute_query(self, request: str, retry_count: int) -> dict: - return await self._transport.send(request) + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + + if debug_enabled: + _LOGGER.debug( + "%s >> %s", + self._host, + request, + ) + resp = await self._transport.send(request) + + if debug_enabled: + data = redact_data(resp, REDACTORS) if self._redact_data else resp + _LOGGER.debug( + "%s << %s", + self._host, + pf(data), + ) + return resp async def close(self) -> None: """Close the underlying transport.""" diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index c138ba38e..b7976101e 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -50,7 +50,6 @@ import secrets import struct import time -from pprint import pformat as pf from typing import Any, cast from cryptography.hazmat.primitives import padding @@ -353,7 +352,7 @@ async def send(self, request: str): + f"request with seq {seq}" ) else: - _LOGGER.debug("Query posted " + msg) + _LOGGER.debug("Device %s query posted %s", self._host, msg) # Check for mypy if self._encryption_session is not None: @@ -361,11 +360,7 @@ async def send(self, request: str): json_payload = json_loads(decrypted_response) - _LOGGER.debug( - "%s << %s", - self._host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(json_payload), - ) + _LOGGER.debug("Device %s query response received", self._host) return json_payload diff --git a/kasa/protocol.py b/kasa/protocol.py index 7d717c5ed..9b5ffa3d3 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -18,6 +18,7 @@ import logging import struct from abc import ABC, abstractmethod +from typing import Any, Callable, TypeVar, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -28,6 +29,46 @@ _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} _UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") +_T = TypeVar("_T") + + +def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> _T: + """Redact sensitive data for logging.""" + if not isinstance(data, (dict, list)): + return data + + if isinstance(data, list): + return cast(_T, [redact_data(val, redactors) for val in data]) + + redacted = {**data} + + for key, value in redacted.items(): + if value is None: + continue + if isinstance(value, str) and not value: + continue + if key in redactors: + if redactor := redactors[key]: + try: + redacted[key] = redactor(value) + except: # noqa: E722 + redacted[key] = "**REDACTEX**" + else: + redacted[key] = "**REDACTED**" + elif isinstance(value, dict): + redacted[key] = redact_data(value, redactors) + elif isinstance(value, list): + redacted[key] = [redact_data(item, redactors) for item in value] + + return cast(_T, redacted) + + +def mask_mac(mac: str) -> str: + """Return mac address with last two octects blanked.""" + delim = ":" if ":" in mac else "-" + rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000")) + return f"{mac[:8]}{delim}{rest}" + def md5(payload: bytes) -> bytes: """Return the MD5 hash of the payload.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 21e46cc34..156db4615 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -193,11 +193,9 @@ async def update(self, update_children: bool = False): if not self._features: await self._initialize_features() - _LOGGER.debug( - "Update completed %s: %s", - self.host, - self._last_update if first_update else resp, - ) + if _LOGGER.isEnabledFor(logging.DEBUG): + updated = self._last_update if first_update else resp + _LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys())) def _handle_module_post_update_hook(self, module: SmartModule) -> bool: try: diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index c0dfb31e4..8b22f0cba 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -12,7 +12,7 @@ import time import uuid from pprint import pformat as pf -from typing import Any +from typing import Any, Callable from .exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -26,10 +26,31 @@ _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport, md5 +from .protocol import BaseProtocol, BaseTransport, mask_mac, md5, redact_data _LOGGER = logging.getLogger(__name__) +REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "latitude": lambda x: 0, + "longitude": lambda x: 0, + "la": lambda x: 0, # lat on ks240 + "lo": lambda x: 0, # lon on ks240 + "device_id": lambda x: "REDACTED_" + x[9::], + "parent_device_id": lambda x: "REDACTED_" + x[9::], # Hub attached children + "original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children + "nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "", + "mac": mask_mac, + "ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "", + "bssid": lambda _: "000000000000", + "oem_id": lambda x: "REDACTED_" + x[9::], + "setup_code": None, # matter + "setup_payload": None, # matter + "mfi_setup_code": None, # mfi_ for homekit + "mfi_setup_id": None, + "mfi_token_token": None, + "mfi_token_uuid": None, +} + class SmartProtocol(BaseProtocol): """Class for the new TPLink SMART protocol.""" @@ -50,6 +71,7 @@ def __init__( self._multi_request_batch_size = ( self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE ) + self._redact_data = True def get_smart_request(self, method, params=None) -> str: """Get a request message as a string.""" @@ -167,11 +189,15 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic ) response_step = await self._transport.send(smart_request) if debug_enabled: + if self._redact_data: + data = redact_data(response_step, REDACTORS) + else: + data = response_step _LOGGER.debug( "%s %s << %s", self._host, batch_name, - pf(response_step), + pf(data), ) try: self._handle_response_error_code(response_step, batch_name) diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 1ba24bf1a..1451a5cab 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -90,21 +90,26 @@ class _DiscoveryMock: query_data: dict device_type: str encrypt_type: str - _datagram: bytes login_version: int | None = None port_override: int | None = None + @property + def _datagram(self) -> bytes: + if self.default_port == 9999: + return XorEncryption.encrypt(json_dumps(self.discovery_data))[4:] + else: + return ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(self.discovery_data).encode() + ) + if "discovery_result" in fixture_data: - discovery_data = {"result": fixture_data["discovery_result"]} + discovery_data = {"result": fixture_data["discovery_result"].copy()} device_type = fixture_data["discovery_result"]["device_type"] encrypt_type = fixture_data["discovery_result"]["mgt_encrypt_schm"][ "encrypt_type" ] login_version = fixture_data["discovery_result"]["mgt_encrypt_schm"].get("lv") - datagram = ( - b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" - + json_dumps(discovery_data).encode() - ) dm = _DiscoveryMock( ip, 80, @@ -113,16 +118,14 @@ class _DiscoveryMock: fixture_data, device_type, encrypt_type, - datagram, login_version, ) else: sys_info = fixture_data["system"]["get_sysinfo"] - discovery_data = {"system": {"get_sysinfo": sys_info}} + discovery_data = {"system": {"get_sysinfo": sys_info.copy()}} device_type = sys_info.get("mic_type") or sys_info.get("type") encrypt_type = "XOR" login_version = None - datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:] dm = _DiscoveryMock( ip, 9999, @@ -131,7 +134,6 @@ class _DiscoveryMock: fixture_data, device_type, encrypt_type, - datagram, login_version, ) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index b657b12ec..19eef1f75 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -2,6 +2,7 @@ # ruff: noqa: S106 import asyncio +import logging import re import socket from unittest.mock import MagicMock @@ -565,3 +566,38 @@ async def test_do_discover_external_cancel(mocker): with pytest.raises(asyncio.TimeoutError): async with asyncio_timeout(0): await dp.wait_for_discovery_to_complete() + + +async def test_discovery_redaction(discovery_mock, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + mac = "12:34:56:78:9A:BC" + + if discovery_mock.default_port == 9999: + sysinfo = discovery_mock.discovery_data["system"]["get_sysinfo"] + if "mac" in sysinfo: + sysinfo["mac"] = mac + elif "mic_mac" in sysinfo: + sysinfo["mic_mac"] = mac + else: + discovery_mock.discovery_data["result"]["mac"] = mac + + # Info no message logging + caplog.set_level(logging.INFO) + await Discover.discover() + + assert mac not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + Discover._redact_data = False + await Discover.discover() + assert mac in caplog.text + + # Debug redaction + caplog.clear() + Discover._redact_data = True + await Discover.discover() + assert mac not in caplog.text + assert "12:34:56:00:00:00" in caplog.text diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 1aeeedb27..cb38b6198 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -8,9 +8,12 @@ import pkgutil import struct import sys +from typing import cast import pytest +from kasa.iot import IotDevice + from ..aestransport import AesTransport from ..credentials import Credentials from ..device import Device @@ -21,8 +24,12 @@ from ..protocol import ( BaseProtocol, BaseTransport, + mask_mac, + redact_data, ) from ..xortransport import XorEncryption, XorTransport +from .conftest import device_iot +from .fakeprotocol_iot import FakeIotTransport @pytest.mark.parametrize( @@ -676,3 +683,63 @@ def test_deprecated_protocol(): host = "127.0.0.1" proto = TPLinkSmartHomeProtocol(host=host) assert proto.config.host == host + + +@device_iot +async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + device_id = "123456789ABCDEF" + cast(FakeIotTransport, dev.protocol._transport).proto["system"]["get_sysinfo"][ + "deviceId" + ] = device_id + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG, logger="kasa") + # The fake iot protocol also logs so disable it + test_logger = logging.getLogger("kasa.tests.fakeprotocol_iot") + test_logger.setLevel(logging.INFO) + + # Debug no redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = True + await dev.update() + assert device_id not in caplog.text + assert "REDACTED_" + device_id[9::] in caplog.text + + +async def test_redact_data(): + """Test redact data function.""" + data = { + "device_id": "123456789ABCDEF", + "owner": "0987654", + "mac": "12:34:56:78:90:AB", + "ip": "192.168.1", + "no_val": None, + } + excpected_data = { + "device_id": "REDACTED_ABCDEF", + "owner": "**REDACTED**", + "mac": "12:34:56:00:00:00", + "ip": "**REDACTEX**", + "no_val": None, + } + REDACTORS = { + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": None, + "mac": mask_mac, + "ip": lambda x: "127.0.0." + x.split(".")[3], + } + + redacted_data = redact_data(data, REDACTORS) + + assert redacted_data == excpected_data diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 204d0c7f2..058bfc3b3 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,8 +1,11 @@ import logging +from typing import cast import pytest import pytest_mock +from kasa.smart import SmartDevice + from ..exceptions import ( SMART_RETRYABLE_ERRORS, DeviceError, @@ -10,6 +13,7 @@ SmartErrorCode, ) from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper +from .conftest import device_smart from .fakeprotocol_smart import FakeSmartTransport DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -409,3 +413,34 @@ async def test_incomplete_list(mocker, caplog): "Device 127.0.0.123 returned empty results list for method get_preset_rules" in caplog.text ) + + +@device_smart +async def test_smart_queries_redaction( + dev: SmartDevice, caplog: pytest.LogCaptureFixture +): + """Test query sensitive info redaction.""" + device_id = "123456789ABCDEF" + cast(FakeSmartTransport, dev.protocol._transport).info["get_device_info"][ + "device_id" + ] = device_id + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + dev.protocol._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + dev.protocol._redact_data = True + await dev.update() + assert device_id not in caplog.text + assert "REDACTED_" + device_id[9::] in caplog.text diff --git a/kasa/xortransport.py b/kasa/xortransport.py index 52fba3d3e..e8d0303bd 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -19,7 +19,6 @@ import socket import struct from collections.abc import Generator -from pprint import pformat as pf # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -78,9 +77,8 @@ async def _execute_send(self, request: str) -> dict: """Execute a query on the device and wait for the response.""" assert self.writer is not None # noqa: S101 assert self.reader is not None # noqa: S101 - debug_log = _LOGGER.isEnabledFor(logging.DEBUG) - if debug_log: - _LOGGER.debug("%s >> %s", self._host, request) + _LOGGER.debug("Device %s sending query %s", self._host, request) + self.writer.write(XorEncryption.encrypt(request)) await self.writer.drain() @@ -90,8 +88,8 @@ async def _execute_send(self, request: str) -> dict: buffer = await self.reader.readexactly(length) response = XorEncryption.decrypt(buffer) json_payload = json_loads(response) - if debug_log: - _LOGGER.debug("%s << %s", self._host, pf(json_payload)) + + _LOGGER.debug("Device %s query response received", self._host) return json_payload From 06ff598d9c5148191b5711bae8caf2430ad22687 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 22 Jul 2024 19:33:31 +0100 Subject: [PATCH 24/38] Raise KasaException on decryption errors (#1078) Currently if the library encounters an invalid decryption error it raises a value error. This PR wraps it in a KasaException so consumers such as HA can catch an expected library exception. --- kasa/klaptransport.py | 11 ++++-- kasa/tests/test_klapprotocol.py | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index b7976101e..97b231453 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -50,7 +50,7 @@ import secrets import struct import time -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -354,9 +354,14 @@ async def send(self, request: str): else: _LOGGER.debug("Device %s query posted %s", self._host, msg) - # Check for mypy - if self._encryption_session is not None: + if TYPE_CHECKING: + assert self._encryption_session + try: decrypted_response = self._encryption_session.decrypt(response_data) + except Exception as ex: + raise KasaException( + f"Error trying to decrypt device {self._host} response: {ex}" + ) from ex json_payload = json_loads(decrypted_response) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index b71ea460d..0565683a1 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -238,6 +238,71 @@ def test_encrypt_unicode(): assert d == decrypted +async def test_transport_decrypt(mocker): + """Test transport decryption.""" + d = {"great": "success"} + + seed = secrets.token_bytes(16) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + transport = KlapTransport(config=DeviceConfig(host="127.0.0.1")) + transport._handshake_done = True + transport._session_expire_at = time.monotonic() + 60 + transport._encryption_session = encryption_session + + async def _return_response(url: URL, params=None, data=None, *_, **__): + encryption_session = KlapEncryptionSession( + transport._encryption_session.local_seed, + transport._encryption_session.remote_seed, + transport._encryption_session.user_hash, + ) + seq = params.get("seq") + encryption_session._seq = seq - 1 + encrypted, seq = encryption_session.encrypt(json.dumps(d)) + seq = seq + return 200, encrypted + + mocker.patch.object(HttpClient, "post", side_effect=_return_response) + + resp = await transport.send(json.dumps({})) + assert d == resp + + +async def test_transport_decrypt_error(mocker, caplog): + """Test that a decryption error raises a kasa exception.""" + d = {"great": "success"} + + seed = secrets.token_bytes(16) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + transport = KlapTransport(config=DeviceConfig(host="127.0.0.1")) + transport._handshake_done = True + transport._session_expire_at = time.monotonic() + 60 + transport._encryption_session = encryption_session + + async def _return_response(url: URL, params=None, data=None, *_, **__): + encryption_session = KlapEncryptionSession( + secrets.token_bytes(16), + transport._encryption_session.remote_seed, + transport._encryption_session.user_hash, + ) + seq = params.get("seq") + encryption_session._seq = seq - 1 + encrypted, seq = encryption_session.encrypt(json.dumps(d)) + seq = seq + return 200, encrypted + + mocker.patch.object(HttpClient, "post", side_effect=_return_response) + + with pytest.raises( + KasaException, + match="Error trying to decrypt device 127.0.0.1 response: Invalid padding bytes.", + ): + await transport.send(json.dumps({})) + + @pytest.mark.parametrize( "device_credentials, expectation", [ From 58afeb28a1e48436c0d8ed78f5efaba07284558d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jul 2024 19:02:20 +0100 Subject: [PATCH 25/38] Update smart request parameter handling (#1061) Changes to the smart request handling: - Do not send params if null - Drop the requestId parameter - get_preset_rules doesn't send parameters for preset component version less than 3 - get_led_info no longer sends the wrong parameters - get_on_off_gradually_info no longer sends an empty {} parameter --- kasa/smart/modules/led.py | 2 +- kasa/smart/modules/lightpreset.py | 3 + kasa/smart/modules/lighttransition.py | 2 +- kasa/smartprotocol.py | 93 +++------------------------ kasa/tests/fakeprotocol_smart.py | 10 +-- 5 files changed, 19 insertions(+), 91 deletions(-) diff --git a/kasa/smart/modules/led.py b/kasa/smart/modules/led.py index bbfe3579b..9c02be85a 100644 --- a/kasa/smart/modules/led.py +++ b/kasa/smart/modules/led.py @@ -16,7 +16,7 @@ class Led(SmartModule, LedInterface): def query(self) -> dict: """Query to execute during the update cycle.""" - return {self.QUERY_GETTER_NAME: {"led_rule": None}} + return {self.QUERY_GETTER_NAME: None} @property def mode(self): diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 6bb2fb3fa..16cd15ae2 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -153,6 +153,9 @@ def query(self) -> dict: """Query to execute during the update cycle.""" if self._state_in_sysinfo: # Child lights can have states in the child info return {} + if self.supported_version < 3: + return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {"start_index": 0}} async def _check_supported(self): diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 3a5897d12..e0aeb4d71 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -234,7 +234,7 @@ def query(self) -> dict: if self._state_in_sysinfo: return {} else: - return {self.QUERY_GETTER_NAME: {}} + return {self.QUERY_GETTER_NAME: None} async def _check_supported(self): """Additional check to see if the module is supported by the device.""" diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 8b22f0cba..8f92b94eb 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -66,7 +66,6 @@ def __init__( """Create a protocol object.""" super().__init__(transport=transport) self._terminal_uuid: str = base64.b64encode(md5(uuid.uuid4().bytes)).decode() - self._request_id_generator = SnowflakeId(1, 1) self._query_lock = asyncio.Lock() self._multi_request_batch_size = ( self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE @@ -77,11 +76,11 @@ def get_smart_request(self, method, params=None) -> str: """Get a request message as a string.""" request = { "method": method, - "params": params, - "requestID": self._request_id_generator.generate_id(), "request_time_milis": round(time.time() * 1000), "terminal_uuid": self._terminal_uuid, } + if params: + request["params"] = params return json_dumps(request) async def query(self, request: str | dict, retry_count: int = 3) -> dict: @@ -157,8 +156,10 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) multi_result: dict[str, Any] = {} smart_method = "multipleRequest" + multi_requests = [ - {"method": method, "params": params} for method, params in requests.items() + {"method": method, "params": params} if params else {"method": method} + for method, params in requests.items() ] end = len(multi_requests) @@ -168,7 +169,7 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic # If step is 1 do not send request batches for request in multi_requests: method = request["method"] - req = self.get_smart_request(method, request["params"]) + req = self.get_smart_request(method, request.get("params")) resp = await self._transport.send(req) self._handle_response_error_code(resp, method, raise_on_error=False) multi_result[method] = resp["result"] @@ -347,86 +348,6 @@ async def close(self) -> None: await self._transport.close() -class SnowflakeId: - """Class for generating snowflake ids.""" - - EPOCH = 1420041600000 # Custom epoch (in milliseconds) - WORKER_ID_BITS = 5 - DATA_CENTER_ID_BITS = 5 - SEQUENCE_BITS = 12 - - MAX_WORKER_ID = (1 << WORKER_ID_BITS) - 1 - MAX_DATA_CENTER_ID = (1 << DATA_CENTER_ID_BITS) - 1 - - SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1 - - def __init__(self, worker_id, data_center_id): - if worker_id > SnowflakeId.MAX_WORKER_ID or worker_id < 0: - raise ValueError( - "Worker ID can't be greater than " - + str(SnowflakeId.MAX_WORKER_ID) - + " or less than 0" - ) - if data_center_id > SnowflakeId.MAX_DATA_CENTER_ID or data_center_id < 0: - raise ValueError( - "Data center ID can't be greater than " - + str(SnowflakeId.MAX_DATA_CENTER_ID) - + " or less than 0" - ) - - self.worker_id = worker_id - self.data_center_id = data_center_id - self.sequence = 0 - self.last_timestamp = -1 - - def generate_id(self): - """Generate a snowflake id.""" - timestamp = self._current_millis() - - if timestamp < self.last_timestamp: - raise ValueError("Clock moved backwards. Refusing to generate ID.") - - if timestamp == self.last_timestamp: - # Within the same millisecond, increment the sequence number - self.sequence = (self.sequence + 1) & SnowflakeId.SEQUENCE_MASK - if self.sequence == 0: - # Sequence exceeds its bit range, wait until the next millisecond - timestamp = self._wait_next_millis(self.last_timestamp) - else: - # New millisecond, reset the sequence number - self.sequence = 0 - - # Update the last timestamp - self.last_timestamp = timestamp - - # Generate and return the final ID - return ( - ( - (timestamp - SnowflakeId.EPOCH) - << ( - SnowflakeId.WORKER_ID_BITS - + SnowflakeId.SEQUENCE_BITS - + SnowflakeId.DATA_CENTER_ID_BITS - ) - ) - | ( - self.data_center_id - << (SnowflakeId.SEQUENCE_BITS + SnowflakeId.WORKER_ID_BITS) - ) - | (self.worker_id << SnowflakeId.SEQUENCE_BITS) - | self.sequence - ) - - def _current_millis(self): - return round(time.monotonic() * 1000) - - def _wait_next_millis(self, last_timestamp): - timestamp = self._current_millis() - while timestamp <= last_timestamp: - timestamp = self._current_millis() - return timestamp - - class _ChildProtocolWrapper(SmartProtocol): """Protocol wrapper for controlling child devices. @@ -456,6 +377,8 @@ def _get_method_and_params_for_request(self, request): smart_method = "multipleRequest" requests = [ {"method": method, "params": params} + if params + else {"method": method} for method, params in request.items() ] smart_params = {"requests": requests} diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 600cd75d3..7a54be170 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -119,8 +119,9 @@ def credentials_hash(self): async def send(self, request: str): request_dict = json_loads(request) method = request_dict["method"] - params = request_dict["params"] + if method == "multipleRequest": + params = request_dict["params"] responses = [] for request in params["requests"]: response = self._send_request(request) # type: ignore[arg-type] @@ -308,12 +309,13 @@ def _edit_preset_rules(self, info, params): def _send_request(self, request_dict: dict): method = request_dict["method"] - params = request_dict["params"] info = self.info if method == "control_child": - return self._handle_control_child(params) - elif method == "component_nego" or method[:4] == "get_": + return self._handle_control_child(request_dict["params"]) + + params = request_dict.get("params") + if method == "component_nego" or method[:4] == "get_": if method in info: result = copy.deepcopy(info[method]) if "start_index" in result and "sum" in result: From ed033679e5f7e570129c1c6437562c670fa1bc26 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jul 2024 19:13:52 +0100 Subject: [PATCH 26/38] Split out main cli module into lazily loaded submodules (#1039) --- kasa/cli/__main__.py | 3 +- kasa/cli/common.py | 231 ++++++++ kasa/cli/device.py | 184 ++++++ kasa/cli/discover.py | 142 +++++ kasa/cli/feature.py | 134 +++++ kasa/cli/lazygroup.py | 70 +++ kasa/cli/light.py | 200 +++++++ kasa/cli/main.py | 1208 ++++------------------------------------ kasa/cli/schedule.py | 46 ++ kasa/cli/time.py | 55 ++ kasa/cli/usage.py | 134 +++++ kasa/cli/wifi.py | 50 ++ kasa/tests/test_cli.py | 42 +- pyproject.toml | 2 +- 14 files changed, 1393 insertions(+), 1108 deletions(-) create mode 100644 kasa/cli/common.py create mode 100644 kasa/cli/device.py create mode 100644 kasa/cli/discover.py create mode 100644 kasa/cli/feature.py create mode 100644 kasa/cli/lazygroup.py create mode 100644 kasa/cli/light.py create mode 100644 kasa/cli/schedule.py create mode 100644 kasa/cli/time.py create mode 100644 kasa/cli/usage.py create mode 100644 kasa/cli/wifi.py diff --git a/kasa/cli/__main__.py b/kasa/cli/__main__.py index 5d4ca6a05..1cf92da16 100644 --- a/kasa/cli/__main__.py +++ b/kasa/cli/__main__.py @@ -2,4 +2,5 @@ from kasa.cli.main import cli -cli() +if __name__ == "__main__": + cli() diff --git a/kasa/cli/common.py b/kasa/cli/common.py new file mode 100644 index 000000000..1977d0c83 --- /dev/null +++ b/kasa/cli/common.py @@ -0,0 +1,231 @@ +"""Common cli module.""" + +from __future__ import annotations + +import json +import re +import sys +from contextlib import contextmanager +from functools import singledispatch, update_wrapper, wraps +from typing import Final + +import asyncclick as click + +from kasa import ( + Device, +) + +# Value for optional options if passed without a value +OPTIONAL_VALUE_FLAG: Final = "_FLAG_" + +# Block list of commands which require no update +SKIP_UPDATE_COMMANDS = ["raw-command", "command"] + +pass_dev = click.make_pass_decorator(Device) # type: ignore[type-abstract] + + +try: + from rich import print as _echo +except ImportError: + # Strip out rich formatting if rich is not installed + # but only lower case tags to avoid stripping out + # raw data from the device that is printed from + # the device state. + rich_formatting = re.compile(r"\[/?[a-z]+]") + + def _strip_rich_formatting(echo_func): + """Strip rich formatting from messages.""" + + @wraps(echo_func) + def wrapper(message=None, *args, **kwargs): + if message is not None: + message = rich_formatting.sub("", message) + echo_func(message, *args, **kwargs) + + return wrapper + + _echo = _strip_rich_formatting(click.echo) + + +def echo(*args, **kwargs): + """Print a message.""" + ctx = click.get_current_context().find_root() + if "json" not in ctx.params or ctx.params["json"] is False: + _echo(*args, **kwargs) + + +def error(msg: str): + """Print an error and exit.""" + echo(f"[bold red]{msg}[/bold red]") + sys.exit(1) + + +def json_formatter_cb(result, **kwargs): + """Format and output the result as JSON, if requested.""" + if not kwargs.get("json"): + return + + @singledispatch + def to_serializable(val): + """Regular obj-to-string for json serialization. + + The singledispatch trick is from hynek: https://hynek.me/articles/serialization/ + """ + return str(val) + + @to_serializable.register(Device) + def _device_to_serializable(val: Device): + """Serialize smart device data, just using the last update raw payload.""" + return val.internal_state + + json_content = json.dumps(result, indent=4, default=to_serializable) + print(json_content) + + +def pass_dev_or_child(wrapped_function): + """Pass the device or child to the click command based on the child options.""" + child_help = ( + "Child ID or alias for controlling sub-devices. " + "If no value provided will show an interactive prompt allowing you to " + "select a child." + ) + child_index_help = "Child index controlling sub-devices" + + @contextmanager + def patched_device_update(parent: Device, child: Device): + try: + orig_update = child.update + # patch child update method. Can be removed once update can be called + # directly on child devices + child.update = parent.update # type: ignore[method-assign] + yield child + finally: + child.update = orig_update # type: ignore[method-assign] + + @click.pass_obj + @click.pass_context + @click.option( + "--child", + "--name", + is_flag=False, + flag_value=OPTIONAL_VALUE_FLAG, + default=None, + required=False, + type=click.STRING, + help=child_help, + ) + @click.option( + "--child-index", + "--index", + required=False, + default=None, + type=click.INT, + help=child_index_help, + ) + async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs): + if child := await _get_child_device(dev, child, child_index, ctx.info_name): + ctx.obj = ctx.with_resource(patched_device_update(dev, child)) + dev = child + return await ctx.invoke(wrapped_function, dev, *args, **kwargs) + + # Update wrapper function to look like wrapped function + return update_wrapper(wrapper, wrapped_function) + + +async def _get_child_device( + device: Device, child_option, child_index_option, info_command +) -> Device | None: + def _list_children(): + return "\n".join( + [ + f"{idx}: {child.device_id} ({child.alias})" + for idx, child in enumerate(device.children) + ] + ) + + if child_option is None and child_index_option is None: + return None + + if info_command in SKIP_UPDATE_COMMANDS: + # The device hasn't had update called (e.g. for cmd_command) + # The way child devices are accessed requires a ChildDevice to + # wrap the communications. Doing this properly would require creating + # a common interfaces for both IOT and SMART child devices. + # As a stop-gap solution, we perform an update instead. + await device.update() + + if not device.children: + error(f"Device: {device.host} does not have children") + + if child_option is not None and child_index_option is not None: + raise click.BadOptionUsage( + "child", "Use either --child or --child-index, not both." + ) + + if child_option is not None: + if child_option is OPTIONAL_VALUE_FLAG: + msg = _list_children() + child_index_option = click.prompt( + f"\n{msg}\nEnter the index number of the child device", + type=click.IntRange(0, len(device.children) - 1), + ) + elif child := device.get_child_device(child_option): + echo(f"Targeting child device {child.alias}") + return child + else: + error( + "No child device found with device_id or name: " + f"{child_option} children are:\n{_list_children()}" + ) + + if child_index_option + 1 > len(device.children) or child_index_option < 0: + error( + f"Invalid index {child_index_option}, " + f"device has {len(device.children)} children" + ) + child_by_index = device.children[child_index_option] + echo(f"Targeting child device {child_by_index.alias}") + return child_by_index + + +def CatchAllExceptions(cls): + """Capture all exceptions and prints them nicely. + + Idea from https://stackoverflow.com/a/44347763 and + https://stackoverflow.com/questions/52213375 + """ + + def _handle_exception(debug, exc): + if isinstance(exc, click.ClickException): + raise + # Handle exit request from click. + if isinstance(exc, click.exceptions.Exit): + sys.exit(exc.exit_code) + + echo(f"Raised error: {exc}") + if debug: + raise + echo("Run with --debug enabled to see stacktrace") + sys.exit(1) + + class _CommandCls(cls): + _debug = False + + async def make_context(self, info_name, args, parent=None, **extra): + self._debug = any( + [arg for arg in args if arg in ["--debug", "-d", "--verbose", "-v"]] + ) + try: + return await super().make_context( + info_name, args, parent=parent, **extra + ) + except Exception as exc: + _handle_exception(self._debug, exc) + + async def invoke(self, ctx): + try: + return await super().invoke(ctx) + except Exception as exc: + _handle_exception(self._debug, exc) + + return _CommandCls diff --git a/kasa/cli/device.py b/kasa/cli/device.py new file mode 100644 index 000000000..604380354 --- /dev/null +++ b/kasa/cli/device.py @@ -0,0 +1,184 @@ +"""Module for cli device commands.""" + +from __future__ import annotations + +from pprint import pformat as pf + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.smart import SmartDevice + +from .common import ( + echo, + error, + pass_dev, + pass_dev_or_child, +) + + +@click.group() +@pass_dev_or_child +def device(dev): + """Commands to control basic device settings.""" + + +@device.command() +@pass_dev_or_child +@click.pass_context +async def state(ctx, dev: Device): + """Print out device state and versions.""" + from .feature import _echo_all_features + + verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False + + echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") + echo(f"Host: {dev.host}") + echo(f"Port: {dev.port}") + echo(f"Device state: {dev.is_on}") + + echo(f"Time: {dev.time} (tz: {dev.timezone}") + echo(f"Hardware: {dev.hw_info['hw_ver']}") + echo(f"Software: {dev.hw_info['sw_ver']}") + echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") + if verbose: + echo(f"Location: {dev.location}") + + echo() + _echo_all_features(dev.features, verbose=verbose) + + if verbose: + echo("\n[bold]== Modules ==[/bold]") + for module in dev.modules.values(): + echo(f"[green]+ {module}[/green]") + + if dev.children: + echo("\n[bold]== Children ==[/bold]") + for child in dev.children: + _echo_all_features( + child.features, + title_prefix=f"{child.alias} ({child.model})", + verbose=verbose, + indent="\t", + ) + if verbose: + echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]") + for module in child.modules.values(): + echo(f"\t[green]+ {module}[/green]") + echo() + + if verbose: + echo("\n\t[bold]== Protocol information ==[/bold]") + echo(f"\tCredentials hash: {dev.credentials_hash}") + echo() + from .discover import _echo_discovery_info + + _echo_discovery_info(dev._discovery_info) + + return dev.internal_state + + +@device.command() +@pass_dev_or_child +async def sysinfo(dev): + """Print out full system information.""" + echo("== System info ==") + echo(pf(dev.sys_info)) + return dev.sys_info + + +@device.command() +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def on(dev: Device, transition: int): + """Turn the device on.""" + echo(f"Turning on {dev.alias}") + return await dev.turn_on(transition=transition) + + +@click.command +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def off(dev: Device, transition: int): + """Turn the device off.""" + echo(f"Turning off {dev.alias}") + return await dev.turn_off(transition=transition) + + +@device.command() +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def toggle(dev: Device, transition: int): + """Toggle the device on/off.""" + if dev.is_on: + echo(f"Turning off {dev.alias}") + return await dev.turn_off(transition=transition) + + echo(f"Turning on {dev.alias}") + return await dev.turn_on(transition=transition) + + +@device.command() +@click.argument("state", type=bool, required=False) +@pass_dev_or_child +async def led(dev: Device, state): + """Get or set (Plug's) led state.""" + if not (led := dev.modules.get(Module.Led)): + error("Device does not support led.") + return + if state is not None: + echo(f"Turning led to {state}") + return await led.set_led(state) + else: + echo(f"LED state: {led.led}") + return led.led + + +@device.command() +@click.argument("new_alias", required=False, default=None) +@pass_dev_or_child +async def alias(dev, new_alias): + """Get or set the device (or plug) alias.""" + if new_alias is not None: + echo(f"Setting alias to {new_alias}") + res = await dev.set_alias(new_alias) + await dev.update() + echo(f"Alias set to: {dev.alias}") + return res + + echo(f"Alias: {dev.alias}") + if dev.children: + for plug in dev.children: + echo(f" * {plug.alias}") + + return dev.alias + + +@device.command() +@click.option("--delay", default=1) +@pass_dev +async def reboot(plug, delay): + """Reboot the device.""" + echo("Rebooting the device..") + return await plug.reboot(delay) + + +@device.command() +@pass_dev +@click.option( + "--username", required=True, prompt=True, help="New username to set on the device" +) +@click.option( + "--password", required=True, prompt=True, help="New password to set on the device" +) +async def update_credentials(dev, username, password): + """Update device credentials for authenticated devices.""" + if not isinstance(dev, SmartDevice): + error("Credentials can only be updated on authenticated devices.") + + click.confirm("Do you really want to replace the existing credentials?", abort=True) + + return await dev.update_credentials(username, password) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py new file mode 100644 index 000000000..6bf58e725 --- /dev/null +++ b/kasa/cli/discover.py @@ -0,0 +1,142 @@ +"""Module for cli discovery commands.""" + +from __future__ import annotations + +import asyncio + +import asyncclick as click +from pydantic.v1 import ValidationError + +from kasa import ( + AuthenticationError, + Credentials, + Device, + Discover, + UnsupportedDeviceError, +) +from kasa.discover import DiscoveryResult + +from .common import echo + + +@click.command() +@click.pass_context +async def discover(ctx): + """Discover devices in the network.""" + target = ctx.parent.params["target"] + username = ctx.parent.params["username"] + password = ctx.parent.params["password"] + discovery_timeout = ctx.parent.params["discovery_timeout"] + timeout = ctx.parent.params["timeout"] + port = ctx.parent.params["port"] + + credentials = Credentials(username, password) if username and password else None + + sem = asyncio.Semaphore() + discovered = dict() + unsupported = [] + auth_failed = [] + + async def print_unsupported(unsupported_exception: UnsupportedDeviceError): + unsupported.append(unsupported_exception) + async with sem: + if unsupported_exception.discovery_result: + echo("== Unsupported device ==") + _echo_discovery_info(unsupported_exception.discovery_result) + echo() + else: + echo("== Unsupported device ==") + echo(f"\t{unsupported_exception}") + echo() + + echo(f"Discovering devices on {target} for {discovery_timeout} seconds") + + from .device import state + + async def print_discovered(dev: Device): + async with sem: + try: + await dev.update() + except AuthenticationError: + auth_failed.append(dev._discovery_info) + echo("== Authentication failed for device ==") + _echo_discovery_info(dev._discovery_info) + echo() + else: + ctx.parent.obj = dev + await ctx.parent.invoke(state) + discovered[dev.host] = dev.internal_state + echo() + + discovered_devices = await Discover.discover( + target=target, + discovery_timeout=discovery_timeout, + on_discovered=print_discovered, + on_unsupported=print_unsupported, + port=port, + timeout=timeout, + credentials=credentials, + ) + + for device in discovered_devices.values(): + await device.protocol.close() + + echo(f"Found {len(discovered)} devices") + if unsupported: + echo(f"Found {len(unsupported)} unsupported devices") + if auth_failed: + echo(f"Found {len(auth_failed)} devices that failed to authenticate") + + return discovered + + +def _echo_dictionary(discovery_info: dict): + echo("\t[bold]== Discovery information ==[/bold]") + for key, value in discovery_info.items(): + key_name = " ".join(x.capitalize() or "_" for x in key.split("_")) + key_name_and_spaces = "{:<15}".format(key_name + ":") + echo(f"\t{key_name_and_spaces}{value}") + + +def _echo_discovery_info(discovery_info): + # We don't have discovery info when all connection params are passed manually + if discovery_info is None: + return + + if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: + _echo_dictionary(discovery_info["system"]["get_sysinfo"]) + return + + try: + dr = DiscoveryResult(**discovery_info) + except ValidationError: + _echo_dictionary(discovery_info) + return + + echo("\t[bold]== Discovery Result ==[/bold]") + echo(f"\tDevice Type: {dr.device_type}") + echo(f"\tDevice Model: {dr.device_model}") + echo(f"\tIP: {dr.ip}") + echo(f"\tMAC: {dr.mac}") + echo(f"\tDevice Id (hash): {dr.device_id}") + echo(f"\tOwner (hash): {dr.owner}") + echo(f"\tHW Ver: {dr.hw_ver}") + echo(f"\tSupports IOT Cloud: {dr.is_support_iot_cloud}") + echo(f"\tOBD Src: {dr.obd_src}") + echo(f"\tFactory Default: {dr.factory_default}") + echo(f"\tEncrypt Type: {dr.mgt_encrypt_schm.encrypt_type}") + echo(f"\tSupports HTTPS: {dr.mgt_encrypt_schm.is_support_https}") + echo(f"\tHTTP Port: {dr.mgt_encrypt_schm.http_port}") + echo(f"\tLV (Login Level): {dr.mgt_encrypt_schm.lv}") + + +async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): + """Discover a device identified by its alias.""" + for _attempt in range(1, attempts): + found_devs = await Discover.discover(target=target, timeout=timeout) + for _ip, dev in found_devs.items(): + if dev.alias.lower() == alias.lower(): + host = dev.host + return host + + return None diff --git a/kasa/cli/feature.py b/kasa/cli/feature.py new file mode 100644 index 000000000..f8cba4e32 --- /dev/null +++ b/kasa/cli/feature.py @@ -0,0 +1,134 @@ +"""Module for cli feature commands.""" + +from __future__ import annotations + +import ast + +import asyncclick as click + +from kasa import ( + Device, + Feature, +) + +from .common import ( + echo, + error, + pass_dev_or_child, +) + + +def _echo_features( + features: dict[str, Feature], + title: str, + category: Feature.Category | None = None, + verbose: bool = False, + indent: str = "\t", +): + """Print out a listing of features and their values.""" + if category is not None: + features = { + id_: feat for id_, feat in features.items() if feat.category == category + } + + echo(f"{indent}[bold]{title}[/bold]") + for _, feat in features.items(): + try: + echo(f"{indent}{feat}") + if verbose: + echo(f"{indent}\tType: {feat.type}") + echo(f"{indent}\tCategory: {feat.category}") + echo(f"{indent}\tIcon: {feat.icon}") + except Exception as ex: + echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]") + + +def _echo_all_features(features, *, verbose=False, title_prefix=None, indent=""): + """Print out all features by category.""" + if title_prefix is not None: + echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]") + echo() + _echo_features( + features, + title="== Primary features ==", + category=Feature.Category.Primary, + verbose=verbose, + indent=indent, + ) + echo() + _echo_features( + features, + title="== Information ==", + category=Feature.Category.Info, + verbose=verbose, + indent=indent, + ) + echo() + _echo_features( + features, + title="== Configuration ==", + category=Feature.Category.Config, + verbose=verbose, + indent=indent, + ) + echo() + _echo_features( + features, + title="== Debug ==", + category=Feature.Category.Debug, + verbose=verbose, + indent=indent, + ) + + +@click.command(name="feature") +@click.argument("name", required=False) +@click.argument("value", required=False) +@pass_dev_or_child +@click.pass_context +async def feature( + ctx: click.Context, + dev: Device, + name: str, + value, +): + """Access and modify features. + + If no *name* is given, lists available features and their values. + If only *name* is given, the value of named feature is returned. + If both *name* and *value* are set, the described setting is changed. + """ + verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False + + if not name: + _echo_all_features(dev.features, verbose=verbose, indent="") + + if dev.children: + for child_dev in dev.children: + _echo_all_features( + child_dev.features, + verbose=verbose, + title_prefix=f"Child {child_dev.alias}", + indent="\t", + ) + + return + + if name not in dev.features: + error(f"No feature by name '{name}'") + return + + feat = dev.features[name] + + if value is None: + unit = f" {feat.unit}" if feat.unit else "" + echo(f"{feat.name} ({name}): {feat.value}{unit}") + return feat.value + + value = ast.literal_eval(value) + echo(f"Changing {name} from {feat.value} to {value}") + response = await dev.features[name].set_value(value) + await dev.update() + echo(f"New state: {feat.value}") + + return response diff --git a/kasa/cli/lazygroup.py b/kasa/cli/lazygroup.py new file mode 100644 index 000000000..9e9724aae --- /dev/null +++ b/kasa/cli/lazygroup.py @@ -0,0 +1,70 @@ +"""Module for lazily instantiating sub modules. + +Taken from the click help files. +""" + +import importlib + +import asyncclick as click + + +class LazyGroup(click.Group): + """Lazy group class.""" + + def __init__(self, *args, lazy_subcommands=None, **kwargs): + super().__init__(*args, **kwargs) + # lazy_subcommands is a map of the form: + # + # {command-name} -> {module-name}.{command-object-name} + # + self.lazy_subcommands = lazy_subcommands or {} + + def list_commands(self, ctx): + """List click commands.""" + base = super().list_commands(ctx) + lazy = list(self.lazy_subcommands.keys()) + return lazy + base + + def get_command(self, ctx, cmd_name): + """Get click command.""" + if cmd_name in self.lazy_subcommands: + return self._lazy_load(cmd_name) + return super().get_command(ctx, cmd_name) + + def format_commands(self, ctx, formatter): + """Format the top level help output.""" + sections = {} + for cmd, parent in self.lazy_subcommands.items(): + sections.setdefault(parent, []) + cmd_obj = self.get_command(ctx, cmd) + help = cmd_obj.get_short_help_str() + sections[parent].append((cmd, help)) + for section in sections: + if section: + header = ( + f"Common {section} commands (also available " + f"under the `{section}` subcommand)" + ) + else: + header = "Subcommands" + with formatter.section(header): + formatter.write_dl(sections[section]) + + def _lazy_load(self, cmd_name): + # lazily loading a command, first get the module name and attribute name + if not (import_path := self.lazy_subcommands[cmd_name]): + import_path = f".{cmd_name}.{cmd_name}" + else: + import_path = f".{import_path}.{cmd_name}" + modname, cmd_object_name = import_path.rsplit(".", 1) + # do the import + mod = importlib.import_module(modname, package=__package__) + # get the Command object from that module + cmd_object = getattr(mod, cmd_object_name) + # check the result to make debugging easier + if not isinstance(cmd_object, click.BaseCommand): + raise ValueError( + f"Lazy loading of {cmd_name} failed by returning " + "a non-command object" + ) + return cmd_object diff --git a/kasa/cli/light.py b/kasa/cli/light.py new file mode 100644 index 000000000..06c469077 --- /dev/null +++ b/kasa/cli/light.py @@ -0,0 +1,200 @@ +"""Module for cli light control commands.""" + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.iot import ( + IotBulb, +) + +from .common import echo, error, pass_dev_or_child + + +@click.group() +@pass_dev_or_child +def light(dev): + """Commands to control light settings.""" + + +@light.command() +@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +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: + error("This device does not support brightness.") + return + + if brightness is None: + echo(f"Brightness: {light.brightness}") + return light.brightness + else: + echo(f"Setting brightness to {brightness}") + return await light.set_brightness(brightness, transition=transition) + + +@light.command() +@click.argument( + "temperature", type=click.IntRange(2500, 9000), default=None, required=False +) +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +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: + error("Device does not support color temperature") + return + + if temperature is None: + echo(f"Color temperature: {light.color_temp}") + valid_temperature_range = light.valid_temperature_range + if valid_temperature_range != (0, 0): + echo("(min: {}, max: {})".format(*valid_temperature_range)) + else: + echo( + "Temperature range unknown, please open a github issue" + f" or a pull request for model '{dev.model}'" + ) + return light.valid_temperature_range + else: + echo(f"Setting color temperature to {temperature}") + return await light.set_color_temp(temperature, transition=transition) + + +@light.command() +@click.argument("effect", type=click.STRING, default=None, required=False) +@click.pass_context +@pass_dev_or_child +async def effect(dev: Device, ctx, effect): + """Set an effect.""" + if not (light_effect := dev.modules.get(Module.LightEffect)): + error("Device does not support effects") + return + if effect is None: + echo( + f"Light effect: {light_effect.effect}\n" + + f"Available Effects: {light_effect.effect_list}" + ) + return light_effect.effect + + if effect not in light_effect.effect_list: + raise click.BadArgumentUsage( + f"Effect must be one of: {light_effect.effect_list}", ctx + ) + + echo(f"Setting Effect: {effect}") + return await light_effect.set_effect(effect) + + +@light.command() +@click.argument("h", type=click.IntRange(0, 360), default=None, required=False) +@click.argument("s", type=click.IntRange(0, 100), default=None, required=False) +@click.argument("v", type=click.IntRange(0, 100), default=None, required=False) +@click.option("--transition", type=int, required=False) +@click.pass_context +@pass_dev_or_child +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: + error("Device does not support colors") + return + + if h is None and s is None and v is None: + echo(f"Current HSV: {light.hsv}") + return light.hsv + elif s is None or v is None: + raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) + else: + echo(f"Setting HSV: {h} {s} {v}") + return await light.set_hsv(h, s, v, transition=transition) + + +@light.group(invoke_without_command=True) +@pass_dev_or_child +@click.pass_context +async def presets(ctx, dev): + """List and modify bulb setting presets.""" + if ctx.invoked_subcommand is None: + return await ctx.invoke(presets_list) + + +@presets.command(name="list") +@pass_dev_or_child +def presets_list(dev: Device): + """List presets.""" + if not (light_preset := dev.modules.get(Module.LightPreset)): + error("Presets not supported on device") + return + + for preset in light_preset.preset_states_list: + echo(preset) + + return light_preset.preset_states_list + + +@presets.command(name="modify") +@click.argument("index", type=int) +@click.option("--brightness", type=int) +@click.option("--hue", type=int) +@click.option("--saturation", type=int) +@click.option("--temperature", type=int) +@pass_dev_or_child +async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): + """Modify a preset.""" + for preset in dev.presets: + if preset.index == index: + break + else: + error(f"No preset found for index {index}") + return + + if brightness is not None: + preset.brightness = brightness + if hue is not None: + preset.hue = hue + if saturation is not None: + preset.saturation = saturation + if temperature is not None: + preset.color_temp = temperature + + echo(f"Going to save preset: {preset}") + + return await dev.save_preset(preset) + + +@light.command() +@pass_dev_or_child +@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) +@click.option("--last", is_flag=True) +@click.option("--preset", type=int) +async def turn_on_behavior(dev: Device, type, last, preset): + """Modify bulb turn-on behavior.""" + if not dev.is_bulb or not isinstance(dev, IotBulb): + error("Presets only supported on iot bulbs") + return + settings = await dev.get_turn_on_behavior() + echo(f"Current turn on behavior: {settings}") + + # Return if we are not setting the value + if not type and not last and not preset: + return settings + + # If we are setting the value, the type has to be specified + if (last or preset) and type is None: + echo("To set the behavior, you need to define --type") + return + + behavior = getattr(settings, type) + + if last: + echo(f"Going to set {type} to last") + behavior.preset = None + elif preset is not None: + echo(f"Going to set {type} to preset {preset}") + behavior.preset = preset + + return await dev.set_turn_on_behavior(settings) diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 10c422978..88b768c41 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -1,4 +1,4 @@ -"""python-kasa cli tool.""" +"""Main module for cli tool.""" from __future__ import annotations @@ -6,282 +6,90 @@ import asyncio import json import logging -import re import sys -from contextlib import asynccontextmanager, contextmanager -from datetime import datetime -from functools import singledispatch, update_wrapper, wraps -from pprint import pformat as pf -from typing import Any, Final, cast +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any import asyncclick as click -from pydantic.v1 import ValidationError - -from kasa import ( - AuthenticationError, - Credentials, - Device, - DeviceConfig, - DeviceConnectionParameters, - DeviceEncryptionType, - DeviceFamily, - Discover, - Feature, - KasaException, - Module, - UnsupportedDeviceError, -) -from kasa.discover import DiscoveryResult -from kasa.iot import ( - IotBulb, - IotDevice, - IotDimmer, - IotLightStrip, - IotPlug, - IotStrip, - IotWallSwitch, -) -from kasa.iot.iotstrip import IotStripPlug -from kasa.iot.modules import Usage -from kasa.smart import SmartDevice - -try: - from rich import print as _do_echo -except ImportError: - # Strip out rich formatting if rich is not installed - # but only lower case tags to avoid stripping out - # raw data from the device that is printed from - # the device state. - rich_formatting = re.compile(r"\[/?[a-z]+]") - - def _strip_rich_formatting(echo_func): - """Strip rich formatting from messages.""" - - @wraps(echo_func) - def wrapper(message=None, *args, **kwargs): - if message is not None: - message = rich_formatting.sub("", message) - echo_func(message, *args, **kwargs) - - return wrapper - - _do_echo = _strip_rich_formatting(click.echo) - -# echo is set to _do_echo so that it can be reset to _do_echo later after -# --json has set it to _nop_echo -echo = _do_echo - - -def error(msg: str): - """Print an error and exit.""" - echo(f"[bold red]{msg}[/bold red]") - sys.exit(1) +if TYPE_CHECKING: + from kasa import Device -# Value for optional options if passed without a value -OPTIONAL_VALUE_FLAG: Final = "_FLAG_" +from kasa.deviceconfig import DeviceEncryptionType -TYPE_TO_CLASS = { - "plug": IotPlug, - "switch": IotWallSwitch, - "bulb": IotBulb, - "dimmer": IotDimmer, - "strip": IotStrip, - "lightstrip": IotLightStrip, - "iot.plug": IotPlug, - "iot.switch": IotWallSwitch, - "iot.bulb": IotBulb, - "iot.dimmer": IotDimmer, - "iot.strip": IotStrip, - "iot.lightstrip": IotLightStrip, - "smart.plug": SmartDevice, - "smart.bulb": SmartDevice, -} +from .common import ( + SKIP_UPDATE_COMMANDS, + CatchAllExceptions, + echo, + error, + json_formatter_cb, + pass_dev_or_child, +) +from .lazygroup import LazyGroup + +TYPES = [ + "plug", + "switch", + "bulb", + "dimmer", + "strip", + "lightstrip", + "smart", +] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] -DEVICE_FAMILY_TYPES = [device_family_type.value for device_family_type in DeviceFamily] - -# Block list of commands which require no update -SKIP_UPDATE_COMMANDS = ["raw-command", "command"] - -pass_dev = click.make_pass_decorator(Device) # type: ignore[type-abstract] - - -def CatchAllExceptions(cls): - """Capture all exceptions and prints them nicely. - - Idea from https://stackoverflow.com/a/44347763 and - https://stackoverflow.com/questions/52213375 - """ - - def _handle_exception(debug, exc): - if isinstance(exc, click.ClickException): - raise - # Handle exit request from click. - if isinstance(exc, click.exceptions.Exit): - sys.exit(exc.exit_code) - - echo(f"Raised error: {exc}") - if debug: - raise - echo("Run with --debug enabled to see stacktrace") - sys.exit(1) - - class _CommandCls(cls): - _debug = False - - async def make_context(self, info_name, args, parent=None, **extra): - self._debug = any( - [arg for arg in args if arg in ["--debug", "-d", "--verbose", "-v"]] - ) - try: - return await super().make_context( - info_name, args, parent=parent, **extra - ) - except Exception as exc: - _handle_exception(self._debug, exc) - - async def invoke(self, ctx): - try: - return await super().invoke(ctx) - except Exception as exc: - _handle_exception(self._debug, exc) - return _CommandCls - - -def json_formatter_cb(result, **kwargs): - """Format and output the result as JSON, if requested.""" - if not kwargs.get("json"): - return - - @singledispatch - def to_serializable(val): - """Regular obj-to-string for json serialization. - - The singledispatch trick is from hynek: https://hynek.me/articles/serialization/ - """ - return str(val) - - @to_serializable.register(Device) - def _device_to_serializable(val: Device): - """Serialize smart device data, just using the last update raw payload.""" - return val.internal_state - - json_content = json.dumps(result, indent=4, default=to_serializable) - print(json_content) - - -def pass_dev_or_child(wrapped_function): - """Pass the device or child to the click command based on the child options.""" - child_help = ( - "Child ID or alias for controlling sub-devices. " - "If no value provided will show an interactive prompt allowing you to " - "select a child." +def _legacy_type_to_class(_type): + from kasa.iot import ( + IotBulb, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, ) - child_index_help = "Child index controlling sub-devices" - - @contextmanager - def patched_device_update(parent: Device, child: Device): - try: - orig_update = child.update - # patch child update method. Can be removed once update can be called - # directly on child devices - child.update = parent.update # type: ignore[method-assign] - yield child - finally: - child.update = orig_update # type: ignore[method-assign] - - @click.pass_obj - @click.pass_context - @click.option( - "--child", - "--name", - is_flag=False, - flag_value=OPTIONAL_VALUE_FLAG, - default=None, - required=False, - type=click.STRING, - help=child_help, - ) - @click.option( - "--child-index", - "--index", - required=False, - default=None, - type=click.INT, - help=child_index_help, - ) - async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs): - if child := await _get_child_device(dev, child, child_index, ctx.info_name): - ctx.obj = ctx.with_resource(patched_device_update(dev, child)) - dev = child - return await ctx.invoke(wrapped_function, dev, *args, **kwargs) - - # Update wrapper function to look like wrapped function - return update_wrapper(wrapper, wrapped_function) - - -async def _get_child_device( - device: Device, child_option, child_index_option, info_command -) -> Device | None: - def _list_children(): - return "\n".join( - [ - f"{idx}: {child.device_id} ({child.alias})" - for idx, child in enumerate(device.children) - ] - ) - - if child_option is None and child_index_option is None: - return None - - if info_command in SKIP_UPDATE_COMMANDS: - # The device hasn't had update called (e.g. for cmd_command) - # The way child devices are accessed requires a ChildDevice to - # wrap the communications. Doing this properly would require creating - # a common interfaces for both IOT and SMART child devices. - # As a stop-gap solution, we perform an update instead. - await device.update() - if not device.children: - error(f"Device: {device.host} does not have children") - - if child_option is not None and child_index_option is not None: - raise click.BadOptionUsage( - "child", "Use either --child or --child-index, not both." - ) - - if child_option is not None: - if child_option is OPTIONAL_VALUE_FLAG: - msg = _list_children() - child_index_option = click.prompt( - f"\n{msg}\nEnter the index number of the child device", - type=click.IntRange(0, len(device.children) - 1), - ) - elif child := device.get_child_device(child_option): - echo(f"Targeting child device {child.alias}") - return child - else: - error( - "No child device found with device_id or name: " - f"{child_option} children are:\n{_list_children()}" - ) - - if child_index_option + 1 > len(device.children) or child_index_option < 0: - error( - f"Invalid index {child_index_option}, " - f"device has {len(device.children)} children" - ) - child_by_index = device.children[child_index_option] - echo(f"Targeting child device {child_by_index.alias}") - return child_by_index + TYPE_TO_CLASS = { + "plug": IotPlug, + "switch": IotWallSwitch, + "bulb": IotBulb, + "dimmer": IotDimmer, + "strip": IotStrip, + "lightstrip": IotLightStrip, + } + return TYPE_TO_CLASS[_type] @click.group( invoke_without_command=True, - cls=CatchAllExceptions(click.Group), + cls=CatchAllExceptions(LazyGroup), + lazy_subcommands={ + "discover": None, + "device": None, + "feature": None, + "light": None, + "wifi": None, + "time": None, + "schedule": None, + "usage": None, + # device commands runnnable at top level + "state": "device", + "on": "device", + "off": "device", + "toggle": "device", + "led": "device", + "alias": "device", + "reboot": "device", + "update_credentials": "device", + "sysinfo": "device", + # light commands runnnable at top level + "presets": "light", + "brightness": "light", + "hsv": "light", + "temperature": "light", + "effect": "light", + }, result_callback=json_formatter_cb, ) @click.option( @@ -332,7 +140,8 @@ def _list_children(): "--type", envvar="KASA_TYPE", default=None, - type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False), + type=click.Choice(TYPES, case_sensitive=False), + help="The device type in order to bypass discovery. Use `smart` for newer devices", ) @click.option( "--json/--no-json", @@ -352,7 +161,7 @@ def _list_children(): "--device-family", envvar="KASA_DEVICE_FAMILY", default="SMART.TAPOPLUG", - type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False), + help="Device family type, e.g. `SMART.KASASWITCH`. Deprecated use `--type smart`", ) @click.option( "-lv", @@ -360,6 +169,7 @@ def _list_children(): envvar="KASA_LOGIN_VERSION", default=2, type=int, + help="The login version for device authentication. Defaults to 2", ) @click.option( "--timeout", @@ -426,19 +236,6 @@ async def cli( ctx.obj = object() return - # If JSON output is requested, disable echo - global echo - if json: - - def _nop_echo(*args, **kwargs): - pass - - echo = _nop_echo - else: - # Set back to default is required if running tests with CliRunner - global _do_echo - echo = _do_echo - logging_config: dict[str, Any] = { "level": logging.DEBUG if debug > 0 else logging.INFO } @@ -465,6 +262,9 @@ def _nop_echo(*args, **kwargs): if alias is not None and host is None: echo(f"Alias is given, using discovery to find host {alias}") + + from .discover import find_host_from_alias + host = await find_host_from_alias(alias=alias, target=target) if host: echo(f"Found hostname is {host}") @@ -478,6 +278,8 @@ def _nop_echo(*args, **kwargs): ) if username: + from kasa.credentials import Credentials + credentials = Credentials(username=username, password=password) else: credentials = None @@ -487,13 +289,27 @@ def _nop_echo(*args, **kwargs): error("Only discover is available without --host or --alias") echo("No host name given, trying discovery..") + from .discover import discover + return await ctx.invoke(discover) device_updated = False - if type is not None: + if type is not None and type != "smart": + from kasa.deviceconfig import DeviceConfig + config = DeviceConfig(host=host, port_override=port, timeout=timeout) - dev = TYPE_TO_CLASS[type](host, config=config) - elif device_family and encrypt_type: + dev = _legacy_type_to_class(type)(host, config=config) + elif type == "smart" or (device_family and encrypt_type): + from kasa.device import Device + from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, + ) + + if not encrypt_type: + encrypt_type = "KLAP" ctype = DeviceConnectionParameters( DeviceFamily(device_family), DeviceEncryptionType(encrypt_type), @@ -510,6 +326,8 @@ def _nop_echo(*args, **kwargs): dev = await Device.connect(config=config) device_updated = True else: + from kasa.discover import Discover + dev = await Discover.discover_single( host, port=port, @@ -533,307 +351,30 @@ async def async_wrapped_device(device: Device): ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev)) if ctx.invoked_subcommand is None: - return await ctx.invoke(state) - - -@cli.group() -@pass_dev -def wifi(dev): - """Commands to control wifi settings.""" - - -@wifi.command() -@pass_dev -async def scan(dev): - """Scan for available wifi networks.""" - echo("Scanning for wifi networks, wait a second..") - devs = await dev.wifi_scan() - echo(f"Found {len(devs)} wifi networks!") - for dev in devs: - echo(f"\t {dev}") - - return devs - - -@wifi.command() -@click.argument("ssid") -@click.option("--keytype", prompt=True) -@click.option("--password", prompt=True, hide_input=True) -@pass_dev -async def join(dev: Device, ssid: str, password: str, keytype: str): - """Join the given wifi network.""" - echo(f"Asking the device to connect to {ssid}..") - res = await dev.wifi_join(ssid, password, keytype=keytype) - echo( - f"Response: {res} - if the device is not able to join the network, " - f"it will revert back to its previous state." - ) - - return res - - -@cli.command() -@click.pass_context -async def discover(ctx): - """Discover devices in the network.""" - target = ctx.parent.params["target"] - username = ctx.parent.params["username"] - password = ctx.parent.params["password"] - discovery_timeout = ctx.parent.params["discovery_timeout"] - timeout = ctx.parent.params["timeout"] - port = ctx.parent.params["port"] - - credentials = Credentials(username, password) if username and password else None - - sem = asyncio.Semaphore() - discovered = dict() - unsupported = [] - auth_failed = [] - - async def print_unsupported(unsupported_exception: UnsupportedDeviceError): - unsupported.append(unsupported_exception) - async with sem: - if unsupported_exception.discovery_result: - echo("== Unsupported device ==") - _echo_discovery_info(unsupported_exception.discovery_result) - echo() - else: - echo("== Unsupported device ==") - echo(f"\t{unsupported_exception}") - echo() - - echo(f"Discovering devices on {target} for {discovery_timeout} seconds") - - async def print_discovered(dev: Device): - async with sem: - try: - await dev.update() - except AuthenticationError: - auth_failed.append(dev._discovery_info) - echo("== Authentication failed for device ==") - _echo_discovery_info(dev._discovery_info) - echo() - else: - ctx.parent.obj = dev - await ctx.parent.invoke(state) - discovered[dev.host] = dev.internal_state - echo() - - discovered_devices = await Discover.discover( - target=target, - discovery_timeout=discovery_timeout, - on_discovered=print_discovered, - on_unsupported=print_unsupported, - port=port, - timeout=timeout, - credentials=credentials, - ) - - for device in discovered_devices.values(): - await device.protocol.close() + from .device import state - echo(f"Found {len(discovered)} devices") - if unsupported: - echo(f"Found {len(unsupported)} unsupported devices") - if auth_failed: - echo(f"Found {len(auth_failed)} devices that failed to authenticate") - - return discovered - - -def _echo_dictionary(discovery_info: dict): - echo("\t[bold]== Discovery information ==[/bold]") - for key, value in discovery_info.items(): - key_name = " ".join(x.capitalize() or "_" for x in key.split("_")) - key_name_and_spaces = "{:<15}".format(key_name + ":") - echo(f"\t{key_name_and_spaces}{value}") - - -def _echo_discovery_info(discovery_info): - # We don't have discovery info when all connection params are passed manually - if discovery_info is None: - return - - if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: - _echo_dictionary(discovery_info["system"]["get_sysinfo"]) - return - - try: - dr = DiscoveryResult(**discovery_info) - except ValidationError: - _echo_dictionary(discovery_info) - return - - echo("\t[bold]== Discovery Result ==[/bold]") - echo(f"\tDevice Type: {dr.device_type}") - echo(f"\tDevice Model: {dr.device_model}") - echo(f"\tIP: {dr.ip}") - echo(f"\tMAC: {dr.mac}") - echo(f"\tDevice Id (hash): {dr.device_id}") - echo(f"\tOwner (hash): {dr.owner}") - echo(f"\tHW Ver: {dr.hw_ver}") - echo(f"\tSupports IOT Cloud: {dr.is_support_iot_cloud}") - echo(f"\tOBD Src: {dr.obd_src}") - echo(f"\tFactory Default: {dr.factory_default}") - echo(f"\tEncrypt Type: {dr.mgt_encrypt_schm.encrypt_type}") - echo(f"\tSupports HTTPS: {dr.mgt_encrypt_schm.is_support_https}") - echo(f"\tHTTP Port: {dr.mgt_encrypt_schm.http_port}") - echo(f"\tLV (Login Level): {dr.mgt_encrypt_schm.lv}") - - -async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): - """Discover a device identified by its alias.""" - for _attempt in range(1, attempts): - found_devs = await Discover.discover(target=target, timeout=timeout) - for _ip, dev in found_devs.items(): - if dev.alias.lower() == alias.lower(): - host = dev.host - return host - - return None - - -@cli.command() -@pass_dev_or_child -async def sysinfo(dev): - """Print out full system information.""" - echo("== System info ==") - echo(pf(dev.sys_info)) - return dev.sys_info - - -def _echo_features( - features: dict[str, Feature], - title: str, - category: Feature.Category | None = None, - verbose: bool = False, - indent: str = "\t", -): - """Print out a listing of features and their values.""" - if category is not None: - features = { - id_: feat for id_, feat in features.items() if feat.category == category - } - - echo(f"{indent}[bold]{title}[/bold]") - for _, feat in features.items(): - try: - echo(f"{indent}{feat}") - if verbose: - echo(f"{indent}\tType: {feat.type}") - echo(f"{indent}\tCategory: {feat.category}") - echo(f"{indent}\tIcon: {feat.icon}") - except Exception as ex: - echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]") - - -def _echo_all_features(features, *, verbose=False, title_prefix=None, indent=""): - """Print out all features by category.""" - if title_prefix is not None: - echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]") - echo() - _echo_features( - features, - title="== Primary features ==", - category=Feature.Category.Primary, - verbose=verbose, - indent=indent, - ) - echo() - _echo_features( - features, - title="== Information ==", - category=Feature.Category.Info, - verbose=verbose, - indent=indent, - ) - echo() - _echo_features( - features, - title="== Configuration ==", - category=Feature.Category.Config, - verbose=verbose, - indent=indent, - ) - echo() - _echo_features( - features, - title="== Debug ==", - category=Feature.Category.Debug, - verbose=verbose, - indent=indent, - ) - - -@cli.command() -@pass_dev_or_child -@click.pass_context -async def state(ctx, dev: Device): - """Print out device state and versions.""" - verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False - - echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") - echo(f"Host: {dev.host}") - echo(f"Port: {dev.port}") - echo(f"Device state: {dev.is_on}") - - echo(f"Time: {dev.time} (tz: {dev.timezone}") - echo(f"Hardware: {dev.hw_info['hw_ver']}") - echo(f"Software: {dev.hw_info['sw_ver']}") - echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") - if verbose: - echo(f"Location: {dev.location}") - - echo() - _echo_all_features(dev.features, verbose=verbose) - - if verbose: - echo("\n[bold]== Modules ==[/bold]") - for module in dev.modules.values(): - echo(f"[green]+ {module}[/green]") - - if dev.children: - echo("\n[bold]== Children ==[/bold]") - for child in dev.children: - _echo_all_features( - child.features, - title_prefix=f"{child.alias} ({child.model})", - verbose=verbose, - indent="\t", - ) - if verbose: - echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]") - for module in child.modules.values(): - echo(f"\t[green]+ {module}[/green]") - echo() - - if verbose: - echo("\n\t[bold]== Protocol information ==[/bold]") - echo(f"\tCredentials hash: {dev.credentials_hash}") - echo() - _echo_discovery_info(dev._discovery_info) - - return dev.internal_state + return await ctx.invoke(state) @cli.command() -@click.argument("new_alias", required=False, default=None) @pass_dev_or_child -async def alias(dev, new_alias): - """Get or set the device (or plug) alias.""" - if new_alias is not None: - echo(f"Setting alias to {new_alias}") - res = await dev.set_alias(new_alias) - await dev.update() - echo(f"Alias set to: {dev.alias}") - return res - - echo(f"Alias: {dev.alias}") - if dev.children: - for plug in dev.children: - echo(f" * {plug.alias}") +async def shell(dev: Device): + """Open interactive shell.""" + echo("Opening shell for %s" % dev) + from ptpython.repl import embed - return dev.alias + logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing + logging.getLogger("asyncio").setLevel(logging.WARNING) + loop = asyncio.get_event_loop() + try: + await embed( # type: ignore[func-returns-value] + globals=globals(), + locals=locals(), + return_asyncio_coroutine=True, + patch_stdout=True, + ) + except EOFError: + loop.stop() @cli.command() @@ -857,6 +398,10 @@ async def cmd_command(dev: Device, module, command, parameters): if parameters is not None: parameters = ast.literal_eval(parameters) + from kasa import KasaException + from kasa.iot import IotDevice + from kasa.smart import SmartDevice + if isinstance(dev, IotDevice): res = await dev._query_helper(module, command, parameters) elif isinstance(dev, SmartDevice): @@ -865,518 +410,3 @@ async def cmd_command(dev: Device, module, command, parameters): raise KasaException("Unexpected device type %s.", dev) echo(json.dumps(res)) return res - - -@cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) -@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) -@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) -@click.option("--erase", is_flag=True) -@click.pass_context -async def emeter(ctx: click.Context, index, name, year, month, erase): - """Query emeter for historical consumption.""" - logging.warning("Deprecated, use 'kasa energy'") - return await ctx.invoke( - energy, child_index=index, child=name, year=year, month=month, erase=erase - ) - - -@cli.command() -@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) -@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) -@click.option("--erase", is_flag=True) -@pass_dev_or_child -async def energy(dev: Device, year, month, erase): - """Query energy module for historical consumption. - - Daily and monthly data provided in CSV format. - """ - echo("[bold]== Emeter ==[/bold]") - if not dev.has_emeter: - error("Device has no emeter") - return - - if (year or month or erase) and not isinstance(dev, IotDevice): - error("Device has no historical statistics") - return - else: - dev = cast(IotDevice, dev) - - if erase: - echo("Erasing emeter statistics..") - return await dev.erase_emeter_stats() - - if year: - echo(f"== For year {year.year} ==") - echo("Month, usage (kWh)") - usage_data = await dev.get_emeter_monthly(year=year.year) - elif month: - echo(f"== For month {month.month} of {month.year} ==") - echo("Day, usage (kWh)") - usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) - else: - # Call with no argument outputs summary data and returns - if isinstance(dev, IotStripPlug): - emeter_status = await dev.get_emeter_realtime() - else: - emeter_status = dev.emeter_realtime - - echo("Current: %s A" % emeter_status["current"]) - echo("Voltage: %s V" % emeter_status["voltage"]) - echo("Power: %s W" % emeter_status["power"]) - echo("Total consumption: %s kWh" % emeter_status["total"]) - - echo("Today: %s kWh" % dev.emeter_today) - echo("This month: %s kWh" % dev.emeter_this_month) - - return emeter_status - - # output any detailed usage data - for index, usage in usage_data.items(): - echo(f"{index}, {usage}") - - return usage_data - - -@cli.command() -@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) -@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) -@click.option("--erase", is_flag=True) -@pass_dev_or_child -async def usage(dev: Device, year, month, erase): - """Query usage for historical consumption. - - Daily and monthly data provided in CSV format. - """ - echo("[bold]== Usage ==[/bold]") - usage = cast(Usage, dev.modules["usage"]) - - if erase: - echo("Erasing usage statistics..") - return await usage.erase_stats() - - if year: - echo(f"== For year {year.year} ==") - echo("Month, usage (minutes)") - usage_data = await usage.get_monthstat(year=year.year) - elif month: - echo(f"== For month {month.month} of {month.year} ==") - echo("Day, usage (minutes)") - usage_data = await usage.get_daystat(year=month.year, month=month.month) - else: - # Call with no argument outputs summary data and returns - echo("Today: %s minutes" % usage.usage_today) - echo("This month: %s minutes" % usage.usage_this_month) - - return usage - - # output any detailed usage data - for index, usage in usage_data.items(): - echo(f"{index}, {usage}") - - return usage_data - - -@cli.command() -@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) -@click.option("--transition", type=int, required=False) -@pass_dev_or_child -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: - error("This device does not support brightness.") - return - - if brightness is None: - echo(f"Brightness: {light.brightness}") - return light.brightness - else: - echo(f"Setting brightness to {brightness}") - return await light.set_brightness(brightness, transition=transition) - - -@cli.command() -@click.argument( - "temperature", type=click.IntRange(2500, 9000), default=None, required=False -) -@click.option("--transition", type=int, required=False) -@pass_dev_or_child -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: - error("Device does not support color temperature") - return - - if temperature is None: - echo(f"Color temperature: {light.color_temp}") - valid_temperature_range = light.valid_temperature_range - if valid_temperature_range != (0, 0): - echo("(min: {}, max: {})".format(*valid_temperature_range)) - else: - echo( - "Temperature range unknown, please open a github issue" - f" or a pull request for model '{dev.model}'" - ) - return light.valid_temperature_range - else: - echo(f"Setting color temperature to {temperature}") - return await light.set_color_temp(temperature, transition=transition) - - -@cli.command() -@click.argument("effect", type=click.STRING, default=None, required=False) -@click.pass_context -@pass_dev_or_child -async def effect(dev: Device, ctx, effect): - """Set an effect.""" - if not (light_effect := dev.modules.get(Module.LightEffect)): - error("Device does not support effects") - return - if effect is None: - echo( - f"Light effect: {light_effect.effect}\n" - + f"Available Effects: {light_effect.effect_list}" - ) - return light_effect.effect - - if effect not in light_effect.effect_list: - raise click.BadArgumentUsage( - f"Effect must be one of: {light_effect.effect_list}", ctx - ) - - echo(f"Setting Effect: {effect}") - return await light_effect.set_effect(effect) - - -@cli.command() -@click.argument("h", type=click.IntRange(0, 360), default=None, required=False) -@click.argument("s", type=click.IntRange(0, 100), default=None, required=False) -@click.argument("v", type=click.IntRange(0, 100), default=None, required=False) -@click.option("--transition", type=int, required=False) -@click.pass_context -@pass_dev_or_child -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: - error("Device does not support colors") - return - - if h is None and s is None and v is None: - echo(f"Current HSV: {light.hsv}") - return light.hsv - elif s is None or v is None: - raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) - else: - echo(f"Setting HSV: {h} {s} {v}") - return await light.set_hsv(h, s, v, transition=transition) - - -@cli.command() -@click.argument("state", type=bool, required=False) -@pass_dev_or_child -async def led(dev: Device, state): - """Get or set (Plug's) led state.""" - if not (led := dev.modules.get(Module.Led)): - error("Device does not support led.") - return - if state is not None: - echo(f"Turning led to {state}") - return await led.set_led(state) - else: - echo(f"LED state: {led.led}") - return led.led - - -@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_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: Device): - """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("--transition", type=int, required=False) -@pass_dev_or_child -async def on(dev: Device, transition: int): - """Turn the device on.""" - echo(f"Turning on {dev.alias}") - return await dev.turn_on(transition=transition) - - -@cli.command -@click.option("--transition", type=int, required=False) -@pass_dev_or_child -async def off(dev: Device, transition: int): - """Turn the device off.""" - echo(f"Turning off {dev.alias}") - return await dev.turn_off(transition=transition) - - -@cli.command() -@click.option("--transition", type=int, required=False) -@pass_dev_or_child -async def toggle(dev: Device, transition: int): - """Toggle the device on/off.""" - if dev.is_on: - echo(f"Turning off {dev.alias}") - return await dev.turn_off(transition=transition) - - echo(f"Turning on {dev.alias}") - return await dev.turn_on(transition=transition) - - -@cli.command() -@click.option("--delay", default=1) -@pass_dev -async def reboot(plug, delay): - """Reboot the device.""" - echo("Rebooting the device..") - return await plug.reboot(delay) - - -@cli.group() -@pass_dev -async def schedule(dev): - """Scheduling commands.""" - - -@schedule.command(name="list") -@pass_dev_or_child -@click.argument("type", default="schedule") -async def _schedule_list(dev, type): - """Return the list of schedule actions for the given type.""" - sched = dev.modules[type] - for rule in sched.rules: - print(rule) - else: - error(f"No rules of type {type}") - - return sched.rules - - -@schedule.command(name="delete") -@pass_dev_or_child -@click.option("--id", type=str, required=True) -async def delete_rule(dev, id): - """Delete rule from device.""" - schedule = dev.modules["schedule"] - rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None) - if rule_to_delete: - echo(f"Deleting rule id {id}") - return await schedule.delete_rule(rule_to_delete) - else: - error(f"No rule with id {id} was found") - - -@cli.group(invoke_without_command=True) -@pass_dev_or_child -@click.pass_context -async def presets(ctx, dev): - """List and modify bulb setting presets.""" - if ctx.invoked_subcommand is None: - return await ctx.invoke(presets_list) - - -@presets.command(name="list") -@pass_dev_or_child -def presets_list(dev: Device): - """List presets.""" - if not (light_preset := dev.modules.get(Module.LightPreset)): - error("Presets not supported on device") - return - - for preset in light_preset.preset_states_list: - echo(preset) - - return light_preset.preset_states_list - - -@presets.command(name="modify") -@click.argument("index", type=int) -@click.option("--brightness", type=int) -@click.option("--hue", type=int) -@click.option("--saturation", type=int) -@click.option("--temperature", type=int) -@pass_dev_or_child -async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): - """Modify a preset.""" - for preset in dev.presets: - if preset.index == index: - break - else: - error(f"No preset found for index {index}") - return - - if brightness is not None: - preset.brightness = brightness - if hue is not None: - preset.hue = hue - if saturation is not None: - preset.saturation = saturation - if temperature is not None: - preset.color_temp = temperature - - echo(f"Going to save preset: {preset}") - - return await dev.save_preset(preset) - - -@cli.command() -@pass_dev_or_child -@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) -@click.option("--last", is_flag=True) -@click.option("--preset", type=int) -async def turn_on_behavior(dev: Device, type, last, preset): - """Modify bulb turn-on behavior.""" - if not dev.is_bulb or not isinstance(dev, IotBulb): - error("Presets only supported on iot bulbs") - return - settings = await dev.get_turn_on_behavior() - echo(f"Current turn on behavior: {settings}") - - # Return if we are not setting the value - if not type and not last and not preset: - return settings - - # If we are setting the value, the type has to be specified - if (last or preset) and type is None: - echo("To set the behavior, you need to define --type") - return - - behavior = getattr(settings, type) - - if last: - echo(f"Going to set {type} to last") - behavior.preset = None - elif preset is not None: - echo(f"Going to set {type} to preset {preset}") - behavior.preset = preset - - return await dev.set_turn_on_behavior(settings) - - -@cli.command() -@pass_dev -@click.option( - "--username", required=True, prompt=True, help="New username to set on the device" -) -@click.option( - "--password", required=True, prompt=True, help="New password to set on the device" -) -async def update_credentials(dev, username, password): - """Update device credentials for authenticated devices.""" - if not isinstance(dev, SmartDevice): - error("Credentials can only be updated on authenticated devices.") - - click.confirm("Do you really want to replace the existing credentials?", abort=True) - - return await dev.update_credentials(username, password) - - -@cli.command() -@pass_dev_or_child -async def shell(dev: Device): - """Open interactive shell.""" - echo("Opening shell for %s" % dev) - from ptpython.repl import embed - - logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing - logging.getLogger("asyncio").setLevel(logging.WARNING) - loop = asyncio.get_event_loop() - try: - await embed( # type: ignore[func-returns-value] - globals=globals(), - locals=locals(), - return_asyncio_coroutine=True, - patch_stdout=True, - ) - except EOFError: - loop.stop() - - -@cli.command(name="feature") -@click.argument("name", required=False) -@click.argument("value", required=False) -@pass_dev_or_child -@click.pass_context -async def feature( - ctx: click.Context, - dev: Device, - name: str, - value, -): - """Access and modify features. - - If no *name* is given, lists available features and their values. - If only *name* is given, the value of named feature is returned. - If both *name* and *value* are set, the described setting is changed. - """ - verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False - - if not name: - _echo_all_features(dev.features, verbose=verbose, indent="") - - if dev.children: - for child_dev in dev.children: - _echo_all_features( - child_dev.features, - verbose=verbose, - title_prefix=f"Child {child_dev.alias}", - indent="\t", - ) - - return - - if name not in dev.features: - error(f"No feature by name '{name}'") - return - - feat = dev.features[name] - - if value is None: - unit = f" {feat.unit}" if feat.unit else "" - echo(f"{feat.name} ({name}): {feat.value}{unit}") - return feat.value - - value = ast.literal_eval(value) - echo(f"Changing {name} from {feat.value} to {value}") - response = await dev.features[name].set_value(value) - await dev.update() - echo(f"New state: {feat.value}") - - return response - - -if __name__ == "__main__": - cli() diff --git a/kasa/cli/schedule.py b/kasa/cli/schedule.py new file mode 100644 index 000000000..8deda3150 --- /dev/null +++ b/kasa/cli/schedule.py @@ -0,0 +1,46 @@ +"""Module for cli schedule commands..""" + +from __future__ import annotations + +import asyncclick as click + +from .common import ( + echo, + error, + pass_dev, + pass_dev_or_child, +) + + +@click.group() +@pass_dev +async def schedule(dev): + """Scheduling commands.""" + + +@schedule.command(name="list") +@pass_dev_or_child +@click.argument("type", default="schedule") +async def _schedule_list(dev, type): + """Return the list of schedule actions for the given type.""" + sched = dev.modules[type] + for rule in sched.rules: + print(rule) + else: + error(f"No rules of type {type}") + + return sched.rules + + +@schedule.command(name="delete") +@pass_dev_or_child +@click.option("--id", type=str, required=True) +async def delete_rule(dev, id): + """Delete rule from device.""" + schedule = dev.modules["schedule"] + rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None) + if rule_to_delete: + echo(f"Deleting rule id {id}") + return await schedule.delete_rule(rule_to_delete) + else: + error(f"No rule with id {id} was found") diff --git a/kasa/cli/time.py b/kasa/cli/time.py new file mode 100644 index 000000000..c66812222 --- /dev/null +++ b/kasa/cli/time.py @@ -0,0 +1,55 @@ +"""Module for cli time commands..""" + +from __future__ import annotations + +from datetime import datetime + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.smart import SmartDevice + +from .common import ( + echo, + pass_dev, +) + + +@click.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_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: Device): + """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) diff --git a/kasa/cli/usage.py b/kasa/cli/usage.py new file mode 100644 index 000000000..1a336c743 --- /dev/null +++ b/kasa/cli/usage.py @@ -0,0 +1,134 @@ +"""Module for cli usage commands..""" + +from __future__ import annotations + +import logging +from typing import cast + +import asyncclick as click + +from kasa import ( + Device, +) +from kasa.iot import ( + IotDevice, +) +from kasa.iot.iotstrip import IotStripPlug +from kasa.iot.modules import Usage + +from .common import ( + echo, + error, + pass_dev_or_child, +) + + +@click.command() +@click.option("--index", type=int, required=False) +@click.option("--name", type=str, required=False) +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@click.pass_context +async def emeter(ctx: click.Context, index, name, year, month, erase): + """Query emeter for historical consumption.""" + logging.warning("Deprecated, use 'kasa energy'") + return await ctx.invoke( + energy, child_index=index, child=name, year=year, month=month, erase=erase + ) + + +@click.command() +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@pass_dev_or_child +async def energy(dev: Device, year, month, erase): + """Query energy module for historical consumption. + + Daily and monthly data provided in CSV format. + """ + echo("[bold]== Emeter ==[/bold]") + if not dev.has_emeter: + error("Device has no emeter") + return + + if (year or month or erase) and not isinstance(dev, IotDevice): + error("Device has no historical statistics") + return + else: + dev = cast(IotDevice, dev) + + if erase: + echo("Erasing emeter statistics..") + return await dev.erase_emeter_stats() + + if year: + echo(f"== For year {year.year} ==") + echo("Month, usage (kWh)") + usage_data = await dev.get_emeter_monthly(year=year.year) + elif month: + echo(f"== For month {month.month} of {month.year} ==") + echo("Day, usage (kWh)") + usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) + else: + # Call with no argument outputs summary data and returns + if isinstance(dev, IotStripPlug): + emeter_status = await dev.get_emeter_realtime() + else: + emeter_status = dev.emeter_realtime + + echo("Current: %s A" % emeter_status["current"]) + echo("Voltage: %s V" % emeter_status["voltage"]) + echo("Power: %s W" % emeter_status["power"]) + echo("Total consumption: %s kWh" % emeter_status["total"]) + + echo("Today: %s kWh" % dev.emeter_today) + echo("This month: %s kWh" % dev.emeter_this_month) + + return emeter_status + + # output any detailed usage data + for index, usage in usage_data.items(): + echo(f"{index}, {usage}") + + return usage_data + + +@click.command() +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@pass_dev_or_child +async def usage(dev: Device, year, month, erase): + """Query usage for historical consumption. + + Daily and monthly data provided in CSV format. + """ + echo("[bold]== Usage ==[/bold]") + usage = cast(Usage, dev.modules["usage"]) + + if erase: + echo("Erasing usage statistics..") + return await usage.erase_stats() + + if year: + echo(f"== For year {year.year} ==") + echo("Month, usage (minutes)") + usage_data = await usage.get_monthstat(year=year.year) + elif month: + echo(f"== For month {month.month} of {month.year} ==") + echo("Day, usage (minutes)") + usage_data = await usage.get_daystat(year=month.year, month=month.month) + else: + # Call with no argument outputs summary data and returns + echo("Today: %s minutes" % usage.usage_today) + echo("This month: %s minutes" % usage.usage_this_month) + + return usage + + # output any detailed usage data + for index, usage in usage_data.items(): + echo(f"{index}, {usage}") + + return usage_data diff --git a/kasa/cli/wifi.py b/kasa/cli/wifi.py new file mode 100644 index 000000000..07fb5f207 --- /dev/null +++ b/kasa/cli/wifi.py @@ -0,0 +1,50 @@ +"""Module for cli wifi commands.""" + +from __future__ import annotations + +import asyncclick as click + +from kasa import ( + Device, +) + +from .common import ( + echo, + pass_dev, +) + + +@click.group() +@pass_dev +def wifi(dev): + """Commands to control wifi settings.""" + + +@wifi.command() +@pass_dev +async def scan(dev): + """Scan for available wifi networks.""" + echo("Scanning for wifi networks, wait a second..") + devs = await dev.wifi_scan() + echo(f"Found {len(devs)} wifi networks!") + for dev in devs: + echo(f"\t {dev}") + + return devs + + +@wifi.command() +@click.argument("ssid") +@click.option("--keytype", prompt=True) +@click.option("--password", prompt=True, hide_input=True) +@pass_dev +async def join(dev: Device, ssid: str, password: str, keytype: str): + """Join the given wifi network.""" + echo(f"Asking the device to connect to {ssid}..") + res = await dev.wifi_join(ssid, password, keytype=keytype) + echo( + f"Response: {res} - if the device is not able to join the network, " + f"it will revert back to its previous state." + ) + + return res diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index e6b96cd73..e55f4d016 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -17,29 +17,28 @@ Module, UnsupportedDeviceError, ) -from kasa.cli.main import ( - TYPE_TO_CLASS, +from kasa.cli.device import ( alias, - brightness, - cli, - cmd_command, - effect, - emeter, - energy, - hsv, led, - raw_command, reboot, state, sysinfo, - temperature, - time, toggle, update_credentials, - wifi, ) +from kasa.cli.light import ( + brightness, + effect, + hsv, + temperature, +) +from kasa.cli.main import TYPES, _legacy_type_to_class, cli, cmd_command, raw_command +from kasa.cli.time import time +from kasa.cli.usage import emeter, energy +from kasa.cli.wifi import wifi from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice +from kasa.smart import SmartDevice from .conftest import ( device_smart, @@ -59,6 +58,12 @@ def runner(): return runner +async def test_help(runner): + """Test that all the lazy modules are correctly names.""" + res = await runner.invoke(cli, ["--help"]) + assert res.exit_code == 0, "--help failed, check lazy module names" + + @pytest.mark.parametrize( ("device_family", "encrypt_type"), [ @@ -500,7 +505,7 @@ async def _state(dev: Device): f"Username:{dev.credentials.username} Password:{dev.credentials.password}" ) - mocker.patch("kasa.cli.main.state", new=_state) + mocker.patch("kasa.cli.device.state", new=_state) dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) res = await runner.invoke( @@ -735,7 +740,7 @@ async def test_host_auth_failed(discovery_mock, mocker, runner): assert isinstance(res.exception, AuthenticationError) -@pytest.mark.parametrize("device_type", list(TYPE_TO_CLASS)) +@pytest.mark.parametrize("device_type", TYPES) async def test_type_param(device_type, mocker, runner): """Test for handling only one of username or password supplied.""" result_device = FileNotFoundError @@ -746,8 +751,11 @@ async def _state(dev: Device): nonlocal result_device result_device = dev - mocker.patch("kasa.cli.main.state", new=_state) - expected_type = TYPE_TO_CLASS[device_type] + mocker.patch("kasa.cli.device.state", new=_state) + if device_type == "smart": + expected_type = SmartDevice + else: + expected_type = _legacy_type_to_class(device_type) mocker.patch.object(expected_type, "update") res = await runner.invoke( cli, diff --git a/pyproject.toml b/pyproject.toml index 91317f489..c5c87072c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ include = [ "Documentation" = "https://python-kasa.readthedocs.io" [tool.poetry.scripts] -kasa = "kasa.cli:__main__" +kasa = "kasa.cli.__main__:cli" [tool.poetry.dependencies] python = "^3.9" From dc0aedad20f082aca01cea781fb6533605899ada Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 24 Jul 2024 15:47:38 +0200 Subject: [PATCH 27/38] Expose reboot action (#1073) Expose reboot through the feature interface. This can be useful in situations where one wants to reboot the device, e.g., in recent cases where frequent update calls will render the device unresponsive after a specific amount of time. --- docs/tutorial.py | 2 +- kasa/device.py | 1 + kasa/feature.py | 1 + kasa/iot/iotdevice.py | 12 ++++++++++++ kasa/smart/smartdevice.py | 12 ++++++++++++ 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/tutorial.py b/docs/tutorial.py index 7bb3381a3..f2b777b16 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -91,5 +91,5 @@ True >>> for feat in dev.features.values(): >>> print(f"{feat.name}: {feat.value}") -Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 +Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nReboot: \nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: False\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: 1.1.6 Build 240130 Rel.173828\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nDevice time: 2024-02-23 02:40:15+01:00 """ diff --git a/kasa/device.py b/kasa/device.py index 69b7370b0..e07c4853c 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -84,6 +84,7 @@ state rssi on_since +reboot current_consumption consumption_today consumption_this_month diff --git a/kasa/feature.py b/kasa/feature.py index 0ce13d45f..ab73f9913 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -25,6 +25,7 @@ RSSI (rssi): -52 SSID (ssid): #MASKED_SSID# Overheated (overheated): False +Reboot (reboot): Brightness (brightness): 100 Cloud connection (cloud_connection): True HSV (hsv): HSV(hue=0, saturation=100, value=100) diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index c637387ae..28ae12281 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -359,6 +359,18 @@ async def _initialize_features(self): ) ) + self._add_feature( + Feature( + device=self, + id="reboot", + name="Reboot", + attribute_setter="reboot", + icon="mdi:restart", + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + for module in self._supported_modules.values(): module._initialize_features() for module_feat in module._module_features.values(): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 156db4615..fcdbef971 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -439,6 +439,18 @@ async def _initialize_features(self): ) ) + self._add_feature( + Feature( + device=self, + id="reboot", + name="Reboot", + attribute_setter="reboot", + icon="mdi:restart", + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + for module in self.modules.values(): module._initialize_features() for feat in module._module_features.values(): From 055bbcc0c9b5a87f00e0d3cc68be46fbda18f512 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 24 Jul 2024 15:48:33 +0200 Subject: [PATCH 28/38] Add support for T100 motion sensor (#1079) Add support for T100 motion sensor. Thanks to @DarthSonic for the fixture file! --- README.md | 2 +- SUPPORTED.md | 2 + kasa/module.py | 1 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/motionsensor.py | 36 ++ kasa/tests/device_fixtures.py | 2 +- .../smart/child/T100(EU)_1.0_1.12.0.json | 537 ++++++++++++++++++ kasa/tests/smart/modules/test_motionsensor.py | 28 + 8 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 kasa/smart/modules/motionsensor.py create mode 100644 kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json create mode 100644 kasa/tests/smart/modules/test_motionsensor.py diff --git a/README.md b/README.md index fcc28190d..2533b908e 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 -- **Hub-Connected Devices\*\*\***: T110, T300, T310, T315 +- **Hub-Connected Devices\*\*\***: T100, T110, T300, T310, T315 \*   Model requires authentication
diff --git a/SUPPORTED.md b/SUPPORTED.md index a0d301b32..5e6e8553f 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -231,6 +231,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Hub-Connected Devices +- **T100** + - Hardware: 1.0 (EU) / Firmware: 1.12.0 - **T110** - Hardware: 1.0 (EU) / Firmware: 1.8.0 - **T300** diff --git a/kasa/module.py b/kasa/module.py index 69c4e9e21..fe370603c 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -111,6 +111,7 @@ class Module(ABC): LightTransition: Final[ModuleName[smart.LightTransition]] = ModuleName( "LightTransition" ) + MotionSensor: Final[ModuleName[smart.MotionSensor]] = ModuleName("MotionSensor") ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode") SmartLightEffect: Final[ModuleName[smart.SmartLightEffect]] = ModuleName( "LightEffect" diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index fd9877513..24d5749e6 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -22,6 +22,7 @@ from .lightpreset import LightPreset from .lightstripeffect import LightStripEffect from .lighttransition import LightTransition +from .motionsensor import MotionSensor from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl from .temperaturesensor import TemperatureSensor @@ -54,6 +55,7 @@ "Color", "WaterleakSensor", "ContactSensor", + "MotionSensor", "FrostProtection", "SmartLightEffect", ] diff --git a/kasa/smart/modules/motionsensor.py b/kasa/smart/modules/motionsensor.py new file mode 100644 index 000000000..169b25b61 --- /dev/null +++ b/kasa/smart/modules/motionsensor.py @@ -0,0 +1,36 @@ +"""Implementation of motion sensor module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class MotionSensor(SmartModule): + """Implementation of motion sensor module.""" + + REQUIRED_COMPONENT = "sensitivity" + + def _initialize_features(self): + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="motion_detected", + name="Motion detected", + container=self, + attribute_getter="motion_detected", + icon="mdi:motion-sensor", + category=Feature.Category.Primary, + type=Feature.Type.BinarySensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def motion_detected(self): + """Return True if the motion has been detected.""" + return self._device.sys_info["detected"] diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 1eb3e829b..fca5960aa 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -117,7 +117,7 @@ } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300", "T110"} +SENSORS_SMART = {"T310", "T315", "T300", "T100", "T110"} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} diff --git a/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json b/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json new file mode 100644 index 000000000..00e46787c --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json @@ -0,0 +1,537 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "sensitivity", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor", + "bind_count": 1, + "category": "subg.trigger.motion-sensor", + "detected": false, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "fw_ver": "1.12.0 Build 230512 Rel.103011", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -118, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1703860126, + "mac": "E4FAC4000000", + "model": "T100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 60, + "rssi": -73, + "signal_level": 2, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.12.0 Build 230512 Rel.103011", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1721645923, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "motion", + "eventId": "f883b62c-e18f-30ef-883b-62ce18f30ef8", + "id": 28763, + "timestamp": 1721643865 + }, + { + "event": "motion", + "eventId": "c5157545-55d5-157d-4157-54555d5157d4", + "id": 28748, + "timestamp": 1721630821 + }, + { + "event": "motion", + "eventId": "1b587961-edab-08d1-b587-961edab08d1b", + "id": 28746, + "timestamp": 1721629441 + }, + { + "event": "motion", + "eventId": "8ac5e271-3894-c269-bc5e-2713894c269b", + "id": 28738, + "timestamp": 1721622777 + }, + { + "event": "motion", + "eventId": "1ef8037e-c097-bc21-ef80-37ec097bc21e", + "id": 28722, + "timestamp": 1721596432 + } + ], + "start_id": 28763, + "sum": 86 + } +} diff --git a/kasa/tests/smart/modules/test_motionsensor.py b/kasa/tests/smart/modules/test_motionsensor.py new file mode 100644 index 000000000..59fbef68f --- /dev/null +++ b/kasa/tests/smart/modules/test_motionsensor.py @@ -0,0 +1,28 @@ +import pytest + +from kasa import Module, SmartDevice +from kasa.tests.device_fixtures import parametrize + +motion = parametrize( + "is motion sensor", model_filter="T100", protocol_filter={"SMART.CHILD"} +) + + +@motion +@pytest.mark.parametrize( + "feature, type", + [ + ("motion_detected", bool), + ], +) +async def test_motion_features(dev: SmartDevice, feature, type): + """Test that features are registered and work as expected.""" + motion = dev.modules.get(Module.MotionSensor) + assert motion is not None + + prop = getattr(motion, feature) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) From 1c83675e57ce5c3ef9ab610d2aadbfd9bc520dac Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Jul 2024 18:58:37 +0100 Subject: [PATCH 29/38] Fix intermittently failing decryption error test (#1082) --- kasa/tests/test_klapprotocol.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 0565683a1..4a7b3e18f 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -1,5 +1,6 @@ import json import logging +import re import secrets import time from contextlib import nullcontext as does_not_raise @@ -298,7 +299,7 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): with pytest.raises( KasaException, - match="Error trying to decrypt device 127.0.0.1 response: Invalid padding bytes.", + match=re.escape("Error trying to decrypt device 127.0.0.1 response:"), ): await transport.send(json.dumps({})) From 7416e855f1b358aa96e0937d692d861a7d093ed3 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 25 Jul 2024 09:11:48 +0100 Subject: [PATCH 30/38] Fix mypy pre-commit hook on windows (#1081) --- devtools/run-in-env.sh | 18 ++++++++++++++++-- kasa/discover.py | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/devtools/run-in-env.sh b/devtools/run-in-env.sh index 3e67c70eb..008d4d289 100755 --- a/devtools/run-in-env.sh +++ b/devtools/run-in-env.sh @@ -1,3 +1,17 @@ -#!/bin/bash -source $(poetry env info --path)/bin/activate +#!/usr/bin/env bash + +OS_KERNEL=$(uname -s) +OS_VER=$(uname -v) +if [[ ( $OS_KERNEL == "Linux" && $OS_VER == *"Microsoft"* ) ]]; then + echo "Pre-commit hook needs git-bash to run. It cannot run in the windows linux subsystem." + echo "Add git bin directory to the front of your path variable, e.g:" + echo "set PATH=C:\Program Files\Git\bin;%PATH%" + exit 1 +fi +if [[ "$(expr substr $OS_KERNEL 1 10)" == "MINGW64_NT" ]]; then + POETRY_PATH=$(poetry.exe env info --path) + source "$POETRY_PATH"\\Scripts\\activate +else + source $(poetry env info --path)/bin/activate +fi exec "$@" diff --git a/kasa/discover.py b/kasa/discover.py index c69933a95..7c1475978 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -209,7 +209,8 @@ def connection_made(self, transport) -> None: except OSError as ex: # WSL does not support SO_REUSEADDR, see #246 _LOGGER.debug("Unable to set SO_REUSEADDR: %s", ex) - if self.interface is not None: + # windows does not support SO_BINDTODEVICE + if self.interface is not None and hasattr(socket, "SO_BINDTODEVICE"): sock.setsockopt( socket.SOL_SOCKET, socket.SO_BINDTODEVICE, self.interface.encode() ) From 91bf9bb73d2e402129b0d8f8c7f384287bc246ff Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 28 Jul 2024 19:41:33 +0100 Subject: [PATCH 31/38] Fix generate_supported pre commit to run in venv (#1085) I noticed after building a new linux instance that running `git commit` when the virtual environment is not active causes the pre-commit to fail, as the `generate_supported` hook is not explicitly configured to run in the virtual env. This PR calls `generate_supported` via the `run-in-env.sh` script. --- .pre-commit-config.yaml | 4 ++-- devtools/run-in-env.sh | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2587eff5c..c3acdb8db 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: - id: mypy name: mypy entry: devtools/run-in-env.sh mypy - language: script + language: system types_or: [python, pyi] require_serial: true exclude: | # exclude required because --all-files passes py and pyi @@ -39,7 +39,7 @@ repos: - id: generate-supported name: Generate supported devices description: This hook generates the supported device sections of README.md and SUPPORTED.md - entry: devtools/generate_supported.py + entry: devtools/run-in-env.sh ./devtools/generate_supported.py language: system # Required or pre-commit creates a new venv verbose: true # Show output on success types: [json] diff --git a/devtools/run-in-env.sh b/devtools/run-in-env.sh index 008d4d289..5efdbc65d 100755 --- a/devtools/run-in-env.sh +++ b/devtools/run-in-env.sh @@ -1,11 +1,15 @@ #!/usr/bin/env bash +# pre-commit by default runs hooks in an isolated environment. +# For some hooks it's needed to run in the virtual environment so this script will activate it. + OS_KERNEL=$(uname -s) OS_VER=$(uname -v) if [[ ( $OS_KERNEL == "Linux" && $OS_VER == *"Microsoft"* ) ]]; then echo "Pre-commit hook needs git-bash to run. It cannot run in the windows linux subsystem." echo "Add git bin directory to the front of your path variable, e.g:" - echo "set PATH=C:\Program Files\Git\bin;%PATH%" + echo "set PATH=C:\Program Files\Git\bin;%PATH% (for CMD prompt)" + echo "\$env:Path = 'C:\Program Files\Git\bin;' + \$env:Path (for Powershell prompt)" exit 1 fi if [[ "$(expr substr $OS_KERNEL 1 10)" == "MINGW64_NT" ]]; then From 60be6e03b7f0840565cf3ae36247f67664a581b1 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 29 Jul 2024 10:51:21 +0100 Subject: [PATCH 32/38] Bump project version to 0.7.0.5 (#1087) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c5c87072c..c7288e101 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.4" +version = "0.7.0.5" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 7bba9926ed89b50cba503e1d50571bf880cc1433 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 30 Jul 2024 19:23:07 +0100 Subject: [PATCH 33/38] Allow erroring modules to recover (#1080) Re-query failed modules after some delay instead of immediately disabling them. Changes to features so they can still be created when modules are erroring. --- kasa/feature.py | 73 ++++++---- kasa/interfaces/energy.py | 12 +- kasa/iot/iotdevice.py | 2 +- kasa/iot/modules/ambientlight.py | 2 +- kasa/iot/modules/light.py | 3 +- kasa/smart/modules/alarm.py | 2 +- kasa/smart/modules/autooff.py | 2 +- kasa/smart/modules/batterysensor.py | 2 +- kasa/smart/modules/brightness.py | 3 +- kasa/smart/modules/cloud.py | 7 - kasa/smart/modules/energy.py | 10 +- kasa/smart/modules/fan.py | 3 +- kasa/smart/modules/humiditysensor.py | 2 +- kasa/smart/modules/lighttransition.py | 4 +- kasa/smart/modules/reportmode.py | 2 +- kasa/smart/modules/temperaturecontrol.py | 3 +- kasa/smart/modules/temperaturesensor.py | 2 +- kasa/smart/smartchilddevice.py | 13 +- kasa/smart/smartdevice.py | 72 +++++----- kasa/smart/smartmodule.py | 53 +++++++ kasa/tests/fakeprotocol_smart.py | 1 + kasa/tests/test_feature.py | 4 +- kasa/tests/test_smartdevice.py | 172 ++++++++++++----------- 23 files changed, 263 insertions(+), 186 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index ab73f9913..18bed554d 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -69,6 +69,7 @@ import logging from dataclasses import dataclass from enum import Enum, auto +from functools import cached_property from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: @@ -142,11 +143,9 @@ class Category(Enum): container: Any = None #: Icon suggestion icon: str | None = None - #: Unit, if applicable - unit: str | None = None #: Attribute containing the name of the unit getter property. - #: If set, this property will be used to set *unit*. - unit_getter: str | None = None + #: If set, this property will be used to get the *unit*. + unit_getter: str | Callable[[], str] | None = None #: Category hint for downstreams category: Feature.Category = Category.Unset @@ -154,38 +153,18 @@ class Category(Enum): #: Hint to help rounding the sensor values to given after-comma digits precision_hint: int | None = None - # Number-specific attributes - #: Minimum value - minimum_value: int = 0 - #: Maximum value - maximum_value: int = DEFAULT_MAX #: Attribute containing the name of the range getter property. #: If set, this property will be used to set *minimum_value* and *maximum_value*. - range_getter: str | None = None + range_getter: str | Callable[[], tuple[int, int]] | None = None - # Choice-specific attributes - #: List of choices as enum - choices: list[str] | None = None #: Attribute name of the choices getter property. - #: If set, this property will be used to set *choices*. - choices_getter: str | None = None + #: If set, this property will be used to get *choices*. + choices_getter: str | Callable[[], list[str]] | None = None def __post_init__(self): """Handle late-binding of members.""" # Populate minimum & maximum values, if range_getter is given - container = self.container if self.container is not None else self.device - if self.range_getter is not None: - self.minimum_value, self.maximum_value = getattr( - container, self.range_getter - ) - - # Populate choices, if choices_getter is given - if self.choices_getter is not None: - self.choices = getattr(container, self.choices_getter) - - # Populate unit, if unit_getter is given - if self.unit_getter is not None: - self.unit = getattr(container, self.unit_getter) + self._container = self.container if self.container is not None else self.device # Set the category, if unset if self.category is Feature.Category.Unset: @@ -208,6 +187,44 @@ def __post_init__(self): f"Read-only feat defines attribute_setter: {self.name} ({self.id}):" ) + def _get_property_value(self, getter): + if getter is None: + return None + if isinstance(getter, str): + return getattr(self._container, getter) + if callable(getter): + return getter() + raise ValueError("Invalid getter: %s", getter) # pragma: no cover + + @property + def choices(self) -> list[str] | None: + """List of choices.""" + return self._get_property_value(self.choices_getter) + + @property + def unit(self) -> str | None: + """Unit if applicable.""" + return self._get_property_value(self.unit_getter) + + @cached_property + def range(self) -> tuple[int, int] | None: + """Range of values if applicable.""" + return self._get_property_value(self.range_getter) + + @cached_property + def maximum_value(self) -> int: + """Maximum value.""" + if range := self.range: + return range[1] + return self.DEFAULT_MAX + + @cached_property + def minimum_value(self) -> int: + """Minimum value.""" + if range := self.range: + return range[0] + return 0 + @property def value(self): """Return the current value.""" diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py index 76859647d..51579322f 100644 --- a/kasa/interfaces/energy.py +++ b/kasa/interfaces/energy.py @@ -40,7 +40,7 @@ def _initialize_features(self): name="Current consumption", attribute_getter="current_consumption", container=self, - unit="W", + unit_getter=lambda: "W", id="current_consumption", precision_hint=1, category=Feature.Category.Primary, @@ -53,7 +53,7 @@ def _initialize_features(self): name="Today's consumption", attribute_getter="consumption_today", container=self, - unit="kWh", + unit_getter=lambda: "kWh", id="consumption_today", precision_hint=3, category=Feature.Category.Info, @@ -67,7 +67,7 @@ def _initialize_features(self): name="This month's consumption", attribute_getter="consumption_this_month", container=self, - unit="kWh", + unit_getter=lambda: "kWh", precision_hint=3, category=Feature.Category.Info, type=Feature.Type.Sensor, @@ -80,7 +80,7 @@ def _initialize_features(self): name="Total consumption since reboot", attribute_getter="consumption_total", container=self, - unit="kWh", + unit_getter=lambda: "kWh", id="consumption_total", precision_hint=3, category=Feature.Category.Info, @@ -94,7 +94,7 @@ def _initialize_features(self): name="Voltage", attribute_getter="voltage", container=self, - unit="V", + unit_getter=lambda: "V", id="voltage", precision_hint=1, category=Feature.Category.Primary, @@ -107,7 +107,7 @@ def _initialize_features(self): name="Current", attribute_getter="current", container=self, - unit="A", + unit_getter=lambda: "A", id="current", precision_hint=2, category=Feature.Category.Primary, diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 28ae12281..234ea9feb 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -340,7 +340,7 @@ async def _initialize_features(self): name="RSSI", attribute_getter="rssi", icon="mdi:signal", - unit="dBm", + unit_getter=lambda: "dBm", category=Feature.Category.Debug, type=Feature.Type.Sensor, ) diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py index d49768ef8..fd693ed52 100644 --- a/kasa/iot/modules/ambientlight.py +++ b/kasa/iot/modules/ambientlight.py @@ -28,7 +28,7 @@ def __init__(self, device, module): attribute_getter="ambientlight_brightness", type=Feature.Type.Sensor, category=Feature.Category.Primary, - unit="%", + unit_getter=lambda: "%", ) ) diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 8c4e22c90..358771a65 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -41,8 +41,7 @@ def _initialize_features(self): container=self, attribute_getter="brightness", attribute_setter="set_brightness", - minimum_value=BRIGHTNESS_MIN, - maximum_value=BRIGHTNESS_MAX, + range_getter=lambda: (BRIGHTNESS_MIN, BRIGHTNESS_MAX), type=Feature.Type.Number, category=Feature.Category.Primary, ) diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py index 89f133f54..439bc5716 100644 --- a/kasa/smart/modules/alarm.py +++ b/kasa/smart/modules/alarm.py @@ -69,7 +69,7 @@ def _initialize_features(self): attribute_setter="set_alarm_volume", category=Feature.Category.Config, type=Feature.Type.Choice, - choices=["low", "normal", "high"], + choices_getter=lambda: ["low", "normal", "high"], ) ) self._add_feature( diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index 5e4b100f8..ae1bb0828 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -39,7 +39,7 @@ def _initialize_features(self): attribute_getter="delay", attribute_setter="set_delay", type=Feature.Type.Number, - unit="min", # ha-friendly unit, see UnitOfTime.MINUTES + unit_getter=lambda: "min", # ha-friendly unit, see UnitOfTime.MINUTES ) ) self._add_feature( diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py index 7ff7df2d8..7ecfad20f 100644 --- a/kasa/smart/modules/batterysensor.py +++ b/kasa/smart/modules/batterysensor.py @@ -37,7 +37,7 @@ def _initialize_features(self): container=self, attribute_getter="battery", icon="mdi:battery", - unit="%", + unit_getter=lambda: "%", category=Feature.Category.Info, type=Feature.Type.Sensor, ) diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index f5e6d6d64..f6e5c3229 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -27,8 +27,7 @@ def _initialize_features(self): container=self, attribute_getter="brightness", attribute_setter="set_brightness", - minimum_value=BRIGHTNESS_MIN, - maximum_value=BRIGHTNESS_MAX, + range_getter=lambda: (BRIGHTNESS_MIN, BRIGHTNESS_MAX), type=Feature.Type.Number, category=Feature.Category.Primary, ) diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index e7513a562..e66f18581 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -18,13 +18,6 @@ class Cloud(SmartModule): REQUIRED_COMPONENT = "cloud_connect" MINIMUM_UPDATE_INTERVAL_SECS = 60 - def _post_update_hook(self): - """Perform actions after a device update. - - Overrides the default behaviour to disable a module if the query returns - an error because the logic here is to treat that as not connected. - """ - def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py index 3edbddb47..166f688ea 100644 --- a/kasa/smart/modules/energy.py +++ b/kasa/smart/modules/energy.py @@ -5,7 +5,7 @@ from ...emeterstatus import EmeterStatus from ...exceptions import KasaException from ...interfaces.energy import Energy as EnergyInterface -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, raise_if_update_error class Energy(SmartModule, EnergyInterface): @@ -23,6 +23,7 @@ def query(self) -> dict: return req @property + @raise_if_update_error def current_consumption(self) -> float | None: """Current power in watts.""" if (power := self.energy.get("current_power")) is not None: @@ -30,6 +31,7 @@ def current_consumption(self) -> float | None: return None @property + @raise_if_update_error def energy(self): """Return get_energy_usage results.""" if en := self.data.get("get_energy_usage"): @@ -45,6 +47,7 @@ def _get_status_from_energy(self, energy) -> EmeterStatus: ) @property + @raise_if_update_error def status(self): """Get the emeter status.""" return self._get_status_from_energy(self.energy) @@ -55,26 +58,31 @@ async def get_status(self): return self._get_status_from_energy(res["get_energy_usage"]) @property + @raise_if_update_error 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 + @raise_if_update_error def consumption_today(self) -> float | None: """Get the emeter value for today in kWh.""" return self.energy.get("today_energy") / 1_000 @property + @raise_if_update_error def consumption_total(self) -> float | None: """Return total consumption since last reboot in kWh.""" return None @property + @raise_if_update_error def current(self) -> float | None: """Return the current in A.""" return None @property + @raise_if_update_error def voltage(self) -> float | None: """Get the current voltage in V.""" return None diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py index 153f9c8f9..245bef2c2 100644 --- a/kasa/smart/modules/fan.py +++ b/kasa/smart/modules/fan.py @@ -30,8 +30,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_setter="set_fan_speed_level", icon="mdi:fan", type=Feature.Type.Number, - minimum_value=0, - maximum_value=4, + range_getter=lambda: (0, 4), category=Feature.Category.Primary, ) ) diff --git a/kasa/smart/modules/humiditysensor.py b/kasa/smart/modules/humiditysensor.py index b137736ff..606b1d548 100644 --- a/kasa/smart/modules/humiditysensor.py +++ b/kasa/smart/modules/humiditysensor.py @@ -27,7 +27,7 @@ def __init__(self, device: SmartDevice, module: str): container=self, attribute_getter="humidity", icon="mdi:water-percent", - unit="%", + unit_getter=lambda: "%", category=Feature.Category.Primary, type=Feature.Type.Sensor, ) diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index e0aeb4d71..da05995d1 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -73,7 +73,7 @@ def _initialize_features(self): attribute_setter="set_turn_on_transition", icon=icon, type=Feature.Type.Number, - maximum_value=self._turn_on_transition_max, + range_getter=lambda: (0, self._turn_on_transition_max), ) ) self._add_feature( @@ -86,7 +86,7 @@ def _initialize_features(self): attribute_setter="set_turn_off_transition", icon=icon, type=Feature.Type.Number, - maximum_value=self._turn_off_transition_max, + range_getter=lambda: (0, self._turn_off_transition_max), ) ) diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index 8d210a5b3..d2c9d929a 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -26,7 +26,7 @@ def __init__(self, device: SmartDevice, module: str): name="Report interval", container=self, attribute_getter="report_interval", - unit="s", + unit_getter=lambda: "s", category=Feature.Category.Debug, type=Feature.Type.Sensor, ) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index 00afe5b53..96630ce55 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -51,8 +51,7 @@ def _initialize_features(self): container=self, attribute_getter="temperature_offset", attribute_setter="set_temperature_offset", - minimum_value=-10, - maximum_value=10, + range_getter=lambda: (-10, 10), type=Feature.Type.Number, category=Feature.Category.Config, ) diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index a61859cdc..1741b26ba 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -54,7 +54,7 @@ def __init__(self, device: SmartDevice, module: str): attribute_getter="temperature_unit", attribute_setter="set_temperature_unit", type=Feature.Type.Choice, - choices=["celsius", "fahrenheit"], + choices_getter=lambda: ["celsius", "fahrenheit"], ) ) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 679692baf..8fe3b969c 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -10,6 +10,7 @@ from ..deviceconfig import DeviceConfig from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper from .smartdevice import SmartDevice +from .smartmodule import SmartModule _LOGGER = logging.getLogger(__name__) @@ -49,13 +50,21 @@ async def _update(self, update_children: bool = True): Internal implementation to allow patching of public update in the cli or test framework. """ + now = time.monotonic() + module_queries: list[SmartModule] = [] req: dict[str, Any] = {} for module in self.modules.values(): - if mod_query := module.query(): + if module.disabled is False and (mod_query := module.query()): + module_queries.append(module) req.update(mod_query) if req: self._last_update = await self.protocol.query(req) - self._last_update_time = time.time() + + for module in self.modules.values(): + self._handle_module_post_update( + module, now, had_query=module in module_queries + ) + self._last_update_time = now @classmethod async def create(cls, parent: SmartDevice, child_info, child_components): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index fcdbef971..04a9608a6 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -165,28 +165,25 @@ async def update(self, update_children: bool = False): if first_update: await self._negotiate() await self._initialize_modules() + # Run post update for the cloud module + if cloud_mod := self.modules.get(Module.Cloud): + self._handle_module_post_update(cloud_mod, now, had_query=True) resp = await self._modular_update(first_update, now) + if child_info := self._try_get_response( + self._last_update, "get_child_device_list", {} + ): + for info in child_info["child_device_list"]: + self._children[info["device_id"]]._update_internal_state(info) # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other # devices will always update children to prevent errors on module access. + # This needs to go after updating the internal state of the children so that + # child modules have access to their sysinfo. if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): await child._update() - if child_info := self._try_get_response( - self._last_update, "get_child_device_list", {} - ): - for info in child_info["child_device_list"]: - self._children[info["device_id"]]._update_internal_state(info) - - for child in self._children.values(): - errors = [] - for child_module_name, child_module in child._modules.items(): - if not self._handle_module_post_update_hook(child_module): - errors.append(child_module_name) - for error in errors: - child._modules.pop(error) # We can first initialize the features after the first update. # We make here an assumption that every device has at least a single feature. @@ -197,18 +194,26 @@ async def update(self, update_children: bool = False): updated = self._last_update if first_update else resp _LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys())) - def _handle_module_post_update_hook(self, module: SmartModule) -> bool: + def _handle_module_post_update( + self, module: SmartModule, update_time: float, had_query: bool + ): + if module.disabled: + return # pragma: no cover + if had_query: + module._last_update_time = update_time try: module._post_update_hook() - return True + module._set_error(None) except Exception as ex: - _LOGGER.warning( - "Error processing %s for device %s, module will be unavailable: %s", - module.name, - self.host, - ex, - ) - return False + # Only set the error if a query happened. + if had_query: + module._set_error(ex) + _LOGGER.warning( + "Error processing %s for device %s, module will be unavailable: %s", + module.name, + self.host, + ex, + ) async def _modular_update( self, first_update: bool, update_time: float @@ -221,17 +226,16 @@ async def _modular_update( mq = { module: query for module in self._modules.values() - if (query := module.query()) + if module.disabled is False and (query := module.query()) } for module, query in mq.items(): if first_update and module.__class__ in FIRST_UPDATE_MODULES: module._last_update_time = update_time continue if ( - not module.MINIMUM_UPDATE_INTERVAL_SECS + not module.update_interval or not module._last_update_time - or (update_time - module._last_update_time) - >= module.MINIMUM_UPDATE_INTERVAL_SECS + or (update_time - module._last_update_time) >= module.update_interval ): module_queries.append(module) req.update(query) @@ -254,16 +258,10 @@ async def _modular_update( self._info = self._try_get_response(info_resp, "get_device_info") # Call handle update for modules that want to update internal data - errors = [] - for module_name, module in self._modules.items(): - if not self._handle_module_post_update_hook(module): - errors.append(module_name) - for error in errors: - self._modules.pop(error) - - # Set the last update time for modules that had queries made. - for module in module_queries: - module._last_update_time = update_time + for module in self._modules.values(): + self._handle_module_post_update( + module, update_time, had_query=module in module_queries + ) return resp @@ -392,7 +390,7 @@ async def _initialize_features(self): name="RSSI", attribute_getter=lambda x: x._info["rssi"], icon="mdi:signal", - unit="dBm", + unit_getter=lambda: "dBm", category=Feature.Category.Debug, type=Feature.Type.Sensor, ) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index f5f2c212a..0e6256a0f 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -18,6 +18,7 @@ _T = TypeVar("_T", bound="SmartModule") _P = ParamSpec("_P") +_R = TypeVar("_R") def allow_update_after( @@ -38,6 +39,17 @@ async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: return _async_wrap +def raise_if_update_error(func: Callable[[_T], _R]) -> Callable[[_T], _R]: + """Define a wrapper to raise an error if the last module update was an error.""" + + def _wrap(self: _T) -> _R: + if err := self._last_update_error: + raise err + return func(self) + + return _wrap + + class SmartModule(Module): """Base class for SMART modules.""" @@ -52,17 +64,58 @@ class SmartModule(Module): REGISTERED_MODULES: dict[str, type[SmartModule]] = {} MINIMUM_UPDATE_INTERVAL_SECS = 0 + UPDATE_INTERVAL_AFTER_ERROR_SECS = 30 + + DISABLE_AFTER_ERROR_COUNT = 10 def __init__(self, device: SmartDevice, module: str): self._device: SmartDevice super().__init__(device, module) self._last_update_time: float | None = None + self._last_update_error: KasaException | None = None + self._error_count = 0 def __init_subclass__(cls, **kwargs): name = getattr(cls, "NAME", cls.__name__) _LOGGER.debug("Registering %s" % cls) cls.REGISTERED_MODULES[name] = cls + def _set_error(self, err: Exception | None): + if err is None: + self._error_count = 0 + self._last_update_error = None + else: + self._last_update_error = KasaException("Module update error", err) + self._error_count += 1 + if self._error_count == self.DISABLE_AFTER_ERROR_COUNT: + _LOGGER.error( + "Error processing %s for device %s, module will be disabled: %s", + self.name, + self._device.host, + err, + ) + if self._error_count > self.DISABLE_AFTER_ERROR_COUNT: + _LOGGER.error( # pragma: no cover + "Unexpected error processing %s for device %s, " + "module should be disabled: %s", + self.name, + self._device.host, + err, + ) + + @property + def update_interval(self) -> int: + """Time to wait between updates.""" + if self._last_update_error is None: + return self.MINIMUM_UPDATE_INTERVAL_SECS + + return self.UPDATE_INTERVAL_AFTER_ERROR_SECS * self._error_count + + @property + def disabled(self) -> bool: + """Return true if the module is disabled due to errors.""" + return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT + @property def name(self) -> str: """Name of the module.""" diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 7a54be170..40465b6f7 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -114,6 +114,7 @@ def credentials_hash(self): }, ), "get_device_usage": ("device", {}), + "get_connect_cloud_state": ("cloud_connect", {"status": 0}), } async def send(self, request: str): diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 440c9c1b7..fd4008562 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -27,7 +27,7 @@ def dummy_feature() -> Feature: container=None, icon="mdi:dummy", type=Feature.Type.Switch, - unit="dummyunit", + unit_getter=lambda: "dummyunit", ) return feat @@ -127,7 +127,7 @@ async def test_feature_action(mocker): async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture): """Test the choice feature type.""" dummy_feature.type = Feature.Type.Choice - dummy_feature.choices = ["first", "second"] + dummy_feature.choices_getter = lambda: ["first", "second"] mock_setter = mocker.patch.object(dummy_feature.device, "dummysetter", create=True) await dummy_feature.set_value("first") diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 4e6706444..d96542e5e 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -12,8 +12,11 @@ from pytest_mock import MockerFixture from kasa import Device, KasaException, Module -from kasa.exceptions import SmartErrorCode +from kasa.exceptions import DeviceError, SmartErrorCode from kasa.smart import SmartDevice +from kasa.smart.modules.energy import Energy +from kasa.smart.smartmodule import SmartModule +from kasa.smartprotocol import _ChildProtocolWrapper from .conftest import ( device_smart, @@ -139,78 +142,6 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): spies[device].assert_not_called() -@device_smart -async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): - """Test that modules that error are disabled / removed.""" - # We need to have some modules initialized by now - assert dev._modules - - critical_modules = {Module.DeviceModule, Module.ChildDevice} - not_disabling_modules = {Module.Cloud} - - new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) - - module_queries = { - modname: q - for modname, module in dev._modules.items() - if (q := module.query()) and modname not in critical_modules - } - child_module_queries = { - modname: q - for child in dev.children - for modname, module in child._modules.items() - if (q := module.query()) and modname not in critical_modules - } - all_queries_names = { - key for mod_query in module_queries.values() for key in mod_query - } - all_child_queries_names = { - key for mod_query in child_module_queries.values() for key in mod_query - } - - async def _query(request, *args, **kwargs): - responses = await dev.protocol._query(request, *args, **kwargs) - for k in responses: - if k in all_queries_names: - responses[k] = SmartErrorCode.PARAMS_ERROR - return responses - - async def _child_query(self, request, *args, **kwargs): - responses = await child_protocols[self._device_id]._query( - request, *args, **kwargs - ) - for k in responses: - if k in all_child_queries_names: - responses[k] = SmartErrorCode.PARAMS_ERROR - return responses - - mocker.patch.object(new_dev.protocol, "query", side_effect=_query) - - from kasa.smartprotocol import _ChildProtocolWrapper - - child_protocols = { - cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol - for child in dev.children - } - # children not created yet so cannot patch.object - mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) - - await new_dev.update() - for modname in module_queries: - no_disable = modname in not_disabling_modules - mod_present = modname in new_dev._modules - assert ( - mod_present is no_disable - ), f"{modname} present {mod_present} when no_disable {no_disable}" - - for modname in child_module_queries: - no_disable = modname in not_disabling_modules - mod_present = any(modname in child._modules for child in new_dev.children) - assert ( - mod_present is no_disable - ), f"{modname} present {mod_present} when no_disable {no_disable}" - - @device_smart async def test_update_module_update_delays( dev: SmartDevice, @@ -218,7 +149,7 @@ async def test_update_module_update_delays( caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, ): - """Test that modules that disabled / removed on query failures.""" + """Test that modules with minimum delays delay.""" # We need to have some modules initialized by now assert dev._modules @@ -257,6 +188,20 @@ async def test_update_module_update_delays( pytest.param(False, id="First update false"), ], ) +@pytest.mark.parametrize( + ("error_type"), + [ + pytest.param(SmartErrorCode.PARAMS_ERROR, id="Device error"), + pytest.param(TimeoutError("Dummy timeout"), id="Query error"), + ], +) +@pytest.mark.parametrize( + ("recover"), + [ + pytest.param(True, id="recover"), + pytest.param(False, id="no recover"), + ], +) @device_smart async def test_update_module_query_errors( dev: SmartDevice, @@ -264,15 +209,20 @@ async def test_update_module_query_errors( caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, first_update, + error_type, + recover, ): - """Test that modules that disabled / removed on query failures.""" + """Test that modules that disabled / removed on query failures. + + i.e. the whole query times out rather than device returns an error. + """ # We need to have some modules initialized by now assert dev._modules + SmartModule.DISABLE_AFTER_ERROR_COUNT = 2 first_update_queries = {"get_device_info", "get_connect_cloud_state"} critical_modules = {Module.DeviceModule, Module.ChildDevice} - not_disabling_modules = {Module.Cloud} new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) if not first_update: @@ -293,13 +243,18 @@ async def _query(request, *args, **kwargs): or "get_child_device_component_list" in request or "control_child" in request ): - return await dev.protocol._query(request, *args, **kwargs) + resp = await dev.protocol._query(request, *args, **kwargs) + resp["get_connect_cloud_state"] = SmartErrorCode.CLOUD_FAILED_ERROR + return resp + # Don't test for errors on get_device_info as that is likely terminal if len(request) == 1 and "get_device_info" in request: return await dev.protocol._query(request, *args, **kwargs) - raise TimeoutError("Dummy timeout") - - from kasa.smartprotocol import _ChildProtocolWrapper + if isinstance(error_type, SmartErrorCode): + if len(request) == 1: + raise DeviceError("Dummy device error", error_code=error_type) + raise TimeoutError("Dummy timeout") + raise error_type child_protocols = { cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol @@ -314,19 +269,66 @@ async def _child_query(self, request, *args, **kwargs): mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" assert msg in caplog.text for modname in module_queries: - no_disable = modname in not_disabling_modules - mod_present = modname in new_dev._modules - assert ( - mod_present is no_disable - ), f"{modname} present {mod_present} when no_disable {no_disable}" + mod = cast(SmartModule, new_dev.modules[modname]) + assert mod.disabled is False, f"{modname} disabled" + assert mod.update_interval == mod.UPDATE_INTERVAL_AFTER_ERROR_SECS for mod_query in module_queries[modname]: if not first_update or mod_query not in first_update_queries: msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" assert msg in caplog.text + # Query again should not run for the modules + caplog.clear() + await new_dev.update() + for modname in module_queries: + mod = cast(SmartModule, new_dev.modules[modname]) + assert mod.disabled is False, f"{modname} disabled" + + freezer.tick(SmartModule.UPDATE_INTERVAL_AFTER_ERROR_SECS) + + caplog.clear() + + if recover: + mocker.patch.object( + new_dev.protocol, "query", side_effect=new_dev.protocol._query + ) + mocker.patch( + "kasa.smartprotocol._ChildProtocolWrapper.query", + new=_ChildProtocolWrapper._query, + ) + + await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" + if not recover: + assert msg in caplog.text + for modname in module_queries: + mod = cast(SmartModule, new_dev.modules[modname]) + if not recover: + assert mod.disabled is True, f"{modname} not disabled" + assert mod._error_count == 2 + assert mod._last_update_error + for mod_query in module_queries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + # Test one of the raise_if_update_error + if mod.name == "Energy": + emod = cast(Energy, mod) + with pytest.raises(KasaException, match="Module update error"): + assert emod.current_consumption is not None + else: + assert mod.disabled is False + assert mod._error_count == 0 + assert mod._last_update_error is None + # Test one of the raise_if_update_error doesn't raise + if mod.name == "Energy": + emod = cast(Energy, mod) + assert emod.current_consumption is not None + async def test_get_modules(): """Test getting modules for child and parent modules.""" From cb7e904d30c7b3818bb87140f140d78a53be4f08 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:52:27 +0100 Subject: [PATCH 34/38] Enable setting brightness with color temp for smart devices (#1091) --- kasa/feature.py | 4 +-- kasa/iot/iotbulb.py | 2 +- kasa/smart/modules/colortemperature.py | 19 +++++----- kasa/smart/modules/light.py | 4 ++- kasa/tests/test_common_modules.py | 48 ++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/kasa/feature.py b/kasa/feature.py index 18bed554d..ad709424d 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -211,14 +211,14 @@ def range(self) -> tuple[int, int] | None: """Range of values if applicable.""" return self._get_property_value(self.range_getter) - @cached_property + @property def maximum_value(self) -> int: """Maximum value.""" if range := self.range: return range[1] return self.DEFAULT_MAX - @cached_property + @property def minimum_value(self) -> int: """Minimum value.""" if range := self.range: diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 26c73096a..81d647e87 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -429,7 +429,7 @@ async def _set_color_temp( if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - valid_temperature_range = self.valid_temperature_range + valid_temperature_range = self._valid_temperature_range if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: raise ValueError( "Temperature should be between {} and {}, was {}".format( diff --git a/kasa/smart/modules/colortemperature.py b/kasa/smart/modules/colortemperature.py index fa3b74126..920fa6d2c 100644 --- a/kasa/smart/modules/colortemperature.py +++ b/kasa/smart/modules/colortemperature.py @@ -3,16 +3,11 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING from ...feature import Feature from ...interfaces.light import ColorTempRange from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - _LOGGER = logging.getLogger(__name__) DEFAULT_TEMP_RANGE = [2500, 6500] @@ -23,11 +18,11 @@ class ColorTemperature(SmartModule): REQUIRED_COMPONENT = "color_temperature" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features.""" self._add_feature( Feature( - device, + self._device, "color_temperature", "Color temperature", container=self, @@ -61,7 +56,7 @@ def color_temp(self): """Return current color temperature.""" return self.data["color_temp"] - async def set_color_temp(self, temp: int): + async def set_color_temp(self, temp: int, *, brightness=None): """Set the color temperature.""" valid_temperature_range = self.valid_temperature_range if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: @@ -70,8 +65,10 @@ async def set_color_temp(self, temp: int): *valid_temperature_range, temp ) ) - - return await self.call("set_device_info", {"color_temp": temp}) + params = {"color_temp": temp} + if brightness: + params["brightness"] = brightness + return await self.call("set_device_info", params) async def _check_supported(self) -> bool: """Check the color_temp_range has more than one value.""" diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 0a255bb2a..8e0a37d89 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -107,7 +107,9 @@ async def set_color_temp( """ if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - return await self._device.modules[Module.ColorTemperature].set_color_temp(temp) + return await self._device.modules[Module.ColorTemperature].set_color_temp( + temp, brightness=brightness + ) async def set_brightness( self, brightness: int, *, transition: int | None = None diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index beed8e8ba..114615d4f 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -12,6 +12,7 @@ parametrize, parametrize_combine, plug_iot, + variable_temp_iot, ) led_smart = parametrize( @@ -36,6 +37,14 @@ ) dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) +variable_temp_smart = parametrize( + "variable temp smart", + component_filter="color_temperature", + protocol_filter={"SMART"}, +) + +variable_temp = parametrize_combine([variable_temp_iot, variable_temp_smart]) + light_preset_smart = parametrize( "has light preset smart", component_filter="preset", protocol_filter={"SMART"} ) @@ -147,6 +156,45 @@ async def test_light_brightness(dev: Device): await light.set_brightness(feature.maximum_value + 10) +@variable_temp +async def test_light_color_temp(dev: Device): + """Test color temp setter and getter.""" + assert isinstance(dev, Device) + + light = next(get_parent_and_child_modules(dev, Module.Light)) + assert light + if not light.is_variable_color_temp: + pytest.skip( + "Some smart light strips have color_temperature" + " component but min and max are the same" + ) + + # Test getting the value + feature = light._device.features["color_temperature"] + assert isinstance(feature.minimum_value, int) + assert isinstance(feature.maximum_value, int) + + await light.set_color_temp(feature.minimum_value + 10) + await dev.update() + assert light.color_temp == feature.minimum_value + 10 + + # Test setting brightness with color temp + await light.set_brightness(50) + await dev.update() + assert light.brightness == 50 + + await light.set_color_temp(feature.minimum_value + 20, brightness=60) + await dev.update() + assert light.color_temp == feature.minimum_value + 20 + assert light.brightness == 60 + + with pytest.raises(ValueError): + await light.set_color_temp(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await light.set_color_temp(feature.maximum_value + 10) + + @light async def test_light_set_state(dev: Device): """Test brightness setter and getter.""" From cb0077f6349fac6c4429d5c16b9bdb81158e2043 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:56:07 +0100 Subject: [PATCH 35/38] Do not send light_on value to iot bulb set_state (#1090) Passing this extra value caused the `ignore_default` check in the `IotBulb._set_light_state` method to fail which causes the device to come back on to the default state. --- kasa/iot/iotbulb.py | 1 + kasa/iot/modules/light.py | 2 ++ kasa/tests/test_bulb.py | 18 +++++++++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 81d647e87..97826f2ae 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -326,6 +326,7 @@ async def _set_light_state( self, state: dict, *, transition: int | None = None ) -> dict: """Set the light state.""" + state = {**state} if transition is not None: state["transition_period"] = transition diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 358771a65..c4d6cb09b 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -230,6 +230,8 @@ async def set_state(self, state: LightState) -> dict: state_dict["on_off"] = 1 else: state_dict["on_off"] = int(state.light_on) + # Remove the light_on from the dict + state_dict.pop("light_on", None) return await bulb._set_light_state(state_dict, transition=transition) @property diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index c78c539c9..002cbd419 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -9,7 +9,7 @@ Schema, ) -from kasa import Device, DeviceType, IotLightPreset, KasaException, Module +from kasa import Device, DeviceType, IotLightPreset, KasaException, LightState, Module from kasa.iot import IotBulb, IotDimmer from .conftest import ( @@ -96,6 +96,22 @@ async def test_set_hsv_transition(dev: IotBulb, mocker): ) +@bulb_iot +async def test_light_set_state(dev: IotBulb, mocker): + """Testing setting LightState on the light module.""" + light = dev.modules.get(Module.Light) + assert light + set_light_state = mocker.spy(dev, "_set_light_state") + state = LightState(light_on=True) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 1}, transition=None) + state = LightState(light_on=False) + await light.set_state(state) + + set_light_state.assert_called_with({"on_off": 0}, transition=None) + + @color_bulb @turn_on async def test_invalid_hsv(dev: Device, turn_on): From 31ec27c1c875781c5574bc43ccb7aed2338197ec Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 15:58:48 +0100 Subject: [PATCH 36/38] Fix iot light effect brightness (#1092) Fixes issue where the brightness of the `iot` light effect is set properly on the light effect but read back incorrectly from the light. --- kasa/iot/iotbulb.py | 9 +++++- kasa/iot/modules/lighteffect.py | 25 +++++++++++------ kasa/module.py | 1 + kasa/smart/modules/lightstripeffect.py | 13 +++++++-- kasa/tests/fakeprotocol_iot.py | 26 +++++++++++++---- kasa/tests/fakeprotocol_smart.py | 9 +++--- .../smart/modules/test_light_strip_effect.py | 27 ++++++++---------- kasa/tests/test_common_modules.py | 28 +++++++++++++++++++ 8 files changed, 101 insertions(+), 37 deletions(-) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 97826f2ae..a979e4e62 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -365,7 +365,7 @@ def _hsv(self) -> HSV: hue = light_state["hue"] saturation = light_state["saturation"] - value = light_state["brightness"] + value = self._brightness return HSV(hue, saturation, value) @@ -455,6 +455,13 @@ def _brightness(self) -> int: if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") + # If the device supports effects and one is active, we get the brightness + # from the effect. This is not required when setting the brightness as + # the device handles it via set_light_state + if ( + light_effect := self.modules.get(Module.IotLightEffect) + ) is not None and light_effect.effect != light_effect.LIGHT_EFFECTS_OFF: + return light_effect.brightness light_state = self.light_state return int(light_state["brightness"]) diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py index 8f855bcf2..3a13f6806 100644 --- a/kasa/iot/modules/lighteffect.py +++ b/kasa/iot/modules/lighteffect.py @@ -3,7 +3,6 @@ from __future__ import annotations from ...interfaces.lighteffect import LightEffect as LightEffectInterface -from ...module import Module from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..iotmodule import IotModule @@ -29,6 +28,11 @@ def effect(self) -> str: return self.LIGHT_EFFECTS_OFF + @property + def brightness(self) -> int: + """Return light effect brightness.""" + return self.data["lighting_effect_state"]["brightness"] + @property def effect_list(self) -> list[str]: """Return built-in effects list. @@ -60,18 +64,21 @@ async def set_effect( :param int transition: The wanted transition time """ if effect == self.LIGHT_EFFECTS_OFF: - light_module = self._device.modules[Module.Light] - effect_off_state = light_module.state - if brightness is not None: - effect_off_state.brightness = brightness - if transition is not None: - effect_off_state.transition = transition - await light_module.set_state(effect_off_state) + if self.effect in EFFECT_MAPPING_V1: + # TODO: We could query get_lighting_effect here to + # get the custom effect although not sure how to find + # custom effects + effect_dict = EFFECT_MAPPING_V1[self.effect] + else: + effect_dict = EFFECT_MAPPING_V1["Aurora"] + effect_dict = {**effect_dict} + effect_dict["enable"] = 0 + await self.set_custom_effect(effect_dict) elif effect not in EFFECT_MAPPING_V1: raise ValueError(f"The effect {effect} is not a built in effect.") else: effect_dict = EFFECT_MAPPING_V1[effect] - + effect_dict = {**effect_dict} if brightness is not None: effect_dict["brightness"] = brightness if transition is not None: diff --git a/kasa/module.py b/kasa/module.py index fe370603c..faf17c4d3 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -116,6 +116,7 @@ class Module(ABC): SmartLightEffect: Final[ModuleName[smart.SmartLightEffect]] = ModuleName( "LightEffect" ) + IotLightEffect: Final[ModuleName[iot.LightEffect]] = ModuleName("LightEffect") TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( "TemperatureSensor" ) diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index f75620686..3b0ff7da5 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -106,14 +106,23 @@ async def set_effect( """ brightness_module = self._device.modules[Module.Brightness] if effect == self.LIGHT_EFFECTS_OFF: - state = self._device.modules[Module.Light].state - await self._device.modules[Module.Light].set_state(state) + if self.effect in self._effect_mapping: + # TODO: We could query get_lighting_effect here to + # get the custom effect although not sure how to find + # custom effects + effect_dict = self._effect_mapping[self.effect] + else: + effect_dict = self._effect_mapping["Aurora"] + effect_dict = {**effect_dict} + effect_dict["enable"] = 0 + await self.set_custom_effect(effect_dict) return if effect not in self._effect_mapping: raise ValueError(f"The effect {effect} is not a built in effect.") else: effect_dict = self._effect_mapping[effect] + effect_dict = {**effect_dict} # Use explicitly given brightness if brightness is not None: diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 9c5f655c4..0a5433206 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -292,6 +292,26 @@ def set_lighting_effect(self, effect, *args): self.proto["system"]["get_sysinfo"]["lighting_effect_state"] = dict(effect) def transition_light_state(self, state_changes, *args): + # Setting the light state on a device will turn off any active lighting effects. + # Unless it's just the brightness in which case it will update the brightness for + # the lighting effect + if lighting_effect_state := self.proto["system"]["get_sysinfo"].get( + "lighting_effect_state" + ): + if ( + "hue" in state_changes + or "saturation" in state_changes + or "color_temp" in state_changes + ): + lighting_effect_state["enable"] = 0 + elif ( + lighting_effect_state["enable"] == 1 + and state_changes.get("on_off") != 0 + and (brightness := state_changes.get("brightness")) + ): + lighting_effect_state["brightness"] = brightness + return + _LOGGER.debug("Setting light state to %s", state_changes) light_state = self.proto["system"]["get_sysinfo"]["light_state"] @@ -317,12 +337,6 @@ def transition_light_state(self, state_changes, *args): _LOGGER.debug("New light state: %s", new_state) self.proto["system"]["get_sysinfo"]["light_state"] = new_state - # Setting the light state on a device will turn off any active lighting effects. - if lighting_effect_state := self.proto["system"]["get_sysinfo"].get( - "lighting_effect_state" - ): - lighting_effect_state["enable"] = 0 - def set_preferred_state(self, new_state, *args): """Implement set_preferred_state.""" self.proto["system"]["get_sysinfo"]["preferred_state"][new_state["index"]] = ( diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 40465b6f7..6c9423ecc 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -271,13 +271,14 @@ def _set_edit_dynamic_light_effect_rule(self, info, params): def _set_light_strip_effect(self, info, params): """Set or remove values as per the device behaviour.""" - info["get_device_info"]["lighting_effect"]["enable"] = params["enable"] - info["get_device_info"]["lighting_effect"]["name"] = params["name"] - info["get_device_info"]["lighting_effect"]["id"] = params["id"] # Brightness is not always available if (brightness := params.get("brightness")) is not None: info["get_device_info"]["lighting_effect"]["brightness"] = brightness - info["get_lighting_effect"] = copy.deepcopy(params) + if "enable" in params: + info["get_device_info"]["lighting_effect"]["enable"] = params["enable"] + info["get_device_info"]["lighting_effect"]["name"] = params["name"] + info["get_device_info"]["lighting_effect"]["id"] = params["id"] + info["get_lighting_effect"] = copy.deepcopy(params) def _set_led_info(self, info, params): """Set or remove values as per the device behaviour.""" diff --git a/kasa/tests/smart/modules/test_light_strip_effect.py b/kasa/tests/smart/modules/test_light_strip_effect.py index 92ef2202c..283d294d2 100644 --- a/kasa/tests/smart/modules/test_light_strip_effect.py +++ b/kasa/tests/smart/modules/test_light_strip_effect.py @@ -30,26 +30,23 @@ async def test_light_strip_effect(dev: Device, mocker: MockerFixture): call = mocker.spy(light_effect, "call") - light = dev.modules[Module.Light] - light_call = mocker.spy(light, "call") - assert feature.choices == light_effect.effect_list assert feature.choices for effect in chain(reversed(feature.choices), feature.choices): + if effect == LightEffect.LIGHT_EFFECTS_OFF: + off_effect = ( + light_effect.effect + if light_effect.effect in light_effect._effect_mapping + else "Aurora" + ) await light_effect.set_effect(effect) - if effect == LightEffect.LIGHT_EFFECTS_OFF: - light_call.assert_called() - continue - - # Start with the current effect data - params = light_effect.data["lighting_effect"] - enable = effect != LightEffect.LIGHT_EFFECTS_OFF - params["enable"] = enable - if enable: - params = light_effect._effect_mapping[effect] - params["enable"] = enable - params["brightness"] = brightness.brightness # use the existing brightness + if effect != LightEffect.LIGHT_EFFECTS_OFF: + params = {**light_effect._effect_mapping[effect]} + else: + params = {**light_effect._effect_mapping[off_effect]} + params["enable"] = 0 + params["brightness"] = brightness.brightness # use the existing brightness call.assert_called_with("set_lighting_effect", params) diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 114615d4f..548e11916 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -133,6 +133,31 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): call.assert_not_called() +@light_effect +async def test_light_effect_brightness(dev: Device, mocker: MockerFixture): + """Test that light module uses light_effect for brightness when active.""" + light_module = dev.modules[Module.Light] + + light_effect = dev.modules[Module.LightEffect] + + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await light_module.set_brightness(50) + await dev.update() + assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF + assert light_module.brightness == 50 + await light_effect.set_effect(light_effect.effect_list[1]) + await dev.update() + # assert light_module.brightness == 100 + + await light_module.set_brightness(75) + await dev.update() + assert light_module.brightness == 75 + + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await dev.update() + assert light_module.brightness == 50 + + @dimmable async def test_light_brightness(dev: Device): """Test brightness setter and getter.""" @@ -201,6 +226,9 @@ async def test_light_set_state(dev: Device): assert isinstance(dev, Device) light = next(get_parent_and_child_modules(dev, Module.Light)) assert light + # For fixtures that have a light effect active switch off + if light_effect := light._device.modules.get(Module.LightEffect): + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) await light.set_state(LightState(light_on=False)) await dev.update() From 6f14330e093775460eed7b700ba86bc9894a6f20 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 17:56:06 +0100 Subject: [PATCH 37/38] Update RELEASING.md for patch releases (#1076) --- RELEASING.md | 187 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 166 insertions(+), 21 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index e42e1c871..a330c002a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,3 +1,5 @@ +# Releasing + ## Requirements * [github client](https://github.com/cli/cli#installation) * [gitchub_changelog_generator](https://github.com/github-changelog-generator) @@ -18,7 +20,9 @@ export NEW_RELEASE=x.x.x.devx export PREVIOUS_RELEASE=0.3.5 ``` -## Create a branch for the release +## Normal releases from master + +### Create a branch for the release ```bash git checkout master @@ -27,35 +31,35 @@ git rebase upstream/master git checkout -b release/$NEW_RELEASE ``` -## Update the version number +### Update the version number ```bash poetry version $NEW_RELEASE ``` -## Update dependencies +### Update dependencies ```bash poetry install --all-extras --sync poetry update ``` -## Run pre-commit and tests +### Run pre-commit and tests ```bash pre-commit run --all-files pytest kasa ``` -## Create release summary (skip for dev releases) +### Create release summary (skip for dev releases) Write a short and understandable summary for the release. Can include images. -### Create $NEW_RELEASE milestone in github +#### Create $NEW_RELEASE milestone in github If not already created -### Create new issue linked to the milestone +#### Create new issue linked to the milestone ```bash gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "## Release Summary" @@ -63,7 +67,7 @@ gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW You can exclude the --body option to get an interactive editor or go into the issue on github and edit there. -### Close the issue +#### Close the issue Either via github or: @@ -71,11 +75,11 @@ Either via github or: gh issue close ISSUE_NUMBER ``` -## Generate changelog +### Generate changelog Configuration settings are in `.github_changelog_generator` -### For pre-release +#### For pre-release EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags. @@ -87,7 +91,7 @@ echo "$EXCLUDE_TAGS" github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex "$EXCLUDE_TAGS" ``` -### For production +#### For production ```bash github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex 'dev\d$' @@ -99,28 +103,28 @@ Warning: PR 908 merge commit was not found in the release branch or tagged git h ``` -## Export new release notes to variable +### Export new release notes to variable ```bash export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' ) echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary ``` -## Commit and push the changed files +### Commit and push the changed files ```bash git commit --all --verbose -m "Prepare $NEW_RELEASE" git push upstream release/$NEW_RELEASE -u ``` -## Create a PR for the release, merge it, and re-fetch the master +### Create a PR for the release, merge it, and re-fetch the master -### Create the PR +#### Create the PR ``` gh pr create --title "Prepare $NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base master ``` -### Merge the PR once the CI passes +#### Merge the PR once the CI passes Create a squash commit and add the markdown from the PR description to the commit description. @@ -136,7 +140,7 @@ git fetch upstream master git rebase upstream/master ``` -## Create a release tag +### Create a release tag Note, add changelog release notes as the tag commit message so `gh release create --notes-from-tag` can be used to create a release draft. @@ -145,21 +149,162 @@ git tag --annotate $NEW_RELEASE -m "$RELEASE_NOTES" git push upstream $NEW_RELEASE ``` -## Create release +### Create release -### Pre-releases +#### Pre-releases ```bash gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=false --prerelease ``` -### Production release +#### Production release ```bash gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true ``` -## Manually publish the release +### Manually publish the release Go to the linked URL, verify the contents, and click "release" button to trigger the release CI. + +## Patch releases + +This requires git commit signing to be enabled. + +https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification + +### Create release branch + +#### For the first patch release since a new release only + +```bash +export NEW_RELEASE=x.x.x.x +export CURRENT_RELEASE=x.x.x +``` + +```bash +git fetch upstream $CURRENT_RELEASE +git checkout patch +git fetch upstream patch +git rebase upstream/patch +git fetch upstream $CURRENT_RELEASE +git merge $CURRENT_RELEASE --ff-only +git push upstream patch -u +git checkout -b release/$NEW_RELEASE +``` + +#### For subsequent patch releases + +```bash +export NEW_RELEASE=x.x.x.x +``` + +```bash +git checkout patch +git fetch upstream patch +git rebase upstream/patch +git checkout -b release/$NEW_RELEASE +``` +### Cherry pick required commits + +```bash +git cherry-pick commitSHA1 -S +git cherry-pick commitSHA2 -S +``` + +### Update the version number + +```bash +poetry version $NEW_RELEASE +``` + +### Manually edit the changelog + +github_changlog generator_does not work with patch releases so manually add the section for the new release to CHANGELOG.md. + +### Export new release notes to variable + +```bash +export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' ) +echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary +``` + +### Commit and push the changed files + +```bash +git commit --all --verbose -m "Prepare $NEW_RELEASE" -S +git push upstream release/$NEW_RELEASE -u +``` + +### Create a PR for the release, merge it, and re-fetch patch + +#### Create the PR +``` +gh pr create --title "$NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base patch +``` + +#### Merge the PR once the CI passes + +Create a **merge** commit and add the markdown from the PR description to the commit description. + +```bash +gh pr merge --merge --body "$RELEASE_NOTES" +``` + +### Rebase local patch + +```bash +git checkout patch +git fetch upstream patch +git rebase upstream/patch +``` + +### Create a release tag + +```bash +git tag -s --annotate $NEW_RELEASE -m "$RELEASE_NOTES" +git push upstream $NEW_RELEASE +``` + +### Create release + +```bash +gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true +``` +Then go into github, review and release + +### Merge patch back to master + +```bash +git checkout master +git fetch upstream master +git rebase upstream/master +git checkout -b janitor/merge_patch +git fetch upstream patch +git merge upstream/patch --no-commit +git diff --name-only --diff-filter=U | xargs git checkout upstream/master +git diff --staged +# The only diff should be the version in pyproject.toml and CHANGELOG.md +# unless a change made on patch that was not part of a cherry-pick commit +# If there are any other unexpected diffs `git checkout upstream/master [thefilename]` +git commit -m "Merge patch into local master" -S +git push upstream janitor/merge_patch -u +gh pr create --title "Merge patch into master" --body '' --label release-prep --base master +``` + +#### Temporarily allow merge commits to master + +1. Open [repository settings](https://github.com/python-kasa/python-kasa/settings) +2. From the left select `Rules` > `Rulesets` +3. Open `master` ruleset, under `Bypass list` select `+ Add bypass` +4. Check `Repository admin` > `Add selected`, select `Save changes` + +#### Merge commit the PR +```bash +gh pr merge --merge --body "" +``` +#### Revert allow merge commits + +1. Under `Bypass list` select `...` next to `Repository admins` +2. `Delete bypass`, select `Save changes` From 145a16db4c0b64d2a02a3b5163789ae3527e7ab4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 31 Jul 2024 19:02:53 +0100 Subject: [PATCH 38/38] Prepare 0.7.1 (#1094) ## [0.7.1](https://github.com/python-kasa/python-kasa/tree/0.7.1) (2024-07-31) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.5...0.7.1) **Release highlights:** - This release consists mainly of bugfixes and project improvements. - There is also new support for Tapo T100 motion sensors. - The CLI now supports child devices on all applicable commands. **Implemented enhancements:** - Expose reboot action [\#1073](https://github.com/python-kasa/python-kasa/pull/1073) (@rytilahti) - Decrypt KLAP data from PCAP files [\#1041](https://github.com/python-kasa/python-kasa/pull/1041) (@clstrickland) - Support child devices in all applicable cli commands [\#1020](https://github.com/python-kasa/python-kasa/pull/1020) (@sdb9696) **Fixed bugs:** - Fix iot light effect brightness [\#1092](https://github.com/python-kasa/python-kasa/pull/1092) (@sdb9696) - Enable setting brightness with color temp for smart devices [\#1091](https://github.com/python-kasa/python-kasa/pull/1091) (@sdb9696) - Do not send light\_on value to iot bulb set\_state [\#1090](https://github.com/python-kasa/python-kasa/pull/1090) (@sdb9696) - Allow erroring modules to recover [\#1080](https://github.com/python-kasa/python-kasa/pull/1080) (@sdb9696) - Raise KasaException on decryption errors [\#1078](https://github.com/python-kasa/python-kasa/pull/1078) (@sdb9696) - Update smart request parameter handling [\#1061](https://github.com/python-kasa/python-kasa/pull/1061) (@sdb9696) - Fix light preset module when list contains lighting effects [\#1048](https://github.com/python-kasa/python-kasa/pull/1048) (@sdb9696) - Handle module errors more robustly and add query params to light preset and transition [\#1036](https://github.com/python-kasa/python-kasa/pull/1036) (@sdb9696) - Fix credential hash to return None on empty credentials [\#1029](https://github.com/python-kasa/python-kasa/pull/1029) (@sdb9696) **Added support for devices:** - Add support for T100 motion sensor [\#1079](https://github.com/python-kasa/python-kasa/pull/1079) (@rytilahti) **Project maintenance:** - Bump project version to 0.7.0.5 [\#1087](https://github.com/python-kasa/python-kasa/pull/1087) (@sdb9696) - Fix generate\_supported pre commit to run in venv [\#1085](https://github.com/python-kasa/python-kasa/pull/1085) (@sdb9696) - Fix intermittently failing decryption error test [\#1082](https://github.com/python-kasa/python-kasa/pull/1082) (@sdb9696) - Fix mypy pre-commit hook on windows [\#1081](https://github.com/python-kasa/python-kasa/pull/1081) (@sdb9696) - Update RELEASING.md for patch releases [\#1076](https://github.com/python-kasa/python-kasa/pull/1076) (@sdb9696) - Use monotonic time for query timing [\#1070](https://github.com/python-kasa/python-kasa/pull/1070) (@sdb9696) - Fix parse\_pcap\_klap on windows and support default credentials [\#1068](https://github.com/python-kasa/python-kasa/pull/1068) (@sdb9696) - Add fixture file for KP405 fw 1.0.6 [\#1063](https://github.com/python-kasa/python-kasa/pull/1063) (@daleye) - Bump project version to 0.7.0.3 [\#1053](https://github.com/python-kasa/python-kasa/pull/1053) (@sdb9696) - Add KP400\(US\) v1.0.4 fixture [\#1051](https://github.com/python-kasa/python-kasa/pull/1051) (@gimpy88) - Add new HS220 kasa aes fixture [\#1050](https://github.com/python-kasa/python-kasa/pull/1050) (@sdb9696) - Add KS205\(US\) v1.1.0 fixture [\#1049](https://github.com/python-kasa/python-kasa/pull/1049) (@gimpy88) - Add KS200M\(US\) v1.0.11 fixture [\#1047](https://github.com/python-kasa/python-kasa/pull/1047) (@sdb9696) - Add KS225\(US\) v1.1.0 fixture [\#1046](https://github.com/python-kasa/python-kasa/pull/1046) (@sdb9696) - Split out main cli module into lazily loaded submodules [\#1039](https://github.com/python-kasa/python-kasa/pull/1039) (@sdb9696) - Structure cli into a package [\#1038](https://github.com/python-kasa/python-kasa/pull/1038) (@sdb9696) - Add KP400 v1.0.3 fixture [\#1037](https://github.com/python-kasa/python-kasa/pull/1037) (@gimpy88) - Add L920\(EU\) v1.1.3 fixture [\#1031](https://github.com/python-kasa/python-kasa/pull/1031) (@rytilahti) - Update changelog generator config [\#1030](https://github.com/python-kasa/python-kasa/pull/1030) (@sdb9696) --- CHANGELOG.md | 207 ++++++++----- poetry.lock | 807 +++++++++++++++++++++++++------------------------ pyproject.toml | 2 +- 3 files changed, 554 insertions(+), 462 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ab6cd7a..314b2985a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [0.7.1](https://github.com/python-kasa/python-kasa/tree/0.7.1) (2024-07-31) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.5...0.7.1) + +**Release highlights:** +- This release consists mainly of bugfixes and project improvements. +- There is also new support for Tapo T100 motion sensors. +- The CLI now supports child devices on all applicable commands. + +**Implemented enhancements:** + +- Expose reboot action [\#1073](https://github.com/python-kasa/python-kasa/pull/1073) (@rytilahti) +- Decrypt KLAP data from PCAP files [\#1041](https://github.com/python-kasa/python-kasa/pull/1041) (@clstrickland) +- Support child devices in all applicable cli commands [\#1020](https://github.com/python-kasa/python-kasa/pull/1020) (@sdb9696) + +**Fixed bugs:** + +- Fix iot light effect brightness [\#1092](https://github.com/python-kasa/python-kasa/pull/1092) (@sdb9696) +- Enable setting brightness with color temp for smart devices [\#1091](https://github.com/python-kasa/python-kasa/pull/1091) (@sdb9696) +- Do not send light\_on value to iot bulb set\_state [\#1090](https://github.com/python-kasa/python-kasa/pull/1090) (@sdb9696) +- Allow erroring modules to recover [\#1080](https://github.com/python-kasa/python-kasa/pull/1080) (@sdb9696) +- Raise KasaException on decryption errors [\#1078](https://github.com/python-kasa/python-kasa/pull/1078) (@sdb9696) +- Update smart request parameter handling [\#1061](https://github.com/python-kasa/python-kasa/pull/1061) (@sdb9696) +- Fix light preset module when list contains lighting effects [\#1048](https://github.com/python-kasa/python-kasa/pull/1048) (@sdb9696) +- Handle module errors more robustly and add query params to light preset and transition [\#1036](https://github.com/python-kasa/python-kasa/pull/1036) (@sdb9696) +- Fix credential hash to return None on empty credentials [\#1029](https://github.com/python-kasa/python-kasa/pull/1029) (@sdb9696) + +**Added support for devices:** + +- Add support for T100 motion sensor [\#1079](https://github.com/python-kasa/python-kasa/pull/1079) (@rytilahti) + +**Project maintenance:** + +- Bump project version to 0.7.0.5 [\#1087](https://github.com/python-kasa/python-kasa/pull/1087) (@sdb9696) +- Fix generate\_supported pre commit to run in venv [\#1085](https://github.com/python-kasa/python-kasa/pull/1085) (@sdb9696) +- Fix intermittently failing decryption error test [\#1082](https://github.com/python-kasa/python-kasa/pull/1082) (@sdb9696) +- Fix mypy pre-commit hook on windows [\#1081](https://github.com/python-kasa/python-kasa/pull/1081) (@sdb9696) +- Update RELEASING.md for patch releases [\#1076](https://github.com/python-kasa/python-kasa/pull/1076) (@sdb9696) +- Use monotonic time for query timing [\#1070](https://github.com/python-kasa/python-kasa/pull/1070) (@sdb9696) +- Fix parse\_pcap\_klap on windows and support default credentials [\#1068](https://github.com/python-kasa/python-kasa/pull/1068) (@sdb9696) +- Add fixture file for KP405 fw 1.0.6 [\#1063](https://github.com/python-kasa/python-kasa/pull/1063) (@daleye) +- Bump project version to 0.7.0.3 [\#1053](https://github.com/python-kasa/python-kasa/pull/1053) (@sdb9696) +- Add KP400\(US\) v1.0.4 fixture [\#1051](https://github.com/python-kasa/python-kasa/pull/1051) (@gimpy88) +- Add new HS220 kasa aes fixture [\#1050](https://github.com/python-kasa/python-kasa/pull/1050) (@sdb9696) +- Add KS205\(US\) v1.1.0 fixture [\#1049](https://github.com/python-kasa/python-kasa/pull/1049) (@gimpy88) +- Add KS200M\(US\) v1.0.11 fixture [\#1047](https://github.com/python-kasa/python-kasa/pull/1047) (@sdb9696) +- Add KS225\(US\) v1.1.0 fixture [\#1046](https://github.com/python-kasa/python-kasa/pull/1046) (@sdb9696) +- Split out main cli module into lazily loaded submodules [\#1039](https://github.com/python-kasa/python-kasa/pull/1039) (@sdb9696) +- Structure cli into a package [\#1038](https://github.com/python-kasa/python-kasa/pull/1038) (@sdb9696) +- Add KP400 v1.0.3 fixture [\#1037](https://github.com/python-kasa/python-kasa/pull/1037) (@gimpy88) +- Add L920\(EU\) v1.1.3 fixture [\#1031](https://github.com/python-kasa/python-kasa/pull/1031) (@rytilahti) +- Update changelog generator config [\#1030](https://github.com/python-kasa/python-kasa/pull/1030) (@sdb9696) + ## [0.7.0.5](https://github.com/python-kasa/python-kasa/tree/0.7.0.5) (2024-07-18) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.4...0.7.0.5) @@ -8,33 +61,45 @@ A critical bugfix for an issue with some L530 Series devices and a redactor for **Fixed bugs:** -- Only refresh smart LightEffect module daily [\#1064](https://github.com/python-kasa/python-kasa/pull/1064) +- Only refresh smart LightEffect module daily [\#1064](https://github.com/python-kasa/python-kasa/pull/1064) (@sdb9696) **Project maintenance:** -- Redact sensitive info from debug logs [\#1069](https://github.com/python-kasa/python-kasa/pull/1069) +- Redact sensitive info from debug logs [\#1069](https://github.com/python-kasa/python-kasa/pull/1069) (@sdb9696) ## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-11) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) -Critical bugfixes for issues with P100s and thermostats. +Critical bugfixes for issues with P100s and thermostats + **Fixed bugs:** -- Use first known thermostat state as main state (pick #1054) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) -- Defer module updates for less volatile modules (pick 1052) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) +- Error connecting to L920-5 Smart LED Strip [\#1040](https://github.com/python-kasa/python-kasa/issues/1040) +- Use first known thermostat state as main state \(pick \#1054\) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) (@sdb9696) +- Defer module updates for less volatile modules \(pick 1052\) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) (@sdb9696) +- Use first known thermostat state as main state [\#1054](https://github.com/python-kasa/python-kasa/pull/1054) (@rytilahti) +- Defer module updates for less volatile modules [\#1052](https://github.com/python-kasa/python-kasa/pull/1052) (@sdb9696) ## [0.7.0.3](https://github.com/python-kasa/python-kasa/tree/0.7.0.3) (2024-07-04) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) -Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. +Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. Partially fixes light preset module errors with L920 and L930. **Fixed bugs:** -- Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) +- Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) (@sdb9696) + +**Documentation updates:** + +- Misleading usage of asyncio.run\(\) in code examples [\#348](https://github.com/python-kasa/python-kasa/issues/348) + +**Project maintenance:** + +- Enable CI on the patch branch [\#1042](https://github.com/python-kasa/python-kasa/pull/1042) (@sdb9696) ## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01) @@ -76,25 +141,25 @@ This patch release fixes some minor issues found out during testing against all [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0) -We have been working hard behind the scenes to make this major release possible. -This release brings a major refactoring of the library to serve the ever-growing list of supported devices and paves the way for the future, yet unsupported devices. -The library now exposes device features through generic module and feature interfaces, that allows easy extension for future improvements. - -With almost 180 merged pull requests, over 200 changed files and since the last release, this release includes lots of goodies for everyone: -* Support for multi-functional devices like the dimmable fan KS240. -* Initial support for hubs and hub-connected devices like thermostats and sensors. -* Both IOT (legacy kasa) and SMART (tapo and newer kasa) devices now expose features and share common API. -* Modules to allow controlling new devices and functions such as light presets, fan controls, thermostats, humidity sensors, firmware updates and alarms. -* The common APIs allow dynamic introspection of available device features, making it easy to create dynamic interfaces. -* Improved documentation. - -Hope you enjoy the release, feel free to leave a comment and feedback! - -If you have a device that works, but is not listed in our supported devices list, feel free to [contribute fixture files](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) to help us to make the library even better! - -> git diff 0.6.2.1..HEAD|diffstat -> 214 files changed, 26960 insertions(+), 6310 deletions(-) - +We have been working hard behind the scenes to make this major release possible. +This release brings a major refactoring of the library to serve the ever-growing list of supported devices and paves the way for the future, yet unsupported devices. +The library now exposes device features through generic module and feature interfaces, that allows easy extension for future improvements. + +With almost 180 merged pull requests, over 200 changed files and since the last release, this release includes lots of goodies for everyone: +* Support for multi-functional devices like the dimmable fan KS240. +* Initial support for hubs and hub-connected devices like thermostats and sensors. +* Both IOT (legacy kasa) and SMART (tapo and newer kasa) devices now expose features and share common API. +* Modules to allow controlling new devices and functions such as light presets, fan controls, thermostats, humidity sensors, firmware updates and alarms. +* The common APIs allow dynamic introspection of available device features, making it easy to create dynamic interfaces. +* Improved documentation. + +Hope you enjoy the release, feel free to leave a comment and feedback! + +If you have a device that works, but is not listed in our supported devices list, feel free to [contribute fixture files](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) to help us to make the library even better! + +> git diff 0.6.2.1..HEAD|diffstat +> 214 files changed, 26960 insertions(+), 6310 deletions(-) + For more information on the changes please checkout our [documentation on the API changes](https://python-kasa.readthedocs.io/en/latest/deprecated.html) **Breaking changes:** @@ -326,8 +391,8 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) -Release highlights: -* Support for tapo power strips (P300) +Release highlights: +* Support for tapo power strips (P300) * Performance improvements and bug fixes **Implemented enhancements:** @@ -366,9 +431,9 @@ Release highlights: [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.1...0.6.1) -Release highlights: -* Support for tapo wall switches -* Support for unprovisioned devices +Release highlights: +* Support for tapo wall switches +* Support for unprovisioned devices * Performance and stability improvements **Implemented enhancements:** @@ -441,17 +506,17 @@ A patch release to improve the protocol handling. [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) -This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! - -This release adds support to a large range of previously unsupported devices, including: - -* Newer kasa-branded devices, including Matter-enabled devices like KP125M -* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol -* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) -* UK variant of HS110, which was the first device using the new protocol - -If your device that is not currently listed as supported is working, please consider contributing a test fixture file. - +This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! + +This release adds support to a large range of previously unsupported devices, including: + +* Newer kasa-branded devices, including Matter-enabled devices like KP125M +* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol +* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) +* UK variant of HS110, which was the first device using the new protocol + +If your device that is not currently listed as supported is working, please consider contributing a test fixture file. + Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! **Breaking changes:** @@ -546,13 +611,13 @@ Special thanks goes to @SimonWilkinson who created the initial PR for the new co [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.3...0.5.4) -The highlights of this maintenance release: - -* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. -* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. -* Optimizations for downstream device accesses, thanks to @bdraco. -* Support for both pydantic v1 and v2. - +The highlights of this maintenance release: + +* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. +* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. +* Optimizations for downstream device accesses, thanks to @bdraco. +* Support for both pydantic v1 and v2. + As always, see the full changelog for details. **Implemented enhancements:** @@ -612,8 +677,8 @@ This release adds support for defining the device port and introduces dependency [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) -Besides some small improvements, this release: -* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. +Besides some small improvements, this release: +* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. * Drops Python 3.7 support as it is no longer maintained. **Breaking changes:** @@ -648,11 +713,11 @@ Besides some small improvements, this release: [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.0...0.5.1) -This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: -* Improved console tool (JSON output, colorized output if rich is installed) -* Pretty, colorized console output, if `rich` is installed -* Support for configuring bulb presets -* Usage data is now reported in the expected format +This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: +* Improved console tool (JSON output, colorized output if rich is installed) +* Pretty, colorized console output, if `rich` is installed +* Support for configuring bulb presets +* Usage data is now reported in the expected format * Dependency pinning is relaxed to give downstreams more control **Breaking changes:** @@ -716,21 +781,21 @@ This minor release contains mostly small UX fine-tuning and documentation improv [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.3...0.5.0) -This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. - -There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): -* Basic system info -* Emeter -* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device -* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) -* Countdown (new) -* Antitheft (new) -* Schedule (new) -* Motion - for configuring motion settings on some dimmers (new) -* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) -* Cloud - information about cloud connectivity (new) - -For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. +This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. + +There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): +* Basic system info +* Emeter +* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device +* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) +* Countdown (new) +* Antitheft (new) +* Schedule (new) +* Motion - for configuring motion settings on some dimmers (new) +* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) +* Cloud - information about cloud connectivity (new) + +For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. Pull requests improving the functionality of modules as well as adding better interfaces to device classes are welcome! **Breaking changes:** diff --git a/poetry.lock b/poetry.lock index b6511e147..8e9667263 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,91 +1,103 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.3.2" +description = "Happy Eyeballs" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "aiohappyeyeballs-2.3.2-py3-none-any.whl", hash = "sha256:903282fb08c8cfb3de356fd546b263248a477c99cb147e20a115e14ab942a4ae"}, + {file = "aiohappyeyeballs-2.3.2.tar.gz", hash = "sha256:77e15a733090547a1f5369a1287ddfc944bd30df0eb8993f585259c34b405f4e"}, +] [[package]] name = "aiohttp" -version = "3.9.5" +version = "3.10.0" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, - {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, - {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, - {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, - {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, - {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, - {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, - {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, - {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, - {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, + {file = "aiohttp-3.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:68ab608118e212f56feef44d4785aa90b713042da301f26338f36497b481cd79"}, + {file = "aiohttp-3.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:64a117c16273ca9f18670f33fc7fd9604b9f46ddb453ce948262889a6be72868"}, + {file = "aiohttp-3.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:54076a25f32305e585a3abae1f0ad10646bec539e0e5ebcc62b54ee4982ec29f"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71c76685773444d90ae83874433505ed800e1706c391fdf9e57cc7857611e2f4"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdda86ab376f9b3095a1079a16fbe44acb9ddde349634f1c9909d13631ff3bcf"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6dcd1d21da5ae1416f69aa03e883a51e84b6c803b8618cbab341ac89a85b9e"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06ef0135d7ab7fb0284342fbbf8e8ddf73b7fee8ecc55f5c3a3d0a6b765e6d8b"}, + {file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccab9381f38c669bb9254d848f3b41a3284193b3e274a34687822f98412097e9"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:947da3aee057010bc750b7b4bb65cbd01b0bdb7c4e1cf278489a1d4a1e9596b3"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5268b35fee7eb754fb5b3d0f16a84a2e9ed21306f5377f3818596214ad2d7714"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff25d988fd6ce433b5c393094a5ca50df568bdccf90a8b340900e24e0d5fb45c"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:594b4b4f1dfe8378b4a0342576dc87a930c960641159f5ae83843834016dbd59"}, + {file = "aiohttp-3.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c8820dad615cd2f296ed3fdea8402b12663ac9e5ea2aafc90ef5141eb10b50b8"}, + {file = "aiohttp-3.10.0-cp310-cp310-win32.whl", hash = "sha256:ab1d870403817c9a0486ca56ccbc0ebaf85d992277d48777faa5a95e40e5bcca"}, + {file = "aiohttp-3.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:563705a94ea3af43467167f3a21c665f3b847b2a0ae5544fa9e18df686a660da"}, + {file = "aiohttp-3.10.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13679e11937d3f37600860de1f848e2e062e2b396d3aa79b38c89f9c8ab7e791"}, + {file = "aiohttp-3.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c66a1aadafbc0bd7d648cb7fcb3860ec9beb1b436ce3357036a4d9284fcef9a"}, + {file = "aiohttp-3.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7e3545b06aae925f90f06402e05cfb9c62c6409ce57041932163b09c48daad6"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:effafe5144aa32f0388e8f99b1b2692cf094ea2f6b7ceca384b54338b77b1f50"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a04f2c8d41821a2507b49b2694c40495a295b013afb0cc7355b337980b47c546"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6dbfac556219d884d50edc6e1952a93545c2786193f00f5521ec0d9d464040ab"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a65472256c5232681968deeea3cd5453aa091c44e8db09f22f1a1491d422c2d9"}, + {file = "aiohttp-3.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:941366a554e566efdd3f042e17a9e461a36202469e5fd2aee66fe3efe6412aef"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:927b4aca6340301e7d8bb05278d0b6585b8633ea852b7022d604a5df920486bf"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:34adb8412e736a5d0df6d1fccdf71599dfb07a63add241a94a189b6364e997f1"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:43c60d9b332a01ee985f080f639f3e56abcfb95ec1320013c94083c3b6a2e143"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3f49edf7c5cd2987634116e1b6a0ee2438fca17f7c4ee480ff41decb76cf6158"}, + {file = "aiohttp-3.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9784246431eaf9d651b3cc06f9c64f9a9f57299f4971c5ea778fa0b81074ef13"}, + {file = "aiohttp-3.10.0-cp311-cp311-win32.whl", hash = "sha256:bec91402df78b897a47b66b9c071f48051cea68d853d8bc1d4404896c6de41ae"}, + {file = "aiohttp-3.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:25a9924343bf91b0c5082cae32cfc5a1f8787ac0433966319ec07b0ed4570722"}, + {file = "aiohttp-3.10.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:21dab4a704c68dc7bc2a1219a4027158e8968e2079f1444eda2ba88bc9f2895f"}, + {file = "aiohttp-3.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:872c0dcaccebd5733d535868fe2356aa6939f5827dcea7a8b9355bb2eff6f56e"}, + {file = "aiohttp-3.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f381424dbce313bb5a666a215e7a9dcebbc533e9a2c467a1f0c95279d24d1fa7"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ca48e9f092a417c6669ee8d3a19d40b3c66dde1a2ae0d57e66c34812819b671"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbe2f6d0466f5c59c7258e0745c20d74806a1385fbb7963e5bbe2309a11cc69b"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:03799a95402a7ed62671c4465e1eae51d749d5439dbc49edb6eee52ea165c50b"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5549c71c35b5f057a4eebcc538c41299826f7813f28880722b60e41c861a57ec"}, + {file = "aiohttp-3.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6fa7a42b78d8698491dc4ad388169de54cca551aa9900f750547372de396277"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:77bbf0a2f6fefac6c0db1792c234f577d80299a33ce7125467439097cf869198"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:34eaf5cfcc979846d73571b1a4be22cad5e029d55cdbe77cdc7545caa4dcb925"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4f1de31a585344a106db43a9c3af2e15bb82e053618ff759f1fdd31d82da38eb"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f3a1ea61d96146e9b9e5597069466e2e4d9e01e09381c5dd51659f890d5e29e7"}, + {file = "aiohttp-3.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:73c01201219eb039a828bb58dcc13112eec2fed6eea718356316cd552df26e04"}, + {file = "aiohttp-3.10.0-cp312-cp312-win32.whl", hash = "sha256:33e915971eee6d2056d15470a1214e4e0f72b6aad10225548a7ab4c4f54e2db7"}, + {file = "aiohttp-3.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2dc75da06c35a7b47a88ceadbf993a53d77d66423c2a78de8c6f9fb41ec35687"}, + {file = "aiohttp-3.10.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f1bc4d68b83966012813598fe39b35b4e6019b69d29385cf7ec1cb08e1ff829b"}, + {file = "aiohttp-3.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d9b8b31c057a0b7bb822a159c490af05cb11b8069097f3236746a78315998afa"}, + {file = "aiohttp-3.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10f0d7894ddc6ff8f369e3fdc082ef1f940dc1f5b9003cd40945d24845477220"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72de8ffba4a27e3c6e83e58a379fc4fe5548f69f9b541fde895afb9be8c31658"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd36d0f0afc2bd84f007cedd2d9a449c3cf04af471853a25eb71f28bc2e1a119"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f64d503c661864866c09806ac360b95457f872d639ca61719115a9f389b2ec90"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31616121369bc823791056c632f544c6c8f8d1ceecffd8bf3f72ef621eaabf49"}, + {file = "aiohttp-3.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f76c12abb88b7ee64b3f9ae72f0644af49ff139067b5add142836dab405d60d4"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6c99eef30a7e98144bcf44d615bc0f445b3a3730495fcc16124cb61117e1f81e"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:39e7ec718e7a1971a5d98357e3e8c0529477d45c711d32cd91999dc8d8404e1e"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1cef548ee4e84264b78879de0c754bbe223193c6313beb242ce862f82eab184"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f98f036eab11d2f90cdd01b9d1410de9d7eb520d070debeb2edadf158b758431"}, + {file = "aiohttp-3.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cc4376ff537f7d2c1e98f97f6d548e99e5d96078b0333c1d3177c11467b972de"}, + {file = "aiohttp-3.10.0-cp38-cp38-win32.whl", hash = "sha256:ebedc51ee6d39f9ea5e26e255fd56a7f4e79a56e77d960f9bae75ef4f95ed57f"}, + {file = "aiohttp-3.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:aad87626f31a85fd4af02ba7fd6cc424b39d4bff5c8677e612882649da572e47"}, + {file = "aiohttp-3.10.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1dc95c5e2a5e60095f1bb51822e3b504e6a7430c9b44bff2120c29bb876c5202"}, + {file = "aiohttp-3.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1c83977f7b6f4f4a96fab500f5a76d355f19f42675224a3002d375b3fb309174"}, + {file = "aiohttp-3.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8cedc48d36652dd3ac40e5c7c139d528202393e341a5e3475acedb5e8d5c4c75"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b099fbb823efed3c1d736f343ac60d66531b13680ee9b2669e368280f41c2b8"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d583755ddb9c97a2da1322f17fc7d26792f4e035f472d675e2761c766f94c2ff"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a03a4407bdb9ae815f0d5a19df482b17df530cf7bf9c78771aa1c713c37ff1f"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcb6e65f6ea7caa0188e36bebe9e72b259d3d525634758c91209afb5a6cbcba7"}, + {file = "aiohttp-3.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6612c6ed3147a4a2d6463454b94b877566b38215665be4c729cd8b7bdce15b4"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0b0c0148d2a69b82ffe650c2ce235b431d49a90bde7dd2629bcb40314957acf6"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:0d85a173b4dbbaaad1900e197181ea0fafa617ca6656663f629a8a372fdc7d06"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:12c43dace645023583f3dd2337dfc3aa92c99fb943b64dcf2bc15c7aa0fb4a95"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:33acb0d9bf12cdc80ceec6f5fda83ea7990ce0321c54234d629529ca2c54e33d"}, + {file = "aiohttp-3.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:91e0b76502205484a4d1d6f25f461fa60fe81a7987b90e57f7b941b0753c3ec8"}, + {file = "aiohttp-3.10.0-cp39-cp39-win32.whl", hash = "sha256:1ebd8ed91428ffbe8b33a5bd6f50174e11882d5b8e2fe28670406ab5ee045ede"}, + {file = "aiohttp-3.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:0433795c4a8bafc03deb3e662192250ba5db347c41231b0273380d2f53c9ea0b"}, + {file = "aiohttp-3.10.0.tar.gz", hash = "sha256:e8dd7da2609303e3574c95b0ec9f1fd49647ef29b94701a2862cceae76382e1d"}, ] [package.dependencies] +aiohappyeyeballs = ">=2.3.0" aiosignal = ">=1.1.2" async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" @@ -94,7 +106,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns", "brotlicffi"] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] [[package]] name = "aiosignal" @@ -226,24 +238,24 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] [[package]] name = "cachetools" -version = "5.3.3" +version = "5.4.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" files = [ - {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"}, - {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"}, + {file = "cachetools-5.4.0-py3-none-any.whl", hash = "sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474"}, + {file = "cachetools-5.4.0.tar.gz", hash = "sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827"}, ] [[package]] name = "certifi" -version = "2024.6.2" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"}, - {file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -459,63 +471,63 @@ files = [ [[package]] name = "coverage" -version = "7.5.4" +version = "7.6.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6cfb5a4f556bb51aba274588200a46e4dd6b505fb1a5f8c5ae408222eb416f99"}, - {file = "coverage-7.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2174e7c23e0a454ffe12267a10732c273243b4f2d50d07544a91198f05c48f47"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2214ee920787d85db1b6a0bd9da5f8503ccc8fcd5814d90796c2f2493a2f4d2e"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1137f46adb28e3813dec8c01fefadcb8c614f33576f672962e323b5128d9a68d"}, - {file = "coverage-7.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b385d49609f8e9efc885790a5a0e89f2e3ae042cdf12958b6034cc442de428d3"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4a474f799456e0eb46d78ab07303286a84a3140e9700b9e154cfebc8f527016"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5cd64adedf3be66f8ccee418473c2916492d53cbafbfcff851cbec5a8454b136"}, - {file = "coverage-7.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e564c2cf45d2f44a9da56f4e3a26b2236504a496eb4cb0ca7221cd4cc7a9aca9"}, - {file = "coverage-7.5.4-cp310-cp310-win32.whl", hash = "sha256:7076b4b3a5f6d2b5d7f1185fde25b1e54eb66e647a1dfef0e2c2bfaf9b4c88c8"}, - {file = "coverage-7.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:018a12985185038a5b2bcafab04ab833a9a0f2c59995b3cec07e10074c78635f"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5"}, - {file = "coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080"}, - {file = "coverage-7.5.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed550e7442f278af76d9d65af48069f1fb84c9f745ae249c1a183c1e9d1b025c"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0"}, - {file = "coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078"}, - {file = "coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806"}, - {file = "coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233"}, - {file = "coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e"}, - {file = "coverage-7.5.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5013ed890dc917cef2c9f765c4c6a8ae9df983cd60dbb635df8ed9f4ebc9f555"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c"}, - {file = "coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805"}, - {file = "coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b"}, - {file = "coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdd31315fc20868c194130de9ee6bfd99755cc9565edff98ecc12585b90be882"}, - {file = "coverage-7.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:02ff6e898197cc1e9fa375581382b72498eb2e6d5fc0b53f03e496cfee3fac6d"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d05c16cf4b4c2fc880cb12ba4c9b526e9e5d5bb1d81313d4d732a5b9fe2b9d53"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5986ee7ea0795a4095ac4d113cbb3448601efca7f158ec7f7087a6c705304e4"}, - {file = "coverage-7.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df54843b88901fdc2f598ac06737f03d71168fd1175728054c8f5a2739ac3e4"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ab73b35e8d109bffbda9a3e91c64e29fe26e03e49addf5b43d85fc426dde11f9"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:aea072a941b033813f5e4814541fc265a5c12ed9720daef11ca516aeacd3bd7f"}, - {file = "coverage-7.5.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:16852febd96acd953b0d55fc842ce2dac1710f26729b31c80b940b9afcd9896f"}, - {file = "coverage-7.5.4-cp38-cp38-win32.whl", hash = "sha256:8f894208794b164e6bd4bba61fc98bf6b06be4d390cf2daacfa6eca0a6d2bb4f"}, - {file = "coverage-7.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:e2afe743289273209c992075a5a4913e8d007d569a406ffed0bd080ea02b0633"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b95c3a8cb0463ba9f77383d0fa8c9194cf91f64445a63fc26fb2327e1e1eb088"}, - {file = "coverage-7.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3d7564cc09dd91b5a6001754a5b3c6ecc4aba6323baf33a12bd751036c998be4"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44da56a2589b684813f86d07597fdf8a9c6ce77f58976727329272f5a01f99f7"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e16f3d6b491c48c5ae726308e6ab1e18ee830b4cdd6913f2d7f77354b33f91c8"}, - {file = "coverage-7.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbc5958cb471e5a5af41b0ddaea96a37e74ed289535e8deca404811f6cb0bc3d"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a04e990a2a41740b02d6182b498ee9796cf60eefe40cf859b016650147908029"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ddbd2f9713a79e8e7242d7c51f1929611e991d855f414ca9996c20e44a895f7c"}, - {file = "coverage-7.5.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b1ccf5e728ccf83acd313c89f07c22d70d6c375a9c6f339233dcf792094bcbf7"}, - {file = "coverage-7.5.4-cp39-cp39-win32.whl", hash = "sha256:56b4eafa21c6c175b3ede004ca12c653a88b6f922494b023aeb1e836df953ace"}, - {file = "coverage-7.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:65e528e2e921ba8fd67d9055e6b9f9e34b21ebd6768ae1c1723f4ea6ace1234d"}, - {file = "coverage-7.5.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:79b356f3dd5b26f3ad23b35c75dbdaf1f9e2450b6bcefc6d0825ea0aa3f86ca5"}, - {file = "coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dff044f661f59dace805eedb4a7404c573b6ff0cdba4a524141bc63d7be5c7fd"}, + {file = "coverage-7.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8659fd33ee9e6ca03950cfdcdf271d645cf681609153f218826dd9805ab585c"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7792f0ab20df8071d669d929c75c97fecfa6bcab82c10ee4adb91c7a54055463"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4b3cd1ca7cd73d229487fa5caca9e4bc1f0bca96526b922d61053ea751fe791"}, + {file = "coverage-7.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7e128f85c0b419907d1f38e616c4f1e9f1d1b37a7949f44df9a73d5da5cd53c"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a94925102c89247530ae1dab7dc02c690942566f22e189cbd53579b0693c0783"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dcd070b5b585b50e6617e8972f3fbbee786afca71b1936ac06257f7e178f00f6"}, + {file = "coverage-7.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d50a252b23b9b4dfeefc1f663c568a221092cbaded20a05a11665d0dbec9b8fb"}, + {file = "coverage-7.6.0-cp310-cp310-win32.whl", hash = "sha256:0e7b27d04131c46e6894f23a4ae186a6a2207209a05df5b6ad4caee6d54a222c"}, + {file = "coverage-7.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dece71673b3187c86226c3ca793c5f891f9fc3d8aa183f2e3653da18566169"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7b525ab52ce18c57ae232ba6f7010297a87ced82a2383b1afd238849c1ff933"}, + {file = "coverage-7.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bea27c4269234e06f621f3fac3925f56ff34bc14521484b8f66a580aacc2e7d"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed8d1d1821ba5fc88d4a4f45387b65de52382fa3ef1f0115a4f7a20cdfab0e94"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1"}, + {file = "coverage-7.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03cafe82c1b32b770a29fd6de923625ccac3185a54a5e66606da26d105f37dac"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d1b923fc4a40c5832be4f35a5dab0e5ff89cddf83bb4174499e02ea089daf57"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b03741e70fb811d1a9a1d75355cf391f274ed85847f4b78e35459899f57af4d"}, + {file = "coverage-7.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a73d18625f6a8a1cbb11eadc1d03929f9510f4131879288e3f7922097a429f63"}, + {file = "coverage-7.6.0-cp311-cp311-win32.whl", hash = "sha256:65fa405b837060db569a61ec368b74688f429b32fa47a8929a7a2f9b47183713"}, + {file = "coverage-7.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:6379688fb4cfa921ae349c76eb1a9ab26b65f32b03d46bb0eed841fd4cb6afb1"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f7db0b6ae1f96ae41afe626095149ecd1b212b424626175a6633c2999eaad45b"}, + {file = "coverage-7.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bbdf9a72403110a3bdae77948b8011f644571311c2fb35ee15f0f10a8fc082e8"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc44bf0315268e253bf563f3560e6c004efe38f76db03a1558274a6e04bf5d5"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da8549d17489cd52f85a9829d0e1d91059359b3c54a26f28bec2c5d369524807"}, + {file = "coverage-7.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fad32ee9b27350687035cb5fdf9145bc9cf0a094a9577d43e909948ebcfa27b"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:044a0985a4f25b335882b0966625270a8d9db3d3409ddc49a4eb00b0ef5e8cee"}, + {file = "coverage-7.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76d5f82213aa78098b9b964ea89de4617e70e0d43e97900c2778a50856dac605"}, + {file = "coverage-7.6.0-cp312-cp312-win32.whl", hash = "sha256:3c59105f8d58ce500f348c5b56163a4113a440dad6daa2294b5052a10db866da"}, + {file = "coverage-7.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:ca5d79cfdae420a1d52bf177de4bc2289c321d6c961ae321503b2ca59c17ae67"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d39bd10f0ae453554798b125d2f39884290c480f56e8a02ba7a6ed552005243b"}, + {file = "coverage-7.6.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beb08e8508e53a568811016e59f3234d29c2583f6b6e28572f0954a6b4f7e03d"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e16f4cd2bc4d88ba30ca2d3bbf2f21f00f382cf4e1ce3b1ddc96c634bc48ca"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6616d1c9bf1e3faea78711ee42a8b972367d82ceae233ec0ac61cc7fec09fa6b"}, + {file = "coverage-7.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4567d6c334c46046d1c4c20024de2a1c3abc626817ae21ae3da600f5779b44"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d17c6a415d68cfe1091d3296ba5749d3d8696e42c37fca5d4860c5bf7b729f03"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9146579352d7b5f6412735d0f203bbd8d00113a680b66565e205bc605ef81bc6"}, + {file = "coverage-7.6.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:cdab02a0a941af190df8782aafc591ef3ad08824f97850b015c8c6a8b3877b0b"}, + {file = "coverage-7.6.0-cp38-cp38-win32.whl", hash = "sha256:df423f351b162a702c053d5dddc0fc0ef9a9e27ea3f449781ace5f906b664428"}, + {file = "coverage-7.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:f2501d60d7497fd55e391f423f965bbe9e650e9ffc3c627d5f0ac516026000b8"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7221f9ac9dad9492cecab6f676b3eaf9185141539d5c9689d13fd6b0d7de840c"}, + {file = "coverage-7.6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddaaa91bfc4477d2871442bbf30a125e8fe6b05da8a0015507bfbf4718228ab2"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4cbe651f3904e28f3a55d6f371203049034b4ddbce65a54527a3f189ca3b390"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:831b476d79408ab6ccfadaaf199906c833f02fdb32c9ab907b1d4aa0713cfa3b"}, + {file = "coverage-7.6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c3d091059ad0b9c59d1034de74a7f36dcfa7f6d3bde782c49deb42438f2450"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4d5fae0a22dc86259dee66f2cc6c1d3e490c4a1214d7daa2a93d07491c5c04b6"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:07ed352205574aad067482e53dd606926afebcb5590653121063fbf4e2175166"}, + {file = "coverage-7.6.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:49c76cdfa13015c4560702574bad67f0e15ca5a2872c6a125f6327ead2b731dd"}, + {file = "coverage-7.6.0-cp39-cp39-win32.whl", hash = "sha256:482855914928c8175735a2a59c8dc5806cf7d8f032e4820d52e845d1f731dca2"}, + {file = "coverage-7.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:543ef9179bc55edfd895154a51792b01c017c87af0ebaae092720152e19e42ca"}, + {file = "coverage-7.6.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:6fe885135c8a479d3e37a7aae61cbd3a0fb2deccb4dda3c25f92a49189f766d6"}, + {file = "coverage-7.6.0.tar.gz", hash = "sha256:289cc803fa1dc901f84701ac10c9ee873619320f2f9aff38794db4a4a0268d51"}, ] [package.dependencies] @@ -526,43 +538,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "42.0.8" +version = "43.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e"}, - {file = "cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949"}, - {file = "cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b"}, - {file = "cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7"}, - {file = "cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2"}, - {file = "cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba"}, - {file = "cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c"}, - {file = "cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1"}, - {file = "cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14"}, - {file = "cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c"}, - {file = "cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ba4f0a211697362e89ad822e667d8d340b4d8d55fae72cdd619389fb5912eefe"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:81884c4d096c272f00aeb1f11cf62ccd39763581645b0812e99a91505fa48e0c"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c9bb2ae11bfbab395bdd072985abde58ea9860ed84e59dbc0463a5d0159f5b71"}, - {file = "cryptography-42.0.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7016f837e15b0a1c119d27ecd89b3515f01f90a8615ed5e9427e30d9cdbfed3d"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5a94eccb2a81a309806027e1670a358b99b8fe8bfe9f8d329f27d72c094dde8c"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dec9b018df185f08483f294cae6ccac29e7a6e0678996587363dc352dc65c842"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:343728aac38decfdeecf55ecab3264b015be68fc2816ca800db649607aeee648"}, - {file = "cryptography-42.0.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad"}, - {file = "cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2"}, + {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, + {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, + {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, + {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, + {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, + {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, + {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, + {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, + {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, + {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, + {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, + {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, + {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, ] [package.dependencies] @@ -575,7 +582,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -602,13 +609,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.2.1" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, - {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -732,13 +739,13 @@ files = [ [[package]] name = "identify" -version = "2.5.36" +version = "2.6.0" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, - {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, + {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, + {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, ] [package.extras] @@ -768,13 +775,13 @@ files = [ [[package]] name = "importlib-metadata" -version = "8.0.0" +version = "8.2.0" description = "Read metadata from Python packages" optional = true python-versions = ">=3.8" files = [ - {file = "importlib_metadata-8.0.0-py3-none-any.whl", hash = "sha256:15584cf2b1bf449d98ff8a6ff1abef57bf20f3ac6454f431736cd3e660921b2f"}, - {file = "importlib_metadata-8.0.0.tar.gz", hash = "sha256:188bd24e4c346d3f0a933f275c2fec67050326a856b9a359881d7c2a697e8812"}, + {file = "importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369"}, + {file = "importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d"}, ] [package.dependencies] @@ -1092,44 +1099,44 @@ files = [ [[package]] name = "mypy" -version = "1.10.1" +version = "1.11.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e36f229acfe250dc660790840916eb49726c928e8ce10fbdf90715090fe4ae02"}, - {file = "mypy-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:51a46974340baaa4145363b9e051812a2446cf583dfaeba124af966fa44593f7"}, - {file = "mypy-1.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:901c89c2d67bba57aaaca91ccdb659aa3a312de67f23b9dfb059727cce2e2e0a"}, - {file = "mypy-1.10.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0cd62192a4a32b77ceb31272d9e74d23cd88c8060c34d1d3622db3267679a5d9"}, - {file = "mypy-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:a2cbc68cb9e943ac0814c13e2452d2046c2f2b23ff0278e26599224cf164e78d"}, - {file = "mypy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bd6f629b67bb43dc0d9211ee98b96d8dabc97b1ad38b9b25f5e4c4d7569a0c6a"}, - {file = "mypy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1bbb3a6f5ff319d2b9d40b4080d46cd639abe3516d5a62c070cf0114a457d84"}, - {file = "mypy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8edd4e9bbbc9d7b79502eb9592cab808585516ae1bcc1446eb9122656c6066f"}, - {file = "mypy-1.10.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6166a88b15f1759f94a46fa474c7b1b05d134b1b61fca627dd7335454cc9aa6b"}, - {file = "mypy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bb9cd11c01c8606a9d0b83ffa91d0b236a0e91bc4126d9ba9ce62906ada868e"}, - {file = "mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7"}, - {file = "mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3"}, - {file = "mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e"}, - {file = "mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04"}, - {file = "mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31"}, - {file = "mypy-1.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:604282c886497645ffb87b8f35a57ec773a4a2721161e709a4422c1636ddde5c"}, - {file = "mypy-1.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37fd87cab83f09842653f08de066ee68f1182b9b5282e4634cdb4b407266bade"}, - {file = "mypy-1.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8addf6313777dbb92e9564c5d32ec122bf2c6c39d683ea64de6a1fd98b90fe37"}, - {file = "mypy-1.10.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cc3ca0a244eb9a5249c7c583ad9a7e881aa5d7b73c35652296ddcdb33b2b9c7"}, - {file = "mypy-1.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:1b3a2ffce52cc4dbaeee4df762f20a2905aa171ef157b82192f2e2f368eec05d"}, - {file = "mypy-1.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe85ed6836165d52ae8b88f99527d3d1b2362e0cb90b005409b8bed90e9059b3"}, - {file = "mypy-1.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2ae450d60d7d020d67ab440c6e3fae375809988119817214440033f26ddf7bf"}, - {file = "mypy-1.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6be84c06e6abd72f960ba9a71561c14137a583093ffcf9bbfaf5e613d63fa531"}, - {file = "mypy-1.10.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2189ff1e39db399f08205e22a797383613ce1cb0cb3b13d8bcf0170e45b96cc3"}, - {file = "mypy-1.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:97a131ee36ac37ce9581f4220311247ab6cba896b4395b9c87af0675a13a755f"}, - {file = "mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a"}, - {file = "mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0"}, + {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"}, + {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"}, + {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"}, + {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"}, + {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"}, + {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"}, + {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"}, + {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"}, + {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"}, + {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"}, + {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"}, + {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"}, + {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"}, + {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"}, + {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"}, + {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"}, + {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"}, + {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"}, + {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"}, + {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"}, + {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"}, + {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"}, + {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"}, ] [package.dependencies] mypy-extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.1.0" +typing-extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] @@ -1187,57 +1194,62 @@ files = [ [[package]] name = "orjson" -version = "3.10.5" +version = "3.10.6" description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" optional = true python-versions = ">=3.8" files = [ - {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"}, + {file = "orjson-3.10.6-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:fb0ee33124db6eaa517d00890fc1a55c3bfe1cf78ba4a8899d71a06f2d6ff5c7"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c1c4b53b24a4c06547ce43e5fee6ec4e0d8fe2d597f4647fc033fd205707365"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eadc8fd310edb4bdbd333374f2c8fec6794bbbae99b592f448d8214a5e4050c0"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61272a5aec2b2661f4fa2b37c907ce9701e821b2c1285d5c3ab0207ebd358d38"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57985ee7e91d6214c837936dc1608f40f330a6b88bb13f5a57ce5257807da143"}, + {file = "orjson-3.10.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:633a3b31d9d7c9f02d49c4ab4d0a86065c4a6f6adc297d63d272e043472acab5"}, + {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1c680b269d33ec444afe2bdc647c9eb73166fa47a16d9a75ee56a374f4a45f43"}, + {file = "orjson-3.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f759503a97a6ace19e55461395ab0d618b5a117e8d0fbb20e70cfd68a47327f2"}, + {file = "orjson-3.10.6-cp310-none-win32.whl", hash = "sha256:95a0cce17f969fb5391762e5719575217bd10ac5a189d1979442ee54456393f3"}, + {file = "orjson-3.10.6-cp310-none-win_amd64.whl", hash = "sha256:df25d9271270ba2133cc88ee83c318372bdc0f2cd6f32e7a450809a111efc45c"}, + {file = "orjson-3.10.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b1ec490e10d2a77c345def52599311849fc063ae0e67cf4f84528073152bb2ba"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d43d3feb8f19d07e9f01e5b9be4f28801cf7c60d0fa0d279951b18fae1932b"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3045267e98fe749408eee1593a142e02357c5c99be0802185ef2170086a863"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c27bc6a28ae95923350ab382c57113abd38f3928af3c80be6f2ba7eb8d8db0b0"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d27456491ca79532d11e507cadca37fb8c9324a3976294f68fb1eff2dc6ced5a"}, + {file = "orjson-3.10.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05ac3d3916023745aa3b3b388e91b9166be1ca02b7c7e41045da6d12985685f0"}, + {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1335d4ef59ab85cab66fe73fd7a4e881c298ee7f63ede918b7faa1b27cbe5212"}, + {file = "orjson-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4bbc6d0af24c1575edc79994c20e1b29e6fb3c6a570371306db0993ecf144dc5"}, + {file = "orjson-3.10.6-cp311-none-win32.whl", hash = "sha256:450e39ab1f7694465060a0550b3f6d328d20297bf2e06aa947b97c21e5241fbd"}, + {file = "orjson-3.10.6-cp311-none-win_amd64.whl", hash = "sha256:227df19441372610b20e05bdb906e1742ec2ad7a66ac8350dcfd29a63014a83b"}, + {file = "orjson-3.10.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ea2977b21f8d5d9b758bb3f344a75e55ca78e3ff85595d248eee813ae23ecdfb"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6f3d167d13a16ed263b52dbfedff52c962bfd3d270b46b7518365bcc2121eed"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f710f346e4c44a4e8bdf23daa974faede58f83334289df80bc9cd12fe82573c7"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7275664f84e027dcb1ad5200b8b18373e9c669b2a9ec33d410c40f5ccf4b257e"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0943e4c701196b23c240b3d10ed8ecd674f03089198cf503105b474a4f77f21f"}, + {file = "orjson-3.10.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:446dee5a491b5bc7d8f825d80d9637e7af43f86a331207b9c9610e2f93fee22a"}, + {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:64c81456d2a050d380786413786b057983892db105516639cb5d3ee3c7fd5148"}, + {file = "orjson-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:960db0e31c4e52fa0fc3ecbaea5b2d3b58f379e32a95ae6b0ebeaa25b93dfd34"}, + {file = "orjson-3.10.6-cp312-none-win32.whl", hash = "sha256:a6ea7afb5b30b2317e0bee03c8d34c8181bc5a36f2afd4d0952f378972c4efd5"}, + {file = "orjson-3.10.6-cp312-none-win_amd64.whl", hash = "sha256:874ce88264b7e655dde4aeaacdc8fd772a7962faadfb41abe63e2a4861abc3dc"}, + {file = "orjson-3.10.6-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:66680eae4c4e7fc193d91cfc1353ad6d01b4801ae9b5314f17e11ba55e934183"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caff75b425db5ef8e8f23af93c80f072f97b4fb3afd4af44482905c9f588da28"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3722fddb821b6036fd2a3c814f6bd9b57a89dc6337b9924ecd614ebce3271394"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2c116072a8533f2fec435fde4d134610f806bdac20188c7bd2081f3e9e0133f"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eeb13218c8cf34c61912e9df2de2853f1d009de0e46ea09ccdf3d757896af0a"}, + {file = "orjson-3.10.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:965a916373382674e323c957d560b953d81d7a8603fbeee26f7b8248638bd48b"}, + {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:03c95484d53ed8e479cade8628c9cea00fd9d67f5554764a1110e0d5aa2de96e"}, + {file = "orjson-3.10.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e060748a04cccf1e0a6f2358dffea9c080b849a4a68c28b1b907f272b5127e9b"}, + {file = "orjson-3.10.6-cp38-none-win32.whl", hash = "sha256:738dbe3ef909c4b019d69afc19caf6b5ed0e2f1c786b5d6215fbb7539246e4c6"}, + {file = "orjson-3.10.6-cp38-none-win_amd64.whl", hash = "sha256:d40f839dddf6a7d77114fe6b8a70218556408c71d4d6e29413bb5f150a692ff7"}, + {file = "orjson-3.10.6-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:697a35a083c4f834807a6232b3e62c8b280f7a44ad0b759fd4dce748951e70db"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd502f96bf5ea9a61cbc0b2b5900d0dd68aa0da197179042bdd2be67e51a1e4b"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f215789fb1667cdc874c1b8af6a84dc939fd802bf293a8334fce185c79cd359b"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2debd8ddce948a8c0938c8c93ade191d2f4ba4649a54302a7da905a81f00b56"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5410111d7b6681d4b0d65e0f58a13be588d01b473822483f77f513c7f93bd3b2"}, + {file = "orjson-3.10.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb1f28a137337fdc18384079fa5726810681055b32b92253fa15ae5656e1dddb"}, + {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bf2fbbce5fe7cd1aa177ea3eab2b8e6a6bc6e8592e4279ed3db2d62e57c0e1b2"}, + {file = "orjson-3.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:79b9b9e33bd4c517445a62b90ca0cc279b0f1f3970655c3df9e608bc3f91741a"}, + {file = "orjson-3.10.6-cp39-none-win32.whl", hash = "sha256:30b0a09a2014e621b1adf66a4f705f0809358350a757508ee80209b2d8dae219"}, + {file = "orjson-3.10.6-cp39-none-win_amd64.whl", hash = "sha256:49e3bc615652617d463069f91b867a4458114c5b104e13b7ae6872e5f79d0844"}, + {file = "orjson-3.10.6.tar.gz", hash = "sha256:e54b63d0a7c6c54a5f5f726bc93a2078111ef060fec4ecbf34c5db800ca3b3a7"}, ] [[package]] @@ -1299,13 +1311,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.7.1" +version = "3.8.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, - {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, + {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, + {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, ] [package.dependencies] @@ -1331,13 +1343,13 @@ wcwidth = "*" [[package]] name = "ptpython" -version = "3.0.27" +version = "3.0.29" description = "Python REPL build on top of prompt_toolkit" optional = true python-versions = ">=3.7" files = [ - {file = "ptpython-3.0.27-py2.py3-none-any.whl", hash = "sha256:549870d537ab3244243cfb92d36347072bb8be823a121fb2fd95297af0fb42bb"}, - {file = "ptpython-3.0.27.tar.gz", hash = "sha256:24b0fda94b73d1c99a27e6fd0d08be6f2e7cda79a2db995c7e3c7b8b1254bad9"}, + {file = "ptpython-3.0.29-py2.py3-none-any.whl", hash = "sha256:65d75c4871859e4305a020c9b9e204366dceb4d08e0e2bd7b7511bd5e917a402"}, + {file = "ptpython-3.0.29.tar.gz", hash = "sha256:b9d625183aef93a673fc32cbe1c1fcaf51412e7a4f19590521cdaccadf25186e"}, ] [package.dependencies] @@ -1363,109 +1375,122 @@ files = [ [[package]] name = "pydantic" -version = "2.7.4" +version = "2.8.2" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.7.4-py3-none-any.whl", hash = "sha256:ee8538d41ccb9c0a9ad3e0e5f07bf15ed8015b481ced539a1759d8cc89ae90d0"}, - {file = "pydantic-2.7.4.tar.gz", hash = "sha256:0c84efd9548d545f63ac0060c1e4d39bb9b14db8b3c0652338aecc07b5adec52"}, + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.18.4" -typing-extensions = ">=4.6.1" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] [package.extras] email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.18.4" +version = "2.20.1" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.18.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f76d0ad001edd426b92233d45c746fd08f467d56100fd8f30e9ace4b005266e4"}, - {file = "pydantic_core-2.18.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:59ff3e89f4eaf14050c8022011862df275b552caef8082e37b542b066ce1ff26"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a55b5b16c839df1070bc113c1f7f94a0af4433fcfa1b41799ce7606e5c79ce0a"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4d0dcc59664fcb8974b356fe0a18a672d6d7cf9f54746c05f43275fc48636851"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8951eee36c57cd128f779e641e21eb40bc5073eb28b2d23f33eb0ef14ffb3f5d"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4701b19f7e3a06ea655513f7938de6f108123bf7c86bbebb1196eb9bd35cf724"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00a3f196329e08e43d99b79b286d60ce46bed10f2280d25a1718399457e06be"}, - {file = "pydantic_core-2.18.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97736815b9cc893b2b7f663628e63f436018b75f44854c8027040e05230eeddb"}, - {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6891a2ae0e8692679c07728819b6e2b822fb30ca7445f67bbf6509b25a96332c"}, - {file = "pydantic_core-2.18.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bc4ff9805858bd54d1a20efff925ccd89c9d2e7cf4986144b30802bf78091c3e"}, - {file = "pydantic_core-2.18.4-cp310-none-win32.whl", hash = "sha256:1b4de2e51bbcb61fdebd0ab86ef28062704f62c82bbf4addc4e37fa4b00b7cbc"}, - {file = "pydantic_core-2.18.4-cp310-none-win_amd64.whl", hash = "sha256:6a750aec7bf431517a9fd78cb93c97b9b0c496090fee84a47a0d23668976b4b0"}, - {file = "pydantic_core-2.18.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:942ba11e7dfb66dc70f9ae66b33452f51ac7bb90676da39a7345e99ffb55402d"}, - {file = "pydantic_core-2.18.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b2ebef0e0b4454320274f5e83a41844c63438fdc874ea40a8b5b4ecb7693f1c4"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a642295cd0c8df1b86fc3dced1d067874c353a188dc8e0f744626d49e9aa51c4"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f09baa656c904807e832cf9cce799c6460c450c4ad80803517032da0cd062e2"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98906207f29bc2c459ff64fa007afd10a8c8ac080f7e4d5beff4c97086a3dabd"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19894b95aacfa98e7cb093cd7881a0c76f55731efad31073db4521e2b6ff5b7d"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fbbdc827fe5e42e4d196c746b890b3d72876bdbf160b0eafe9f0334525119c8"}, - {file = "pydantic_core-2.18.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f85d05aa0918283cf29a30b547b4df2fbb56b45b135f9e35b6807cb28bc47951"}, - {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e85637bc8fe81ddb73fda9e56bab24560bdddfa98aa64f87aaa4e4b6730c23d2"}, - {file = "pydantic_core-2.18.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2f5966897e5461f818e136b8451d0551a2e77259eb0f73a837027b47dc95dab9"}, - {file = "pydantic_core-2.18.4-cp311-none-win32.whl", hash = "sha256:44c7486a4228413c317952e9d89598bcdfb06399735e49e0f8df643e1ccd0558"}, - {file = "pydantic_core-2.18.4-cp311-none-win_amd64.whl", hash = "sha256:8a7164fe2005d03c64fd3b85649891cd4953a8de53107940bf272500ba8a788b"}, - {file = "pydantic_core-2.18.4-cp311-none-win_arm64.whl", hash = "sha256:4e99bc050fe65c450344421017f98298a97cefc18c53bb2f7b3531eb39bc7805"}, - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6f5c4d41b2771c730ea1c34e458e781b18cc668d194958e0112455fff4e402b2"}, - {file = "pydantic_core-2.18.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fdf2156aa3d017fddf8aea5adfba9f777db1d6022d392b682d2a8329e087cef"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4748321b5078216070b151d5271ef3e7cc905ab170bbfd27d5c83ee3ec436695"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847a35c4d58721c5dc3dba599878ebbdfd96784f3fb8bb2c356e123bdcd73f34"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c40d4eaad41f78e3bbda31b89edc46a3f3dc6e171bf0ecf097ff7a0ffff7cb1"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21a5e440dbe315ab9825fcd459b8814bb92b27c974cbc23c3e8baa2b76890077"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01dd777215e2aa86dfd664daed5957704b769e726626393438f9c87690ce78c3"}, - {file = "pydantic_core-2.18.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b06beb3b3f1479d32befd1f3079cc47b34fa2da62457cdf6c963393340b56e9"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:564d7922e4b13a16b98772441879fcdcbe82ff50daa622d681dd682175ea918c"}, - {file = "pydantic_core-2.18.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0eb2a4f660fcd8e2b1c90ad566db2b98d7f3f4717c64fe0a83e0adb39766d5b8"}, - {file = "pydantic_core-2.18.4-cp312-none-win32.whl", hash = "sha256:8b8bab4c97248095ae0c4455b5a1cd1cdd96e4e4769306ab19dda135ea4cdb07"}, - {file = "pydantic_core-2.18.4-cp312-none-win_amd64.whl", hash = "sha256:14601cdb733d741b8958224030e2bfe21a4a881fb3dd6fbb21f071cabd48fa0a"}, - {file = "pydantic_core-2.18.4-cp312-none-win_arm64.whl", hash = "sha256:c1322d7dd74713dcc157a2b7898a564ab091ca6c58302d5c7b4c07296e3fd00f"}, - {file = "pydantic_core-2.18.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:823be1deb01793da05ecb0484d6c9e20baebb39bd42b5d72636ae9cf8350dbd2"}, - {file = "pydantic_core-2.18.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ebef0dd9bf9b812bf75bda96743f2a6c5734a02092ae7f721c048d156d5fabae"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae1d6df168efb88d7d522664693607b80b4080be6750c913eefb77e34c12c71a"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9899c94762343f2cc2fc64c13e7cae4c3cc65cdfc87dd810a31654c9b7358cc"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99457f184ad90235cfe8461c4d70ab7dd2680e28821c29eca00252ba90308c78"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18f469a3d2a2fdafe99296a87e8a4c37748b5080a26b806a707f25a902c040a8"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7cdf28938ac6b8b49ae5e92f2735056a7ba99c9b110a474473fd71185c1af5d"}, - {file = "pydantic_core-2.18.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:938cb21650855054dc54dfd9120a851c974f95450f00683399006aa6e8abb057"}, - {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:44cd83ab6a51da80fb5adbd9560e26018e2ac7826f9626bc06ca3dc074cd198b"}, - {file = "pydantic_core-2.18.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:972658f4a72d02b8abfa2581d92d59f59897d2e9f7e708fdabe922f9087773af"}, - {file = "pydantic_core-2.18.4-cp38-none-win32.whl", hash = "sha256:1d886dc848e60cb7666f771e406acae54ab279b9f1e4143babc9c2258213daa2"}, - {file = "pydantic_core-2.18.4-cp38-none-win_amd64.whl", hash = "sha256:bb4462bd43c2460774914b8525f79b00f8f407c945d50881568f294c1d9b4443"}, - {file = "pydantic_core-2.18.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:44a688331d4a4e2129140a8118479443bd6f1905231138971372fcde37e43528"}, - {file = "pydantic_core-2.18.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a2fdd81edd64342c85ac7cf2753ccae0b79bf2dfa063785503cb85a7d3593223"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86110d7e1907ab36691f80b33eb2da87d780f4739ae773e5fc83fb272f88825f"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46387e38bd641b3ee5ce247563b60c5ca098da9c56c75c157a05eaa0933ed154"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:123c3cec203e3f5ac7b000bd82235f1a3eced8665b63d18be751f115588fea30"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dc1803ac5c32ec324c5261c7209e8f8ce88e83254c4e1aebdc8b0a39f9ddb443"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53db086f9f6ab2b4061958d9c276d1dbe3690e8dd727d6abf2321d6cce37fa94"}, - {file = "pydantic_core-2.18.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abc267fa9837245cc28ea6929f19fa335f3dc330a35d2e45509b6566dc18be23"}, - {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0d829524aaefdebccb869eed855e2d04c21d2d7479b6cada7ace5448416597b"}, - {file = "pydantic_core-2.18.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:509daade3b8649f80d4e5ff21aa5673e4ebe58590b25fe42fac5f0f52c6f034a"}, - {file = "pydantic_core-2.18.4-cp39-none-win32.whl", hash = "sha256:ca26a1e73c48cfc54c4a76ff78df3727b9d9f4ccc8dbee4ae3f73306a591676d"}, - {file = "pydantic_core-2.18.4-cp39-none-win_amd64.whl", hash = "sha256:c67598100338d5d985db1b3d21f3619ef392e185e71b8d52bceacc4a7771ea7e"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:574d92eac874f7f4db0ca653514d823a0d22e2354359d0759e3f6a406db5d55d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1f4d26ceb5eb9eed4af91bebeae4b06c3fb28966ca3a8fb765208cf6b51102ab"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77450e6d20016ec41f43ca4a6c63e9fdde03f0ae3fe90e7c27bdbeaece8b1ed4"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d323a01da91851a4f17bf592faf46149c9169d68430b3146dcba2bb5e5719abc"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43d447dd2ae072a0065389092a231283f62d960030ecd27565672bd40746c507"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:578e24f761f3b425834f297b9935e1ce2e30f51400964ce4801002435a1b41ef"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:81b5efb2f126454586d0f40c4d834010979cb80785173d1586df845a632e4e6d"}, - {file = "pydantic_core-2.18.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ab86ce7c8f9bea87b9d12c7f0af71102acbf5ecbc66c17796cff45dae54ef9a5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:90afc12421df2b1b4dcc975f814e21bc1754640d502a2fbcc6d41e77af5ec312"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:51991a89639a912c17bef4b45c87bd83593aee0437d8102556af4885811d59f5"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:293afe532740370aba8c060882f7d26cfd00c94cae32fd2e212a3a6e3b7bc15e"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48ece5bde2e768197a2d0f6e925f9d7e3e826f0ad2271120f8144a9db18d5c8"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eae237477a873ab46e8dd748e515c72c0c804fb380fbe6c85533c7de51f23a8f"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:834b5230b5dfc0c1ec37b2fda433b271cbbc0e507560b5d1588e2cc1148cf1ce"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e858ac0a25074ba4bce653f9b5d0a85b7456eaddadc0ce82d3878c22489fa4ee"}, - {file = "pydantic_core-2.18.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2fd41f6eff4c20778d717af1cc50eca52f5afe7805ee530a4fbd0bae284f16e9"}, - {file = "pydantic_core-2.18.4.tar.gz", hash = "sha256:ec3beeada09ff865c344ff3bc2f427f5e6c26401cc6113d77e372c3fdac73864"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, ] [package.dependencies] @@ -1506,13 +1531,13 @@ testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytes [[package]] name = "pytest" -version = "8.2.2" +version = "8.3.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, - {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, ] [package.dependencies] @@ -1520,7 +1545,7 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.5,<2.0" +pluggy = ">=1.5,<2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] @@ -1528,13 +1553,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.23.7" +version = "0.23.8" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, - {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, ] [package.dependencies] @@ -1666,6 +1691,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -1828,49 +1854,49 @@ dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] [[package]] name = "sphinxcontrib-applehelp" -version = "1.0.8" +version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_applehelp-1.0.8-py3-none-any.whl", hash = "sha256:cb61eb0ec1b61f349e5cc36b2028e9e7ca765be05e49641c97241274753067b4"}, - {file = "sphinxcontrib_applehelp-1.0.8.tar.gz", hash = "sha256:c40a4f96f3776c4393d933412053962fac2b84f4c99a7982ba42e09576a70619"}, + {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, + {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-devhelp" -version = "1.0.6" +version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_devhelp-1.0.6-py3-none-any.whl", hash = "sha256:6485d09629944511c893fa11355bda18b742b83a2b181f9a009f7e500595c90f"}, - {file = "sphinxcontrib_devhelp-1.0.6.tar.gz", hash = "sha256:9893fd3f90506bc4b97bdb977ceb8fbd823989f4316b28c3841ec128544372d3"}, + {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, + {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "2.0.5" +version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"}, - {file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"}, + {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, + {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["html5lib", "pytest"] @@ -1918,33 +1944,33 @@ Sphinx = ">=1.7.0" [[package]] name = "sphinxcontrib-qthelp" -version = "1.0.7" +version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"}, - {file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"}, + {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, + {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] -test = ["pytest"] +test = ["defusedxml (>=0.7.1)", "pytest"] [[package]] name = "sphinxcontrib-serializinghtml" -version = "1.1.10" +version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = true python-versions = ">=3.9" files = [ - {file = "sphinxcontrib_serializinghtml-1.1.10-py3-none-any.whl", hash = "sha256:326369b8df80a7d2d8d7f99aa5ac577f51ea51556ed974e7716cfd4fca3f6cb7"}, - {file = "sphinxcontrib_serializinghtml-1.1.10.tar.gz", hash = "sha256:93f3f5dc458b91b192fe10c397e324f262cf163d79f3282c158e8436a2c4511f"}, + {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, + {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, ] [package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] +lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] @@ -1986,30 +2012,30 @@ files = [ [[package]] name = "tox" -version = "4.15.1" +version = "4.16.0" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.8" files = [ - {file = "tox-4.15.1-py3-none-any.whl", hash = "sha256:f00a5dc4222b358e69694e47e3da0227ac41253509bca9f45aa8f012053e8d9d"}, - {file = "tox-4.15.1.tar.gz", hash = "sha256:53a092527d65e873e39213ebd4bd027a64623320b6b0326136384213f95b7076"}, + {file = "tox-4.16.0-py3-none-any.whl", hash = "sha256:61e101061b977b46cf00093d4319438055290ad0009f84497a07bf2d2d7a06d0"}, + {file = "tox-4.16.0.tar.gz", hash = "sha256:43499656f9949edb681c0f907f86fbfee98677af9919d8b11ae5ad77cb800748"}, ] [package.dependencies] -cachetools = ">=5.3.2" +cachetools = ">=5.3.3" chardet = ">=5.2" colorama = ">=0.4.6" -filelock = ">=3.13.1" -packaging = ">=23.2" -platformdirs = ">=4.1" -pluggy = ">=1.3" -pyproject-api = ">=1.6.1" +filelock = ">=3.15.4" +packaging = ">=24.1" +platformdirs = ">=4.2.2" +pluggy = ">=1.5" +pyproject-api = ">=1.7.1" tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.25" +virtualenv = ">=20.26.3" [package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.25.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] -testing = ["build[virtualenv] (>=1.0.3)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=8.0.2)", "distlib (>=0.3.8)", "flaky (>=3.7)", "hatch-vcs (>=0.4)", "hatchling (>=1.21)", "psutil (>=5.9.7)", "pytest (>=7.4.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-xdist (>=3.5)", "re-assert (>=1.1)", "time-machine (>=2.13)", "wheel (>=0.42)"] +docs = ["furo (>=2024.5.6)", "sphinx (>=7.3.7)", "sphinx-argparse-cli (>=1.16)", "sphinx-autodoc-typehints (>=2.2.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"] +testing = ["build[virtualenv] (>=1.2.1)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=70.2)", "time-machine (>=2.14.2)", "wheel (>=0.43)"] [[package]] name = "typing-extensions" @@ -2061,12 +2087,13 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "voluptuous" -version = "0.15.1" +version = "0.15.2" description = "Python data validation library" optional = false python-versions = ">=3.9" files = [ - {file = "voluptuous-0.15.1.tar.gz", hash = "sha256:4ba7f38f624379ecd02666e87e99cb24b6f5997a28258d3302c761d1a2c35d00"}, + {file = "voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566"}, + {file = "voluptuous-0.15.2.tar.gz", hash = "sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index c7288e101..aa532869f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.5" +version = "0.7.1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"]