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/.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/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/README.md b/README.md index 2dfde360f..2533b908e 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\* @@ -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/RELEASING.md b/RELEASING.md index 476e9de59..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,9 +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. @@ -82,13 +88,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 +#### 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: @@ -97,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. @@ -134,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. @@ -143,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` diff --git a/SUPPORTED.md b/SUPPORTED.md index a644254a6..5e6e8553f 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -65,6 +65,8 @@ 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 - **KS205** - Hardware: 1.0 (US) / Firmware: 1.0.2\* + - Hardware: 1.0 (US) / Firmware: 1.1.0\* - **KS220M** - Hardware: 1.0 (US) / Firmware: 1.0.4 - **KS225** - Hardware: 1.0 (US) / Firmware: 1.0.2\* + - Hardware: 1.0 (US) / Firmware: 1.1.0\* - **KS230** - Hardware: 1.0 (US) / Firmware: 1.0.14 - **KS240** @@ -209,6 +216,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** @@ -223,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/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.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/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py new file mode 100755 index 000000000..36384631b --- /dev/null +++ b/devtools/parse_pcap_klap.py @@ -0,0 +1,350 @@ +#!/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. +""" + +from __future__ import annotations + +import asyncio +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 +from kasa.protocol import DEFAULT_CREDENTIALS, get_default_credentials + + +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 = 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_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. + + 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_seed_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed, self._remote_seed, self._auth_hash + ) + 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._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 | 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 | None: + """Get the remote auth hash.""" + return self._remote_seed_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_seed_auth_hash = value + self.update_encryption_session() + + @property + def remote_seed(self) -> bytes | None: + """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( + loop: asyncio.AbstractEventLoop, + username, + password, + device_ip, + pcap_file_path, + output_json_name=None, +): + """Run the main function.""" + 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), + # 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() + + # Call close method which cleans up event loop + capture.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.", +) +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=[loop, username, password, host, pcap_file_path, output], + daemon=True, + ) + thread.start() + thread.join() + + +if __name__ == "__main__": + cli() diff --git a/devtools/run-in-env.sh b/devtools/run-in-env.sh index 3e67c70eb..5efdbc65d 100755 --- a/devtools/run-in-env.sh +++ b/devtools/run-in-env.sh @@ -1,3 +1,21 @@ -#!/bin/bash -source $(poetry env info --path)/bin/activate +#!/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% (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 + 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/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/aestransport.py b/kasa/aestransport.py index abe282c05..cd0f24b38 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/cli.py b/kasa/cli.py deleted file mode 100755 index 4d0a1db5e..000000000 --- a/kasa/cli.py +++ /dev/null @@ -1,1316 +0,0 @@ -"""python-kasa cli tool.""" - -from __future__ import annotations - -import ast -import asyncio -import json -import logging -import re -import sys -from contextlib import asynccontextmanager -from datetime import datetime -from functools import singledispatch, wraps -from pprint import pformat as pf -from typing import Any, cast - -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.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) - - -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, -} - -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) - - -@click.group( - invoke_without_command=True, - cls=CatchAllExceptions(click.Group), - result_callback=json_formatter_cb, -) -@click.option( - "--host", - envvar="KASA_HOST", - required=False, - help="The host name or IP address of the device to connect to.", -) -@click.option( - "--port", - envvar="KASA_PORT", - required=False, - type=int, - help="The port of the device to connect to.", -) -@click.option( - "--alias", - envvar="KASA_NAME", - required=False, - help="The device name, or alias, of the device to connect to.", -) -@click.option( - "--target", - envvar="KASA_TARGET", - default="255.255.255.255", - required=False, - show_default=True, - help="The broadcast address to be used for discovery.", -) -@click.option( - "-v", - "--verbose", - envvar="KASA_VERBOSE", - required=False, - default=False, - is_flag=True, - help="Be more verbose on output", -) -@click.option( - "-d", - "--debug", - envvar="KASA_DEBUG", - default=False, - is_flag=True, - help="Print debug output", -) -@click.option( - "--type", - envvar="KASA_TYPE", - default=None, - type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False), -) -@click.option( - "--json/--no-json", - envvar="KASA_JSON", - default=False, - is_flag=True, - help="Output raw device response as JSON.", -) -@click.option( - "--encrypt-type", - envvar="KASA_ENCRYPT_TYPE", - default=None, - type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), -) -@click.option( - "--device-family", - envvar="KASA_DEVICE_FAMILY", - default=None, - type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False), -) -@click.option( - "--login-version", - envvar="KASA_LOGIN_VERSION", - default=None, - type=int, -) -@click.option( - "--timeout", - envvar="KASA_TIMEOUT", - default=5, - required=False, - show_default=True, - help="Timeout for device communications.", -) -@click.option( - "--discovery-timeout", - envvar="KASA_DISCOVERY_TIMEOUT", - default=5, - required=False, - show_default=True, - help="Timeout for discovery.", -) -@click.option( - "--username", - default=None, - required=False, - envvar="KASA_USERNAME", - help="Username/email address to authenticate to device.", -) -@click.option( - "--password", - default=None, - required=False, - envvar="KASA_PASSWORD", - help="Password to use to authenticate to device.", -) -@click.option( - "--credentials-hash", - default=None, - required=False, - envvar="KASA_CREDENTIALS_HASH", - help="Hashed credentials used to authenticate to the device.", -) -@click.version_option(package_name="python-kasa") -@click.pass_context -async def cli( - ctx, - host, - port, - alias, - target, - verbose, - debug, - type, - encrypt_type, - device_family, - login_version, - json, - timeout, - discovery_timeout, - username, - password, - credentials_hash, -): - """A tool for controlling TP-Link smart home devices.""" # noqa - # no need to perform any checks if we are just displaying the help - if "--help" in sys.argv: - # Context object is required to avoid crashing on sub-groups - 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 - } - try: - from rich.logging import RichHandler - - rich_config = { - "show_time": False, - } - logging_config["handlers"] = [RichHandler(**rich_config)] - logging_config["format"] = "%(message)s" - except ImportError: - pass - - # The configuration should be converted to use dictConfig, - # but this keeps mypy happy for now - logging.basicConfig(**logging_config) # type: ignore - - if ctx.invoked_subcommand == "discover": - return - - if alias is not None and host is not None: - raise click.BadOptionUsage("alias", "Use either --alias or --host, not both.") - - if alias is not None and host is None: - echo(f"Alias is given, using discovery to find host {alias}") - host = await find_host_from_alias(alias=alias, target=target) - if host: - echo(f"Found hostname is {host}") - else: - echo(f"No device with name {alias} found") - return - - if bool(password) != bool(username): - raise click.BadOptionUsage( - "username", "Using authentication requires both --username and --password" - ) - - if username: - credentials = Credentials(username=username, password=password) - else: - credentials = None - - if host is None: - if ctx.invoked_subcommand and ctx.invoked_subcommand != "discover": - error("Only discover is available without --host or --alias") - - echo("No host name given, trying discovery..") - return await ctx.invoke(discover) - - device_updated = False - if type is not None: - dev = TYPE_TO_CLASS[type](host) - elif device_family and encrypt_type: - ctype = DeviceConnectionParameters( - DeviceFamily(device_family), - DeviceEncryptionType(encrypt_type), - login_version, - ) - config = DeviceConfig( - host=host, - port_override=port, - credentials=credentials, - credentials_hash=credentials_hash, - timeout=timeout, - connection_type=ctype, - ) - 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, - credentials=credentials, - timeout=timeout, - discovery_timeout=discovery_timeout, - ) - - # Skip update on specific commands, or if device factory, - # that performs an update was used for the device. - if ctx.invoked_subcommand not in SKIP_UPDATE_COMMANDS and not device_updated: - await dev.update() - - @asynccontextmanager - async def async_wrapped_device(device: Device): - try: - yield device - finally: - await device.disconnect() - - 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() - - 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 -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_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 -@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_all_features(dev.features, verbose=verbose) - echo() - - if dev.children: - echo("[bold]== Children ==[/bold]") - for child in dev.children: - _echo_all_features( - child.features, - title_prefix=f"{child.alias} ({child.model})", - verbose=verbose, - indent="\t", - ) - - 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() - _echo_discovery_info(dev._discovery_info) - - return dev.internal_state - - -@cli.command() -@pass_dev -@click.argument("new_alias", required=False, default=None) -@click.option("--index", type=int) -async def alias(dev, new_alias, index): - """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) - return res - - echo(f"Alias: {dev.alias}") - if dev.is_strip: - for plug in dev.children: - echo(f" * {plug.alias}") - - return dev.alias - - -@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): - """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): - """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): - res = await dev._query_helper(command, parameters) - else: - raise KasaException("Unexpected device type %s.", dev) - echo(json.dumps(res)) - return res - - -@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. - - 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) - - 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 index is not None or name is not None: - 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() -@pass_dev -@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) -@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) -@click.option("--erase", is_flag=True) -async def usage(dev: 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 -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 -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 -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 -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 -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("--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): - """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) -@click.option("--transition", type=int, required=False) -@pass_dev -async def off(dev: Device, index: int, name: str, transition: int): - """Turn the device off.""" - if index is not None or name is not None: - if not dev.children: - 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): - """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) - - 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 -@click.argument("type", default="schedule") -def _schedule_list(dev, type): - """Return the list of schedule actions for the given type.""" - sched = dev.modules[type] - for rule in sched.rules: - print(rule) - else: - error(f"No rules of type {type}") - - return sched.rules - - -@schedule.command(name="delete") -@pass_dev -@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) -@click.pass_context -async def presets(ctx): - """List and modify bulb setting presets.""" - if ctx.invoked_subcommand is None: - return await ctx.invoke(presets_list) - - -@presets.command(name="list") -@pass_dev -def presets_list(dev: Device): - """List presets.""" - if not dev.is_bulb or not isinstance(dev, IotBulb): - error("Presets only supported on iot bulbs") - return - - for preset in dev.presets: - echo(preset) - - return dev.presets - - -@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 -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 -@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 -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) -@click.option("--child", required=False) -@pass_dev -@click.pass_context -async def feature(ctx: click.Context, dev: Device, child: str, 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 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="") - - 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/__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..1cf92da16 --- /dev/null +++ b/kasa/cli/__main__.py @@ -0,0 +1,6 @@ +"""Main module.""" + +from kasa.cli.main import 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 new file mode 100755 index 000000000..88b768c41 --- /dev/null +++ b/kasa/cli/main.py @@ -0,0 +1,412 @@ +"""Main module for cli tool.""" + +from __future__ import annotations + +import ast +import asyncio +import json +import logging +import sys +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any + +import asyncclick as click + +if TYPE_CHECKING: + from kasa import Device + +from kasa.deviceconfig import DeviceEncryptionType + +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] + + +def _legacy_type_to_class(_type): + from kasa.iot import ( + IotBulb, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, + ) + + 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(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( + "--host", + envvar="KASA_HOST", + required=False, + help="The host name or IP address of the device to connect to.", +) +@click.option( + "--port", + envvar="KASA_PORT", + required=False, + type=int, + help="The port of the device to connect to.", +) +@click.option( + "--alias", + envvar="KASA_NAME", + required=False, + help="The device name, or alias, of the device to connect to.", +) +@click.option( + "--target", + envvar="KASA_TARGET", + default="255.255.255.255", + required=False, + show_default=True, + help="The broadcast address to be used for discovery.", +) +@click.option( + "-v", + "--verbose", + envvar="KASA_VERBOSE", + required=False, + default=False, + is_flag=True, + help="Be more verbose on output", +) +@click.option( + "-d", + "--debug", + envvar="KASA_DEBUG", + default=False, + is_flag=True, + help="Print debug output", +) +@click.option( + "--type", + envvar="KASA_TYPE", + default=None, + 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", + envvar="KASA_JSON", + default=False, + is_flag=True, + help="Output raw device response as JSON.", +) +@click.option( + "-e", + "--encrypt-type", + envvar="KASA_ENCRYPT_TYPE", + default=None, + type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), +) +@click.option( + "--device-family", + envvar="KASA_DEVICE_FAMILY", + default="SMART.TAPOPLUG", + help="Device family type, e.g. `SMART.KASASWITCH`. Deprecated use `--type smart`", +) +@click.option( + "-lv", + "--login-version", + envvar="KASA_LOGIN_VERSION", + default=2, + type=int, + help="The login version for device authentication. Defaults to 2", +) +@click.option( + "--timeout", + envvar="KASA_TIMEOUT", + default=5, + required=False, + show_default=True, + help="Timeout for device communications.", +) +@click.option( + "--discovery-timeout", + envvar="KASA_DISCOVERY_TIMEOUT", + default=5, + required=False, + show_default=True, + help="Timeout for discovery.", +) +@click.option( + "--username", + default=None, + required=False, + envvar="KASA_USERNAME", + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + default=None, + required=False, + envvar="KASA_PASSWORD", + help="Password to use to authenticate to device.", +) +@click.option( + "--credentials-hash", + default=None, + required=False, + envvar="KASA_CREDENTIALS_HASH", + help="Hashed credentials used to authenticate to the device.", +) +@click.version_option(package_name="python-kasa") +@click.pass_context +async def cli( + ctx, + host, + port, + alias, + target, + verbose, + debug, + type, + encrypt_type, + device_family, + login_version, + json, + timeout, + discovery_timeout, + username, + password, + credentials_hash, +): + """A tool for controlling TP-Link smart home devices.""" # noqa + # no need to perform any checks if we are just displaying the help + if "--help" in sys.argv: + # Context object is required to avoid crashing on sub-groups + ctx.obj = object() + return + + logging_config: dict[str, Any] = { + "level": logging.DEBUG if debug > 0 else logging.INFO + } + try: + from rich.logging import RichHandler + + rich_config = { + "show_time": False, + } + logging_config["handlers"] = [RichHandler(**rich_config)] + logging_config["format"] = "%(message)s" + except ImportError: + pass + + # The configuration should be converted to use dictConfig, + # but this keeps mypy happy for now + logging.basicConfig(**logging_config) # type: ignore + + if ctx.invoked_subcommand == "discover": + return + + if alias is not None and host is not None: + raise click.BadOptionUsage("alias", "Use either --alias or --host, not both.") + + 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}") + else: + echo(f"No device with name {alias} found") + return + + if bool(password) != bool(username): + raise click.BadOptionUsage( + "username", "Using authentication requires both --username and --password" + ) + + if username: + from kasa.credentials import Credentials + + credentials = Credentials(username=username, password=password) + else: + credentials = None + + if host is None: + if ctx.invoked_subcommand and ctx.invoked_subcommand != "discover": + error("Only discover is available without --host or --alias") + + echo("No host name given, trying discovery..") + from .discover import discover + + return await ctx.invoke(discover) + + device_updated = False + if type is not None and type != "smart": + from kasa.deviceconfig import DeviceConfig + + config = DeviceConfig(host=host, port_override=port, timeout=timeout) + 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), + login_version, + ) + config = DeviceConfig( + host=host, + port_override=port, + credentials=credentials, + credentials_hash=credentials_hash, + timeout=timeout, + connection_type=ctype, + ) + dev = await Device.connect(config=config) + device_updated = True + else: + from kasa.discover import Discover + + dev = await Discover.discover_single( + host, + port=port, + credentials=credentials, + timeout=timeout, + discovery_timeout=discovery_timeout, + ) + + # Skip update on specific commands, or if device factory, + # that performs an update was used for the device. + if ctx.invoked_subcommand not in SKIP_UPDATE_COMMANDS and not device_updated: + await dev.update() + + @asynccontextmanager + async def async_wrapped_device(device: Device): + try: + yield device + finally: + await device.disconnect() + + ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev)) + + if ctx.invoked_subcommand is None: + from .device import state + + return await ctx.invoke(state) + + +@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() +@click.pass_context +@click.argument("module") +@click.argument("command") +@click.argument("parameters", default=None, required=False) +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") +@click.option("--module", required=False, help="Module for IOT protocol.") +@click.argument("command") +@click.argument("parameters", default=None, required=False) +@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) + + 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): + res = await dev._query_helper(command, parameters) + else: + raise KasaException("Unexpected device type %s.", dev) + echo(json.dumps(res)) + return res 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/device.py b/kasa/device.py index ac23fdb24..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 @@ -338,9 +339,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/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() ) diff --git a/kasa/feature.py b/kasa/feature.py index 0ce13d45f..ad709424d 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) @@ -68,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: @@ -141,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 @@ -153,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: @@ -207,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) + + @property + def maximum_value(self) -> int: + """Maximum value.""" + if range := self.range: + return range[1] + return self.DEFAULT_MAX + + @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/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/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/iotbulb.py b/kasa/iot/iotbulb.py index 26c73096a..a979e4e62 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 @@ -364,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) @@ -429,7 +430,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( @@ -454,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/iotdevice.py b/kasa/iot/iotdevice.py index c637387ae..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, ) @@ -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/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/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..c4d6cb09b 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, ) @@ -231,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/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/klaptransport.py b/kasa/klaptransport.py index a3a20000c..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 @@ -131,8 +131,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]: @@ -297,7 +299,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 ) @@ -309,7 +313,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): @@ -350,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/module.py b/kasa/module.py index 69c4e9e21..faf17c4d3 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -111,10 +111,12 @@ 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" ) + IotLightEffect: Final[ModuleName[iot.LightEffect]] = ModuleName("LightEffect") TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( "TemperatureSensor" ) diff --git a/kasa/protocol.py b/kasa/protocol.py index ad0432dd7..9b5ffa3d3 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -100,7 +100,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/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/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/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/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/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/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/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index b96924385..16cd15ae2 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.""" @@ -39,6 +42,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") @@ -142,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/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/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 3a5897d12..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), ) ) @@ -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/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/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 3dfbd1468..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__) @@ -41,13 +42,29 @@ 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. + """ + 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 b183f8db9..04a9608a6 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -159,34 +159,31 @@ 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: 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) - # 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. - 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) + # 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() # 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, ) @@ -439,6 +437,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(): 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/smartprotocol.py b/kasa/smartprotocol.py index 24203007c..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.time() * 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/device_fixtures.py b/kasa/tests/device_fixtures.py index 0d6fbd488..fca5960aa 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -110,14 +110,14 @@ 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, } 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/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 523205989..0a5433206 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: @@ -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 600cd75d3..6c9423ecc 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -114,13 +114,15 @@ def credentials_hash(self): }, ), "get_device_usage": ("device", {}), + "get_connect_cloud_state": ("cloud_connect", {"status": 0}), } 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] @@ -269,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.""" @@ -308,12 +311,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: diff --git a/kasa/tests/fixtures/KP400(US)_3.0_1.0.3.json b/kasa/tests/fixtures/KP400(US)_3.0_1.0.3.json new file mode 100644 index 000000000..e93eea8f8 --- /dev/null +++ b/kasa/tests/fixtures/KP400(US)_3.0_1.0.3.json @@ -0,0 +1,45 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 2, + "children": [ + { + "alias": "#MASKED_NAME#", + "id": "8006521377E30159055A751347B5A5E321A8D0A100", + "next_action": { + "type": -1 + }, + "on_time": 4024, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006521377E30159055A751347B5A5E321A8D0A101", + "next_action": { + "type": -1 + }, + "on_time": 4024, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "3C:52:A1:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP400(US)", + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "rssi": -75, + "status": "new", + "sw_ver": "1.0.3 Build 220803 Rel.172301", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KP400(US)_3.0_1.0.4.json b/kasa/tests/fixtures/KP400(US)_3.0_1.0.4.json new file mode 100644 index 000000000..18580f4ea --- /dev/null +++ b/kasa/tests/fixtures/KP400(US)_3.0_1.0.4.json @@ -0,0 +1,46 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 2, + "children": [ + { + "alias": "#MASKED_NAME#", + "id": "8006521377E30159055A751347B5A5E321A8D0A100", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006521377E30159055A751347B5A5E321A8D0A101", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "3C:52:A1:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP400(US)", + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "rssi": -69, + "status": "new", + "sw_ver": "1.0.4 Build 240305 Rel.111944", + "updating": 0 + } + } +} 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 + } + } +} 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 + } + } +} 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 + } + } +} 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 + } + } +} 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 + } + } +} 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..5f03b5b64 --- /dev/null +++ b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json @@ -0,0 +1,1085 @@ +{ + "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": "B4-B0-24-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": 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": "light_strip", + "brightness": 100, + "color_temp": 0, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 30, + "saturation": 0 + }, + "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": true, + "hue": 30, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "lighting_effect": { + "brightness": 100, + "custom": 0, + "display_colors": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 100, + 100 + ] + ], + "enable": 0, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise" + }, + "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/Bucharest", + "rssi": -57, + "saturation": 0, + "segment_effect": { + "brightness": 97, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "Warm Aurora" + }, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 120, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Europe/Bucharest", + "time_diff": 120, + "timestamp": 1720089009 + }, + "get_device_usage": { + "power_usage": { + "past30": 1211, + "past7": 183, + "today": 7 + }, + "saved_power": { + "past30": 6124, + "past7": 1204, + "today": 30 + }, + "time_usage": { + "past30": 7335, + "past7": 1387, + "today": 37 + } + }, + "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.3 Build 231229 Rel.164316", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 100, + 100 + ] + ], + "duration": 600, + "enable": 0, + "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" + }, + "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 + }, + { + "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" + } + }, + { + "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" + } + }, + { + "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" + } + }, + { + "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" + } + }, + { + "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": 0, + "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": "Warm Aurora" + }, + "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": 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": 24, + "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 + } + } +} 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_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/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) 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): diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 4f8157025..e55f4d016 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, @@ -16,28 +17,28 @@ Module, UnsupportedDeviceError, ) -from kasa.cli import ( - TYPE_TO_CLASS, +from kasa.cli.device import ( alias, - brightness, - cli, - cmd_command, - effect, - emeter, - 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, @@ -57,12 +58,17 @@ 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"), [ 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 +177,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 +323,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") @@ -496,7 +505,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.device.state", new=_state) dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) res = await runner.invoke( @@ -731,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 @@ -742,8 +751,11 @@ async def _state(dev: Device): nonlocal result_device result_device = dev - mocker.patch("kasa.cli.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, @@ -930,3 +942,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 diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index beed8e8ba..548e11916 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"} ) @@ -124,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.""" @@ -147,12 +181,54 @@ 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.""" 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() 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_klapprotocol.py b/kasa/tests/test_klapprotocol.py index b71ea460d..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 @@ -238,6 +239,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=re.escape("Error trying to decrypt device 127.0.0.1 response:"), + ): + await transport.send(json.dumps({})) + + @pytest.mark.parametrize( "device_credentials, expectation", [ diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 57390b744..cb38b6198 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -16,6 +16,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 @@ -519,11 +520,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/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 99e2ddb9e..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,13 +149,13 @@ 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 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 +167,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 @@ -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.""" diff --git a/kasa/xortransport.py b/kasa/xortransport.py index 75572bb09..e8d0303bd 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -53,9 +53,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.""" 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 141edf075..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"] @@ -18,7 +18,7 @@ include = [ "Documentation" = "https://python-kasa.readthedocs.io" [tool.poetry.scripts] -kasa = "kasa.cli:cli" +kasa = "kasa.cli.__main__:cli" [tool.poetry.dependencies] python = "^3.9" @@ -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" ]