Merge remote-tracking branch 'upstream/master' into feat/light_module_feats

This commit is contained in:
Steven B 2024-12-11 13:21:46 +00:00
commit feed5d18a1
No known key found for this signature in database
GPG Key ID: 6D5B46B3679F2A43
78 changed files with 5065 additions and 854 deletions

View File

@ -1,5 +1,137 @@
# Changelog # Changelog
## [0.8.1](https://github.com/python-kasa/python-kasa/tree/0.8.1) (2024-12-06)
This patch release fixes some issues with newly supported smartcam devices.
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.0...0.8.1)
**Fixed bugs:**
- Fix update errors on hubs with unsupported children [\#1344](https://github.com/python-kasa/python-kasa/pull/1344) (@sdb9696)
- Fix smartcam missing device id [\#1343](https://github.com/python-kasa/python-kasa/pull/1343) (@sdb9696)
## [0.8.0](https://github.com/python-kasa/python-kasa/tree/0.8.0) (2024-11-26)
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.7...0.8.0)
**Release highlights:**
- **Initial support for devices using the Tapo camera protocol, i.e. Tapo cameras and the Tapo H200 hub.**
- New camera functionality such as exposing RTSP streaming urls and camera pan/tilt.
- New way of testing module support for individual features with `has_feature` and `get_feature`.
- Adding voltage and current monitoring to `smart` devices.
- Migration from pydantic to mashumaro for serialization.
Special thanks to @ryenitcher and @Puxtril for their new contributions to the improvement of the project! Also thanks to everyone who has helped with testing, contributing fixtures, and reporting issues!
**Breaking change notes:**
- Removed support for python <3.11. If you haven't got a compatible version try [uv](https://docs.astral.sh/uv/).
- Renamed `device_config.to_dict()` to `device_config.to_dict_control_credentials()`. `to_dict()` is still available but takes no parameters.
- From the `iot.Cloud` module the `iot.CloudInfo` class attributes have been converted to snake case.
**Breaking changes:**
- Migrate iot cloud module to mashumaro [\#1282](https://github.com/python-kasa/python-kasa/pull/1282) (@sdb9696)
- Replace custom deviceconfig serialization with mashumaru [\#1274](https://github.com/python-kasa/python-kasa/pull/1274) (@sdb9696)
- Remove support for python \<3.11 [\#1273](https://github.com/python-kasa/python-kasa/pull/1273) (@sdb9696)
**Implemented enhancements:**
- Update cli modify presets to support smart devices [\#1295](https://github.com/python-kasa/python-kasa/pull/1295) (@sdb9696)
- Use credentials\_hash for smartcamera rtsp url [\#1293](https://github.com/python-kasa/python-kasa/pull/1293) (@sdb9696)
- Add voltage and current monitoring to smart Devices [\#1281](https://github.com/python-kasa/python-kasa/pull/1281) (@ryenitcher)
- Update cli feature command for actions not to require a value [\#1264](https://github.com/python-kasa/python-kasa/pull/1264) (@sdb9696)
- Add pan tilt camera module [\#1261](https://github.com/python-kasa/python-kasa/pull/1261) (@sdb9696)
- Add alarm module for smartcamera hubs [\#1258](https://github.com/python-kasa/python-kasa/pull/1258) (@sdb9696)
- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696)
- Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696)
- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696)
- Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril)
- Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696)
- Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696)
**Fixed bugs:**
- TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309)
- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308)
- How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306)
- kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267)
- device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262)
- Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243)
- Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201)
- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti)
- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti)
- Make discovery on unsupported devices less noisy [\#1291](https://github.com/python-kasa/python-kasa/pull/1291) (@rytilahti)
- Fix repr for device created with no sysinfo or discovery info" [\#1266](https://github.com/python-kasa/python-kasa/pull/1266) (@sdb9696)
- Fix discovery by alias for smart devices [\#1260](https://github.com/python-kasa/python-kasa/pull/1260) (@sdb9696)
- Make \_\_repr\_\_ work on discovery info [\#1233](https://github.com/python-kasa/python-kasa/pull/1233) (@rytilahti)
**Added support for devices:**
- Add HS200 \(US\) Smart Fixture [\#1303](https://github.com/python-kasa/python-kasa/pull/1303) (@ZeliardM)
- Add smartcamera devices to supported docs [\#1257](https://github.com/python-kasa/python-kasa/pull/1257) (@sdb9696)
- Add P110M\(AU\) fixture [\#1244](https://github.com/python-kasa/python-kasa/pull/1244) (@rytilahti)
- Add L630 fixture [\#1240](https://github.com/python-kasa/python-kasa/pull/1240) (@rytilahti)
- Add EP40M Fixture [\#1238](https://github.com/python-kasa/python-kasa/pull/1238) (@ryenitcher)
- Add KS220 Fixture [\#1237](https://github.com/python-kasa/python-kasa/pull/1237) (@ryenitcher)
**Documentation updates:**
- Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@sdb9696)
- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696)
- Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696)
**Project maintenance:**
- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696)
- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696)
- Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696)
- Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696)
- Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696)
- Move iot fixtures into iot subfolder [\#1299](https://github.com/python-kasa/python-kasa/pull/1299) (@sdb9696)
- Annotate fan\_speed\_level of Fan interface [\#1298](https://github.com/python-kasa/python-kasa/pull/1298) (@sdb9696)
- Add PIR ADC Values to Test Fixtures [\#1296](https://github.com/python-kasa/python-kasa/pull/1296) (@ryenitcher)
- Exclude \_\_getattr\_\_ for deprecated attributes from type checkers [\#1294](https://github.com/python-kasa/python-kasa/pull/1294) (@sdb9696)
- Simplify omit http\_client in DeviceConfig serialization [\#1292](https://github.com/python-kasa/python-kasa/pull/1292) (@sdb9696)
- Add SMART Voltage Monitoring to Fixtures [\#1290](https://github.com/python-kasa/python-kasa/pull/1290) (@ryenitcher)
- Remove pydantic dependency [\#1289](https://github.com/python-kasa/python-kasa/pull/1289) (@sdb9696)
- Do not print out all the fixture names at the start of test runs [\#1287](https://github.com/python-kasa/python-kasa/pull/1287) (@sdb9696)
- dump\_devinfo: iot light strip commands [\#1286](https://github.com/python-kasa/python-kasa/pull/1286) (@sdb9696)
- Migrate TurnOnBehaviours to mashumaro [\#1285](https://github.com/python-kasa/python-kasa/pull/1285) (@sdb9696)
- dump\_devinfo: query smartlife.iot.common.cloud for fw updates [\#1284](https://github.com/python-kasa/python-kasa/pull/1284) (@rytilahti)
- Migrate RuleModule to mashumaro [\#1283](https://github.com/python-kasa/python-kasa/pull/1283) (@sdb9696)
- Update sphinx dependency to 6.2 to fix docs build [\#1280](https://github.com/python-kasa/python-kasa/pull/1280) (@sdb9696)
- Update DiscoveryResult to use mashu Annotated Alias [\#1279](https://github.com/python-kasa/python-kasa/pull/1279) (@sdb9696)
- Extend dump\_devinfo iot queries [\#1278](https://github.com/python-kasa/python-kasa/pull/1278) (@sdb9696)
- Migrate triggerlogs to mashumaru [\#1277](https://github.com/python-kasa/python-kasa/pull/1277) (@sdb9696)
- Migrate smart firmware module to mashumaro [\#1276](https://github.com/python-kasa/python-kasa/pull/1276) (@sdb9696)
- Migrate IotLightPreset to mashumaru [\#1275](https://github.com/python-kasa/python-kasa/pull/1275) (@sdb9696)
- Allow callable coroutines for feature setters [\#1272](https://github.com/python-kasa/python-kasa/pull/1272) (@sdb9696)
- Fix deprecated SSLContext\(\) usage [\#1271](https://github.com/python-kasa/python-kasa/pull/1271) (@sdb9696)
- Use \_get\_device\_info methods for smart and iot devs in devtools [\#1265](https://github.com/python-kasa/python-kasa/pull/1265) (@sdb9696)
- Remove experimental support [\#1256](https://github.com/python-kasa/python-kasa/pull/1256) (@sdb9696)
- Move protocol modules into protocols package [\#1254](https://github.com/python-kasa/python-kasa/pull/1254) (@sdb9696)
- Add linkcheck to readthedocs CI [\#1253](https://github.com/python-kasa/python-kasa/pull/1253) (@rytilahti)
- Update cli energy command to use energy module [\#1252](https://github.com/python-kasa/python-kasa/pull/1252) (@sdb9696)
- Consolidate warnings for fixtures missing child devices [\#1251](https://github.com/python-kasa/python-kasa/pull/1251) (@sdb9696)
- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696)
- Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti)
- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti)
- Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696)
- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696)
- Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher)
- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696)
- Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti)
- Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti)
**Closed issues:**
- Expose Fan speed range from the library [\#1008](https://github.com/python-kasa/python-kasa/issues/1008)
- \[META\] 0.7 series - module support for SMART devices, support for introspectable device features and refactoring the library [\#783](https://github.com/python-kasa/python-kasa/issues/783)
## [0.7.7](https://github.com/python-kasa/python-kasa/tree/0.7.7) (2024-11-04) ## [0.7.7](https://github.com/python-kasa/python-kasa/tree/0.7.7) (2024-11-04)
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.6...0.7.7) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.6...0.7.7)

View File

@ -182,29 +182,29 @@ The following devices have been tested and confirmed as working. If your device
<!--SUPPORTED_START--> <!--SUPPORTED_START-->
### Supported Kasa devices ### Supported Kasa devices
- **Plugs**: EP10, EP25<sup>\*</sup>, HS100<sup>\*\*</sup>, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M<sup>\*</sup>, KP401 - **Plugs**: EP10, EP25[^1], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401
- **Power Strips**: EP40, EP40M<sup>\*</sup>, HS107, HS300, KP200, KP303, KP400 - **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400
- **Wall Switches**: ES20M, HS200<sup>\*\*</sup>, HS210, HS220<sup>\*\*</sup>, KP405, KS200M, KS205<sup>\*</sup>, KS220, KS220M, KS225<sup>\*</sup>, KS230, KS240<sup>\*</sup> - **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1]
- **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110
- **Light Strips**: KL400L5, KL420L5, KL430 - **Light Strips**: KL400L5, KL420L5, KL430
- **Hubs**: KH100<sup>\*</sup> - **Hubs**: KH100[^1]
- **Hub-Connected Devices<sup>\*\*\*</sup>**: KE100<sup>\*</sup> - **Hub-Connected Devices[^3]**: KE100[^1]
### Supported Tapo<sup>\*</sup> devices ### Supported Tapo[^1] devices
- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 - **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15
- **Power Strips**: P300, P304M, TP25 - **Power Strips**: P300, P304M, TP25
- **Wall Switches**: S500D, S505, S505D - **Wall Switches**: S500D, S505, S505D
- **Bulbs**: L510B, L510E, L530E, L630 - **Bulbs**: L510B, L510E, L530E, L630
- **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Light Strips**: L900-10, L900-5, L920-5, L930-5
- **Cameras**: C210, TC65 - **Cameras**: C210, C520WS, TC65
- **Hubs**: H100, H200 - **Hubs**: H100, H200
- **Hub-Connected Devices<sup>\*\*\*</sup>**: S200B, S200D, T100, T110, T300, T310, T315 - **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315
<!--SUPPORTED_END--> <!--SUPPORTED_END-->
<sup>\*</sup>&nbsp;&nbsp; Model requires authentication<br> [^1]: Model requires authentication
<sup>\*\*</sup>&nbsp; Newer versions require authentication<br> [^2]: Newer versions require authentication
<sup>\*\*\*</sup> Devices may work across TAPO/KASA branded hubs [^3]: Devices may work across TAPO/KASA branded hubs
See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions. See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions.
@ -227,6 +227,7 @@ See [supported devices in our documentation](SUPPORTED.md) for more detailed inf
### Other related projects ### Other related projects
* [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo) * [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo)
* [Home Assistant integration](https://github.com/JurajNyiri/HomeAssistant-Tapo-Control)
* [Tapo P100 (Tapo plugs, Tapo bulbs)](https://github.com/fishbigger/TapoP100) * [Tapo P100 (Tapo plugs, Tapo bulbs)](https://github.com/fishbigger/TapoP100)
* [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control) * [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control)
* [plugp100, another tapo library](https://github.com/petretiandrea/plugp100) * [plugp100, another tapo library](https://github.com/petretiandrea/plugp100)

View File

@ -283,9 +283,12 @@ git rebase upstream/master
git checkout -b janitor/merge_patch git checkout -b janitor/merge_patch
git fetch upstream patch git fetch upstream patch
git merge upstream/patch --no-commit git merge upstream/patch --no-commit
# If there are any merge conflicts run the following command which will simply make master win
# Do not run it if there are no conflicts as it will end up checking out upstream/master
git diff --name-only --diff-filter=U | xargs git checkout upstream/master git diff --name-only --diff-filter=U | xargs git checkout upstream/master
# Check the diff is as expected
git diff --staged git diff --staged
# The only diff should be the version in pyproject.toml and CHANGELOG.md # The only diff should be the version in pyproject.toml and uv.lock, and CHANGELOG.md
# unless a change made on patch that was not part of a cherry-pick commit # 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]` # If there are any other unexpected diffs `git checkout upstream/master [thefilename]`
git commit -m "Merge patch into local master" -S git commit -m "Merge patch into local master" -S

View File

@ -10,18 +10,18 @@ The following devices have been tested and confirmed as working. If your device
<!--SUPPORTED_START--> <!--SUPPORTED_START-->
## Kasa devices ## Kasa devices
Some newer Kasa devices require authentication. These are marked with <sup>*</sup> in the list below.<br>Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps. Some newer Kasa devices require authentication. These are marked with [^1] in the list below.<br>Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps.
### Plugs ### Plugs
- **EP10** - **EP10**
- Hardware: 1.0 (US) / Firmware: 1.0.2 - Hardware: 1.0 (US) / Firmware: 1.0.2
- **EP25** - **EP25**
- Hardware: 2.6 (US) / Firmware: 1.0.1<sup>\*</sup> - Hardware: 2.6 (US) / Firmware: 1.0.1[^1]
- Hardware: 2.6 (US) / Firmware: 1.0.2<sup>\*</sup> - Hardware: 2.6 (US) / Firmware: 1.0.2[^1]
- **HS100** - **HS100**
- Hardware: 1.0 (UK) / Firmware: 1.2.6 - Hardware: 1.0 (UK) / Firmware: 1.2.6
- Hardware: 4.1 (UK) / Firmware: 1.1.0<sup>\*</sup> - Hardware: 4.1 (UK) / Firmware: 1.1.0[^1]
- Hardware: 1.0 (US) / Firmware: 1.2.5 - Hardware: 1.0 (US) / Firmware: 1.2.5
- Hardware: 2.0 (US) / Firmware: 1.5.6 - Hardware: 2.0 (US) / Firmware: 1.5.6
- **HS103** - **HS103**
@ -46,8 +46,8 @@ Some newer Kasa devices require authentication. These are marked with <sup>*</su
- **KP125** - **KP125**
- Hardware: 1.0 (US) / Firmware: 1.0.6 - Hardware: 1.0 (US) / Firmware: 1.0.6
- **KP125M** - **KP125M**
- Hardware: 1.0 (US) / Firmware: 1.1.3<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.1.3[^1]
- Hardware: 1.0 (US) / Firmware: 1.2.3<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.2.3[^1]
- **KP401** - **KP401**
- Hardware: 1.0 (US) / Firmware: 1.0.0 - Hardware: 1.0 (US) / Firmware: 1.0.0
@ -56,7 +56,7 @@ Some newer Kasa devices require authentication. These are marked with <sup>*</su
- **EP40** - **EP40**
- Hardware: 1.0 (US) / Firmware: 1.0.2 - Hardware: 1.0 (US) / Firmware: 1.0.2
- **EP40M** - **EP40M**
- Hardware: 1.0 (US) / Firmware: 1.1.0<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.1.0[^1]
- **HS107** - **HS107**
- Hardware: 1.0 (US) / Firmware: 1.0.8 - Hardware: 1.0 (US) / Firmware: 1.0.8
- **HS300** - **HS300**
@ -86,38 +86,40 @@ Some newer Kasa devices require authentication. These are marked with <sup>*</su
- Hardware: 3.0 (US) / Firmware: 1.1.5 - Hardware: 3.0 (US) / Firmware: 1.1.5
- Hardware: 5.0 (US) / Firmware: 1.0.11 - Hardware: 5.0 (US) / Firmware: 1.0.11
- Hardware: 5.0 (US) / Firmware: 1.0.2 - Hardware: 5.0 (US) / Firmware: 1.0.2
- Hardware: 5.26 (US) / Firmware: 1.0.3<sup>\*</sup> - Hardware: 5.26 (US) / Firmware: 1.0.3[^1]
- **HS210** - **HS210**
- Hardware: 1.0 (US) / Firmware: 1.5.8 - Hardware: 1.0 (US) / Firmware: 1.5.8
- Hardware: 2.0 (US) / Firmware: 1.1.5 - Hardware: 2.0 (US) / Firmware: 1.1.5
- **HS220** - **HS220**
- Hardware: 1.0 (US) / Firmware: 1.5.7 - Hardware: 1.0 (US) / Firmware: 1.5.7
- Hardware: 2.0 (US) / Firmware: 1.0.3 - Hardware: 2.0 (US) / Firmware: 1.0.3
- Hardware: 3.26 (US) / Firmware: 1.0.1<sup>\*</sup> - Hardware: 3.26 (US) / Firmware: 1.0.1[^1]
- **KP405** - **KP405**
- Hardware: 1.0 (US) / Firmware: 1.0.5 - Hardware: 1.0 (US) / Firmware: 1.0.5
- Hardware: 1.0 (US) / Firmware: 1.0.6 - Hardware: 1.0 (US) / Firmware: 1.0.6
- **KS200**
- Hardware: 1.0 (US) / Firmware: 1.0.8
- **KS200M** - **KS200M**
- Hardware: 1.0 (US) / Firmware: 1.0.10 - Hardware: 1.0 (US) / Firmware: 1.0.10
- Hardware: 1.0 (US) / Firmware: 1.0.11 - Hardware: 1.0 (US) / Firmware: 1.0.11
- Hardware: 1.0 (US) / Firmware: 1.0.12 - Hardware: 1.0 (US) / Firmware: 1.0.12
- Hardware: 1.0 (US) / Firmware: 1.0.8 - Hardware: 1.0 (US) / Firmware: 1.0.8
- **KS205** - **KS205**
- Hardware: 1.0 (US) / Firmware: 1.0.2<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.0.2[^1]
- Hardware: 1.0 (US) / Firmware: 1.1.0<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.1.0[^1]
- **KS220** - **KS220**
- Hardware: 1.0 (US) / Firmware: 1.0.13 - Hardware: 1.0 (US) / Firmware: 1.0.13
- **KS220M** - **KS220M**
- Hardware: 1.0 (US) / Firmware: 1.0.4 - Hardware: 1.0 (US) / Firmware: 1.0.4
- **KS225** - **KS225**
- Hardware: 1.0 (US) / Firmware: 1.0.2<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.0.2[^1]
- Hardware: 1.0 (US) / Firmware: 1.1.0<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.1.0[^1]
- **KS230** - **KS230**
- Hardware: 1.0 (US) / Firmware: 1.0.14 - Hardware: 1.0 (US) / Firmware: 1.0.14
- **KS240** - **KS240**
- Hardware: 1.0 (US) / Firmware: 1.0.4<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.0.4[^1]
- Hardware: 1.0 (US) / Firmware: 1.0.5<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.0.5[^1]
- Hardware: 1.0 (US) / Firmware: 1.0.7<sup>\*</sup> - Hardware: 1.0 (US) / Firmware: 1.0.7[^1]
### Bulbs ### Bulbs
@ -161,16 +163,16 @@ Some newer Kasa devices require authentication. These are marked with <sup>*</su
### Hubs ### Hubs
- **KH100** - **KH100**
- Hardware: 1.0 (EU) / Firmware: 1.2.3<sup>\*</sup> - Hardware: 1.0 (EU) / Firmware: 1.2.3[^1]
- Hardware: 1.0 (EU) / Firmware: 1.5.12<sup>\*</sup> - Hardware: 1.0 (EU) / Firmware: 1.5.12[^1]
- Hardware: 1.0 (UK) / Firmware: 1.5.6<sup>\*</sup> - Hardware: 1.0 (UK) / Firmware: 1.5.6[^1]
### Hub-Connected Devices ### Hub-Connected Devices
- **KE100** - **KE100**
- Hardware: 1.0 (EU) / Firmware: 2.4.0<sup>\*</sup> - Hardware: 1.0 (EU) / Firmware: 2.4.0[^1]
- Hardware: 1.0 (EU) / Firmware: 2.8.0<sup>\*</sup> - Hardware: 1.0 (EU) / Firmware: 2.8.0[^1]
- Hardware: 1.0 (UK) / Firmware: 2.8.0<sup>\*</sup> - Hardware: 1.0 (UK) / Firmware: 2.8.0[^1]
## Tapo devices ## Tapo devices
@ -189,8 +191,10 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- Hardware: 1.0 (UK) / Firmware: 1.3.0 - Hardware: 1.0 (UK) / Firmware: 1.3.0
- **P110M** - **P110M**
- Hardware: 1.0 (AU) / Firmware: 1.2.3 - Hardware: 1.0 (AU) / Firmware: 1.2.3
- Hardware: 1.0 (EU) / Firmware: 1.2.3
- **P115** - **P115**
- Hardware: 1.0 (EU) / Firmware: 1.2.3 - Hardware: 1.0 (EU) / Firmware: 1.2.3
- Hardware: 1.0 (US) / Firmware: 1.1.3
- **P125M** - **P125M**
- Hardware: 1.0 (US) / Firmware: 1.1.0 - Hardware: 1.0 (US) / Firmware: 1.1.0
- **P135** - **P135**
@ -254,6 +258,8 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
- **C210** - **C210**
- Hardware: 2.0 (EU) / Firmware: 1.4.2 - Hardware: 2.0 (EU) / Firmware: 1.4.2
- Hardware: 2.0 (EU) / Firmware: 1.4.3 - Hardware: 2.0 (EU) / Firmware: 1.4.3
- **C520WS**
- Hardware: 1.0 (US) / Firmware: 1.2.8
- **TC65** - **TC65**
- Hardware: 1.0 / Firmware: 1.3.9 - Hardware: 1.0 / Firmware: 1.3.9
@ -292,3 +298,4 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
<!--SUPPORTED_END--> <!--SUPPORTED_END-->
[^1]: Model requires authentication

View File

@ -115,6 +115,10 @@ def scrub(res):
"encrypt_info", "encrypt_info",
"local_ip", "local_ip",
"username", "username",
# vacuum
"board_sn",
"custom_sn",
"location",
] ]
for k, v in res.items(): for k, v in res.items():
@ -153,10 +157,18 @@ def scrub(res):
v = base64.b64encode(b"#MASKED_SSID#").decode() v = base64.b64encode(b"#MASKED_SSID#").decode()
elif k in ["nickname"]: elif k in ["nickname"]:
v = base64.b64encode(b"#MASKED_NAME#").decode() v = base64.b64encode(b"#MASKED_NAME#").decode()
elif k in ["alias", "device_alias", "device_name", "username"]: elif k in [
"alias",
"device_alias",
"device_name",
"username",
"location",
]:
v = "#MASKED_NAME#" v = "#MASKED_NAME#"
elif isinstance(res[k], int): elif isinstance(res[k], int):
v = 0 v = 0
elif k in ["map_data"]: #
v = "#SCRUBBED_MAPDATA#"
elif k in ["device_id", "dev_id"] and "SCRUBBED" in v: elif k in ["device_id", "dev_id"] and "SCRUBBED" in v:
pass # already scrubbed pass # already scrubbed
elif k == ["device_id", "dev_id"] and len(v) > 40: elif k == ["device_id", "dev_id"] and len(v) > 40:

View File

@ -142,7 +142,7 @@ def _supported_text(
for brand, types in supported.items(): for brand, types in supported.items():
preamble_text = ( preamble_text = (
"Some newer Kasa devices require authentication. " "Some newer Kasa devices require authentication. "
+ "These are marked with <sup>*</sup> in the list below." + "These are marked with [^1] in the list below."
if brand == "kasa" if brand == "kasa"
else "All Tapo devices require authentication." else "All Tapo devices require authentication."
) )
@ -151,7 +151,7 @@ def _supported_text(
+ "hubs even if they don't work across the native apps." + "hubs even if they don't work across the native apps."
) )
brand_text = brand.capitalize() brand_text = brand.capitalize()
brand_auth = r"<sup>\*</sup>" if brand == "tapo" else "" brand_auth = r"[^1]" if brand == "tapo" else ""
types_text = "" types_text = ""
for supported_type, models in sorted( for supported_type, models in sorted(
# Sort by device type order in the enum # Sort by device type order in the enum
@ -166,9 +166,7 @@ def _supported_text(
for version in sorted(versions): for version in sorted(versions):
region_text = f" ({version.region})" if version.region else "" region_text = f" ({version.region})" if version.region else ""
auth_count += 1 if version.auth else 0 auth_count += 1 if version.auth else 0
vauth_flag = ( vauth_flag = r"[^1]" if version.auth and brand == "kasa" else ""
r"<sup>\*</sup>" if version.auth and brand == "kasa" else ""
)
if version_template: if version_template:
versions_text += versst.substitute( versions_text += versst.substitute(
hw=version.hw, hw=version.hw,
@ -177,11 +175,7 @@ def _supported_text(
auth_flag=vauth_flag, auth_flag=vauth_flag,
) )
if brand == "kasa" and auth_count > 0: if brand == "kasa" and auth_count > 0:
auth_flag = ( auth_flag = r"[^1]" if auth_count == len(versions) else r"[^2]"
r"<sup>\*</sup>"
if auth_count == len(versions)
else r"<sup>\*\*</sup>"
)
else: else:
auth_flag = "" auth_flag = ""
if model_template: if model_template:
@ -191,11 +185,7 @@ def _supported_text(
else: else:
models_list.append(f"{model}{auth_flag}") models_list.append(f"{model}{auth_flag}")
models_text = models_text if models_text else ", ".join(models_list) models_text = models_text if models_text else ", ".join(models_list)
type_asterix = ( type_asterix = r"[^3]" if supported_type == "Hub-Connected Devices" else ""
r"<sup>\*\*\*</sup>"
if supported_type == "Hub-Connected Devices"
else ""
)
types_text += typest.substitute( types_text += typest.substitute(
type_=supported_type, type_asterix=type_asterix, models=models_text type_=supported_type, type_asterix=type_asterix, models=models_text
) )

View File

@ -425,4 +425,28 @@ COMPONENT_REQUESTS = {
"dimmer_calibration": [], "dimmer_calibration": [],
"fan_control": [], "fan_control": [],
"overheat_protection": [], "overheat_protection": [],
# Vacuum components
"clean": [
SmartRequest.get_raw_request("getCleanRecords"),
SmartRequest.get_raw_request("getVacStatus"),
],
"battery": [SmartRequest.get_raw_request("getBatteryInfo")],
"consumables": [SmartRequest.get_raw_request("getConsumablesInfo")],
"direction_control": [],
"button_and_led": [],
"speaker": [
SmartRequest.get_raw_request("getSupportVoiceLanguage"),
SmartRequest.get_raw_request("getCurrentVoiceLanguage"),
],
"map": [
SmartRequest.get_raw_request("getMapInfo"),
SmartRequest.get_raw_request("getMapData"),
],
"auto_change_map": [SmartRequest.get_raw_request("getAutoChangeMap")],
"dust_bucket": [SmartRequest.get_raw_request("getAutoDustCollection")],
"mop": [SmartRequest.get_raw_request("getMopState")],
"do_not_disturb": [SmartRequest.get_raw_request("getDoNotDisturb")],
"charge_pose_clean": [],
"continue_breakpoint_sweep": [],
"goto_point": [],
} }

View File

@ -91,5 +91,5 @@ False
True True
>>> for feat in dev.features.values(): >>> for feat in dev.features.values():
>>> print(f"{feat.name}: {feat.value}") >>> print(f"{feat.name}: {feat.value}")
Device ID: 0000000000000000000000000000000000000000\nState: True\nSignal Level: 2\nRSSI: -52\nSSID: #MASKED_SSID#\nOverheated: False\nReboot: <Action>\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: <Action>\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#\nReboot: <Action>\nBrightness: 50\nCloud connection: True\nHSV: HSV(hue=0, saturation=100, value=50)\nColor temperature: 2700\nAuto update enabled: True\nUpdate available: None\nCurrent firmware version: 1.1.6 Build 240130 Rel.173828\nAvailable firmware version: None\nCheck latest firmware: <Action>\nLight effect: Party\nLight preset: Light preset 1\nSmooth transition on: 2\nSmooth transition off: 2\nOverheated: False\nDevice time: 2024-02-23 02:40:15+01:00
""" """

View File

@ -36,9 +36,11 @@ from kasa.exceptions import (
) )
from kasa.feature import Feature from kasa.feature import Feature
from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState from kasa.interfaces.light import HSV, ColorTempRange, Light, LightState
from kasa.interfaces.thermostat import Thermostat, ThermostatState
from kasa.module import Module from kasa.module import Module
from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol from kasa.protocols import BaseProtocol, IotProtocol, SmartProtocol
from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401 from kasa.protocols.iotprotocol import _deprecated_TPLinkSmartHomeProtocol # noqa: F401
from kasa.smartcam.modules.camera import StreamResolution
from kasa.transports import BaseTransport from kasa.transports import BaseTransport
__version__ = version("python-kasa") __version__ = version("python-kasa")
@ -72,6 +74,9 @@ __all__ = [
"DeviceConnectionParameters", "DeviceConnectionParameters",
"DeviceEncryptionType", "DeviceEncryptionType",
"DeviceFamily", "DeviceFamily",
"ThermostatState",
"Thermostat",
"StreamResolution",
] ]
from . import iot from . import iot

View File

@ -14,8 +14,17 @@ from kasa import (
Discover, Discover,
UnsupportedDeviceError, UnsupportedDeviceError,
) )
from kasa.discover import ConnectAttempt, DiscoveryResult from kasa.discover import (
NEW_DISCOVERY_REDACTORS,
ConnectAttempt,
DiscoveredRaw,
DiscoveryResult,
)
from kasa.iot.iotdevice import _extract_sys_info
from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS
from kasa.protocols.protocol import redact_data
from ..json import dumps as json_dumps
from .common import echo, error from .common import echo, error
@ -63,7 +72,9 @@ async def detail(ctx):
await ctx.parent.invoke(state) await ctx.parent.invoke(state)
echo() echo()
discovered = await _discover(ctx, print_discovered, print_unsupported) discovered = await _discover(
ctx, print_discovered=print_discovered, print_unsupported=print_unsupported
)
if ctx.parent.parent.params["host"]: if ctx.parent.parent.params["host"]:
return discovered return discovered
@ -76,6 +87,33 @@ async def detail(ctx):
return discovered return discovered
@discover.command()
@click.option(
"--redact/--no-redact",
default=False,
is_flag=True,
type=bool,
help="Set flag to redact sensitive data from raw output.",
)
@click.pass_context
async def raw(ctx, redact: bool):
"""Return raw discovery data returned from devices."""
def print_raw(discovered: DiscoveredRaw):
if redact:
redactors = (
NEW_DISCOVERY_REDACTORS
if discovered["meta"]["port"] == Discover.DISCOVERY_PORT_2
else IOT_REDACTORS
)
discovered["discovery_response"] = redact_data(
discovered["discovery_response"], redactors
)
echo(json_dumps(discovered, indent=True))
return await _discover(ctx, print_raw=print_raw, do_echo=False)
@discover.command() @discover.command()
@click.pass_context @click.pass_context
async def list(ctx): async def list(ctx):
@ -101,10 +139,17 @@ async def list(ctx):
echo(f"{host:<15} UNSUPPORTED DEVICE") echo(f"{host:<15} UNSUPPORTED DEVICE")
echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}") echo(f"{'HOST':<15} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} {'ALIAS'}")
return await _discover(ctx, print_discovered, print_unsupported, do_echo=False) return await _discover(
ctx,
print_discovered=print_discovered,
print_unsupported=print_unsupported,
do_echo=False,
)
async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True): async def _discover(
ctx, *, print_discovered=None, print_unsupported=None, print_raw=None, do_echo=True
):
params = ctx.parent.parent.params params = ctx.parent.parent.params
target = params["target"] target = params["target"]
username = params["username"] username = params["username"]
@ -125,6 +170,7 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True):
timeout=timeout, timeout=timeout,
discovery_timeout=discovery_timeout, discovery_timeout=discovery_timeout,
on_unsupported=print_unsupported, on_unsupported=print_unsupported,
on_discovered_raw=print_raw,
) )
if do_echo: if do_echo:
echo(f"Discovering devices on {target} for {discovery_timeout} seconds") echo(f"Discovering devices on {target} for {discovery_timeout} seconds")
@ -136,6 +182,7 @@ async def _discover(ctx, print_discovered, print_unsupported, *, do_echo=True):
port=port, port=port,
timeout=timeout, timeout=timeout,
credentials=credentials, credentials=credentials,
on_discovered_raw=print_raw,
) )
for device in discovered_devices.values(): for device in discovered_devices.values():
@ -201,8 +248,8 @@ def _echo_discovery_info(discovery_info) -> None:
if discovery_info is None: if discovery_info is None:
return return
if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: if sysinfo := _extract_sys_info(discovery_info):
_echo_dictionary(discovery_info["system"]["get_sysinfo"]) _echo_dictionary(sysinfo)
return return
try: try:
@ -230,10 +277,12 @@ def _echo_discovery_info(discovery_info) -> None:
_conditional_echo("Supports IOT Cloud", dr.is_support_iot_cloud) _conditional_echo("Supports IOT Cloud", dr.is_support_iot_cloud)
_conditional_echo("OBD Src", dr.owner) _conditional_echo("OBD Src", dr.owner)
_conditional_echo("Factory Default", dr.factory_default) _conditional_echo("Factory Default", dr.factory_default)
_conditional_echo("Encrypt Type", dr.mgt_encrypt_schm.encrypt_type)
_conditional_echo("Encrypt Type", dr.encrypt_type) _conditional_echo("Encrypt Type", dr.encrypt_type)
_conditional_echo("Supports HTTPS", dr.mgt_encrypt_schm.is_support_https) if mgt_encrypt_schm := dr.mgt_encrypt_schm:
_conditional_echo("HTTP Port", dr.mgt_encrypt_schm.http_port) _conditional_echo("Encrypt Type", mgt_encrypt_schm.encrypt_type)
_conditional_echo("Supports HTTPS", mgt_encrypt_schm.is_support_https)
_conditional_echo("HTTP Port", mgt_encrypt_schm.http_port)
_conditional_echo("Login version", mgt_encrypt_schm.lv)
_conditional_echo("Encrypt info", pf(dr.encrypt_info) if dr.encrypt_info else None) _conditional_echo("Encrypt info", pf(dr.encrypt_info) if dr.encrypt_info else None)
_conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None) _conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None)

View File

@ -75,6 +75,7 @@ def _legacy_type_to_class(_type: str) -> Any:
"time": None, "time": None,
"schedule": None, "schedule": None,
"usage": None, "usage": None,
"energy": "usage",
# device commands runnnable at top level # device commands runnnable at top level
"state": "device", "state": "device",
"on": "device", "on": "device",
@ -307,6 +308,7 @@ async def cli(
if type == "camera": if type == "camera":
encrypt_type = "AES" encrypt_type = "AES"
https = True https = True
login_version = 2
device_family = "SMART.IPCAMERA" device_family = "SMART.IPCAMERA"
from kasa.device import Device from kasa.device import Device

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import cast from typing import cast
import asyncclick as click import asyncclick as click
@ -21,21 +20,6 @@ from .common import (
) )
@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.command()
@click.option("--year", type=click.DateTime(["%Y"]), default=None, 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("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@ -46,7 +30,7 @@ async def energy(dev: Device, year, month, erase):
Daily and monthly data provided in CSV format. Daily and monthly data provided in CSV format.
""" """
echo("[bold]== Emeter ==[/bold]") echo("[bold]== Energy ==[/bold]")
if not (energy := dev.modules.get(Module.Energy)): if not (energy := dev.modules.get(Module.Energy)):
error("Device has no energy module.") error("Device has no energy module.")
return return
@ -71,7 +55,7 @@ async def energy(dev: Device, year, month, erase):
usage_data = await energy.get_daily_stats(year=month.year, month=month.month) usage_data = await energy.get_daily_stats(year=month.year, month=month.month)
else: else:
# Call with no argument outputs summary data and returns # Call with no argument outputs summary data and returns
emeter_status = await energy.get_status() emeter_status = energy.status
echo("Current: {} A".format(emeter_status["current"])) echo("Current: {} A".format(emeter_status["current"]))
echo("Voltage: {} V".format(emeter_status["voltage"])) echo("Voltage: {} V".format(emeter_status["voltage"]))

View File

@ -25,6 +25,7 @@ def get_default_credentials(tuple: tuple[str, str]) -> Credentials:
DEFAULT_CREDENTIALS = { DEFAULT_CREDENTIALS = {
"KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"),
"KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="),
"TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="),
"TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="),
} }

21
kasa/device_factory.py Executable file → Normal file
View File

@ -12,6 +12,7 @@ from .deviceconfig import DeviceConfig
from .exceptions import KasaException, UnsupportedDeviceError from .exceptions import KasaException, UnsupportedDeviceError
from .iot import ( from .iot import (
IotBulb, IotBulb,
IotCamera,
IotDevice, IotDevice,
IotDimmer, IotDimmer,
IotLightStrip, IotLightStrip,
@ -32,6 +33,8 @@ from .transports import (
BaseTransport, BaseTransport,
KlapTransport, KlapTransport,
KlapTransportV2, KlapTransportV2,
LinkieTransportV2,
SslTransport,
XorTransport, XorTransport,
) )
from .transports.sslaestransport import SslAesTransport from .transports.sslaestransport import SslAesTransport
@ -137,6 +140,7 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]:
DeviceType.Strip: IotStrip, DeviceType.Strip: IotStrip,
DeviceType.WallSwitch: IotWallSwitch, DeviceType.WallSwitch: IotWallSwitch,
DeviceType.LightStrip: IotLightStrip, DeviceType.LightStrip: IotLightStrip,
DeviceType.Camera: IotCamera,
} }
return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)] return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)]
@ -155,8 +159,10 @@ def get_device_class_from_family(
"SMART.KASAHUB": SmartDevice, "SMART.KASAHUB": SmartDevice,
"SMART.KASASWITCH": SmartDevice, "SMART.KASASWITCH": SmartDevice,
"SMART.IPCAMERA.HTTPS": SmartCamDevice, "SMART.IPCAMERA.HTTPS": SmartCamDevice,
"SMART.TAPOROBOVAC": SmartDevice,
"IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": IotBulb, "IOT.SMARTBULB": IotBulb,
"IOT.IPCAMERA": IotCamera,
} }
lookup_key = f"{device_type}{'.HTTPS' if https else ''}" lookup_key = f"{device_type}{'.HTTPS' if https else ''}"
if ( if (
@ -176,20 +182,31 @@ def get_protocol(
"""Return the protocol from the connection name.""" """Return the protocol from the connection name."""
protocol_name = config.connection_type.device_family.value.split(".")[0] protocol_name = config.connection_type.device_family.value.split(".")[0]
ctype = config.connection_type ctype = config.connection_type
protocol_transport_key = ( protocol_transport_key = (
protocol_name protocol_name
+ "." + "."
+ ctype.encryption_type.value + ctype.encryption_type.value
+ (".HTTPS" if ctype.https else "") + (".HTTPS" if ctype.https else "")
+ (
f".{ctype.login_version}"
if ctype.login_version and ctype.login_version > 1
else ""
)
) )
_LOGGER.debug("Finding transport for %s", protocol_transport_key)
supported_device_protocols: dict[ supported_device_protocols: dict[
str, tuple[type[BaseProtocol], type[BaseTransport]] str, tuple[type[BaseProtocol], type[BaseTransport]]
] = { ] = {
"IOT.XOR": (IotProtocol, XorTransport), "IOT.XOR": (IotProtocol, XorTransport),
"IOT.KLAP": (IotProtocol, KlapTransport), "IOT.KLAP": (IotProtocol, KlapTransport),
"IOT.XOR.HTTPS.2": (IotProtocol, LinkieTransportV2),
"SMART.AES": (SmartProtocol, AesTransport), "SMART.AES": (SmartProtocol, AesTransport),
"SMART.KLAP": (SmartProtocol, KlapTransportV2), "SMART.AES.2": (SmartProtocol, AesTransport),
"SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), "SMART.KLAP.2": (SmartProtocol, KlapTransportV2),
"SMART.AES.HTTPS.2": (SmartCamProtocol, SslAesTransport),
"SMART.AES.HTTPS": (SmartProtocol, SslTransport),
} }
if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)):
return None return None

View File

@ -21,6 +21,7 @@ class DeviceType(Enum):
Hub = "hub" Hub = "hub"
Fan = "fan" Fan = "fan"
Thermostat = "thermostat" Thermostat = "thermostat"
Vacuum = "vacuum"
Unknown = "unknown" Unknown = "unknown"
@staticmethod @staticmethod

View File

@ -69,6 +69,7 @@ class DeviceFamily(Enum):
IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH" IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH"
IotSmartBulb = "IOT.SMARTBULB" IotSmartBulb = "IOT.SMARTBULB"
IotIpCamera = "IOT.IPCAMERA"
SmartKasaPlug = "SMART.KASAPLUG" SmartKasaPlug = "SMART.KASAPLUG"
SmartKasaSwitch = "SMART.KASASWITCH" SmartKasaSwitch = "SMART.KASASWITCH"
SmartTapoPlug = "SMART.TAPOPLUG" SmartTapoPlug = "SMART.TAPOPLUG"
@ -77,6 +78,7 @@ class DeviceFamily(Enum):
SmartTapoHub = "SMART.TAPOHUB" SmartTapoHub = "SMART.TAPOHUB"
SmartKasaHub = "SMART.KASAHUB" SmartKasaHub = "SMART.KASAHUB"
SmartIpCamera = "SMART.IPCAMERA" SmartIpCamera = "SMART.IPCAMERA"
SmartTapoRobovac = "SMART.TAPOROBOVAC"
class _DeviceConfigBaseMixin(DataClassJSONMixin): class _DeviceConfigBaseMixin(DataClassJSONMixin):

View File

@ -99,6 +99,7 @@ from typing import (
Annotated, Annotated,
Any, Any,
NamedTuple, NamedTuple,
TypedDict,
cast, cast,
) )
@ -123,7 +124,7 @@ from kasa.exceptions import (
TimeoutError, TimeoutError,
UnsupportedDeviceError, UnsupportedDeviceError,
) )
from kasa.iot.iotdevice import IotDevice from kasa.iot.iotdevice import IotDevice, _extract_sys_info
from kasa.json import DataClassJSONMixin from kasa.json import DataClassJSONMixin
from kasa.json import dumps as json_dumps from kasa.json import dumps as json_dumps
from kasa.json import loads as json_loads from kasa.json import loads as json_loads
@ -147,15 +148,35 @@ class ConnectAttempt(NamedTuple):
device: type device: type
class DiscoveredMeta(TypedDict):
"""Meta info about discovery response."""
ip: str
port: int
class DiscoveredRaw(TypedDict):
"""Try to connect attempt."""
meta: DiscoveredMeta
discovery_response: dict
OnDiscoveredCallable = Callable[[Device], Coroutine] OnDiscoveredCallable = Callable[[Device], Coroutine]
OnDiscoveredRawCallable = Callable[[DiscoveredRaw], None]
OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine] OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine]
OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None]
DeviceDict = dict[str, Device] DeviceDict = dict[str, Device]
NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"device_id": lambda x: "REDACTED_" + x[9::], "device_id": lambda x: "REDACTED_" + x[9::],
"device_name": lambda x: "#MASKED_NAME#" if x else "",
"owner": lambda x: "REDACTED_" + x[9::], "owner": lambda x: "REDACTED_" + x[9::],
"mac": mask_mac, "mac": mask_mac,
"master_device_id": lambda x: "REDACTED_" + x[9::],
"group_id": lambda x: "REDACTED_" + x[9::],
"group_name": lambda x: "I01BU0tFRF9TU0lEIw==",
"encrypt_info": lambda x: {**x, "key": "", "data": ""},
} }
@ -213,6 +234,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
self, self,
*, *,
on_discovered: OnDiscoveredCallable | None = None, on_discovered: OnDiscoveredCallable | None = None,
on_discovered_raw: OnDiscoveredRawCallable | None = None,
target: str = "255.255.255.255", target: str = "255.255.255.255",
discovery_packets: int = 3, discovery_packets: int = 3,
discovery_timeout: int = 5, discovery_timeout: int = 5,
@ -237,6 +259,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
self.unsupported_device_exceptions: dict = {} self.unsupported_device_exceptions: dict = {}
self.invalid_device_exceptions: dict = {} self.invalid_device_exceptions: dict = {}
self.on_unsupported = on_unsupported self.on_unsupported = on_unsupported
self.on_discovered_raw = on_discovered_raw
self.credentials = credentials self.credentials = credentials
self.timeout = timeout self.timeout = timeout
self.discovery_timeout = discovery_timeout self.discovery_timeout = discovery_timeout
@ -326,12 +349,23 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
config.timeout = self.timeout config.timeout = self.timeout
try: try:
if port == self.discovery_port: if port == self.discovery_port:
device = Discover._get_device_instance_legacy(data, config) json_func = Discover._get_discovery_json_legacy
device_func = Discover._get_device_instance_legacy
elif port == Discover.DISCOVERY_PORT_2: elif port == Discover.DISCOVERY_PORT_2:
config.uses_http = True config.uses_http = True
device = Discover._get_device_instance(data, config) json_func = Discover._get_discovery_json
device_func = Discover._get_device_instance
else: else:
return return
info = json_func(data, ip)
if self.on_discovered_raw is not None:
self.on_discovered_raw(
{
"discovery_response": info,
"meta": {"ip": ip, "port": port},
}
)
device = device_func(info, config)
except UnsupportedDeviceError as udex: except UnsupportedDeviceError as udex:
_LOGGER.debug("Unsupported device found at %s << %s", ip, udex) _LOGGER.debug("Unsupported device found at %s << %s", ip, udex)
self.unsupported_device_exceptions[ip] = udex self.unsupported_device_exceptions[ip] = udex
@ -388,6 +422,7 @@ class Discover:
*, *,
target: str = "255.255.255.255", target: str = "255.255.255.255",
on_discovered: OnDiscoveredCallable | None = None, on_discovered: OnDiscoveredCallable | None = None,
on_discovered_raw: OnDiscoveredRawCallable | None = None,
discovery_timeout: int = 5, discovery_timeout: int = 5,
discovery_packets: int = 3, discovery_packets: int = 3,
interface: str | None = None, interface: str | None = None,
@ -418,6 +453,8 @@ class Discover:
:param target: The target address where to send the broadcast discovery :param target: The target address where to send the broadcast discovery
queries if multi-homing (e.g. 192.168.xxx.255). queries if multi-homing (e.g. 192.168.xxx.255).
:param on_discovered: coroutine to execute on discovery :param on_discovered: coroutine to execute on discovery
:param on_discovered_raw: Optional callback once discovered json is loaded
before any attempt to deserialize it and create devices
:param discovery_timeout: Seconds to wait for responses, defaults to 5 :param discovery_timeout: Seconds to wait for responses, defaults to 5
:param discovery_packets: Number of discovery packets to broadcast :param discovery_packets: Number of discovery packets to broadcast
:param interface: Bind to specific interface :param interface: Bind to specific interface
@ -440,6 +477,7 @@ class Discover:
discovery_packets=discovery_packets, discovery_packets=discovery_packets,
interface=interface, interface=interface,
on_unsupported=on_unsupported, on_unsupported=on_unsupported,
on_discovered_raw=on_discovered_raw,
credentials=credentials, credentials=credentials,
timeout=timeout, timeout=timeout,
discovery_timeout=discovery_timeout, discovery_timeout=discovery_timeout,
@ -473,6 +511,7 @@ class Discover:
credentials: Credentials | None = None, credentials: Credentials | None = None,
username: str | None = None, username: str | None = None,
password: str | None = None, password: str | None = None,
on_discovered_raw: OnDiscoveredRawCallable | None = None,
on_unsupported: OnUnsupportedCallable | None = None, on_unsupported: OnUnsupportedCallable | None = None,
) -> Device | None: ) -> Device | None:
"""Discover a single device by the given IP address. """Discover a single device by the given IP address.
@ -490,6 +529,9 @@ class Discover:
username and password are ignored if provided. username and password are ignored if provided.
:param username: Username for devices that require authentication :param username: Username for devices that require authentication
:param password: Password for devices that require authentication :param password: Password for devices that require authentication
:param on_discovered_raw: Optional callback once discovered json is loaded
before any attempt to deserialize it and create devices
:param on_unsupported: Optional callback when unsupported devices are discovered
:rtype: SmartDevice :rtype: SmartDevice
:return: Object for querying/controlling found device. :return: Object for querying/controlling found device.
""" """
@ -526,6 +568,7 @@ class Discover:
credentials=credentials, credentials=credentials,
timeout=timeout, timeout=timeout,
discovery_timeout=discovery_timeout, discovery_timeout=discovery_timeout,
on_discovered_raw=on_discovered_raw,
), ),
local_addr=("0.0.0.0", 0), # noqa: S104 local_addr=("0.0.0.0", 0), # noqa: S104
) )
@ -595,10 +638,12 @@ class Discover:
for encrypt in Device.EncryptionType for encrypt in Device.EncryptionType
for device_family in main_device_families for device_family in main_device_families
for https in (True, False) for https in (True, False)
for login_version in (None, 2)
if ( if (
conn_params := DeviceConnectionParameters( conn_params := DeviceConnectionParameters(
device_family=device_family, device_family=device_family,
encryption_type=encrypt, encryption_type=encrypt,
login_version=login_version,
https=https, https=https,
) )
) )
@ -643,7 +688,11 @@ class Discover:
"""Find SmartDevice subclass for device described by passed data.""" """Find SmartDevice subclass for device described by passed data."""
if "result" in info: if "result" in info:
discovery_result = DiscoveryResult.from_dict(info["result"]) discovery_result = DiscoveryResult.from_dict(info["result"])
https = discovery_result.mgt_encrypt_schm.is_support_https https = (
discovery_result.mgt_encrypt_schm.is_support_https
if discovery_result.mgt_encrypt_schm
else False
)
dev_class = get_device_class_from_family( dev_class = get_device_class_from_family(
discovery_result.device_type, https=https discovery_result.device_type, https=https
) )
@ -657,27 +706,36 @@ class Discover:
return get_device_class_from_sys_info(info) return get_device_class_from_sys_info(info)
@staticmethod @staticmethod
def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice: def _get_discovery_json_legacy(data: bytes, ip: str) -> dict:
"""Get SmartDevice from legacy 9999 response.""" """Get discovery json from legacy 9999 response."""
try: try:
info = json_loads(XorEncryption.decrypt(data)) info = json_loads(XorEncryption.decrypt(data))
except Exception as ex: except Exception as ex:
raise KasaException( raise KasaException(
f"Unable to read response from device: {config.host}: {ex}" f"Unable to read response from device: {ip}: {ex}"
) from ex ) from ex
return info
@staticmethod
def _get_device_instance_legacy(info: dict, config: DeviceConfig) -> Device:
"""Get IotDevice from legacy 9999 response."""
if _LOGGER.isEnabledFor(logging.DEBUG): if _LOGGER.isEnabledFor(logging.DEBUG):
data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info
_LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data))
device_class = cast(type[IotDevice], Discover._get_device_class(info)) device_class = cast(type[IotDevice], Discover._get_device_class(info))
device = device_class(config.host, config=config) device = device_class(config.host, config=config)
sys_info = info["system"]["get_sysinfo"] sys_info = _extract_sys_info(info)
if device_type := sys_info.get("mic_type", sys_info.get("type")): device_type = sys_info.get("mic_type", sys_info.get("type"))
config.connection_type = DeviceConnectionParameters.from_values( login_version = (
device_family=device_type, sys_info.get("stream_version") if device_type == "IOT.IPCAMERA" else None
encryption_type=DeviceEncryptionType.Xor.value, )
) config.connection_type = DeviceConnectionParameters.from_values(
device_family=device_type,
encryption_type=DeviceEncryptionType.Xor.value,
https=device_type == "IOT.IPCAMERA",
login_version=login_version,
)
device.protocol = get_protocol(config) # type: ignore[assignment] device.protocol = get_protocol(config) # type: ignore[assignment]
device.update_from_discover_info(info) device.update_from_discover_info(info)
return device return device
@ -701,20 +759,25 @@ class Discover:
discovery_result.decrypted_data = json_loads(decrypted_data) discovery_result.decrypted_data = json_loads(decrypted_data)
@staticmethod
def _get_discovery_json(data: bytes, ip: str) -> dict:
"""Get discovery json from the new 20002 response."""
try:
info = json_loads(data[16:])
except Exception as ex:
_LOGGER.debug("Got invalid response from device %s: %s", ip, data)
raise KasaException(
f"Unable to read response from device: {ip}: {ex}"
) from ex
return info
@staticmethod @staticmethod
def _get_device_instance( def _get_device_instance(
data: bytes, info: dict,
config: DeviceConfig, config: DeviceConfig,
) -> Device: ) -> Device:
"""Get SmartDevice from the new 20002 response.""" """Get SmartDevice from the new 20002 response."""
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
try:
info = json_loads(data[16:])
except Exception as ex:
_LOGGER.debug("Got invalid response from device %s: %s", config.host, data)
raise KasaException(
f"Unable to read response from device: {config.host}: {ex}"
) from ex
try: try:
discovery_result = DiscoveryResult.from_dict(info["result"]) discovery_result = DiscoveryResult.from_dict(info["result"])
@ -743,11 +806,19 @@ class Discover:
Discover._decrypt_discovery_data(discovery_result) Discover._decrypt_discovery_data(discovery_result)
except Exception: except Exception:
_LOGGER.exception( _LOGGER.exception(
"Unable to decrypt discovery data %s: %s", config.host, data "Unable to decrypt discovery data %s: %s",
config.host,
redact_data(info, NEW_DISCOVERY_REDACTORS),
) )
type_ = discovery_result.device_type type_ = discovery_result.device_type
encrypt_schm = discovery_result.mgt_encrypt_schm if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None:
raise UnsupportedDeviceError(
f"Unsupported device {config.host} of type {type_} "
"with no mgt_encrypt_schm",
discovery_result=discovery_result.to_dict(),
host=config.host,
)
try: try:
if not (encrypt_type := encrypt_schm.encrypt_type) and ( if not (encrypt_type := encrypt_schm.encrypt_type) and (
@ -755,6 +826,13 @@ class Discover:
): ):
encrypt_type = encrypt_info.sym_schm encrypt_type = encrypt_info.sym_schm
if (
not (login_version := encrypt_schm.lv)
and (et := discovery_result.encrypt_type)
and et == ["3"]
):
login_version = 2
if not encrypt_type: if not encrypt_type:
raise UnsupportedDeviceError( raise UnsupportedDeviceError(
f"Unsupported device {config.host} of type {type_} " f"Unsupported device {config.host} of type {type_} "
@ -765,13 +843,13 @@ class Discover:
config.connection_type = DeviceConnectionParameters.from_values( config.connection_type = DeviceConnectionParameters.from_values(
type_, type_,
encrypt_type, encrypt_type,
discovery_result.mgt_encrypt_schm.lv, login_version,
discovery_result.mgt_encrypt_schm.is_support_https, encrypt_schm.is_support_https,
) )
except KasaException as ex: except KasaException as ex:
raise UnsupportedDeviceError( raise UnsupportedDeviceError(
f"Unsupported device {config.host} of type {type_} " f"Unsupported device {config.host} of type {type_} "
+ f"with encrypt_type {discovery_result.mgt_encrypt_schm.encrypt_type}", + f"with encrypt_type {encrypt_schm.encrypt_type}",
discovery_result=discovery_result.to_dict(), discovery_result=discovery_result.to_dict(),
host=config.host, host=config.host,
) from ex ) from ex
@ -854,7 +932,7 @@ class DiscoveryResult(_DiscoveryBaseMixin):
device_id: str device_id: str
ip: str ip: str
mac: str mac: str
mgt_encrypt_schm: EncryptionScheme mgt_encrypt_schm: EncryptionScheme | None = None
device_name: str | None = None device_name: str | None = None
encrypt_info: EncryptionInfo | None = None encrypt_info: EncryptionInfo | None = None
encrypt_type: list[str] | None = None encrypt_type: list[str] | None = None

View File

@ -24,7 +24,6 @@ State (state): True
Signal Level (signal_level): 2 Signal Level (signal_level): 2
RSSI (rssi): -52 RSSI (rssi): -52
SSID (ssid): #MASKED_SSID# SSID (ssid): #MASKED_SSID#
Overheated (overheated): False
Reboot (reboot): <Action> Reboot (reboot): <Action>
Brightness (brightness): 100 Brightness (brightness): 100
Cloud connection (cloud_connection): True Cloud connection (cloud_connection): True
@ -39,6 +38,7 @@ Light effect (light_effect): Off
Light preset (light_preset): Not set Light preset (light_preset): Not set
Smooth transition on (smooth_transition_on): 2 Smooth transition on (smooth_transition_on): 2
Smooth transition off (smooth_transition_off): 2 Smooth transition off (smooth_transition_off): 2
Overheated (overheated): False
Device time (device_time): 2024-02-23 02:40:15+01:00 Device time (device_time): 2024-02-23 02:40:15+01:00
To see whether a device supports a feature, check for the existence of it: To see whether a device supports a feature, check for the existence of it:

View File

@ -6,6 +6,7 @@ from .led import Led
from .light import Light, LightState from .light import Light, LightState
from .lighteffect import LightEffect from .lighteffect import LightEffect
from .lightpreset import LightPreset from .lightpreset import LightPreset
from .thermostat import Thermostat, ThermostatState
from .time import Time from .time import Time
__all__ = [ __all__ = [
@ -16,5 +17,7 @@ __all__ = [
"LightEffect", "LightEffect",
"LightState", "LightState",
"LightPreset", "LightPreset",
"Thermostat",
"ThermostatState",
"Time", "Time",
] ]

View File

@ -0,0 +1,65 @@
"""Interact with a TPLink Thermostat."""
from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import Annotated, Literal
from ..module import FeatureAttribute, Module
class ThermostatState(Enum):
"""Thermostat state."""
Heating = "heating"
Calibrating = "progress_calibration"
Idle = "idle"
Off = "off"
Unknown = "unknown"
class Thermostat(Module, ABC):
"""Base class for TP-Link Thermostat."""
@property
@abstractmethod
def state(self) -> bool:
"""Return thermostat state."""
@abstractmethod
async def set_state(self, enabled: bool) -> dict:
"""Set thermostat state."""
@property
@abstractmethod
def mode(self) -> ThermostatState:
"""Return thermostat state."""
@property
@abstractmethod
def target_temperature(self) -> Annotated[float, FeatureAttribute()]:
"""Return target temperature."""
@abstractmethod
async def set_target_temperature(
self, target: float
) -> Annotated[dict, FeatureAttribute()]:
"""Set target temperature."""
@property
@abstractmethod
def temperature(self) -> Annotated[float, FeatureAttribute()]:
"""Return current humidity in percentage."""
return self._device.sys_info["current_temp"]
@property
@abstractmethod
def temperature_unit(self) -> Literal["celsius", "fahrenheit"]:
"""Return current temperature unit."""
@abstractmethod
async def set_temperature_unit(
self, unit: Literal["celsius", "fahrenheit"]
) -> dict:
"""Set the device temperature unit."""

View File

@ -1,6 +1,7 @@
"""Package for supporting legacy kasa devices.""" """Package for supporting legacy kasa devices."""
from .iotbulb import IotBulb from .iotbulb import IotBulb
from .iotcamera import IotCamera
from .iotdevice import IotDevice from .iotdevice import IotDevice
from .iotdimmer import IotDimmer from .iotdimmer import IotDimmer
from .iotlightstrip import IotLightStrip from .iotlightstrip import IotLightStrip
@ -15,4 +16,5 @@ __all__ = [
"IotDimmer", "IotDimmer",
"IotLightStrip", "IotLightStrip",
"IotWallSwitch", "IotWallSwitch",
"IotCamera",
] ]

42
kasa/iot/iotcamera.py Normal file
View File

@ -0,0 +1,42 @@
"""Module for cameras."""
from __future__ import annotations
import logging
from datetime import datetime, tzinfo
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..protocols import BaseProtocol
from .iotdevice import IotDevice
_LOGGER = logging.getLogger(__name__)
class IotCamera(IotDevice):
"""Representation of a TP-Link Camera."""
def __init__(
self,
host: str,
*,
config: DeviceConfig | None = None,
protocol: BaseProtocol | None = None,
) -> None:
super().__init__(host=host, config=config, protocol=protocol)
self._device_type = DeviceType.Camera
@property
def time(self) -> datetime:
"""Get the camera's time."""
return datetime.fromtimestamp(self.sys_info["system_time"])
@property
def timezone(self) -> tzinfo:
"""Get the camera's timezone."""
return None # type: ignore
@property # type: ignore
def is_on(self) -> bool:
"""Return whether device is on."""
return True

View File

@ -70,6 +70,16 @@ def _parse_features(features: str) -> set[str]:
return set(features.split(":")) return set(features.split(":"))
def _extract_sys_info(info: dict[str, Any]) -> dict[str, Any]:
"""Return the system info structure."""
sysinfo_default = info.get("system", {}).get("get_sysinfo", {})
sysinfo_nest = sysinfo_default.get("system", {})
if len(sysinfo_nest) > len(sysinfo_default) and isinstance(sysinfo_nest, dict):
return sysinfo_nest
return sysinfo_default
class IotDevice(Device): class IotDevice(Device):
"""Base class for all supported device types. """Base class for all supported device types.
@ -304,14 +314,14 @@ class IotDevice(Device):
_LOGGER.debug("Performing the initial update to obtain sysinfo") _LOGGER.debug("Performing the initial update to obtain sysinfo")
response = await self.protocol.query(req) response = await self.protocol.query(req)
self._last_update = response self._last_update = response
self._set_sys_info(response["system"]["get_sysinfo"]) self._set_sys_info(_extract_sys_info(response))
if not self._modules: if not self._modules:
await self._initialize_modules() await self._initialize_modules()
await self._modular_update(req) await self._modular_update(req)
self._set_sys_info(self._last_update["system"]["get_sysinfo"]) self._set_sys_info(_extract_sys_info(self._last_update))
for module in self._modules.values(): for module in self._modules.values():
await module._post_update_hook() await module._post_update_hook()
@ -705,10 +715,13 @@ class IotDevice(Device):
@staticmethod @staticmethod
def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType:
"""Find SmartDevice subclass for device described by passed data.""" """Find SmartDevice subclass for device described by passed data."""
if "system" in info.get("system", {}).get("get_sysinfo", {}):
return DeviceType.Camera
if "system" not in info or "get_sysinfo" not in info["system"]: if "system" not in info or "get_sysinfo" not in info["system"]:
raise KasaException("No 'system' or 'get_sysinfo' in response") raise KasaException("No 'system' or 'get_sysinfo' in response")
sysinfo: dict[str, Any] = info["system"]["get_sysinfo"] sysinfo: dict[str, Any] = _extract_sys_info(info)
type_: str | None = sysinfo.get("type", sysinfo.get("mic_type")) type_: str | None = sysinfo.get("type", sysinfo.get("mic_type"))
if type_ is None: if type_ is None:
raise KasaException("Unable to find the device type field!") raise KasaException("Unable to find the device type field!")
@ -728,6 +741,7 @@ class IotDevice(Device):
return DeviceType.LightStrip return DeviceType.LightStrip
return DeviceType.Bulb return DeviceType.Bulb
_LOGGER.warning("Unknown device type %s, falling back to plug", type_) _LOGGER.warning("Unknown device type %s, falling back to plug", type_)
return DeviceType.Plug return DeviceType.Plug
@ -736,7 +750,7 @@ class IotDevice(Device):
info: dict[str, Any], discovery_info: dict[str, Any] | None info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo: ) -> _DeviceInfo:
"""Get model information for a device.""" """Get model information for a device."""
sys_info = info["system"]["get_sysinfo"] sys_info = _extract_sys_info(info)
# Get model and region info # Get model and region info
region = None region = None

View File

@ -8,18 +8,24 @@ from typing import Any
try: try:
import orjson import orjson
def dumps(obj: Any, *, default: Callable | None = None) -> str: def dumps(
obj: Any, *, default: Callable | None = None, indent: bool = False
) -> str:
"""Dump JSON.""" """Dump JSON."""
return orjson.dumps(obj).decode() return orjson.dumps(
obj, option=orjson.OPT_INDENT_2 if indent else None
).decode()
loads = orjson.loads loads = orjson.loads
except ImportError: except ImportError:
import json import json
def dumps(obj: Any, *, default: Callable | None = None) -> str: def dumps(
obj: Any, *, default: Callable | None = None, indent: bool = False
) -> str:
"""Dump JSON.""" """Dump JSON."""
# Separators specified for consistency with orjson # Separators specified for consistency with orjson
return json.dumps(obj, separators=(",", ":")) return json.dumps(obj, separators=(",", ":"), indent=2 if indent else None)
loads = json.loads loads = json.loads

View File

@ -14,9 +14,17 @@ Light, AutoOff, Firmware etc.
>>> print(dev.alias) >>> print(dev.alias)
Living Room Bulb Living Room Bulb
To see whether a device supports functionality check for the existence of the module: To see whether a device supports a group of functionality
check for the existence of the module:
>>> if light := dev.modules.get("Light"): >>> if light := dev.modules.get("Light"):
>>> print(light.brightness)
100
To see whether a device supports specific functionality, you can check whether the
module has that feature:
>>> if light.has_feature("hsv"):
>>> print(light.hsv) >>> print(light.hsv)
HSV(hue=0, saturation=100, value=100) HSV(hue=0, saturation=100, value=100)
@ -70,6 +78,9 @@ ModuleT = TypeVar("ModuleT", bound="Module")
class FeatureAttribute: class FeatureAttribute:
"""Class for annotating attributes bound to feature.""" """Class for annotating attributes bound to feature."""
def __repr__(self) -> str:
return "FeatureAttribute"
class Module(ABC): class Module(ABC):
"""Base class implemention for all modules. """Base class implemention for all modules.
@ -85,6 +96,7 @@ class Module(ABC):
Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led")
Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light")
LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset") LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset")
Thermostat: Final[ModuleName[interfaces.Thermostat]] = ModuleName("Thermostat")
Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time") Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time")
# IOT only Modules # IOT only Modules

View File

@ -24,9 +24,11 @@ from .lightpreset import LightPreset
from .lightstripeffect import LightStripEffect from .lightstripeffect import LightStripEffect
from .lighttransition import LightTransition from .lighttransition import LightTransition
from .motionsensor import MotionSensor from .motionsensor import MotionSensor
from .overheatprotection import OverheatProtection
from .reportmode import ReportMode from .reportmode import ReportMode
from .temperaturecontrol import TemperatureControl from .temperaturecontrol import TemperatureControl
from .temperaturesensor import TemperatureSensor from .temperaturesensor import TemperatureSensor
from .thermostat import Thermostat
from .time import Time from .time import Time
from .triggerlogs import TriggerLogs from .triggerlogs import TriggerLogs
from .waterleaksensor import WaterleakSensor from .waterleaksensor import WaterleakSensor
@ -61,5 +63,7 @@ __all__ = [
"MotionSensor", "MotionSensor",
"TriggerLogs", "TriggerLogs",
"FrostProtection", "FrostProtection",
"Thermostat",
"SmartLightEffect", "SmartLightEffect",
"OverheatProtection",
] ]

View File

@ -10,7 +10,7 @@ class ContactSensor(SmartModule):
"""Implementation of contact sensor module.""" """Implementation of contact sensor module."""
REQUIRED_COMPONENT = None # we depend on availability of key REQUIRED_COMPONENT = None # we depend on availability of key
REQUIRED_KEY_ON_PARENT = "open" SYSINFO_LOOKUP_KEYS = ["open"]
def _initialize_features(self) -> None: def _initialize_features(self) -> None:
"""Initialize features after the initial update.""" """Initialize features after the initial update."""

View File

@ -75,8 +75,12 @@ class Energy(SmartModule, EnergyInterface):
async def get_status(self) -> EmeterStatus: async def get_status(self) -> EmeterStatus:
"""Return real-time statistics.""" """Return real-time statistics."""
res = await self.call("get_energy_usage") if "get_emeter_data" in self.data:
return self._get_status_from_energy(res["get_energy_usage"]) res = await self.call("get_emeter_data")
return EmeterStatus(res["get_emeter_data"])
else:
res = await self.call("get_energy_usage")
return self._get_status_from_energy(res["get_energy_usage"])
@property @property
@raise_if_update_error @raise_if_update_error

View File

@ -24,6 +24,7 @@ class LightTransition(SmartModule):
REQUIRED_COMPONENT = "on_off_gradually" REQUIRED_COMPONENT = "on_off_gradually"
QUERY_GETTER_NAME = "get_on_off_gradually_info" QUERY_GETTER_NAME = "get_on_off_gradually_info"
MINIMUM_UPDATE_INTERVAL_SECS = 60 MINIMUM_UPDATE_INTERVAL_SECS = 60
# v3 added max_duration, we default to 60 when it's not available
MAXIMUM_DURATION = 60 MAXIMUM_DURATION = 60
# Key in sysinfo that indicates state can be retrieved from there. # Key in sysinfo that indicates state can be retrieved from there.
@ -144,10 +145,22 @@ class LightTransition(SmartModule):
return await self.call("set_on_off_gradually_info", {"enable": enable}) return await self.call("set_on_off_gradually_info", {"enable": enable})
else: else:
on = await self.call( on = await self.call(
"set_on_off_gradually_info", {"on_state": {"enable": enable}} "set_on_off_gradually_info",
{
"on_state": {
"enable": enable,
"duration": self._on_state["duration"],
}
},
) )
off = await self.call( off = await self.call(
"set_on_off_gradually_info", {"off_state": {"enable": enable}} "set_on_off_gradually_info",
{
"off_state": {
"enable": enable,
"duration": self._off_state["duration"],
}
},
) )
return {**on, **off} return {**on, **off}
@ -167,7 +180,6 @@ class LightTransition(SmartModule):
@property @property
def _turn_on_transition_max(self) -> int: def _turn_on_transition_max(self) -> int:
"""Maximum turn on duration.""" """Maximum turn on duration."""
# v3 added max_duration, we default to 60 when it's not available
return self._on_state["max_duration"] return self._on_state["max_duration"]
@allow_update_after @allow_update_after
@ -184,7 +196,7 @@ class LightTransition(SmartModule):
if seconds <= 0: if seconds <= 0:
return await self.call( return await self.call(
"set_on_off_gradually_info", "set_on_off_gradually_info",
{"on_state": {"enable": False}}, {"on_state": {"enable": False, "duration": self._on_state["duration"]}},
) )
return await self.call( return await self.call(
@ -220,7 +232,12 @@ class LightTransition(SmartModule):
if seconds <= 0: if seconds <= 0:
return await self.call( return await self.call(
"set_on_off_gradually_info", "set_on_off_gradually_info",
{"off_state": {"enable": False}}, {
"off_state": {
"enable": False,
"duration": self._off_state["duration"],
}
},
) )
return await self.call( return await self.call(

View File

@ -0,0 +1,41 @@
"""Overheat module."""
from __future__ import annotations
from ...feature import Feature
from ..smartmodule import SmartModule
class OverheatProtection(SmartModule):
"""Implementation for overheat_protection."""
SYSINFO_LOOKUP_KEYS = ["overheated", "overheat_status"]
def _initialize_features(self) -> None:
"""Initialize features after the initial update."""
self._add_feature(
Feature(
self._device,
container=self,
id="overheated",
name="Overheated",
attribute_getter="overheated",
icon="mdi:heat-wave",
type=Feature.Type.BinarySensor,
category=Feature.Category.Info,
)
)
@property
def overheated(self) -> bool:
"""Return True if device reports overheating."""
if (value := self._device.sys_info.get("overheat_status")) is not None:
# Value can be normal, cooldown, or overheated.
# We report all but normal as overheated.
return value != "normal"
return self._device.sys_info["overheated"]
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {}

View File

@ -3,24 +3,14 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from enum import Enum
from ...feature import Feature from ...feature import Feature
from ...interfaces.thermostat import ThermostatState
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
class ThermostatState(Enum):
"""Thermostat state."""
Heating = "heating"
Calibrating = "progress_calibration"
Idle = "idle"
Off = "off"
Unknown = "unknown"
class TemperatureControl(SmartModule): class TemperatureControl(SmartModule):
"""Implementation of temperature module.""" """Implementation of temperature module."""
@ -56,7 +46,6 @@ class TemperatureControl(SmartModule):
category=Feature.Category.Config, category=Feature.Category.Config,
) )
) )
self._add_feature( self._add_feature(
Feature( Feature(
self._device, self._device,
@ -69,7 +58,6 @@ class TemperatureControl(SmartModule):
type=Feature.Type.Switch, type=Feature.Type.Switch,
) )
) )
self._add_feature( self._add_feature(
Feature( Feature(
self._device, self._device,

View File

@ -0,0 +1,74 @@
"""Module for a Thermostat."""
from __future__ import annotations
from typing import Annotated, Literal
from ...feature import Feature
from ...interfaces.thermostat import Thermostat as ThermostatInterface
from ...interfaces.thermostat import ThermostatState
from ...module import FeatureAttribute, Module
from ..smartmodule import SmartModule
class Thermostat(SmartModule, ThermostatInterface):
"""Implementation of a Thermostat."""
@property
def _all_features(self) -> dict[str, Feature]:
"""Get the features for this module and any sub modules."""
ret: dict[str, Feature] = {}
if temp_control := self._device.modules.get(Module.TemperatureControl):
ret.update(**temp_control._module_features)
if temp_sensor := self._device.modules.get(Module.TemperatureSensor):
ret.update(**temp_sensor._module_features)
return ret
def query(self) -> dict:
"""Query to execute during the update cycle."""
return {}
@property
def state(self) -> bool:
"""Return thermostat state."""
return self._device.modules[Module.TemperatureControl].state
async def set_state(self, enabled: bool) -> dict:
"""Set thermostat state."""
return await self._device.modules[Module.TemperatureControl].set_state(enabled)
@property
def mode(self) -> ThermostatState:
"""Return thermostat state."""
return self._device.modules[Module.TemperatureControl].mode
@property
def target_temperature(self) -> Annotated[float, FeatureAttribute()]:
"""Return target temperature."""
return self._device.modules[Module.TemperatureControl].target_temperature
async def set_target_temperature(
self, target: float
) -> Annotated[dict, FeatureAttribute()]:
"""Set target temperature."""
return await self._device.modules[
Module.TemperatureControl
].set_target_temperature(target)
@property
def temperature(self) -> Annotated[float, FeatureAttribute()]:
"""Return current humidity in percentage."""
return self._device.modules[Module.TemperatureSensor].temperature
@property
def temperature_unit(self) -> Literal["celsius", "fahrenheit"]:
"""Return current temperature unit."""
return self._device.modules[Module.TemperatureSensor].temperature_unit
async def set_temperature_unit(
self, unit: Literal["celsius", "fahrenheit"]
) -> dict:
"""Set the device temperature unit."""
return await self._device.modules[
Module.TemperatureSensor
].set_temperature_unit(unit)

View File

@ -24,6 +24,7 @@ from .modules import (
DeviceModule, DeviceModule,
Firmware, Firmware,
Light, Light,
Thermostat,
Time, Time,
) )
from .smartmodule import SmartModule from .smartmodule import SmartModule
@ -166,7 +167,14 @@ class SmartDevice(Device):
self._last_update, "get_child_device_list", {} self._last_update, "get_child_device_list", {}
): ):
for info in child_info["child_device_list"]: for info in child_info["child_device_list"]:
self._children[info["device_id"]]._update_internal_state(info) child_id = info["device_id"]
if child_id not in self._children:
_LOGGER.debug(
"Skipping child update for %s, probably unsupported device",
child_id,
)
continue
self._children[child_id]._update_internal_state(info)
def _update_internal_info(self, info_resp: dict) -> None: def _update_internal_info(self, info_resp: dict) -> None:
"""Update the internal device info.""" """Update the internal device info."""
@ -341,9 +349,8 @@ class SmartDevice(Device):
) or mod.__name__ in child_modules_to_skip: ) or mod.__name__ in child_modules_to_skip:
continue continue
required_component = cast(str, mod.REQUIRED_COMPONENT) required_component = cast(str, mod.REQUIRED_COMPONENT)
if required_component in self._components or ( if required_component in self._components or any(
mod.REQUIRED_KEY_ON_PARENT self.sys_info.get(key) is not None for key in mod.SYSINFO_LOOKUP_KEYS
and self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None
): ):
_LOGGER.debug( _LOGGER.debug(
"Device %s, found required %s, adding %s to modules.", "Device %s, found required %s, adding %s to modules.",
@ -361,6 +368,11 @@ class SmartDevice(Device):
or Module.ColorTemperature in self._modules or Module.ColorTemperature in self._modules
): ):
self._modules[Light.__name__] = Light(self, "light") self._modules[Light.__name__] = Light(self, "light")
if (
Module.TemperatureControl in self._modules
and Module.TemperatureSensor in self._modules
):
self._modules[Thermostat.__name__] = Thermostat(self, "thermostat")
async def _initialize_features(self) -> None: async def _initialize_features(self) -> None:
"""Initialize device features.""" """Initialize device features."""
@ -427,19 +439,6 @@ class SmartDevice(Device):
) )
) )
if "overheated" in self._info:
self._add_feature(
Feature(
self,
id="overheated",
name="Overheated",
attribute_getter=lambda x: x._info["overheated"],
icon="mdi:heat-wave",
type=Feature.Type.BinarySensor,
category=Feature.Category.Info,
)
)
# We check for the key available, and not for the property truthiness, # We check for the key available, and not for the property truthiness,
# as the value is falsy when the device is off. # as the value is falsy when the device is off.
if "on_time" in self._info: if "on_time" in self._info:
@ -759,10 +758,11 @@ class SmartDevice(Device):
if self._device_type is not DeviceType.Unknown: if self._device_type is not DeviceType.Unknown:
return self._device_type return self._device_type
# Fallback to device_type (from disco info) if (
type_str = self._info.get("type", self._info.get("device_type")) not (type_str := self._info.get("type", self._info.get("device_type")))
or not self._components
if not type_str: # no update or discovery info ):
# no update or discovery info
return self._device_type return self._device_type
self._device_type = self._get_device_type_from_components( self._device_type = self._get_device_type_from_components(
@ -796,6 +796,8 @@ class SmartDevice(Device):
return DeviceType.Sensor return DeviceType.Sensor
if "ENERGY" in device_type: if "ENERGY" in device_type:
return DeviceType.Thermostat return DeviceType.Thermostat
if "ROBOVAC" in device_type:
return DeviceType.Vacuum
_LOGGER.warning("Unknown device type, falling back to plug") _LOGGER.warning("Unknown device type, falling back to plug")
return DeviceType.Plug return DeviceType.Plug

View File

@ -54,8 +54,8 @@ class SmartModule(Module):
NAME: str NAME: str
#: Module is initialized, if the given component is available #: Module is initialized, if the given component is available
REQUIRED_COMPONENT: str | None = None REQUIRED_COMPONENT: str | None = None
#: Module is initialized, if the given key available in the main sysinfo #: Module is initialized, if any of the given keys exists in the sysinfo
REQUIRED_KEY_ON_PARENT: str | None = None SYSINFO_LOOKUP_KEYS: list[str] = []
#: Query to execute during the main update cycle #: Query to execute during the main update cycle
QUERY_GETTER_NAME: str QUERY_GETTER_NAME: str

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import base64 import base64
import logging import logging
from enum import StrEnum
from urllib.parse import quote_plus from urllib.parse import quote_plus
from ...credentials import Credentials from ...credentials import Credentials
@ -15,6 +16,14 @@ from ..smartcammodule import SmartCamModule
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
LOCAL_STREAMING_PORT = 554 LOCAL_STREAMING_PORT = 554
ONVIF_PORT = 2020
class StreamResolution(StrEnum):
"""Class for stream resolution."""
HD = "HD"
SD = "SD"
class Camera(SmartCamModule): class Camera(SmartCamModule):
@ -64,7 +73,12 @@ class Camera(SmartCamModule):
return None return None
def stream_rtsp_url(self, credentials: Credentials | None = None) -> str | None: def stream_rtsp_url(
self,
credentials: Credentials | None = None,
*,
stream_resolution: StreamResolution = StreamResolution.HD,
) -> str | None:
"""Return the local rtsp streaming url. """Return the local rtsp streaming url.
:param credentials: Credentials for camera account. :param credentials: Credentials for camera account.
@ -73,17 +87,27 @@ class Camera(SmartCamModule):
:return: rtsp url with escaped credentials or None if no credentials or :return: rtsp url with escaped credentials or None if no credentials or
camera is off. camera is off.
""" """
if not self.is_on: streams = {
StreamResolution.HD: "stream1",
StreamResolution.SD: "stream2",
}
if (stream := streams.get(stream_resolution)) is None:
return None return None
dev = self._device
if not credentials: if not credentials:
credentials = self._get_credentials() credentials = self._get_credentials()
if not credentials or not credentials.username or not credentials.password: if not credentials or not credentials.username or not credentials.password:
return None return None
username = quote_plus(credentials.username) username = quote_plus(credentials.username)
password = quote_plus(credentials.password) password = quote_plus(credentials.password)
return f"rtsp://{username}:{password}@{dev.host}:{LOCAL_STREAMING_PORT}/stream1"
return f"rtsp://{username}:{password}@{self._device.host}:{LOCAL_STREAMING_PORT}/{stream}"
def onvif_url(self) -> str | None:
"""Return the onvif url."""
return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service"
async def set_state(self, on: bool) -> dict: async def set_state(self, on: bool) -> dict:
"""Set the device state.""" """Set the device state."""

View File

@ -68,7 +68,14 @@ class SmartCamDevice(SmartDevice):
self._last_update, "getChildDeviceList", {} self._last_update, "getChildDeviceList", {}
): ):
for info in child_info["child_device_list"]: for info in child_info["child_device_list"]:
self._children[info["device_id"]]._update_internal_state(info) child_id = info["device_id"]
if child_id not in self._children:
_LOGGER.debug(
"Skipping child update for %s, probably unsupported device",
child_id,
)
continue
self._children[child_id]._update_internal_state(info)
async def _initialize_smart_child( async def _initialize_smart_child(
self, info: dict, child_components: dict self, info: dict, child_components: dict
@ -100,20 +107,29 @@ class SmartCamDevice(SmartDevice):
resp = await self.protocol.query(child_info_query) resp = await self.protocol.query(child_info_query)
self.internal_state.update(resp) self.internal_state.update(resp)
children_components = { smart_children_components = {
child["device_id"]: { child["device_id"]: {
comp["id"]: int(comp["ver_code"]) for comp in child["component_list"] comp["id"]: int(comp["ver_code"]) for comp in component_list
} }
for child in resp["getChildDeviceComponentList"]["child_component_list"] for child in resp["getChildDeviceComponentList"]["child_component_list"]
if (component_list := child.get("component_list"))
# Child camera devices will have a different component schema so only
# extract smart values.
and (first_comp := next(iter(component_list), None))
and isinstance(first_comp, dict)
and "id" in first_comp
and "ver_code" in first_comp
} }
children = {} children = {}
for info in resp["getChildDeviceList"]["child_device_list"]: for info in resp["getChildDeviceList"]["child_device_list"]:
if ( if (
category := info.get("category") (category := info.get("category"))
) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP
child_id = info["device_id"] and (child_id := info.get("device_id"))
and (child_components := smart_children_components.get(child_id))
):
children[child_id] = await self._initialize_smart_child( children[child_id] = await self._initialize_smart_child(
info, children_components[child_id] info, child_components
) )
else: else:
_LOGGER.debug("Child device type not supported: %s", info) _LOGGER.debug("Child device type not supported: %s", info)
@ -191,6 +207,7 @@ class SmartCamDevice(SmartDevice):
"mac": basic_info["mac"], "mac": basic_info["mac"],
"hwId": basic_info.get("hw_id"), "hwId": basic_info.get("hw_id"),
"oem_id": basic_info["oem_id"], "oem_id": basic_info["oem_id"],
"device_id": basic_info["dev_id"],
} }
@property @property

View File

@ -3,14 +3,18 @@
from .aestransport import AesEncyptionSession, AesTransport from .aestransport import AesEncyptionSession, AesTransport
from .basetransport import BaseTransport from .basetransport import BaseTransport
from .klaptransport import KlapTransport, KlapTransportV2 from .klaptransport import KlapTransport, KlapTransportV2
from .linkietransport import LinkieTransportV2
from .ssltransport import SslTransport
from .xortransport import XorEncryption, XorTransport from .xortransport import XorEncryption, XorTransport
__all__ = [ __all__ = [
"AesTransport", "AesTransport",
"AesEncyptionSession", "AesEncyptionSession",
"SslTransport",
"BaseTransport", "BaseTransport",
"KlapTransport", "KlapTransport",
"KlapTransportV2", "KlapTransportV2",
"LinkieTransportV2",
"XorTransport", "XorTransport",
"XorEncryption", "XorEncryption",
] ]

View File

@ -0,0 +1,143 @@
"""Implementation of the linkie kasa camera transport."""
from __future__ import annotations
import asyncio
import base64
import logging
import ssl
from typing import TYPE_CHECKING, cast
from urllib.parse import quote
from yarl import URL
from kasa.credentials import DEFAULT_CREDENTIALS, get_default_credentials
from kasa.deviceconfig import DeviceConfig
from kasa.exceptions import KasaException, _RetryableError
from kasa.httpclient import HttpClient
from kasa.json import loads as json_loads
from kasa.transports.xortransport import XorEncryption
from .basetransport import BaseTransport
_LOGGER = logging.getLogger(__name__)
class LinkieTransportV2(BaseTransport):
"""Implementation of the Linkie encryption protocol.
Linkie is used as the endpoint for TP-Link's camera encryption
protocol, used by newer firmware versions.
"""
DEFAULT_PORT: int = 10443
CIPHERS = ":".join(
[
"AES256-GCM-SHA384",
"AES256-SHA256",
"AES128-GCM-SHA256",
"AES128-SHA256",
"AES256-SHA",
]
)
def __init__(self, *, config: DeviceConfig) -> None:
super().__init__(config=config)
self._http_client = HttpClient(config)
self._ssl_context: ssl.SSLContext | None = None
self._app_url = URL(f"https://{self._host}:{self._port}/data/LINKIE2.json")
self._headers = {
"Authorization": f"Basic {self.credentials_hash}",
"Content-Type": "application/x-www-form-urlencoded",
}
@property
def default_port(self) -> int:
"""Default port for the transport."""
return self.DEFAULT_PORT
@property
def credentials_hash(self) -> str | None:
"""The hashed credentials used by the transport."""
creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"])
creds_combined = f"{creds.username}:{creds.password}"
return base64.b64encode(creds_combined.encode()).decode()
async def _execute_send(self, request: str) -> dict:
"""Execute a query on the device and wait for the response."""
_LOGGER.debug("%s >> %s", self._host, request)
encrypted_cmd = XorEncryption.encrypt(request)[4:]
b64_cmd = base64.b64encode(encrypted_cmd).decode()
url_safe_cmd = quote(b64_cmd, safe="!~*'()")
status_code, response = await self._http_client.post(
self._app_url,
headers=self._headers,
data=f"content={url_safe_cmd}".encode(),
ssl=await self._get_ssl_context(),
)
if TYPE_CHECKING:
response = cast(bytes, response)
if status_code != 200:
raise KasaException(
f"{self._host} responded with an unexpected "
+ f"status code {status_code} to passthrough"
)
# Expected response
try:
json_payload: dict = json_loads(
XorEncryption.decrypt(base64.b64decode(response))
)
_LOGGER.debug("%s << %s", self._host, json_payload)
return json_payload
except Exception: # noqa: S110
pass
# Device returned error as json plaintext
to_raise: KasaException | None = None
try:
error_payload: dict = json_loads(response)
to_raise = KasaException(f"Device {self._host} send error: {error_payload}")
except Exception as ex:
raise KasaException("Unable to read response") from ex
raise to_raise
async def close(self) -> None:
"""Close the http client and reset internal state."""
await self._http_client.close()
async def reset(self) -> None:
"""Reset the transport.
NOOP for this transport.
"""
async def send(self, request: str) -> dict:
"""Send a message to the device and return a response."""
try:
return await self._execute_send(request)
except Exception as ex:
await self.reset()
raise _RetryableError(
f"Unable to query the device {self._host}:{self._port}: {ex}"
) from ex
async def _get_ssl_context(self) -> ssl.SSLContext:
if not self._ssl_context:
loop = asyncio.get_running_loop()
self._ssl_context = await loop.run_in_executor(
None, self._create_ssl_context
)
return self._ssl_context
def _create_ssl_context(self) -> ssl.SSLContext:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.set_ciphers(self.CIPHERS)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context

View File

@ -0,0 +1,233 @@
"""Implementation of the clear-text passthrough ssl transport.
This transport does not encrypt the passthrough payloads at all, but requires a login.
This has been seen on some devices (like robovacs).
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
import time
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, cast
from yarl import URL
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
from kasa.deviceconfig import DeviceConfig
from kasa.exceptions import (
SMART_AUTHENTICATION_ERRORS,
SMART_RETRYABLE_ERRORS,
AuthenticationError,
DeviceError,
KasaException,
SmartErrorCode,
_RetryableError,
)
from kasa.httpclient import HttpClient
from kasa.json import dumps as json_dumps
from kasa.json import loads as json_loads
from kasa.transports import BaseTransport
_LOGGER = logging.getLogger(__name__)
ONE_DAY_SECONDS = 86400
SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20
def _md5_hash(payload: bytes) -> str:
return hashlib.md5(payload).hexdigest().upper() # noqa: S324
class TransportState(Enum):
"""Enum for transport state."""
LOGIN_REQUIRED = auto() # Login needed
ESTABLISHED = auto() # Ready to send requests
class SslTransport(BaseTransport):
"""Implementation of the cleartext transport protocol.
This transport uses HTTPS without any further payload encryption.
"""
DEFAULT_PORT: int = 4433
COMMON_HEADERS = {
"Content-Type": "application/json",
}
BACKOFF_SECONDS_AFTER_LOGIN_ERROR = 1
def __init__(
self,
*,
config: DeviceConfig,
) -> None:
super().__init__(config=config)
if (
not self._credentials or self._credentials.username is None
) and not self._credentials_hash:
self._credentials = Credentials()
if self._credentials:
self._login_params = self._get_login_params(self._credentials)
else:
self._login_params = json_loads(
base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr]
)
self._default_credentials: Credentials | None = None
self._http_client: HttpClient = HttpClient(config)
self._state = TransportState.LOGIN_REQUIRED
self._session_expire_at: float | None = None
self._app_url = URL(f"https://{self._host}:{self._port}/app")
_LOGGER.debug("Created ssltransport for %s", self._host)
@property
def default_port(self) -> int:
"""Default port for the transport."""
return self.DEFAULT_PORT
@property
def credentials_hash(self) -> str:
"""The hashed credentials used by the transport."""
return base64.b64encode(json_dumps(self._login_params).encode()).decode()
def _get_login_params(self, credentials: Credentials) -> dict[str, str]:
"""Get the login parameters based on the login_version."""
un, pw = self.hash_credentials(credentials)
return {"password": pw, "username": un}
@staticmethod
def hash_credentials(credentials: Credentials) -> tuple[str, str]:
"""Hash the credentials."""
un = credentials.username
pw = _md5_hash(credentials.password.encode())
return un, pw
async def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None:
"""Handle response errors to request reauth etc."""
error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type]
if error_code == SmartErrorCode.SUCCESS:
return
msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})"
if error_code in SMART_RETRYABLE_ERRORS:
raise _RetryableError(msg, error_code=error_code)
if error_code in SMART_AUTHENTICATION_ERRORS:
await self.reset()
raise AuthenticationError(msg, error_code=error_code)
raise DeviceError(msg, error_code=error_code)
async def send_request(self, request: str) -> dict[str, Any]:
"""Send request."""
url = self._app_url
_LOGGER.debug("Sending %s to %s", request, url)
status_code, resp_dict = await self._http_client.post(
url,
json=request,
headers=self.COMMON_HEADERS,
)
if status_code != 200:
raise KasaException(
f"{self._host} responded with an unexpected "
+ f"status code {status_code}"
)
_LOGGER.debug("Response with %s: %r", status_code, resp_dict)
await self._handle_response_error_code(resp_dict, "Error sending request")
if TYPE_CHECKING:
resp_dict = cast(dict[str, Any], resp_dict)
return resp_dict
async def perform_login(self) -> None:
"""Login to the device."""
try:
await self.try_login(self._login_params)
except AuthenticationError as aex:
try:
if aex.error_code is not SmartErrorCode.LOGIN_ERROR:
raise aex
_LOGGER.debug("Login failed, going to try default credentials")
if self._default_credentials is None:
self._default_credentials = get_default_credentials(
DEFAULT_CREDENTIALS["TAPO"]
)
await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_LOGIN_ERROR)
await self.try_login(self._get_login_params(self._default_credentials))
_LOGGER.debug(
"%s: logged in with default credentials",
self._host,
)
except AuthenticationError:
raise
except Exception as ex:
raise KasaException(
"Unable to login and trying default "
+ f"login raised another exception: {ex}",
ex,
) from ex
async def try_login(self, login_params: dict[str, Any]) -> None:
"""Try to login with supplied login_params."""
login_request = {
"method": "login",
"params": login_params,
}
request = json_dumps(login_request)
_LOGGER.debug("Going to send login request")
resp_dict = await self.send_request(request)
await self._handle_response_error_code(resp_dict, "Error logging in")
login_token = resp_dict["result"]["token"]
self._app_url = self._app_url.with_query(f"token={login_token}")
self._state = TransportState.ESTABLISHED
self._session_expire_at = (
time.time() + ONE_DAY_SECONDS - SESSION_EXPIRE_BUFFER_SECONDS
)
def _session_expired(self) -> bool:
"""Return true if session has expired."""
return (
self._session_expire_at is None
or self._session_expire_at - time.time() <= 0
)
async def send(self, request: str) -> dict[str, Any]:
"""Send the request."""
_LOGGER.info("Going to send %s", request)
if self._state is not TransportState.ESTABLISHED or self._session_expired():
_LOGGER.debug("Transport not established or session expired, logging in")
await self.perform_login()
return await self.send_request(request)
async def close(self) -> None:
"""Close the http client and reset internal state."""
await self.reset()
await self._http_client.close()
async def reset(self) -> None:
"""Reset internal login state."""
self._state = TransportState.LOGIN_REQUIRED
self._app_url = URL(f"https://{self._host}:{self._port}/app")

View File

@ -1,6 +1,6 @@
[project] [project]
name = "python-kasa" name = "python-kasa"
version = "0.7.7" version = "0.8.1"
description = "Python API for TP-Link Kasa and Tapo devices" description = "Python API for TP-Link Kasa and Tapo devices"
license = {text = "GPL-3.0-or-later"} license = {text = "GPL-3.0-or-later"}
authors = [ { name = "python-kasa developers" }] authors = [ { name = "python-kasa developers" }]
@ -25,7 +25,13 @@ classifiers = [
[project.optional-dependencies] [project.optional-dependencies]
speedups = ["orjson>=3.9.1", "kasa-crypt>=0.2.0"] speedups = ["orjson>=3.9.1", "kasa-crypt>=0.2.0"]
docs = ["sphinx~=6.2", "sphinx_rtd_theme~=2.0", "sphinxcontrib-programoutput~=0.0", "myst-parser", "docutils>=0.17"] docs = [
"sphinx_rtd_theme~=2.0",
"sphinxcontrib-programoutput~=0.0",
"myst-parser",
"docutils>=0.17",
"sphinx>=7.4.7",
]
shell = ["ptpython", "rich"] shell = ["ptpython", "rich"]
[project.urls] [project.urls]

View File

@ -98,6 +98,7 @@ PLUGS = {
SWITCHES_IOT = { SWITCHES_IOT = {
"HS200", "HS200",
"HS210", "HS210",
"KS200",
"KS200M", "KS200M",
} }
SWITCHES_SMART = { SWITCHES_SMART = {
@ -217,6 +218,9 @@ no_emeter = parametrize(
model_filter=ALL_DEVICES - WITH_EMETER, model_filter=ALL_DEVICES - WITH_EMETER,
protocol_filter={"SMART", "IOT"}, protocol_filter={"SMART", "IOT"},
) )
has_emeter_smart = parametrize(
"has emeter smart", model_filter=WITH_EMETER_SMART, protocol_filter={"SMART"}
)
has_emeter_iot = parametrize( has_emeter_iot = parametrize(
"has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"} "has emeter iot", model_filter=WITH_EMETER_IOT, protocol_filter={"IOT"}
) )

View File

@ -22,6 +22,29 @@ class DiscoveryResponse(TypedDict):
error_code: int error_code: int
UNSUPPORTED_HOMEWIFISYSTEM = {
"error_code": 0,
"result": {
"channel_2g": "10",
"channel_5g": "44",
"device_id": "REDACTED_51f72a752213a6c45203530",
"device_model": "X20",
"device_type": "HOMEWIFISYSTEM",
"factory_default": False,
"group_id": "REDACTED_07d902da02fa9beab8a64",
"group_name": "I01BU0tFRF9TU0lEIw==", # '#MASKED_SSID#'
"hardware_version": "3.0",
"ip": "192.168.1.192",
"mac": "24:2F:D0:00:00:00",
"master_device_id": "REDACTED_51f72a752213a6c45203530",
"need_account_digest": True,
"owner": "REDACTED_341c020d7e8bda184e56a90",
"role": "master",
"tmp_port": [20001],
},
}
def _make_unsupported( def _make_unsupported(
device_family, device_family,
encrypt_type, encrypt_type,
@ -75,13 +98,14 @@ UNSUPPORTED_DEVICES = {
"unable_to_parse": _make_unsupported( "unable_to_parse": _make_unsupported(
"SMART.TAPOBULB", "SMART.TAPOBULB",
"FOO", "FOO",
omit_keys={"mgt_encrypt_schm": None}, omit_keys={"device_id": None},
), ),
"invalidinstance": _make_unsupported( "invalidinstance": _make_unsupported(
"IOT.SMARTPLUGSWITCH", "IOT.SMARTPLUGSWITCH",
"KLAP", "KLAP",
https=True, https=True,
), ),
"homewifi": UNSUPPORTED_HOMEWIFISYSTEM,
} }
@ -106,6 +130,8 @@ new_discovery = parametrize_discovery(
"new discovery", data_root_filter="discovery_result" "new discovery", data_root_filter="discovery_result"
) )
smart_discovery = parametrize_discovery("smart discovery", protocol_filter={"SMART"})
@pytest.fixture( @pytest.fixture(
params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}),

View File

@ -449,6 +449,17 @@ class FakeSmartTransport(BaseTransport):
info["get_preset_rules"]["states"][params["index"]] = params["state"] info["get_preset_rules"]["states"][params["index"]] = params["state"]
return {"error_code": 0} return {"error_code": 0}
def _set_temperature_unit(self, info, params):
"""Set or remove values as per the device behaviour."""
unit = params["temp_unit"]
if unit not in {"celsius", "fahrenheit"}:
raise ValueError(f"Invalid value for temperature unit {unit}")
if "temp_unit" not in info["get_device_info"]:
return {"error_code": SmartErrorCode.UNKNOWN_METHOD_ERROR}
else:
info["get_device_info"]["temp_unit"] = unit
return {"error_code": 0}
def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict: def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict:
"""Update a single key in the main system info. """Update a single key in the main system info.
@ -551,6 +562,8 @@ class FakeSmartTransport(BaseTransport):
return self._set_preset_rules(info, params) return self._set_preset_rules(info, params)
elif method == "edit_preset_rules": elif method == "edit_preset_rules":
return self._edit_preset_rules(info, params) return self._edit_preset_rules(info, params)
elif method == "set_temperature_unit":
return self._set_temperature_unit(info, params)
elif method == "set_on_off_gradually_info": elif method == "set_on_off_gradually_info":
return self._set_on_off_gradually_info(info, params) return self._set_on_off_gradually_info(info, params)
elif method == "set_child_protection": elif method == "set_child_protection":

View File

@ -0,0 +1,63 @@
{
"cnCloud": {
"get_info": {
"binded": 1,
"cld_connection": 1,
"err_code": 0,
"fwDlPage": "",
"fwNotifyType": -1,
"illegalType": 0,
"server": "n-devs.tplinkcloud.com",
"stopConnect": 0,
"tcspInfo": "",
"tcspStatus": 1,
"username": "#MASKED_NAME#"
},
"get_intl_fw_list": {
"err_code": 0,
"fw_list": []
}
},
"schedule": {
"get_next_action": {
"err_code": 0,
"type": -1
},
"get_rules": {
"enable": 1,
"err_code": 0,
"rule_list": [],
"version": 2
}
},
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Light Switch",
"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": "A8:42:A1:00:00:00",
"mic_type": "IOT.SMARTPLUGSWITCH",
"model": "KS200(US)",
"next_action": {
"type": -1
},
"obd_src": "tplink",
"oemId": "00000000000000000000000000000000",
"on_time": 0,
"relay_state": 0,
"rssi": -46,
"status": "new",
"sw_ver": "1.0.8 Build 240424 Rel.101842",
"updating": 0
}
}
}

View File

@ -0,0 +1,86 @@
{
"emeter": {
"get_realtime": {
"err_code": -10008,
"err_msg": "Unsupported API call."
}
},
"smartlife.iot.LAS": {
"get_current_brt": {
"err_code": -10008,
"err_msg": "Unsupported API call."
}
},
"smartlife.iot.PIR": {
"get_config": {
"err_code": -10008,
"err_msg": "Unsupported API call."
}
},
"smartlife.iot.common.emeter": {
"get_realtime": {
"err_code": -10008,
"err_msg": "Unsupported API call."
}
},
"smartlife.iot.dimmer": {
"get_dimmer_parameters": {
"err_code": -10008,
"err_msg": "Unsupported API call."
}
},
"smartlife.iot.smartbulb.lightingservice": {
"get_light_state": {
"err_code": -10008,
"err_msg": "Unsupported API call."
}
},
"system": {
"get_sysinfo": {
"err_code": 0,
"system": {
"a_type": 2,
"alias": "#MASKED_NAME#",
"bind_status": false,
"c_opt": [
0,
1
],
"camera_switch": "on",
"dev_name": "Kasa Spot, 24/7 Recording",
"deviceId": "0000000000000000000000000000000000000000",
"f_list": [
1,
2
],
"hwId": "00000000000000000000000000000000",
"hw_ver": "4.0",
"is_cal": 1,
"last_activity_timestamp": 0,
"latitude": 0,
"led_status": "on",
"longitude": 0,
"mac": "74:FE:CE:00:00:00",
"mic_mac": "74FECE000000",
"model": "EC60(US)",
"new_feature": [
2,
3,
4,
5,
7,
9
],
"oemId": "00000000000000000000000000000000",
"resolution": "720P",
"rssi": -28,
"status": "new",
"stream_version": 2,
"sw_ver": "2.3.22 Build 20230731 rel.69808",
"system_time": 1690827820,
"type": "IOT.IPCAMERA",
"updating": false
}
}
}
}

View File

@ -0,0 +1,614 @@
{
"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": "localSmart",
"ver_code": 1
},
{
"id": "energy_monitoring",
"ver_code": 2
},
{
"id": "power_protection",
"ver_code": 1
},
{
"id": "charging_protection",
"ver_code": 2
},
{
"id": "matter",
"ver_code": 2
},
{
"id": "current_protection",
"ver_code": 1
}
]
},
"discovery_result": {
"device_id": "00000000000000000000000000000000",
"device_model": "P110M(EU)",
"device_type": "SMART.TAPOPLUG",
"factory_default": false,
"ip": "127.0.0.123",
"is_support_iot_cloud": true,
"mac": "F0-A7-31-00-00-00",
"mgt_encrypt_schm": {
"encrypt_type": "KLAP",
"http_port": 80,
"is_support_https": false,
"lv": 2
},
"obd_src": "matter",
"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": false,
"random_range": 120,
"time": 180
},
"get_connect_cloud_state": {
"status": 1
},
"get_countdown_rules": {
"countdown_rule_max_count": 1,
"enable": false,
"rule_list": []
},
"get_current_power": {
"current_power": 0
},
"get_device_info": {
"auto_off_remain_time": 0,
"auto_off_status": "off",
"avatar": "",
"charging_status": "normal",
"default_states": {
"state": {},
"type": "last_states"
},
"device_id": "0000000000000000000000000000000000000000",
"device_on": false,
"fw_id": "00000000000000000000000000000000",
"fw_ver": "1.2.3 Build 240617 Rel.153525",
"has_set_location_info": false,
"hw_id": "00000000000000000000000000000000",
"hw_ver": "1.0",
"ip": "127.0.0.123",
"lang": "",
"latitude": 0,
"longitude": 0,
"mac": "F0-A7-31-00-00-00",
"model": "P110M",
"nickname": "I01BU0tFRF9OQU1FIw==",
"oem_id": "00000000000000000000000000000000",
"on_time": 0,
"overcurrent_status": "normal",
"overheat_status": "normal",
"power_protection_status": "normal",
"region": "CET",
"rssi": -33,
"signal_level": 3,
"specs": "",
"ssid": "I01BU0tFRF9TU0lEIw==",
"time_diff": 60,
"type": "SMART.TAPOPLUG"
},
"get_device_time": {
"region": "CET",
"time_diff": 60,
"timestamp": 1732361090
},
"get_device_usage": {
"power_usage": {
"past30": 7892,
"past7": 1549,
"today": 0
},
"saved_power": {
"past30": 9381,
"past7": 1362,
"today": 0
},
"time_usage": {
"past30": 17273,
"past7": 2911,
"today": 0
}
},
"get_electricity_price_config": {
"constant_price": 0,
"time_of_use_config": {
"summer": {
"midpeak": 0,
"offpeak": 0,
"onpeak": 0,
"period": [
0,
0,
0,
0
],
"weekday_config": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"weekend_config": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
]
},
"winter": {
"midpeak": 0,
"offpeak": 0,
"onpeak": 0,
"period": [
0,
0,
0,
0
],
"weekday_config": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"weekend_config": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
]
}
},
"type": "constant"
},
"get_emeter_data": {
"current_ma": 0,
"energy_wh": 1469,
"power_mw": 0,
"voltage_mv": 233509
},
"get_emeter_vgain_igain": {
"igain": 11299,
"vgain": 124300
},
"get_energy_usage": {
"current_power": 0,
"electricity_charge": [
0,
0,
0
],
"local_time": "2024-11-23 12:24:51",
"month_energy": 6266,
"month_runtime": 12705,
"today_energy": 0,
"today_runtime": 0
},
"get_fw_download_state": {
"auto_upgrade": false,
"download_progress": 0,
"reboot_time": 5,
"status": 0,
"upgrade_time": 5
},
"get_led_info": {
"bri_config": {
"bri_type": "overall",
"overall_bri": 50
},
"led_rule": "always",
"led_status": false,
"night_mode": {
"end_time": 420,
"night_mode_type": "sunrise_sunset",
"start_time": 1140,
"sunrise_offset": 0,
"sunset_offset": 0
}
},
"get_matter_setup_info": {
"setup_code": "00000000000",
"setup_payload": "00:000000-000000000000"
},
"get_max_power": {
"max_power": 3896
},
"get_next_event": {},
"get_protection_power": {
"enabled": false,
"protection_power": 0
},
"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": 2,
"ssid": "I01BU0tFRF9TU0lEIw=="
},
{
"bssid": "000000000000",
"channel": 0,
"cipher_type": 0,
"key_type": "none",
"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": 0,
"key_type": "none",
"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": 0,
"key_type": "none",
"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": 0,
"key_type": "none",
"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": 22,
"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": "P110M",
"device_type": "SMART.TAPOPLUG",
"is_klap": true
}
}
}

View File

@ -0,0 +1,640 @@
{
"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": "localSmart",
"ver_code": 1
},
{
"id": "energy_monitoring",
"ver_code": 2
},
{
"id": "power_protection",
"ver_code": 1
},
{
"id": "charging_protection",
"ver_code": 2
},
{
"id": "overheat_protection",
"ver_code": 1
},
{
"id": "current_protection",
"ver_code": 1
}
]
},
"discovery_result": {
"device_id": "00000000000000000000000000000000",
"device_model": "P115(US)",
"device_type": "SMART.TAPOPLUG",
"factory_default": false,
"ip": "127.0.0.123",
"is_support_iot_cloud": true,
"mac": "B0-19-21-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_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_current_power": {
"current_power": 0
},
"get_device_info": {
"auto_off_remain_time": 0,
"auto_off_status": "off",
"avatar": "plug",
"charging_status": "normal",
"default_states": {
"state": {},
"type": "last_states"
},
"device_id": "0000000000000000000000000000000000000000",
"device_on": false,
"fw_id": "00000000000000000000000000000000",
"fw_ver": "1.1.3 Build 240523 Rel.175054",
"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": "B0-19-21-00-00-00",
"model": "P115",
"nickname": "I01BU0tFRF9OQU1FIw==",
"oem_id": "00000000000000000000000000000000",
"on_time": 0,
"overcurrent_status": "normal",
"overheat_status": "normal",
"power_protection_status": "normal",
"region": "America/Indiana/Indianapolis",
"rssi": -54,
"signal_level": 2,
"specs": "US",
"ssid": "I01BU0tFRF9TU0lEIw==",
"time_diff": -300,
"type": "SMART.TAPOPLUG"
},
"get_device_time": {
"region": "America/Indiana/Indianapolis",
"time_diff": -300,
"timestamp": 1733673137
},
"get_device_usage": {
"power_usage": {
"past30": 4376,
"past7": 1879,
"today": 0
},
"saved_power": {
"past30": 8618,
"past7": 69,
"today": 0
},
"time_usage": {
"past30": 12994,
"past7": 1948,
"today": 0
}
},
"get_electricity_price_config": {
"constant_price": 0,
"time_of_use_config": {
"summer": {
"midpeak": 0,
"offpeak": 0,
"onpeak": 0,
"period": [
0,
0,
0,
0
],
"weekday_config": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"weekend_config": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
]
},
"winter": {
"midpeak": 0,
"offpeak": 0,
"onpeak": 0,
"period": [
0,
0,
0,
0
],
"weekday_config": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
"weekend_config": [
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
]
}
},
"type": "constant"
},
"get_emeter_data": {
"current_ma": 30,
"energy_wh": 1465,
"power_mw": 0,
"voltage_mv": 122133
},
"get_emeter_vgain_igain": {
"igain": 11101,
"vgain": 125071
},
"get_energy_usage": {
"current_power": 0,
"electricity_charge": [
0,
0,
0
],
"local_time": "2024-12-08 10:52:19",
"month_energy": 2532,
"month_runtime": 2630,
"today_energy": 0,
"today_runtime": 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.3 Build 240523 Rel.175054",
"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": 476,
"night_mode_type": "sunrise_sunset",
"start_time": 1040,
"sunrise_offset": 0,
"sunset_offset": 0
}
},
"get_max_power": {
"max_power": 1934
},
"get_next_event": {},
"get_protection_power": {
"enabled": false,
"protection_power": 0
},
"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": 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": 0,
"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": 0,
"key_type": "none",
"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": 25,
"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": "iot_cloud",
"ver_code": 1
},
{
"id": "inherit",
"ver_code": 1
},
{
"id": "firmware",
"ver_code": 2
}
],
"extra_info": {
"device_model": "P115",
"device_type": "SMART.TAPOPLUG",
"is_klap": true
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -12,25 +12,23 @@ from voluptuous import (
from kasa import Device, DeviceType, EmeterStatus, Module from kasa import Device, DeviceType, EmeterStatus, Module
from kasa.interfaces.energy import Energy from kasa.interfaces.energy import Energy
from kasa.iot import IotDevice, IotStrip from kasa.iot import IotStrip
from kasa.iot.modules.emeter import Emeter from kasa.iot.modules.emeter import Emeter
from kasa.smart import SmartDevice from tests.conftest import has_emeter_iot, no_emeter_iot
from kasa.smart.modules import Energy as SmartEnergyModule
from kasa.smart.smartmodule import SmartModule
from .conftest import has_emeter, has_emeter_iot, no_emeter
CURRENT_CONSUMPTION_SCHEMA = Schema( CURRENT_CONSUMPTION_SCHEMA = Schema(
Any( Any(
{ {
"voltage": Any(All(float, Range(min=0, max=300)), None),
"power": Any(Coerce(float), None),
"total": Any(Coerce(float), None),
"current": Any(All(float), None),
"voltage_mv": Any(All(float, Range(min=0, max=300000)), int, None), "voltage_mv": Any(All(float, Range(min=0, max=300000)), int, None),
"power_mw": Any(Coerce(float), None), "power_mw": Any(Coerce(float), None),
"total_wh": Any(Coerce(float), None),
"current_ma": Any(All(float), int, None), "current_ma": Any(All(float), int, None),
"energy_wh": Any(Coerce(float), None),
"total_wh": Any(Coerce(float), None),
"voltage": Any(All(float, Range(min=0, max=300)), None),
"power": Any(Coerce(float), None),
"current": Any(All(float), None),
"total": Any(Coerce(float), None),
"energy": Any(Coerce(float), None),
"slot_id": Any(Coerce(int), None), "slot_id": Any(Coerce(int), None),
}, },
None, None,
@ -38,33 +36,30 @@ CURRENT_CONSUMPTION_SCHEMA = Schema(
) )
@no_emeter @no_emeter_iot
async def test_no_emeter(dev): async def test_no_emeter(dev):
assert not dev.has_emeter assert not dev.has_emeter
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
await dev.get_emeter_realtime() await dev.get_emeter_realtime()
# Only iot devices support the historical stats so other
# devices will not implement the methods below with pytest.raises(AttributeError):
if isinstance(dev, IotDevice): await dev.get_emeter_daily()
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
await dev.get_emeter_daily() await dev.get_emeter_monthly()
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
await dev.get_emeter_monthly() await dev.erase_emeter_stats()
with pytest.raises(AttributeError):
await dev.erase_emeter_stats()
@has_emeter @has_emeter_iot
async def test_get_emeter_realtime(dev): async def test_get_emeter_realtime(dev):
if isinstance(dev, SmartDevice):
mod = SmartEnergyModule(dev, str(Module.Energy))
if not await mod._check_supported():
pytest.skip(f"Energy module not supported for {dev}.")
emeter = dev.modules[Module.Energy] emeter = dev.modules[Module.Energy]
current_emeter = await emeter.get_status() current_emeter = await emeter.get_status()
# Check realtime query gets the same value as status property
# iot _query_helper strips out the error code from module responses.
# but it's not stripped out of the _modular_update queries.
assert current_emeter == {k: v for k, v in emeter.status.items() if k != "err_code"}
CURRENT_CONSUMPTION_SCHEMA(current_emeter) CURRENT_CONSUMPTION_SCHEMA(current_emeter)
@ -130,7 +125,7 @@ async def test_emeter_status(dev):
@pytest.mark.skip("not clearing your stats..") @pytest.mark.skip("not clearing your stats..")
@has_emeter @has_emeter_iot
async def test_erase_emeter_stats(dev): async def test_erase_emeter_stats(dev):
emeter = dev.modules[Module.Energy] emeter = dev.modules[Module.Energy]
@ -185,37 +180,22 @@ async def test_emeter_daily():
assert emeter.consumption_today == 0.500 assert emeter.consumption_today == 0.500
@has_emeter @has_emeter_iot
async def test_supported(dev: Device): async def test_supported(dev: Device):
if isinstance(dev, SmartDevice):
mod = SmartEnergyModule(dev, str(Module.Energy))
if not await mod._check_supported():
pytest.skip(f"Energy module not supported for {dev}.")
energy_module = dev.modules.get(Module.Energy) energy_module = dev.modules.get(Module.Energy)
assert energy_module assert energy_module
if isinstance(dev, IotDevice): info = (
info = ( dev._last_update
dev._last_update if not isinstance(dev, IotStrip)
if not isinstance(dev, IotStrip) else dev.children[0].internal_state
else dev.children[0].internal_state )
) emeter = info[energy_module._module]["get_realtime"]
emeter = info[energy_module._module]["get_realtime"] has_total = "total" in emeter or "total_wh" in emeter
has_total = "total" in emeter or "total_wh" in emeter has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter
has_voltage_current = "voltage" in emeter or "voltage_mv" in emeter assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total
assert ( assert (
energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is has_total energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT)
) is has_voltage_current
assert ( )
energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True
is has_voltage_current
)
assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is True
else:
assert isinstance(energy_module, SmartModule)
assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False
assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False
if energy_module.supported_version < 2:
assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False
else:
assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True

320
tests/iot/test_iotbulb.py Normal file
View File

@ -0,0 +1,320 @@
from __future__ import annotations
import re
import pytest
from voluptuous import (
All,
Boolean,
Optional,
Range,
Schema,
)
from kasa import Device, IotLightPreset, KasaException, LightState, Module
from kasa.iot import IotBulb, IotDimmer
from kasa.iot.modules import LightPreset as IotLightPresetModule
from tests.conftest import (
bulb_iot,
color_bulb_iot,
dimmable_iot,
handle_turn_on,
non_dimmable_iot,
turn_on,
variable_temp_iot,
)
from tests.iot.test_iotdevice import SYSINFO_SCHEMA
@bulb_iot
async def test_bulb_sysinfo(dev: Device):
assert dev.sys_info is not None
SYSINFO_SCHEMA_BULB(dev.sys_info)
assert dev.model is not None
@bulb_iot
async def test_light_state_without_update(dev: IotBulb, monkeypatch):
monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None)
with pytest.raises(KasaException):
print(dev.light_state)
@bulb_iot
async def test_get_light_state(dev: IotBulb):
LIGHT_STATE_SCHEMA(await dev.get_light_state())
@color_bulb_iot
async def test_set_hsv_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
light = dev.modules.get(Module.Light)
assert light
await light.set_hsv(10, 10, 100, transition=1000)
set_light_state.assert_called_with(
{"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0},
transition=1000,
)
@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)
@variable_temp_iot
async def test_set_color_temp_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
light = dev.modules.get(Module.Light)
assert light
await light.set_color_temp(2700, transition=100)
set_light_state.assert_called_with({"color_temp": 2700}, transition=100)
@variable_temp_iot
@pytest.mark.xdist_group(name="caplog")
async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
monkeypatch.setitem(dev._sys_info, "model", "unknown bulb")
light = dev.modules.get(Module.Light)
assert light
assert light.valid_temperature_range == (2700, 5000)
assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text
@dimmable_iot
@turn_on
async def test_dimmable_brightness(dev: IotBulb, turn_on):
assert isinstance(dev, IotBulb | IotDimmer)
light = dev.modules.get(Module.Light)
assert light
await handle_turn_on(dev, turn_on)
assert dev._is_dimmable
await light.set_brightness(50)
await dev.update()
assert light.brightness == 50
await light.set_brightness(10)
await dev.update()
assert light.brightness == 10
with pytest.raises(TypeError, match="Brightness must be an integer"):
await light.set_brightness("foo") # type: ignore[arg-type]
@bulb_iot
async def test_turn_on_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
await dev.turn_on(transition=1000)
set_light_state.assert_called_with({"on_off": 1}, transition=1000)
await dev.turn_off(transition=100)
set_light_state.assert_called_with({"on_off": 0}, transition=100)
@bulb_iot
async def test_dimmable_brightness_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
light = dev.modules.get(Module.Light)
assert light
await light.set_brightness(10, transition=1000)
set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000)
@dimmable_iot
async def test_invalid_brightness(dev: IotBulb):
assert dev._is_dimmable
light = dev.modules.get(Module.Light)
assert light
with pytest.raises(
ValueError,
match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"),
):
await light.set_brightness(110)
with pytest.raises(
ValueError,
match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"),
):
await light.set_brightness(-100)
@non_dimmable_iot
async def test_non_dimmable(dev: IotBulb):
assert not dev._is_dimmable
light = dev.modules.get(Module.Light)
assert light
with pytest.raises(KasaException):
assert light.brightness == 0
with pytest.raises(KasaException):
await light.set_brightness(100)
@bulb_iot
async def test_ignore_default_not_set_without_color_mode_change_turn_on(
dev: IotBulb, mocker
):
query_helper = mocker.patch("kasa.iot.IotBulb._query_helper")
# When turning back without settings, ignore default to restore the state
await dev.turn_on()
args, kwargs = query_helper.call_args_list[0]
assert args[2] == {"on_off": 1, "ignore_default": 0}
await dev.turn_off()
args, kwargs = query_helper.call_args_list[1]
assert args[2] == {"on_off": 0, "ignore_default": 1}
@bulb_iot
async def test_list_presets(dev: IotBulb):
light_preset = dev.modules.get(Module.LightPreset)
assert light_preset
assert isinstance(light_preset, IotLightPresetModule)
presets = light_preset._deprecated_presets
# Light strip devices may list some light effects along with normal presets but these
# are handled by the LightEffect module so exclude preferred states with id
raw_presets = [
pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate
]
assert len(presets) == len(raw_presets)
for preset, raw in zip(presets, raw_presets, strict=False):
assert preset.index == raw["index"]
assert preset.brightness == raw["brightness"]
assert preset.hue == raw["hue"]
assert preset.saturation == raw["saturation"]
assert preset.color_temp == raw["color_temp"]
@bulb_iot
async def test_modify_preset(dev: IotBulb, mocker):
"""Verify that modifying preset calls the and exceptions are raised properly."""
if (
not (light_preset := dev.modules.get(Module.LightPreset))
or not light_preset._deprecated_presets
):
pytest.skip("Some strips do not support presets")
assert isinstance(light_preset, IotLightPresetModule)
data: dict[str, int | None] = {
"index": 0,
"brightness": 10,
"hue": 0,
"saturation": 0,
"color_temp": 0,
}
preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type]
assert preset.index == 0
assert preset.brightness == 10
assert preset.hue == 0
assert preset.saturation == 0
assert preset.color_temp == 0
await light_preset._deprecated_save_preset(preset)
await dev.update()
assert light_preset._deprecated_presets[0].brightness == 10
with pytest.raises(KasaException):
await light_preset._deprecated_save_preset(
IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg]
)
@bulb_iot
@pytest.mark.parametrize(
("preset", "payload"),
[
(
IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg]
{"index": 0, "hue": 0, "brightness": 1, "saturation": 0},
),
(
IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg]
{"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0},
),
],
)
async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker):
"""Test that modify preset payloads ignore none values."""
if (
not (light_preset := dev.modules.get(Module.LightPreset))
or not light_preset._deprecated_presets
):
pytest.skip("Some strips do not support presets")
query_helper = mocker.patch("kasa.iot.IotBulb._query_helper")
await light_preset._deprecated_save_preset(preset)
query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload)
LIGHT_STATE_SCHEMA = Schema(
{
"brightness": All(int, Range(min=0, max=100)),
"color_temp": int,
"hue": All(int, Range(min=0, max=360)),
"mode": str,
"on_off": Boolean,
"saturation": All(int, Range(min=0, max=100)),
"length": Optional(int),
"transition": Optional(int),
"dft_on_state": Optional(
{
"brightness": All(int, Range(min=0, max=100)),
"color_temp": All(int, Range(min=0, max=9000)),
"hue": All(int, Range(min=0, max=360)),
"mode": str,
"saturation": All(int, Range(min=0, max=100)),
"groups": Optional(list[int]),
}
),
"err_code": int,
}
)
SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend(
{
"ctrl_protocols": Optional(dict),
"description": Optional(str), # Seen on LBxxx, similar to dev_name
"dev_state": str,
"disco_ver": str,
"heapsize": int,
"is_color": Boolean,
"is_dimmable": Boolean,
"is_factory": Boolean,
"is_variable_color_temp": Boolean,
"light_state": LIGHT_STATE_SCHEMA,
"preferred_state": [
{
"brightness": All(int, Range(min=0, max=100)),
"color_temp": int,
"hue": All(int, Range(min=0, max=360)),
"index": int,
"saturation": All(int, Range(min=0, max=100)),
}
],
}
)
@bulb_iot
async def test_turn_on_behaviours(dev: IotBulb):
behavior = await dev.get_turn_on_behavior()
assert behavior

View File

@ -19,10 +19,9 @@ from voluptuous import (
from kasa import DeviceType, KasaException, Module from kasa import DeviceType, KasaException, Module
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.iot.iotmodule import _merge_dict from kasa.iot.iotmodule import _merge_dict
from tests.conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on
from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on from tests.device_fixtures import device_iot, has_emeter_iot, no_emeter_iot
from .device_fixtures import device_iot, has_emeter_iot, no_emeter_iot from tests.fakeprotocol_iot import FakeIotProtocol
from .fakeprotocol_iot import FakeIotProtocol
TZ_SCHEMA = Schema( TZ_SCHEMA = Schema(
{"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str} {"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str}

View File

@ -2,8 +2,7 @@ import pytest
from kasa import DeviceType, Module from kasa import DeviceType, Module
from kasa.iot import IotDimmer from kasa.iot import IotDimmer
from tests.conftest import dimmer_iot, handle_turn_on, turn_on
from .conftest import dimmer_iot, handle_turn_on, turn_on
@dimmer_iot @dimmer_iot

View File

@ -3,8 +3,7 @@ import pytest
from kasa import DeviceType, Module from kasa import DeviceType, Module
from kasa.iot import IotLightStrip from kasa.iot import IotLightStrip
from kasa.iot.modules import LightEffect from kasa.iot.modules import LightEffect
from tests.conftest import lightstrip_iot
from .conftest import lightstrip_iot
@lightstrip_iot @lightstrip_iot

View File

@ -29,8 +29,8 @@ from kasa.transports.basetransport import BaseTransport
from kasa.transports.klaptransport import KlapTransport, KlapTransportV2 from kasa.transports.klaptransport import KlapTransport, KlapTransportV2
from kasa.transports.xortransport import XorEncryption, XorTransport from kasa.transports.xortransport import XorEncryption, XorTransport
from .conftest import device_iot from ..conftest import device_iot
from .fakeprotocol_iot import FakeIotTransport from ..fakeprotocol_iot import FakeIotTransport
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -12,8 +12,8 @@ from kasa.exceptions import (
from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from .conftest import device_smart from ..conftest import device_smart
from .fakeprotocol_smart import FakeSmartTransport from ..fakeprotocol_smart import FakeSmartTransport
DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}}
DUMMY_MULTIPLE_QUERY = { DUMMY_MULTIPLE_QUERY = {

View File

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import sys
from datetime import datetime from datetime import datetime
import pytest import pytest
@ -25,10 +24,6 @@ autooff = parametrize(
("auto_off_at", "auto_off_at", datetime | None), ("auto_off_at", "auto_off_at", datetime | None),
], ],
) )
@pytest.mark.skipif(
sys.version_info < (3, 10),
reason="Subscripted generics cannot be used with class and instance checks",
)
async def test_autooff_features( async def test_autooff_features(
dev: SmartDevice, feature: str, prop_name: str, type: type dev: SmartDevice, feature: str, prop_name: str, type: type
): ):

View File

@ -0,0 +1,21 @@
import pytest
from kasa import Module, SmartDevice
from kasa.interfaces.energy import Energy
from kasa.smart.modules import Energy as SmartEnergyModule
from tests.conftest import has_emeter_smart
@has_emeter_smart
async def test_supported(dev: SmartDevice):
energy_module = dev.modules.get(Module.Energy)
if not energy_module:
pytest.skip(f"Energy module not supported for {dev}.")
assert isinstance(energy_module, SmartEnergyModule)
assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False
assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False
if energy_module.supported_version < 2:
assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False
else:
assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import copy
import logging import logging
import time import time
from typing import Any, cast from typing import Any, cast
@ -11,18 +12,20 @@ import pytest
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import Device, KasaException, Module from kasa import Device, DeviceType, KasaException, Module
from kasa.exceptions import DeviceError, SmartErrorCode from kasa.exceptions import DeviceError, SmartErrorCode
from kasa.protocols.smartprotocol import _ChildProtocolWrapper from kasa.protocols.smartprotocol import _ChildProtocolWrapper
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from kasa.smart.modules.energy import Energy from kasa.smart.modules.energy import Energy
from kasa.smart.smartmodule import SmartModule from kasa.smart.smartmodule import SmartModule
from tests.conftest import (
from .conftest import ( DISCOVERY_MOCK_IP,
device_smart, device_smart,
get_device_for_fixture_protocol, get_device_for_fixture_protocol,
get_parent_and_child_modules, get_parent_and_child_modules,
smart_discovery,
) )
from tests.device_fixtures import variable_temp_smart
@device_smart @device_smart
@ -51,6 +54,31 @@ async def test_update_no_device_info(dev: SmartDevice, mocker: MockerFixture):
await dev.update() await dev.update()
@smart_discovery
async def test_device_type_no_update(discovery_mock, caplog: pytest.LogCaptureFixture):
"""Test device type and repr when device not updated."""
dev = SmartDevice(DISCOVERY_MOCK_IP)
assert dev.device_type is DeviceType.Unknown
assert repr(dev) == f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - update() needed>"
discovery_result = copy.deepcopy(discovery_mock.discovery_data["result"])
dev.update_from_discover_info(discovery_result)
assert dev.device_type is DeviceType.Unknown
assert (
repr(dev)
== f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - None (None) - update() needed>"
)
discovery_result["device_type"] = "SMART.FOOBAR"
dev.update_from_discover_info(discovery_result)
dev._components = {"dummy": 1}
assert dev.device_type is DeviceType.Plug
assert (
repr(dev)
== f"<DeviceType.Plug at {DISCOVERY_MOCK_IP} - None (None) - update() needed>"
)
assert "Unknown device type, falling back to plug" in caplog.text
@device_smart @device_smart
async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): async def test_initial_update(dev: SmartDevice, mocker: MockerFixture):
"""Test the initial update cycle.""" """Test the initial update cycle."""
@ -435,3 +463,68 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt
): ):
await new_dev.update() await new_dev.update()
assert new_dev.is_cloud_connected is False assert new_dev.is_cloud_connected is False
@variable_temp_smart
async def test_smart_temp_range(dev: Device):
light = dev.modules.get(Module.Light)
assert light
assert light.valid_temperature_range
@device_smart
async def test_initialize_modules_sysinfo_lookup_keys(
dev: SmartDevice, mocker: MockerFixture
):
"""Test that matching modules using SYSINFO_LOOKUP_KEYS are initialized correctly."""
class AvailableKey(SmartModule):
SYSINFO_LOOKUP_KEYS = ["device_id"]
class NonExistingKey(SmartModule):
SYSINFO_LOOKUP_KEYS = ["this_does_not_exist"]
# The __init_subclass__ hook in smartmodule checks the path,
# so we have to manually add these for testing.
mocker.patch.dict(
"kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES",
{
AvailableKey._module_name(): AvailableKey,
NonExistingKey._module_name(): NonExistingKey,
},
)
# We have an already initialized device, so we try to initialize the modules again
await dev._initialize_modules()
assert "AvailableKey" in dev.modules
assert "NonExistingKey" not in dev.modules
@device_smart
async def test_initialize_modules_required_component(
dev: SmartDevice, mocker: MockerFixture
):
"""Test that matching modules using REQUIRED_COMPONENT are initialized correctly."""
class AvailableComponent(SmartModule):
REQUIRED_COMPONENT = "device"
class NonExistingComponent(SmartModule):
REQUIRED_COMPONENT = "this_does_not_exist"
# The __init_subclass__ hook in smartmodule checks the path,
# so we have to manually add these for testing.
mocker.patch.dict(
"kasa.smart.smartmodule.SmartModule.REGISTERED_MODULES",
{
AvailableComponent._module_name(): AvailableComponent,
NonExistingComponent._module_name(): NonExistingComponent,
},
)
# We have an already initialized device, so we try to initialize the modules again
await dev._initialize_modules()
assert "AvailableComponent" in dev.modules
assert "NonExistingComponent" not in dev.modules

View File

View File

@ -4,15 +4,13 @@ from __future__ import annotations
import base64 import base64
import json import json
from datetime import UTC, datetime
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from freezegun.api import FrozenDateTimeFactory
from kasa import Credentials, Device, DeviceType, Module from kasa import Credentials, Device, DeviceType, Module, StreamResolution
from ..conftest import camera_smartcam, device_smartcam, hub_smartcam from ...conftest import camera_smartcam, device_smartcam
@device_smartcam @device_smartcam
@ -37,6 +35,16 @@ async def test_stream_rtsp_url(dev: Device):
url = camera_module.stream_rtsp_url(Credentials("foo", "bar")) url = camera_module.stream_rtsp_url(Credentials("foo", "bar"))
assert url == "rtsp://foo:bar@127.0.0.123:554/stream1" assert url == "rtsp://foo:bar@127.0.0.123:554/stream1"
url = camera_module.stream_rtsp_url(
Credentials("foo", "bar"), stream_resolution=StreamResolution.HD
)
assert url == "rtsp://foo:bar@127.0.0.123:554/stream1"
url = camera_module.stream_rtsp_url(
Credentials("foo", "bar"), stream_resolution=StreamResolution.SD
)
assert url == "rtsp://foo:bar@127.0.0.123:554/stream2"
with patch.object(dev.config, "credentials", Credentials("bar", "foo")): with patch.object(dev.config, "credentials", Credentials("bar", "foo")):
url = camera_module.stream_rtsp_url() url = camera_module.stream_rtsp_url()
assert url == "rtsp://bar:foo@127.0.0.123:554/stream1" assert url == "rtsp://bar:foo@127.0.0.123:554/stream1"
@ -75,49 +83,12 @@ async def test_stream_rtsp_url(dev: Device):
url = camera_module.stream_rtsp_url() url = camera_module.stream_rtsp_url()
assert url is None assert url is None
# Test with camera off
await camera_module.set_state(False)
await dev.update()
url = camera_module.stream_rtsp_url(Credentials("foo", "bar"))
assert url is None
with patch.object(dev.config, "credentials", Credentials("bar", "foo")):
url = camera_module.stream_rtsp_url()
assert url is None
@camera_smartcam
async def test_onvif_url(dev: Device):
"""Test the onvif url."""
camera_module = dev.modules.get(Module.Camera)
assert camera_module
@device_smartcam url = camera_module.onvif_url()
async def test_alias(dev): assert url == "http://127.0.0.123:2020/onvif/device_service"
test_alias = "TEST1234"
original = dev.alias
assert isinstance(original, str)
await dev.set_alias(test_alias)
await dev.update()
assert dev.alias == test_alias
await dev.set_alias(original)
await dev.update()
assert dev.alias == original
@hub_smartcam
async def test_hub(dev):
assert dev.children
for child in dev.children:
assert "Cloud" in child.modules
assert child.modules["Cloud"].data
assert child.alias
await child.update()
assert "Time" not in child.modules
assert child.time
@device_smartcam
async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory):
"""Test a child device gets the time from it's parent module."""
fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0)
assert dev.time != fallback_time
module = dev.modules[Module.Time]
await module.set_time(fallback_time)
await dev.update()
assert dev.time == fallback_time

View File

@ -0,0 +1,61 @@
"""Tests for smart camera devices."""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from freezegun.api import FrozenDateTimeFactory
from kasa import Device, DeviceType, Module
from ..conftest import device_smartcam, hub_smartcam
@device_smartcam
async def test_state(dev: Device):
if dev.device_type is DeviceType.Hub:
pytest.skip("Hubs cannot be switched on and off")
state = dev.is_on
await dev.set_state(not state)
await dev.update()
assert dev.is_on is not state
@device_smartcam
async def test_alias(dev):
test_alias = "TEST1234"
original = dev.alias
assert isinstance(original, str)
await dev.set_alias(test_alias)
await dev.update()
assert dev.alias == test_alias
await dev.set_alias(original)
await dev.update()
assert dev.alias == original
@hub_smartcam
async def test_hub(dev):
assert dev.children
for child in dev.children:
assert "Cloud" in child.modules
assert child.modules["Cloud"].data
assert child.alias
await child.update()
assert "Time" not in child.modules
assert child.time
@device_smartcam
async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory):
"""Test a child device gets the time from it's parent module."""
fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0)
assert dev.time != fallback_time
module = dev.modules[Module.Time]
await module.set_time(fallback_time)
await dev.update()
assert dev.time == fallback_time

View File

@ -1,44 +1,16 @@
from __future__ import annotations from __future__ import annotations
import re
import pytest import pytest
from voluptuous import (
All,
Boolean,
Optional,
Range,
Schema,
)
from kasa import Device, DeviceType, IotLightPreset, KasaException, LightState, Module from kasa import Device, DeviceType, KasaException, Module
from kasa.iot import IotBulb, IotDimmer from tests.conftest import handle_turn_on, turn_on
from kasa.iot.modules import LightPreset as IotLightPresetModule from tests.device_fixtures import (
from .conftest import (
bulb, bulb,
bulb_iot,
color_bulb, color_bulb,
color_bulb_iot,
dimmable_iot,
handle_turn_on,
non_color_bulb, non_color_bulb,
non_dimmable_iot,
non_variable_temp, non_variable_temp,
turn_on,
variable_temp, variable_temp,
variable_temp_iot,
variable_temp_smart,
) )
from .test_iotdevice import SYSINFO_SCHEMA
@bulb_iot
async def test_bulb_sysinfo(dev: Device):
assert dev.sys_info is not None
SYSINFO_SCHEMA_BULB(dev.sys_info)
assert dev.model is not None
@bulb @bulb
@ -47,18 +19,6 @@ async def test_state_attributes(dev: Device):
assert isinstance(dev.state_information["Cloud connection"], bool) assert isinstance(dev.state_information["Cloud connection"], bool)
@bulb_iot
async def test_light_state_without_update(dev: IotBulb, monkeypatch):
monkeypatch.setitem(dev._last_update["system"]["get_sysinfo"], "light_state", None)
with pytest.raises(KasaException):
print(dev.light_state)
@bulb_iot
async def test_get_light_state(dev: IotBulb):
LIGHT_STATE_SCHEMA(await dev.get_light_state())
@color_bulb @color_bulb
@turn_on @turn_on
async def test_hsv(dev: Device, turn_on): async def test_hsv(dev: Device, turn_on):
@ -81,35 +41,6 @@ async def test_hsv(dev: Device, turn_on):
assert brightness == 1 assert brightness == 1
@color_bulb_iot
async def test_set_hsv_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
light = dev.modules.get(Module.Light)
assert light
await light.set_hsv(10, 10, 100, transition=1000)
set_light_state.assert_called_with(
{"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0},
transition=1000,
)
@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 @color_bulb
@turn_on @turn_on
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -221,33 +152,6 @@ async def test_try_set_colortemp(dev: Device, turn_on):
assert light.color_temp == 2700 assert light.color_temp == 2700
@variable_temp_iot
async def test_set_color_temp_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
light = dev.modules.get(Module.Light)
assert light
await light.set_color_temp(2700, transition=100)
set_light_state.assert_called_with({"color_temp": 2700}, transition=100)
@variable_temp_iot
@pytest.mark.xdist_group(name="caplog")
async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
monkeypatch.setitem(dev._sys_info, "model", "unknown bulb")
light = dev.modules.get(Module.Light)
assert light
assert light.valid_temperature_range == (2700, 5000)
assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text
@variable_temp_smart
async def test_smart_temp_range(dev: Device):
light = dev.modules.get(Module.Light)
assert light
assert light.valid_temperature_range
@variable_temp @variable_temp
async def test_out_of_range_temperature(dev: Device): async def test_out_of_range_temperature(dev: Device):
light = dev.modules.get(Module.Light) light = dev.modules.get(Module.Light)
@ -276,231 +180,6 @@ async def test_non_variable_temp(dev: Device):
print(light.color_temp) print(light.color_temp)
@dimmable_iot
@turn_on
async def test_dimmable_brightness(dev: IotBulb, turn_on):
assert isinstance(dev, IotBulb | IotDimmer)
light = dev.modules.get(Module.Light)
assert light
await handle_turn_on(dev, turn_on)
assert dev._is_dimmable
await light.set_brightness(50)
await dev.update()
assert light.brightness == 50
await light.set_brightness(10)
await dev.update()
assert light.brightness == 10
with pytest.raises(TypeError, match="Brightness must be an integer"):
await light.set_brightness("foo") # type: ignore[arg-type]
@bulb_iot
async def test_turn_on_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
await dev.turn_on(transition=1000)
set_light_state.assert_called_with({"on_off": 1}, transition=1000)
await dev.turn_off(transition=100)
set_light_state.assert_called_with({"on_off": 0}, transition=100)
@bulb_iot
async def test_dimmable_brightness_transition(dev: IotBulb, mocker):
set_light_state = mocker.patch("kasa.iot.IotBulb._set_light_state")
light = dev.modules.get(Module.Light)
assert light
await light.set_brightness(10, transition=1000)
set_light_state.assert_called_with({"brightness": 10, "on_off": 1}, transition=1000)
@dimmable_iot
async def test_invalid_brightness(dev: IotBulb):
assert dev._is_dimmable
light = dev.modules.get(Module.Light)
assert light
with pytest.raises(
ValueError,
match=re.escape("Invalid brightness value: 110 (valid range: 0-100%)"),
):
await light.set_brightness(110)
with pytest.raises(
ValueError,
match=re.escape("Invalid brightness value: -100 (valid range: 0-100%)"),
):
await light.set_brightness(-100)
@non_dimmable_iot
async def test_non_dimmable(dev: IotBulb):
assert not dev._is_dimmable
light = dev.modules.get(Module.Light)
assert light
with pytest.raises(KasaException):
assert light.brightness == 0
with pytest.raises(KasaException):
await light.set_brightness(100)
@bulb_iot
async def test_ignore_default_not_set_without_color_mode_change_turn_on(
dev: IotBulb, mocker
):
query_helper = mocker.patch("kasa.iot.IotBulb._query_helper")
# When turning back without settings, ignore default to restore the state
await dev.turn_on()
args, kwargs = query_helper.call_args_list[0]
assert args[2] == {"on_off": 1, "ignore_default": 0}
await dev.turn_off()
args, kwargs = query_helper.call_args_list[1]
assert args[2] == {"on_off": 0, "ignore_default": 1}
@bulb_iot
async def test_list_presets(dev: IotBulb):
light_preset = dev.modules.get(Module.LightPreset)
assert light_preset
assert isinstance(light_preset, IotLightPresetModule)
presets = light_preset._deprecated_presets
# Light strip devices may list some light effects along with normal presets but these
# are handled by the LightEffect module so exclude preferred states with id
raw_presets = [
pstate for pstate in dev.sys_info["preferred_state"] if "id" not in pstate
]
assert len(presets) == len(raw_presets)
for preset, raw in zip(presets, raw_presets, strict=False):
assert preset.index == raw["index"]
assert preset.brightness == raw["brightness"]
assert preset.hue == raw["hue"]
assert preset.saturation == raw["saturation"]
assert preset.color_temp == raw["color_temp"]
@bulb_iot
async def test_modify_preset(dev: IotBulb, mocker):
"""Verify that modifying preset calls the and exceptions are raised properly."""
if (
not (light_preset := dev.modules.get(Module.LightPreset))
or not light_preset._deprecated_presets
):
pytest.skip("Some strips do not support presets")
assert isinstance(light_preset, IotLightPresetModule)
data: dict[str, int | None] = {
"index": 0,
"brightness": 10,
"hue": 0,
"saturation": 0,
"color_temp": 0,
}
preset = IotLightPreset(**data) # type: ignore[call-arg, arg-type]
assert preset.index == 0
assert preset.brightness == 10
assert preset.hue == 0
assert preset.saturation == 0
assert preset.color_temp == 0
await light_preset._deprecated_save_preset(preset)
await dev.update()
assert light_preset._deprecated_presets[0].brightness == 10
with pytest.raises(KasaException):
await light_preset._deprecated_save_preset(
IotLightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) # type: ignore[call-arg]
)
@bulb_iot
@pytest.mark.parametrize(
("preset", "payload"),
[
(
IotLightPreset(index=0, hue=0, brightness=1, saturation=0), # type: ignore[call-arg]
{"index": 0, "hue": 0, "brightness": 1, "saturation": 0},
),
(
IotLightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), # type: ignore[call-arg]
{"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0},
),
],
)
async def test_modify_preset_payloads(dev: IotBulb, preset, payload, mocker):
"""Test that modify preset payloads ignore none values."""
if (
not (light_preset := dev.modules.get(Module.LightPreset))
or not light_preset._deprecated_presets
):
pytest.skip("Some strips do not support presets")
query_helper = mocker.patch("kasa.iot.IotBulb._query_helper")
await light_preset._deprecated_save_preset(preset)
query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload)
LIGHT_STATE_SCHEMA = Schema(
{
"brightness": All(int, Range(min=0, max=100)),
"color_temp": int,
"hue": All(int, Range(min=0, max=360)),
"mode": str,
"on_off": Boolean,
"saturation": All(int, Range(min=0, max=100)),
"length": Optional(int),
"transition": Optional(int),
"dft_on_state": Optional(
{
"brightness": All(int, Range(min=0, max=100)),
"color_temp": All(int, Range(min=0, max=9000)),
"hue": All(int, Range(min=0, max=360)),
"mode": str,
"saturation": All(int, Range(min=0, max=100)),
"groups": Optional(list[int]),
}
),
"err_code": int,
}
)
SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend(
{
"ctrl_protocols": Optional(dict),
"description": Optional(str), # Seen on LBxxx, similar to dev_name
"dev_state": str,
"disco_ver": str,
"heapsize": int,
"is_color": Boolean,
"is_dimmable": Boolean,
"is_factory": Boolean,
"is_variable_color_temp": Boolean,
"light_state": LIGHT_STATE_SCHEMA,
"preferred_state": [
{
"brightness": All(int, Range(min=0, max=100)),
"color_temp": int,
"hue": All(int, Range(min=0, max=360)),
"index": int,
"saturation": All(int, Range(min=0, max=100)),
}
],
}
)
@bulb @bulb
def test_device_type_bulb(dev: Device): def test_device_type_bulb(dev: Device):
assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip} assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip}
@bulb_iot
async def test_turn_on_behaviours(dev: IotBulb):
behavior = await dev.get_turn_on_behavior()
assert behavior

View File

@ -2,7 +2,7 @@ import json
import os import os
import re import re
from datetime import datetime from datetime import datetime
from unittest.mock import ANY from unittest.mock import ANY, PropertyMock, patch
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import asyncclick as click import asyncclick as click
@ -40,10 +40,11 @@ from kasa.cli.light import (
) )
from kasa.cli.main import TYPES, _legacy_type_to_class, cli, cmd_command, raw_command from kasa.cli.main import TYPES, _legacy_type_to_class, cli, cmd_command, raw_command
from kasa.cli.time import time from kasa.cli.time import time
from kasa.cli.usage import emeter, energy from kasa.cli.usage import energy
from kasa.cli.wifi import wifi from kasa.cli.wifi import wifi
from kasa.discover import Discover, DiscoveryResult from kasa.discover import Discover, DiscoveryResult, redact_data
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.json import dumps as json_dumps
from kasa.smart import SmartDevice from kasa.smart import SmartDevice
from kasa.smartcam import SmartCamDevice from kasa.smartcam import SmartCamDevice
@ -126,6 +127,36 @@ async def test_list_devices(discovery_mock, runner):
assert row in res.output assert row in res.output
async def test_discover_raw(discovery_mock, runner, mocker):
"""Test the discover raw command."""
redact_spy = mocker.patch(
"kasa.protocols.protocol.redact_data", side_effect=redact_data
)
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar", "discover", "raw"],
catch_exceptions=False,
)
assert res.exit_code == 0
expected = {
"discovery_response": discovery_mock.discovery_data,
"meta": {"ip": "127.0.0.123", "port": discovery_mock.discovery_port},
}
assert res.output == json_dumps(expected, indent=True) + "\n"
redact_spy.assert_not_called()
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar", "discover", "raw", "--redact"],
catch_exceptions=False,
)
assert res.exit_code == 0
redact_spy.assert_called()
@new_discovery @new_discovery
async def test_list_auth_failed(discovery_mock, mocker, runner): async def test_list_auth_failed(discovery_mock, mocker, runner):
"""Test that device update is called on main.""" """Test that device update is called on main."""
@ -432,38 +463,45 @@ async def test_time_set(dev: Device, mocker, runner):
async def test_emeter(dev: Device, mocker, runner): async def test_emeter(dev: Device, mocker, runner):
res = await runner.invoke(emeter, obj=dev) mocker.patch("kasa.Discover.discover_single", return_value=dev)
base_cmd = ["--host", "dummy", "energy"]
res = await runner.invoke(cli, base_cmd, obj=dev)
if not (energy := dev.modules.get(Module.Energy)): if not (energy := dev.modules.get(Module.Energy)):
assert "Device has no energy module." in res.output assert "Device has no energy module." in res.output
return return
assert "== Emeter ==" in res.output assert "== Energy ==" in res.output
if dev.device_type is not DeviceType.Strip: if dev.device_type is not DeviceType.Strip:
res = await runner.invoke(emeter, ["--index", "0"], obj=dev) res = await runner.invoke(cli, [*base_cmd, "--index", "0"], obj=dev)
assert f"Device: {dev.host} does not have children" in res.output assert f"Device: {dev.host} does not have children" in res.output
res = await runner.invoke(emeter, ["--name", "mock"], obj=dev) res = await runner.invoke(cli, [*base_cmd, "--name", "mock"], obj=dev)
assert f"Device: {dev.host} does not have children" in res.output assert f"Device: {dev.host} does not have children" in res.output
if dev.device_type is DeviceType.Strip and len(dev.children) > 0: if dev.device_type is DeviceType.Strip and len(dev.children) > 0:
child_energy = dev.children[0].modules.get(Module.Energy) child_energy = dev.children[0].modules.get(Module.Energy)
assert child_energy assert child_energy
realtime_emeter = mocker.patch.object(child_energy, "get_status")
realtime_emeter.return_value = EmeterStatus({"voltage_mv": 122066})
res = await runner.invoke(emeter, ["--index", "0"], obj=dev) with patch.object(
assert "Voltage: 122.066 V" in res.output type(child_energy), "status", new_callable=PropertyMock
realtime_emeter.assert_called() ) as child_status:
assert realtime_emeter.call_count == 1 child_status.return_value = EmeterStatus({"voltage_mv": 122066})
res = await runner.invoke(emeter, ["--name", dev.children[0].alias], obj=dev) res = await runner.invoke(cli, [*base_cmd, "--index", "0"], obj=dev)
assert "Voltage: 122.066 V" in res.output assert "Voltage: 122.066 V" in res.output
assert realtime_emeter.call_count == 2 child_status.assert_called()
assert child_status.call_count == 1
res = await runner.invoke(
cli, [*base_cmd, "--name", dev.children[0].alias], obj=dev
)
assert "Voltage: 122.066 V" in res.output
assert child_status.call_count == 2
if isinstance(dev, IotDevice): if isinstance(dev, IotDevice):
monthly = mocker.patch.object(energy, "get_monthly_stats") monthly = mocker.patch.object(energy, "get_monthly_stats")
monthly.return_value = {1: 1234} monthly.return_value = {1: 1234}
res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) res = await runner.invoke(cli, [*base_cmd, "--year", "1900"], obj=dev)
if not isinstance(dev, IotDevice): if not isinstance(dev, IotDevice):
assert "Device does not support historical statistics" in res.output assert "Device does not support historical statistics" in res.output
return return
@ -474,7 +512,7 @@ async def test_emeter(dev: Device, mocker, runner):
if isinstance(dev, IotDevice): if isinstance(dev, IotDevice):
daily = mocker.patch.object(energy, "get_daily_stats") daily = mocker.patch.object(energy, "get_daily_stats")
daily.return_value = {1: 1234} daily.return_value = {1: 1234}
res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) res = await runner.invoke(cli, [*base_cmd, "--month", "1900-12"], obj=dev)
if not isinstance(dev, IotDevice): if not isinstance(dev, IotDevice):
assert "Device has no historical statistics" in res.output assert "Device has no historical statistics" in res.output
return return
@ -685,6 +723,8 @@ async def test_credentials(discovery_mock, mocker, runner):
dr.device_type, dr.device_type,
"--encrypt-type", "--encrypt-type",
dr.mgt_encrypt_schm.encrypt_type, dr.mgt_encrypt_schm.encrypt_type,
"--login-version",
dr.mgt_encrypt_schm.lv or 1,
], ],
) )
assert res.exit_code == 0 assert res.exit_code == 0
@ -722,6 +762,7 @@ async def test_without_device_type(dev, mocker, runner):
timeout=5, timeout=5,
discovery_timeout=7, discovery_timeout=7,
on_unsupported=ANY, on_unsupported=ANY,
on_discovered_raw=ANY,
) )

View File

@ -4,7 +4,7 @@ from zoneinfo import ZoneInfo
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from kasa import Device, LightState, Module from kasa import Device, LightState, Module, ThermostatState
from .device_fixtures import ( from .device_fixtures import (
bulb_iot, bulb_iot,
@ -57,6 +57,12 @@ light_preset = parametrize_combine([light_preset_smart, bulb_iot])
light = parametrize_combine([bulb_smart, bulb_iot, dimmable]) light = parametrize_combine([bulb_smart, bulb_iot, dimmable])
temp_control_smart = parametrize(
"has temp control smart",
component_filter="temp_control",
protocol_filter={"SMART.CHILD"},
)
@led @led
async def test_led_module(dev: Device, mocker: MockerFixture): async def test_led_module(dev: Device, mocker: MockerFixture):
@ -325,6 +331,39 @@ async def test_light_preset_save(dev: Device, mocker: MockerFixture):
assert new_preset_state.color_temp == new_preset.color_temp assert new_preset_state.color_temp == new_preset.color_temp
@temp_control_smart
async def test_thermostat(dev: Device, mocker: MockerFixture):
"""Test saving a new preset value."""
therm_mod = next(get_parent_and_child_modules(dev, Module.Thermostat))
assert therm_mod
await therm_mod.set_state(False)
await dev.update()
assert therm_mod.state is False
assert therm_mod.mode is ThermostatState.Off
await therm_mod.set_target_temperature(10)
await dev.update()
assert therm_mod.state is True
assert therm_mod.mode is ThermostatState.Heating
assert therm_mod.target_temperature == 10
target_temperature_feature = therm_mod.get_feature(therm_mod.set_target_temperature)
temp_control = dev.modules.get(Module.TemperatureControl)
assert temp_control
allowed_range = temp_control.allowed_temperature_range
assert target_temperature_feature.minimum_value == allowed_range[0]
assert target_temperature_feature.maximum_value == allowed_range[1]
await therm_mod.set_temperature_unit("celsius")
await dev.update()
assert therm_mod.temperature_unit == "celsius"
await therm_mod.set_temperature_unit("fahrenheit")
await dev.update()
assert therm_mod.temperature_unit == "fahrenheit"
async def test_set_time(dev: Device): async def test_set_time(dev: Device):
"""Test setting the device time.""" """Test setting the device time."""
time_mod = dev.modules[Module.Time] time_mod = dev.modules[Module.Time]

View File

@ -16,6 +16,7 @@ import kasa
from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module
from kasa.iot import ( from kasa.iot import (
IotBulb, IotBulb,
IotCamera,
IotDevice, IotDevice,
IotDimmer, IotDimmer,
IotLightStrip, IotLightStrip,
@ -55,6 +56,11 @@ device_classes = pytest.mark.parametrize(
) )
async def test_device_id(dev: Device):
"""Test all devices have a device id."""
assert dev.device_id
async def test_alias(dev): async def test_alias(dev):
test_alias = "TEST1234" test_alias = "TEST1234"
original = dev.alias original = dev.alias
@ -113,6 +119,7 @@ async def test_device_class_repr(device_class_name_obj):
IotStrip: DeviceType.Strip, IotStrip: DeviceType.Strip,
IotWallSwitch: DeviceType.WallSwitch, IotWallSwitch: DeviceType.WallSwitch,
IotLightStrip: DeviceType.LightStrip, IotLightStrip: DeviceType.LightStrip,
IotCamera: DeviceType.Camera,
SmartChildDevice: DeviceType.Unknown, SmartChildDevice: DeviceType.Unknown,
SmartDevice: DeviceType.Unknown, SmartDevice: DeviceType.Unknown,
SmartCamDevice: DeviceType.Camera, SmartCamDevice: DeviceType.Camera,

View File

@ -47,7 +47,10 @@ def _get_connection_type_device_class(discovery_info):
dr = DiscoveryResult.from_dict(discovery_info["result"]) dr = DiscoveryResult.from_dict(discovery_info["result"])
connection_type = DeviceConnectionParameters.from_values( connection_type = DeviceConnectionParameters.from_values(
dr.device_type, dr.mgt_encrypt_schm.encrypt_type dr.device_type,
dr.mgt_encrypt_schm.encrypt_type,
dr.mgt_encrypt_schm.lv,
dr.mgt_encrypt_schm.is_support_https,
) )
else: else:
connection_type = DeviceConnectionParameters.from_values( connection_type = DeviceConnectionParameters.from_values(

View File

@ -1,9 +1,9 @@
import pytest import pytest
from kasa import DeviceType from kasa import DeviceType
from tests.iot.test_iotdevice import SYSINFO_SCHEMA
from .conftest import plug, plug_iot, plug_smart, switch_smart, wallswitch_iot from .conftest import plug, plug_iot, plug_smart, switch_smart, wallswitch_iot
from .test_iotdevice import SYSINFO_SCHEMA
# these schemas should go to the mainlib as # these schemas should go to the mainlib as
# they can be useful when adding support for new features/devices # they can be useful when adding support for new features/devices

View File

View File

@ -0,0 +1,144 @@
import base64
from unittest.mock import ANY
import aiohttp
import pytest
from yarl import URL
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
from kasa.deviceconfig import DeviceConfig
from kasa.exceptions import KasaException
from kasa.httpclient import HttpClient
from kasa.json import dumps as json_dumps
from kasa.transports.linkietransport import LinkieTransportV2
KASACAM_REQUEST_PLAINTEXT = '{"smartlife.cam.ipcamera.dateTime":{"get_status":{}}}'
KASACAM_RESPONSE_ENCRYPTED = "0PKG74LnnfKc+dvhw5bCgaycqZOjk7Gdv96syaiKsJLTvtupwKPC7aPGse632KrB48/tiPiX9JzDsNW2lK6fqZCgmKuZoZGh3A=="
KASACAM_RESPONSE_ERROR = '{"smartlife.cam.ipcamera.cloud": {"get_inf": {"err_code": -10008, "err_msg": "Unsupported API call."}}}'
KASA_DEFAULT_CREDENTIALS_HASH = "YWRtaW46MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="
async def test_working(mocker):
"""No errors with an expected request/response."""
host = "127.0.0.1"
mock_linkie_device = MockLinkieDevice(host)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post
)
transport_no_creds = LinkieTransportV2(config=DeviceConfig(host))
response = await transport_no_creds.send(KASACAM_REQUEST_PLAINTEXT)
assert response == {
"timezone": "UTC-05:00",
"area": "America/New_York",
"epoch_sec": 1690832800,
}
async def test_credentials_hash(mocker):
"""Ensure the default credentials are always passed as Basic Auth."""
# Test without credentials input
host = "127.0.0.1"
mock_linkie_device = MockLinkieDevice(host)
mock_post = mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post
)
transport_no_creds = LinkieTransportV2(config=DeviceConfig(host))
await transport_no_creds.send(KASACAM_REQUEST_PLAINTEXT)
mock_post.assert_called_once_with(
URL(f"https://{host}:10443/data/LINKIE2.json"),
params=None,
data=ANY,
json=None,
timeout=ANY,
cookies=None,
headers={
"Authorization": "Basic " + _generate_kascam_basic_auth(),
"Content-Type": "application/x-www-form-urlencoded",
},
ssl=ANY,
)
assert transport_no_creds.credentials_hash == KASA_DEFAULT_CREDENTIALS_HASH
# Test with credentials input
transport_with_creds = LinkieTransportV2(
config=DeviceConfig(host, credentials=Credentials("Admin", "password"))
)
mock_post.reset_mock()
await transport_with_creds.send(KASACAM_REQUEST_PLAINTEXT)
mock_post.assert_called_once_with(
URL(f"https://{host}:10443/data/LINKIE2.json"),
params=None,
data=ANY,
json=None,
timeout=ANY,
cookies=None,
headers={
"Authorization": "Basic " + _generate_kascam_basic_auth(),
"Content-Type": "application/x-www-form-urlencoded",
},
ssl=ANY,
)
@pytest.mark.parametrize(
("return_status", "return_data", "expected"),
[
(500, KASACAM_RESPONSE_ENCRYPTED, "500"),
(200, "AAAAAAAAAAAAAAAAAAAAAAAA", "Unable to read response"),
(200, KASACAM_RESPONSE_ERROR, "Unsupported API call"),
],
)
async def test_exceptions(mocker, return_status, return_data, expected):
"""Test a variety of possible responses from the device."""
host = "127.0.0.1"
transport = LinkieTransportV2(config=DeviceConfig(host))
mock_linkie_device = MockLinkieDevice(
host, status_code=return_status, response=return_data
)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post
)
with pytest.raises(KasaException, match=expected):
await transport.send(KASACAM_REQUEST_PLAINTEXT)
def _generate_kascam_basic_auth():
creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"])
creds_combined = f"{creds.username}:{creds.password}"
return base64.b64encode(creds_combined.encode()).decode()
class MockLinkieDevice:
"""Based on MockSslDevice."""
class _mock_response:
def __init__(self, status, request: dict):
self.status = status
self._json = request
async def __aenter__(self):
return self
async def __aexit__(self, exc_t, exc_v, exc_tb):
pass
async def read(self):
if isinstance(self._json, dict):
return json_dumps(self._json).encode()
return self._json
def __init__(self, host, *, status_code=200, response=KASACAM_RESPONSE_ENCRYPTED):
self.host = host
self.http_client = HttpClient(DeviceConfig(self.host))
self.status_code = status_code
self.response = response
async def post(
self, url: URL, *, headers=None, params=None, json=None, data=None, **__
):
return self._mock_response(self.status_code, self.response)

View File

@ -0,0 +1,374 @@
from __future__ import annotations
import logging
from base64 import b64encode
from contextlib import nullcontext as does_not_raise
from typing import Any
import aiohttp
import pytest
from yarl import URL
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
from kasa.deviceconfig import DeviceConfig
from kasa.exceptions import (
AuthenticationError,
DeviceError,
KasaException,
SmartErrorCode,
_RetryableError,
)
from kasa.httpclient import HttpClient
from kasa.json import dumps as json_dumps
from kasa.json import loads as json_loads
from kasa.transports import SslTransport
from kasa.transports.ssltransport import TransportState, _md5_hash
# Transport tests are not designed for real devices
pytestmark = [pytest.mark.requires_dummy]
MOCK_PWD = "correct_pwd" # noqa: S105
MOCK_USER = "mock@example.com"
MOCK_BAD_USER_OR_PWD = "foobar" # noqa: S105
MOCK_TOKEN = "abcdefghijklmnopqrstuvwxyz1234)(" # noqa: S105
DEFAULT_CREDS = get_default_credentials(DEFAULT_CREDENTIALS["TAPO"])
_LOGGER = logging.getLogger(__name__)
@pytest.mark.parametrize(
(
"status_code",
"error_code",
"username",
"password",
"expectation",
),
[
pytest.param(
200,
SmartErrorCode.SUCCESS,
MOCK_USER,
MOCK_PWD,
does_not_raise(),
id="success",
),
pytest.param(
200,
SmartErrorCode.UNSPECIFIC_ERROR,
MOCK_USER,
MOCK_PWD,
pytest.raises(_RetryableError),
id="test retry",
),
pytest.param(
200,
SmartErrorCode.DEVICE_BLOCKED,
MOCK_USER,
MOCK_PWD,
pytest.raises(DeviceError),
id="test regular error",
),
pytest.param(
400,
SmartErrorCode.INTERNAL_UNKNOWN_ERROR,
MOCK_USER,
MOCK_PWD,
pytest.raises(KasaException),
id="400 error",
),
pytest.param(
200,
SmartErrorCode.LOGIN_ERROR,
MOCK_BAD_USER_OR_PWD,
MOCK_PWD,
pytest.raises(AuthenticationError),
id="bad-username",
),
pytest.param(
200,
[SmartErrorCode.LOGIN_ERROR, SmartErrorCode.SUCCESS],
MOCK_BAD_USER_OR_PWD,
"",
does_not_raise(),
id="working-fallback",
),
pytest.param(
200,
[SmartErrorCode.LOGIN_ERROR, SmartErrorCode.LOGIN_ERROR],
MOCK_BAD_USER_OR_PWD,
"",
pytest.raises(AuthenticationError),
id="fallback-fail",
),
pytest.param(
200,
SmartErrorCode.LOGIN_ERROR,
MOCK_USER,
MOCK_BAD_USER_OR_PWD,
pytest.raises(AuthenticationError),
id="bad-password",
),
pytest.param(
200,
SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR,
MOCK_USER,
MOCK_PWD,
pytest.raises(AuthenticationError),
id="auth-error != login_error",
),
],
)
async def test_login(
mocker,
status_code,
error_code,
username,
password,
expectation,
):
host = "127.0.0.1"
mock_ssl_aes_device = MockSslDevice(
host,
status_code=status_code,
send_error_code=error_code,
)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
transport = SslTransport(
config=DeviceConfig(host, credentials=Credentials(username, password))
)
assert transport._state is TransportState.LOGIN_REQUIRED
with expectation:
await transport.perform_login()
assert transport._state is TransportState.ESTABLISHED
await transport.close()
async def test_credentials_hash(mocker):
host = "127.0.0.1"
mock_ssl_aes_device = MockSslDevice(host)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
creds = Credentials(MOCK_USER, MOCK_PWD)
data = {"password": _md5_hash(MOCK_PWD.encode()), "username": MOCK_USER}
creds_hash = b64encode(json_dumps(data).encode()).decode()
# Test with credentials input
transport = SslTransport(config=DeviceConfig(host, credentials=creds))
assert transport.credentials_hash == creds_hash
# Test with credentials_hash input
transport = SslTransport(config=DeviceConfig(host, credentials_hash=creds_hash))
assert transport.credentials_hash == creds_hash
await transport.close()
async def test_send(mocker):
host = "127.0.0.1"
mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
transport = SslTransport(
config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD))
)
try_login_spy = mocker.spy(transport, "try_login")
request = {
"method": "get_device_info",
"params": None,
}
assert transport._state is TransportState.LOGIN_REQUIRED
res = await transport.send(json_dumps(request))
assert "result" in res
try_login_spy.assert_called_once()
assert transport._state is TransportState.ESTABLISHED
# Second request does not
res = await transport.send(json_dumps(request))
try_login_spy.assert_called_once()
await transport.close()
async def test_no_credentials(mocker):
"""Test transport without credentials."""
host = "127.0.0.1"
mock_ssl_aes_device = MockSslDevice(
host, send_error_code=SmartErrorCode.LOGIN_ERROR
)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
transport = SslTransport(config=DeviceConfig(host))
try_login_spy = mocker.spy(transport, "try_login")
with pytest.raises(AuthenticationError):
await transport.send('{"method": "dummy"}')
# We get called twice
assert try_login_spy.call_count == 2
await transport.close()
async def test_reset(mocker):
"""Test that transport state adjusts correctly for reset."""
host = "127.0.0.1"
mock_ssl_aes_device = MockSslDevice(host, send_error_code=SmartErrorCode.SUCCESS)
mocker.patch.object(
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
)
transport = SslTransport(
config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD))
)
assert transport._state is TransportState.LOGIN_REQUIRED
assert str(transport._app_url) == "https://127.0.0.1:4433/app"
await transport.perform_login()
assert transport._state is TransportState.ESTABLISHED
assert str(transport._app_url).startswith("https://127.0.0.1:4433/app?token=")
await transport.close()
assert transport._state is TransportState.LOGIN_REQUIRED
assert str(transport._app_url) == "https://127.0.0.1:4433/app"
async def test_port_override():
"""Test that port override sets the app_url."""
host = "127.0.0.1"
port_override = 12345
config = DeviceConfig(
host, credentials=Credentials("foo", "bar"), port_override=port_override
)
transport = SslTransport(config=config)
assert str(transport._app_url) == f"https://127.0.0.1:{port_override}/app"
await transport.close()
class MockSslDevice:
"""Based on MockAesSslDevice."""
class _mock_response:
def __init__(self, status, request: dict):
self.status = status
self._json = request
async def __aenter__(self):
return self
async def __aexit__(self, exc_t, exc_v, exc_tb):
pass
async def read(self):
if isinstance(self._json, dict):
return json_dumps(self._json).encode()
return self._json
def __init__(
self,
host,
*,
status_code=200,
send_error_code=SmartErrorCode.INTERNAL_UNKNOWN_ERROR,
):
self.host = host
self.http_client = HttpClient(DeviceConfig(self.host))
self._state = TransportState.LOGIN_REQUIRED
# test behaviour attributes
self.status_code = status_code
self.send_error_code = send_error_code
async def post(self, url: URL, params=None, json=None, data=None, *_, **__):
if data:
json = json_loads(data)
_LOGGER.debug("Request %s: %s", url, json)
res = self._post(url, json)
_LOGGER.debug("Response %s, data: %s", res, await res.read())
return res
def _post(self, url: URL, json: dict[str, Any]):
method = json["method"]
if method == "login":
if self._state is TransportState.LOGIN_REQUIRED:
assert json.get("token") is None
assert url == URL(f"https://{self.host}:4433/app")
return self._return_login_response(url, json)
else:
_LOGGER.warning("Received login although already logged in")
pytest.fail("non-handled re-login logic")
assert url == URL(f"https://{self.host}:4433/app?token={MOCK_TOKEN}")
return self._return_send_response(url, json)
def _return_login_response(self, url: URL, request: dict[str, Any]):
request_username = request["params"].get("username")
request_password = request["params"].get("password")
# Handle multiple error codes
if isinstance(self.send_error_code, list):
error_code = self.send_error_code.pop(0)
else:
error_code = self.send_error_code
_LOGGER.debug("Using error code %s", error_code)
def _return_login_error():
resp = {
"error_code": error_code.value,
"result": {"unknown": "payload"},
}
_LOGGER.debug("Returning login error with status %s", self.status_code)
return self._mock_response(self.status_code, resp)
if error_code is not SmartErrorCode.SUCCESS:
# Bad username
if request_username == MOCK_BAD_USER_OR_PWD:
return _return_login_error()
# Bad password
if request_password == _md5_hash(MOCK_BAD_USER_OR_PWD.encode()):
return _return_login_error()
# Empty password
if request_password == _md5_hash(b""):
return _return_login_error()
self._state = TransportState.ESTABLISHED
resp = {
"error_code": error_code.value,
"result": {
"token": MOCK_TOKEN,
},
}
_LOGGER.debug("Returning login success with status %s", self.status_code)
return self._mock_response(self.status_code, resp)
def _return_send_response(self, url: URL, json: dict[str, Any]):
method = json["method"]
result = {
"result": {method: {"dummy": "response"}},
"error_code": self.send_error_code.value,
}
return self._mock_response(self.status_code, result)

421
uv.lock
View File

@ -1,9 +1,5 @@
version = 1 version = 1
requires-python = ">=3.11, <4.0" requires-python = ">=3.11, <4.0"
resolution-markers = [
"python_full_version < '3.13'",
"python_full_version >= '3.13'",
]
[[package]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
@ -16,7 +12,7 @@ wheels = [
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
version = "3.10.10" version = "3.11.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiohappyeyeballs" }, { name = "aiohappyeyeballs" },
@ -24,55 +20,56 @@ dependencies = [
{ name = "attrs" }, { name = "attrs" },
{ name = "frozenlist" }, { name = "frozenlist" },
{ name = "multidict" }, { name = "multidict" },
{ name = "propcache" },
{ name = "yarl" }, { name = "yarl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/17/7e/16e57e6cf20eb62481a2f9ce8674328407187950ccc602ad07c685279141/aiohttp-3.10.10.tar.gz", hash = "sha256:0631dd7c9f0822cc61c88586ca76d5b5ada26538097d0f1df510b082bad3411a", size = 7542993 } sdist = { url = "https://files.pythonhosted.org/packages/4b/cb/f9bb10e0cf6f01730b27d370b10cc15822bea4395acd687abc8cc5fed3ed/aiohttp-3.11.7.tar.gz", hash = "sha256:01a8aca4af3da85cea5c90141d23f4b0eee3cbecfd33b029a45a80f28c66c668", size = 7666482 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/31/3c351d17596194e5a38ef169a4da76458952b2497b4b54645b9d483cbbb0/aiohttp-3.10.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c30a0eafc89d28e7f959281b58198a9fa5e99405f716c0289b7892ca345fe45f", size = 586501 }, { url = "https://files.pythonhosted.org/packages/13/7f/272fa1adf68fe2fbebfe686a67b50cfb40d86dfe47d0441aff6f0b7c4c0e/aiohttp-3.11.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cea52d11e02123f125f9055dfe0ccf1c3857225fb879e4a944fae12989e2aef2", size = 706820 },
{ url = "https://files.pythonhosted.org/packages/a4/a8/a559d09eb08478cdead6b7ce05b0c4a133ba27fcdfa91e05d2e62867300d/aiohttp-3.10.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:258c5dd01afc10015866114e210fb7365f0d02d9d059c3c3415382ab633fcbcb", size = 398993 }, { url = "https://files.pythonhosted.org/packages/79/3c/6d612ef77cdba75364393f04c5c577481e3b5123a774eea447ada1ddd14f/aiohttp-3.11.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3ce18f703b7298e7f7633efd6a90138d99a3f9a656cb52c1201e76cb5d79cf08", size = 466654 },
{ url = "https://files.pythonhosted.org/packages/c5/47/7736d4174613feef61d25332c3bd1a4f8ff5591fbd7331988238a7299485/aiohttp-3.10.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:15ecd889a709b0080f02721255b3f80bb261c2293d3c748151274dfea93ac871", size = 390647 }, { url = "https://files.pythonhosted.org/packages/4f/b8/1052667d4800cd49bb4f869f1ed42f5e9d5acd4676275e64ccc244c9c040/aiohttp-3.11.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:670847ee6aeb3a569cd7cdfbe0c3bec1d44828bbfbe78c5d305f7f804870ef9e", size = 454041 },
{ url = "https://files.pythonhosted.org/packages/27/21/e9ba192a04b7160f5a8952c98a1de7cf8072ad150fa3abd454ead1ab1d7f/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3935f82f6f4a3820270842e90456ebad3af15810cf65932bd24da4463bc0a4c", size = 1306481 }, { url = "https://files.pythonhosted.org/packages/9f/07/80fa7302314a6ee1c9278550e9d95b77a4c895999bfbc5364ed0ee28dc7c/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dda726f89bfa5c465ba45b76515135a3ece0088dfa2da49b8bb278f3bdeea12", size = 1684778 },
{ url = "https://files.pythonhosted.org/packages/cf/50/f364c01c8d0def1dc34747b2470969e216f5a37c7ece00fe558810f37013/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:413251f6fcf552a33c981c4709a6bba37b12710982fec8e558ae944bfb2abd38", size = 1344652 }, { url = "https://files.pythonhosted.org/packages/2e/30/a71eb45197ad6bb6af87dfb39be8b56417d24d916047d35ef3f164af87f4/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25b74a811dba37c7ea6a14d99eb9402d89c8d739d50748a75f3cf994cf19c43", size = 1740992 },
{ url = "https://files.pythonhosted.org/packages/1d/c2/74f608e984e9b585649e2e83883facad6fa3fc1d021de87b20cc67e8e5ae/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1720b4f14c78a3089562b8875b53e36b51c97c51adc53325a69b79b4b48ebcb", size = 1378498 }, { url = "https://files.pythonhosted.org/packages/22/74/0f9394429f3c4197129333a150a85cb2a642df30097a39dd41257f0b3bdc/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5522ee72f95661e79db691310290c4618b86dff2d9b90baedf343fd7a08bf79", size = 1781816 },
{ url = "https://files.pythonhosted.org/packages/9f/a7/05a48c7c0a7a80a5591b1203bf1b64ca2ed6a2050af918d09c05852dc42b/aiohttp-3.10.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:679abe5d3858b33c2cf74faec299fda60ea9de62916e8b67e625d65bf069a3b7", size = 1292718 }, { url = "https://files.pythonhosted.org/packages/7f/1a/1e256b39179c98d16d53ac62f64bfcfe7c5b2c1e68b83cddd4165854524f/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fbf41a6bbc319a7816ae0f0177c265b62f2a59ad301a0e49b395746eb2a9884", size = 1676692 },
{ url = "https://files.pythonhosted.org/packages/7d/78/a925655018747e9790350180330032e27d6e0d7ed30bde545fae42f8c49c/aiohttp-3.10.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79019094f87c9fb44f8d769e41dbb664d6e8fcfd62f665ccce36762deaa0e911", size = 1251776 }, { url = "https://files.pythonhosted.org/packages/9b/37/f19d2e00efcabb9183b16bd91244de1d9c4ff7bf0fb5b8302e29a78f3286/aiohttp-3.11.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59ee1925b5a5efdf6c4e7be51deee93984d0ac14a6897bd521b498b9916f1544", size = 1619523 },
{ url = "https://files.pythonhosted.org/packages/47/9d/85c6b69f702351d1236594745a4fdc042fc43f494c247a98dac17e004026/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2fb38c2ed905a2582948e2de560675e9dfbee94c6d5ccdb1301c6d0a5bf092", size = 1271716 }, { url = "https://files.pythonhosted.org/packages/ae/3c/af50cf5e06b98783fd776f17077f7b7e755d461114af5d6744dc037fc3b0/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:24054fce8c6d6f33a3e35d1c603ef1b91bbcba73e3f04a22b4f2f27dac59b347", size = 1644084 },
{ url = "https://files.pythonhosted.org/packages/7f/a7/55fc805ff9b14af818903882ece08e2235b12b73b867b521b92994c52b14/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a3f00003de6eba42d6e94fabb4125600d6e484846dbf90ea8e48a800430cc142", size = 1266263 }, { url = "https://files.pythonhosted.org/packages/c0/a6/4e0233b085cbf2b6de573515c1eddde82f1c1f17e69347e32a5a5f2617ff/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:351849aca2c6f814575c1a485c01c17a4240413f960df1bf9f5deb0003c61a53", size = 1648332 },
{ url = "https://files.pythonhosted.org/packages/1f/ec/d2be2ca7b063e4f91519d550dbc9c1cb43040174a322470deed90b3d3333/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1bbb122c557a16fafc10354b9d99ebf2f2808a660d78202f10ba9d50786384b9", size = 1321617 }, { url = "https://files.pythonhosted.org/packages/06/20/7062e76e7817318c421c0f9d7b650fb81aaecf6d2f3a9833805b45ec2ea8/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:12724f3a211fa243570e601f65a8831372caf1a149d2f1859f68479f07efec3d", size = 1730912 },
{ url = "https://files.pythonhosted.org/packages/c9/a3/b29f7920e1cd0a9a68a45dd3eb16140074d2efb1518d2e1f3e140357dc37/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:30ca7c3b94708a9d7ae76ff281b2f47d8eaf2579cd05971b5dc681db8caac6e1", size = 1339227 }, { url = "https://files.pythonhosted.org/packages/6c/1c/ff6ae4b1789894e6faf8a4e260cd3861cad618dc80ad15326789a7765750/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7ea4490360b605804bea8173d2d086b6c379d6bb22ac434de605a9cbce006e7d", size = 1752619 },
{ url = "https://files.pythonhosted.org/packages/8a/81/34b67235c47e232d807b4bbc42ba9b927c7ce9476872372fddcfd1e41b3d/aiohttp-3.10.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df9270660711670e68803107d55c2b5949c2e0f2e4896da176e1ecfc068b974a", size = 1299068 }, { url = "https://files.pythonhosted.org/packages/33/58/ddd5cba5ca245c00b04e9d28a7988b0f0eda02de494f8e62ecd2780655c2/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e0bf378db07df0a713a1e32381a1b277e62ad106d0dbe17b5479e76ec706d720", size = 1692801 },
{ url = "https://files.pythonhosted.org/packages/04/1f/26a7fe11b6ad3184f214733428353c89ae9fe3e4f605a657f5245c5e720c/aiohttp-3.10.10-cp311-cp311-win32.whl", hash = "sha256:aafc8ee9b742ce75044ae9a4d3e60e3d918d15a4c2e08a6c3c3e38fa59b92d94", size = 362223 }, { url = "https://files.pythonhosted.org/packages/b2/fc/32d5e2070b43d3722b7ea65ddc6b03ffa39bcc4b5ab6395a825cde0872ad/aiohttp-3.11.7-cp311-cp311-win32.whl", hash = "sha256:cd8d62cab363dfe713067027a5adb4907515861f1e4ce63e7be810b83668b847", size = 414899 },
{ url = "https://files.pythonhosted.org/packages/10/91/85dcd93f64011434359ce2666bece981f08d31bc49df33261e625b28595d/aiohttp-3.10.10-cp311-cp311-win_amd64.whl", hash = "sha256:362f641f9071e5f3ee6f8e7d37d5ed0d95aae656adf4ef578313ee585b585959", size = 381576 }, { url = "https://files.pythonhosted.org/packages/ec/7e/50324c6d3df4540f5963def810b9927f220c99864065849a1dfcae77a6ce/aiohttp-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:bf0e6cce113596377cadda4e3ac5fb89f095bd492226e46d91b4baef1dd16f60", size = 440938 },
{ url = "https://files.pythonhosted.org/packages/ae/99/4c5aefe5ad06a1baf206aed6598c7cdcbc7c044c46801cd0d1ecb758cae3/aiohttp-3.10.10-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9294bbb581f92770e6ed5c19559e1e99255e4ca604a22c5c6397b2f9dd3ee42c", size = 583536 }, { url = "https://files.pythonhosted.org/packages/bf/1e/2e96b2526c590dcb99db0b94ac4f9b927ecc07f94735a8a941dee143d48b/aiohttp-3.11.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4bb7493c3e3a36d3012b8564bd0e2783259ddd7ef3a81a74f0dbfa000fce48b7", size = 702326 },
{ url = "https://files.pythonhosted.org/packages/a9/36/8b3bc49b49cb6d2da40ee61ff15dbcc44fd345a3e6ab5bb20844df929821/aiohttp-3.10.10-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a8fa23fe62c436ccf23ff930149c047f060c7126eae3ccea005f0483f27b2e28", size = 395693 }, { url = "https://files.pythonhosted.org/packages/b5/ce/b5d7f3e68849f1f5e0b85af4ac9080b9d3c0a600857140024603653c2209/aiohttp-3.11.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e143b0ef9cb1a2b4f74f56d4fbe50caa7c2bb93390aff52f9398d21d89bc73ea", size = 461944 },
{ url = "https://files.pythonhosted.org/packages/e1/77/0aa8660dcf11fa65d61712dbb458c4989de220a844bd69778dff25f2d50b/aiohttp-3.10.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c6a5b8c7926ba5d8545c7dd22961a107526562da31a7a32fa2456baf040939f", size = 390898 }, { url = "https://files.pythonhosted.org/packages/28/fa/f4d98db1b7f8f0c3f74bdbd6d0d98cfc89984205cd33f1b8ee3f588ee5ad/aiohttp-3.11.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7c58a240260822dc07f6ae32a0293dd5bccd618bb2d0f36d51c5dbd526f89c0", size = 454348 },
{ url = "https://files.pythonhosted.org/packages/38/d2/b833d95deb48c75db85bf6646de0a697e7fb5d87bd27cbade4f9746b48b1/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:007ec22fbc573e5eb2fb7dec4198ef8f6bf2fe4ce20020798b2eb5d0abda6138", size = 1312060 }, { url = "https://files.pythonhosted.org/packages/04/f0/c238dda5dc9a3d12b76636e2cf0ea475890ac3a1c7e4ff0fd6c3cea2fc2d/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d20cfe63a1c135d26bde8c1d0ea46fd1200884afbc523466d2f1cf517d1fe33", size = 1678795 },
{ url = "https://files.pythonhosted.org/packages/aa/5f/29fd5113165a0893de8efedf9b4737e0ba92dfcd791415a528f947d10299/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9627cc1a10c8c409b5822a92d57a77f383b554463d1884008e051c32ab1b3742", size = 1350553 }, { url = "https://files.pythonhosted.org/packages/79/ee/3a18f792247e6d95dba13aaedc9dc317c3c6e75f4b88c2dd4b960d20ad2f/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12e4d45847a174f77b2b9919719203769f220058f642b08504cf8b1cf185dacf", size = 1734411 },
{ url = "https://files.pythonhosted.org/packages/ad/cc/f835f74b7d344428469200105236d44606cfa448be1e7c95ca52880d9bac/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50edbcad60d8f0e3eccc68da67f37268b5144ecc34d59f27a02f9611c1d4eec7", size = 1392646 }, { url = "https://files.pythonhosted.org/packages/f5/79/3eb84243087a9a32cae821622c935107b4b55a5b21b76772e8e6c41092e9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf4efa2d01f697a7dbd0509891a286a4af0d86902fc594e20e3b1712c28c0106", size = 1788959 },
{ url = "https://files.pythonhosted.org/packages/bf/fe/1332409d845ca601893bbf2d76935e0b93d41686e5f333841c7d7a4a770d/aiohttp-3.10.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a45d85cf20b5e0d0aa5a8dca27cce8eddef3292bc29d72dcad1641f4ed50aa16", size = 1306310 }, { url = "https://files.pythonhosted.org/packages/91/93/ad77782c5edfa17aafc070bef978fbfb8459b2f150595ffb01b559c136f9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee6a4cdcbf54b8083dc9723cdf5f41f722c00db40ccf9ec2616e27869151129", size = 1687463 },
{ url = "https://files.pythonhosted.org/packages/e4/a1/25a7633a5a513278a9892e333501e2e69c83e50be4b57a62285fb7a008c3/aiohttp-3.10.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b00807e2605f16e1e198f33a53ce3c4523114059b0c09c337209ae55e3823a8", size = 1260255 }, { url = "https://files.pythonhosted.org/packages/ba/48/db35bd21b7877efa0be5f28385d8978c55323c5ce7685712e53f3f6c0bd9/aiohttp-3.11.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c6095aaf852c34f42e1bd0cf0dc32d1e4b48a90bfb5054abdbb9d64b36acadcb", size = 1618374 },
{ url = "https://files.pythonhosted.org/packages/f2/39/30eafe89e0e2a06c25e4762844c8214c0c0cd0fd9ffc3471694a7986f421/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f2d4324a98062be0525d16f768a03e0bbb3b9fe301ceee99611dc9a7953124e6", size = 1271141 }, { url = "https://files.pythonhosted.org/packages/ba/77/30f87db55c79fd145ed5fd15b92f2e820ce81065d41ae437797aaa550e3b/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1cf03d27885f8c5ebf3993a220cc84fc66375e1e6e812731f51aab2b2748f4a6", size = 1637021 },
{ url = "https://files.pythonhosted.org/packages/5b/fc/33125df728b48391ef1fcb512dfb02072158cc10d041414fb79803463020/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:438cd072f75bb6612f2aca29f8bd7cdf6e35e8f160bc312e49fbecab77c99e3a", size = 1280244 }, { url = "https://files.pythonhosted.org/packages/af/76/10b188b78ee18d0595af156d6a238bc60f9d8571f0f546027eb7eaf65b25/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1a17f6a230f81eb53282503823f59d61dff14fb2a93847bf0399dc8e87817307", size = 1650792 },
{ url = "https://files.pythonhosted.org/packages/3b/61/e42bf2c2934b5caa4e2ec0b5e5fd86989adb022b5ee60c2572a9d77cf6fe/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:baa42524a82f75303f714108fea528ccacf0386af429b69fff141ffef1c534f9", size = 1316805 }, { url = "https://files.pythonhosted.org/packages/fa/33/4411bbb8ad04c47d0f4c7bd53332aaf350e49469cf6b65b132d4becafe27/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:481f10a1a45c5f4c4a578bbd74cff22eb64460a6549819242a87a80788461fba", size = 1696248 },
{ url = "https://files.pythonhosted.org/packages/18/32/f52a5e2ae9ad3bba10e026a63a7a23abfa37c7d97aeeb9004eaa98df3ce3/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a7d8d14fe962153fc681f6366bdec33d4356f98a3e3567782aac1b6e0e40109a", size = 1343930 }, { url = "https://files.pythonhosted.org/packages/fe/2d/6135d0dc1851a33d3faa937b20fef81340bc95e8310536d4c7f1f8ecc026/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:db37248535d1ae40735d15bdf26ad43be19e3d93ab3f3dad8507eb0f85bb8124", size = 1729188 },
{ url = "https://files.pythonhosted.org/packages/05/be/6a403b464dcab3631fe8e27b0f1d906d9e45c5e92aca97ee007e5a895560/aiohttp-3.10.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1277cd707c465cd09572a774559a3cc7c7a28802eb3a2a9472588f062097205", size = 1306186 }, { url = "https://files.pythonhosted.org/packages/f5/76/a57ceff577ae26fe9a6f31ac799bc638ecf26e4acdf04295290b9929b349/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d18a8b44ec8502a7fde91446cd9c9b95ce7c49f1eacc1fb2358b8907d4369fd", size = 1690038 },
{ url = "https://files.pythonhosted.org/packages/8e/fd/bb50fe781068a736a02bf5c7ad5f3ab53e39f1d1e63110da6d30f7605edc/aiohttp-3.10.10-cp312-cp312-win32.whl", hash = "sha256:59bb3c54aa420521dc4ce3cc2c3fe2ad82adf7b09403fa1f48ae45c0cbde6628", size = 359289 }, { url = "https://files.pythonhosted.org/packages/4b/81/b20e09003b6989a7f23a721692137a6143420a151063c750ab2a04878e3c/aiohttp-3.11.7-cp312-cp312-win32.whl", hash = "sha256:3d1c9c15d3999107cbb9b2d76ca6172e6710a12fda22434ee8bd3f432b7b17e8", size = 409887 },
{ url = "https://files.pythonhosted.org/packages/70/9e/5add7e240f77ef67c275c82cc1d08afbca57b77593118c1f6e920ae8ad3f/aiohttp-3.10.10-cp312-cp312-win_amd64.whl", hash = "sha256:0e1b370d8007c4ae31ee6db7f9a2fe801a42b146cec80a86766e7ad5c4a259cf", size = 379313 }, { url = "https://files.pythonhosted.org/packages/b7/0b/607c98bff1d07bb21e0c39e7711108ef9ff4f2a361a3ec1ce8dce93623a5/aiohttp-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:018f1b04883a12e77e7fc161934c0f298865d3a484aea536a6a2ca8d909f0ba0", size = 436462 },
{ url = "https://files.pythonhosted.org/packages/b1/eb/618b1b76c7fe8082a71c9d62e3fe84c5b9af6703078caa9ec57850a12080/aiohttp-3.10.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ad7593bb24b2ab09e65e8a1d385606f0f47c65b5a2ae6c551db67d6653e78c28", size = 576114 }, { url = "https://files.pythonhosted.org/packages/7a/53/8d77186c6a33bd087714df18274cdcf6e36fd69a9e841c85b7e81a20b18e/aiohttp-3.11.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:241a6ca732d2766836d62c58c49ca7a93d08251daef0c1e3c850df1d1ca0cbc4", size = 695811 },
{ url = "https://files.pythonhosted.org/packages/aa/37/3126995d7869f8b30d05381b81a2d4fb4ec6ad313db788e009bc6d39c211/aiohttp-3.10.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1eb89d3d29adaf533588f209768a9c02e44e4baf832b08118749c5fad191781d", size = 391901 }, { url = "https://files.pythonhosted.org/packages/62/b6/4c3d107a5406aa6f99f618afea82783f54ce2d9644020f50b9c88f6e823d/aiohttp-3.11.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aa3705a8d14de39898da0fbad920b2a37b7547c3afd2a18b9b81f0223b7d0f68", size = 458530 },
{ url = "https://files.pythonhosted.org/packages/3e/f2/8fdfc845be1f811c31ceb797968523813f8e1263ee3e9120d61253f6848f/aiohttp-3.10.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3fe407bf93533a6fa82dece0e74dbcaaf5d684e5a51862887f9eaebe6372cd79", size = 387418 }, { url = "https://files.pythonhosted.org/packages/d9/05/dbf0bd3966be8ebed3beb4007a2d1356d79af4fe7c93e54f984df6385193/aiohttp-3.11.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9acfc7f652b31853eed3b92095b0acf06fd5597eeea42e939bd23a17137679d5", size = 451371 },
{ url = "https://files.pythonhosted.org/packages/60/d5/33d2061d36bf07e80286e04b7e0a4de37ce04b5ebfed72dba67659a05250/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aed5155f819873d23520919e16703fc8925e509abbb1a1491b0087d1cd969e", size = 1287073 }, { url = "https://files.pythonhosted.org/packages/19/6a/2198580314617b6cf9c4b813b84df5832b5f8efedcb8a7e8b321a187233c/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcefcf2915a2dbdbce37e2fc1622129a1918abfe3d06721ce9f6cdac9b6d2eaa", size = 1662905 },
{ url = "https://files.pythonhosted.org/packages/00/52/affb55be16a4747740bd630b4c002dac6c5eac42f9bb64202fc3cf3f1930/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f05e9727ce409358baa615dbeb9b969db94324a79b5a5cea45d39bdb01d82e6", size = 1323612 }, { url = "https://files.pythonhosted.org/packages/2b/65/08696fd7503f6a6f9f782bd012bf47f36d4ed179a7d8c95dba4726d5cc67/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c1f6490dd1862af5aae6cfcf2a274bffa9a5b32a8f5acb519a7ecf5a99a88866", size = 1713794 },
{ url = "https://files.pythonhosted.org/packages/94/f2/cddb69b975387daa2182a8442566971d6410b8a0179bb4540d81c97b1611/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dffb610a30d643983aeb185ce134f97f290f8935f0abccdd32c77bed9388b42", size = 1368406 }, { url = "https://files.pythonhosted.org/packages/c8/a3/b9a72dce6f15e2efbc09fa67c1067c4f3a3bb05661c0ae7b40799cde02b7/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac5462582d6561c1c1708853a9faf612ff4e5ea5e679e99be36143d6eabd8e", size = 1770757 },
{ url = "https://files.pythonhosted.org/packages/c1/e4/afba7327da4d932da8c6e29aecaf855f9d52dace53ac15bfc8030a246f1b/aiohttp-3.10.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa6658732517ddabe22c9036479eabce6036655ba87a0224c612e1ae6af2087e", size = 1282761 }, { url = "https://files.pythonhosted.org/packages/78/7e/8fb371b5f8c4c1eaa0d0a50750c0dd68059f86794aeb36919644815486f5/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1a6309005acc4b2bcc577ba3b9169fea52638709ffacbd071f3503264620da", size = 1673136 },
{ url = "https://files.pythonhosted.org/packages/9f/6b/364856faa0c9031ea76e24ef0f7fef79cddd9fa8e7dba9a1771c6acc56b5/aiohttp-3.10.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:741a46d58677d8c733175d7e5aa618d277cd9d880301a380fd296975a9cdd7bc", size = 1236518 }, { url = "https://files.pythonhosted.org/packages/2f/0f/09685d13d2c7634cb808868ea29c170d4dcde4215a4a90fb86491cd3ae25/aiohttp-3.11.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b973cce96793725ef63eb449adfb74f99c043c718acb76e0d2a447ae369962", size = 1600370 },
{ url = "https://files.pythonhosted.org/packages/46/af/c382846f8356fe64a7b5908bb9b477457aa23b71be7ed551013b7b7d4d87/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e00e3505cd80440f6c98c6d69269dcc2a119f86ad0a9fd70bccc59504bebd68a", size = 1250344 }, { url = "https://files.pythonhosted.org/packages/00/2e/18fd38b117f9b3a375166ccb70ed43cf7e3dfe2cc947139acc15feefc5a2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ce91a24aac80de6be8512fb1c4838a9881aa713f44f4e91dd7bb3b34061b497d", size = 1613459 },
{ url = "https://files.pythonhosted.org/packages/87/53/294f87fc086fd0772d0ab82497beb9df67f0f27a8b3dd5742a2656db2bc6/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ffe595f10566f8276b76dc3a11ae4bb7eba1aac8ddd75811736a15b0d5311414", size = 1248956 }, { url = "https://files.pythonhosted.org/packages/2c/94/10a82abc680d753be33506be699aaa330152ecc4f316eaf081f996ee56c2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:875f7100ce0e74af51d4139495eec4025affa1a605280f23990b6434b81df1bd", size = 1613924 },
{ url = "https://files.pythonhosted.org/packages/86/30/7d746717fe11bdfefb88bb6c09c5fc985d85c4632da8bb6018e273899254/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdfcf6443637c148c4e1a20c48c566aa694fa5e288d34b20fcdc58507882fed3", size = 1293379 }, { url = "https://files.pythonhosted.org/packages/e9/58/897c0561f5c522dda6e173192f1e4f10144e1a7126096f17a3f12b7aa168/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c171fc35d3174bbf4787381716564042a4cbc008824d8195eede3d9b938e29a8", size = 1681164 },
{ url = "https://files.pythonhosted.org/packages/48/b9/45d670a834458db67a24258e9139ba61fa3bd7d69b98ecf3650c22806f8f/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d183cf9c797a5291e8301790ed6d053480ed94070637bfaad914dd38b0981f67", size = 1320108 }, { url = "https://files.pythonhosted.org/packages/8b/8b/3a48b1cdafa612679d976274355f6a822de90b85d7dba55654ecfb01c979/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ee9afa1b0d2293c46954f47f33e150798ad68b78925e3710044e0d67a9487791", size = 1712139 },
{ url = "https://files.pythonhosted.org/packages/72/8c/804bb2e837a175635d2000a0659eafc15b2e9d92d3d81c8f69e141ecd0b0/aiohttp-3.10.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:77abf6665ae54000b98b3c742bc6ea1d1fb31c394bcabf8b5d2c1ac3ebfe7f3b", size = 1281546 }, { url = "https://files.pythonhosted.org/packages/aa/9d/70ab5b4dd7900db04af72840e033aee06e472b1343e372ea256ed675511c/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8360c7cc620abb320e1b8d603c39095101391a82b1d0be05fb2225471c9c5c52", size = 1667446 },
{ url = "https://files.pythonhosted.org/packages/89/c0/862e6a9de3d6eeb126cd9d9ea388243b70df9b871ce1a42b193b7a4a77fc/aiohttp-3.10.10-cp313-cp313-win32.whl", hash = "sha256:4470c73c12cd9109db8277287d11f9dd98f77fc54155fc71a7738a83ffcc8ea8", size = 357516 }, { url = "https://files.pythonhosted.org/packages/cb/98/b5fbcc8f6056f0c56001c75227e6b7ca9ee4f2e5572feca82ff3d65d485d/aiohttp-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7a9318da4b4ada9a67c1dd84d1c0834123081e746bee311a16bb449f363d965e", size = 408689 },
{ url = "https://files.pythonhosted.org/packages/ae/63/3e1aee3e554263f3f1011cca50d78a4894ae16ce99bf78101ac3a2f0ef74/aiohttp-3.10.10-cp313-cp313-win_amd64.whl", hash = "sha256:486f7aabfa292719a2753c016cc3a8f8172965cabb3ea2e7f7436c7f5a22a151", size = 376785 }, { url = "https://files.pythonhosted.org/packages/ef/07/4d1504577fa6349dd2e3839e89fb56e5dee38d64efe3d4366e9fcfda0cdb/aiohttp-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:fc6da202068e0a268e298d7cd09b6e9f3997736cd9b060e2750963754552a0a9", size = 434809 },
] ]
[[package]] [[package]]
@ -290,50 +287,50 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.6.4" version = "7.6.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716 } sdist = { url = "https://files.pythonhosted.org/packages/ab/75/aecfd0a3adbec6e45753976bc2a9fed62b42cea9a206d10fd29244a77953/coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc", size = 801425 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/31/9c0cf84f0dfcbe4215b7eb95c31777cdc0483c13390e69584c8150c85175/coverage-7.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:73d2b73584446e66ee633eaad1a56aad577c077f46c35ca3283cd687b7715b0b", size = 206819 }, { url = "https://files.pythonhosted.org/packages/ab/9f/e98211980f6e2f439e251737482aa77906c9b9c507824c71a2ce7eea0402/coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4", size = 207093 },
{ url = "https://files.pythonhosted.org/packages/53/ed/a38401079ad320ad6e054a01ec2b61d270511aeb3c201c80e99c841229d5/coverage-7.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51b44306032045b383a7a8a2c13878de375117946d68dcb54308111f39775a25", size = 207263 }, { url = "https://files.pythonhosted.org/packages/fd/c7/8bab83fb9c20f7f8163c5a20dcb62d591b906a214a6dc6b07413074afc80/coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94", size = 207536 },
{ url = "https://files.pythonhosted.org/packages/20/e7/c3ad33b179ab4213f0d70da25a9c214d52464efa11caeab438592eb1d837/coverage-7.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3fb02fe73bed561fa12d279a417b432e5b50fe03e8d663d61b3d5990f29546", size = 239205 }, { url = "https://files.pythonhosted.org/packages/1e/d6/00243df625f1b282bb25c83ce153ae2c06f8e7a796a8d833e7235337b4d9/coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4", size = 239482 },
{ url = "https://files.pythonhosted.org/packages/36/91/fc02e8d8e694f557752120487fd982f654ba1421bbaa5560debf96ddceda/coverage-7.6.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed8fe9189d2beb6edc14d3ad19800626e1d9f2d975e436f84e19efb7fa19469b", size = 236612 }, { url = "https://files.pythonhosted.org/packages/1e/07/faf04b3eeb55ffc2a6f24b65dffe6e0359ec3b283e6efb5050ea0707446f/coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1", size = 236886 },
{ url = "https://files.pythonhosted.org/packages/cc/57/cb08f0eda0389a9a8aaa4fc1f9fec7ac361c3e2d68efd5890d7042c18aa3/coverage-7.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b369ead6527d025a0fe7bd3864e46dbee3aa8f652d48df6174f8d0bac9e26e0e", size = 238479 }, { url = "https://files.pythonhosted.org/packages/43/23/c79e497bf4d8fcacd316bebe1d559c765485b8ec23ac4e23025be6bfce09/coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb", size = 238749 },
{ url = "https://files.pythonhosted.org/packages/d5/c9/2c7681a9b3ca6e6f43d489c2e6653a53278ed857fd6e7010490c307b0a47/coverage-7.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ade3ca1e5f0ff46b678b66201f7ff477e8fa11fb537f3b55c3f0568fbfe6e718", size = 237405 }, { url = "https://files.pythonhosted.org/packages/b5/e5/791bae13be3c6451e32ef7af1192e711c6a319f3c597e9b218d148fd0633/coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8", size = 237679 },
{ url = "https://files.pythonhosted.org/packages/b5/4e/ebfc6944b96317df8b537ae875d2e57c27b84eb98820bc0a1055f358f056/coverage-7.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:27fb4a050aaf18772db513091c9c13f6cb94ed40eacdef8dad8411d92d9992db", size = 236038 }, { url = "https://files.pythonhosted.org/packages/05/c6/bbfdfb03aada601fb8993ced17468c8c8e0b4aafb3097026e680fabb7ce1/coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a", size = 236317 },
{ url = "https://files.pythonhosted.org/packages/13/f2/3a0bf1841a97c0654905e2ef531170f02c89fad2555879db8fe41a097871/coverage-7.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f704f0998911abf728a7783799444fcbbe8261c4a6c166f667937ae6a8aa522", size = 236812 }, { url = "https://files.pythonhosted.org/packages/67/f9/f8e5a4b2ce96d1b0e83ae6246369eb8437001dc80ec03bb51c87ff557cd8/coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0", size = 237084 },
{ url = "https://files.pythonhosted.org/packages/b9/9c/66bf59226b52ce6ed9541b02d33e80a6e816a832558fbdc1111a7bd3abd4/coverage-7.6.4-cp311-cp311-win32.whl", hash = "sha256:29155cd511ee058e260db648b6182c419422a0d2e9a4fa44501898cf918866cf", size = 209400 }, { url = "https://files.pythonhosted.org/packages/f0/70/b05328901e4debe76e033717e1452d00246c458c44e9dbd893e7619c2967/coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801", size = 209638 },
{ url = "https://files.pythonhosted.org/packages/2a/a0/b0790934c04dfc8d658d4a62acb8f7ca0efdf3818456fcad757b11c6479d/coverage-7.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:8902dd6a30173d4ef09954bfcb24b5d7b5190cf14a43170e386979651e09ba19", size = 210243 }, { url = "https://files.pythonhosted.org/packages/70/55/1efa24f960a2fa9fbc44a9523d3f3c50ceb94dd1e8cd732168ab2dc41b07/coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9", size = 210506 },
{ url = "https://files.pythonhosted.org/packages/7d/e7/9291de916d084f41adddfd4b82246e68d61d6a75747f075f7e64628998d2/coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", size = 207013 }, { url = "https://files.pythonhosted.org/packages/76/ce/3edf581c8fe429ed8ced6e6d9ac693c25975ef9093413276dab6ed68a80a/coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee", size = 207285 },
{ url = "https://files.pythonhosted.org/packages/27/03/932c2c5717a7fa80cd43c6a07d3177076d97b79f12f40f882f9916db0063/coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", size = 207251 }, { url = "https://files.pythonhosted.org/packages/09/9c/cf102ab046c9cf8895c3f7aadcde6f489a4b2ec326757e8c6e6581829b5e/coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a", size = 207522 },
{ url = "https://files.pythonhosted.org/packages/d5/3f/0af47dcb9327f65a45455fbca846fe96eb57c153af46c4754a3ba678938a/coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", size = 240268 }, { url = "https://files.pythonhosted.org/packages/39/06/42aa6dd13dbfca72e1fd8ffccadbc921b6e75db34545ebab4d955d1e7ad3/coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d", size = 240543 },
{ url = "https://files.pythonhosted.org/packages/8a/3c/37a9d81bbd4b23bc7d46ca820e16174c613579c66342faa390a271d2e18b/coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", size = 237298 }, { url = "https://files.pythonhosted.org/packages/a0/20/2932971dc215adeca8eeff446266a7fef17a0c238e881ffedebe7bfa0669/coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb", size = 237577 },
{ url = "https://files.pythonhosted.org/packages/c0/70/6b0627e5bd68204ee580126ed3513140b2298995c1233bd67404b4e44d0e/coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", size = 239367 }, { url = "https://files.pythonhosted.org/packages/ac/85/4323ece0cd5452c9522f4b6e5cc461e6c7149a4b1887c9e7a8b1f4e51146/coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649", size = 239646 },
{ url = "https://files.pythonhosted.org/packages/3c/eb/634d7dfab24ac3b790bebaf9da0f4a5352cbc125ce6a9d5c6cf4c6cae3c7/coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", size = 238853 }, { url = "https://files.pythonhosted.org/packages/77/52/b2537487d8f36241e518e84db6f79e26bc3343b14844366e35b090fae0d4/coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787", size = 239128 },
{ url = "https://files.pythonhosted.org/packages/d9/0d/8e3ed00f1266ef7472a4e33458f42e39492e01a64281084fb3043553d3f1/coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", size = 237160 }, { url = "https://files.pythonhosted.org/packages/7c/99/7f007762012186547d0ecc3d328da6b6f31a8c99f05dc1e13dcd929918cd/coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c", size = 237434 },
{ url = "https://files.pythonhosted.org/packages/ce/9c/4337f468ef0ab7a2e0887a9c9da0e58e2eada6fc6cbee637a4acd5dfd8a9/coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", size = 238824 }, { url = "https://files.pythonhosted.org/packages/97/53/e9b5cf0682a1cab9352adfac73caae0d77ae1d65abc88975d510f7816389/coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443", size = 239095 },
{ url = "https://files.pythonhosted.org/packages/5e/09/3e94912b8dd37251377bb02727a33a67ee96b84bbbe092f132b401ca5dd9/coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", size = 209639 }, { url = "https://files.pythonhosted.org/packages/0c/50/054f0b464fbae0483217186478eefa2e7df3a79917ed7f1d430b6da2cf0d/coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad", size = 209895 },
{ url = "https://files.pythonhosted.org/packages/01/69/d4f3a4101171f32bc5b3caec8ff94c2c60f700107a6aaef7244b2c166793/coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", size = 210428 }, { url = "https://files.pythonhosted.org/packages/df/d0/09ba870360a27ecf09e177ca2ff59d4337fc7197b456f22ceff85cffcfa5/coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4", size = 210684 },
{ url = "https://files.pythonhosted.org/packages/c2/4d/2dede4f7cb5a70fb0bb40a57627fddf1dbdc6b9c1db81f7c4dcdcb19e2f4/coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", size = 207039 }, { url = "https://files.pythonhosted.org/packages/9a/84/6f0ccf94a098ac3d6d6f236bd3905eeac049a9e0efcd9a63d4feca37ac4b/coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb", size = 207313 },
{ url = "https://files.pythonhosted.org/packages/3f/f9/d86368ae8c79e28f1fb458ebc76ae9ff3e8bd8069adc24e8f2fed03c58b7/coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", size = 207298 }, { url = "https://files.pythonhosted.org/packages/db/2b/e3b3a3a12ebec738c545897ac9f314620470fcbc368cdac88cf14974ba20/coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63", size = 207574 },
{ url = "https://files.pythonhosted.org/packages/64/c5/b4cc3c3f64622c58fbfd4d8b9a7a8ce9d355f172f91fcabbba1f026852f6/coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", size = 239813 }, { url = "https://files.pythonhosted.org/packages/db/c0/5bf95d42b6a8d21dfce5025ce187f15db57d6460a59b67a95fe8728162f1/coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365", size = 240090 },
{ url = "https://files.pythonhosted.org/packages/8a/86/14c42e60b70a79b26099e4d289ccdfefbc68624d096f4481163085aa614c/coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", size = 236959 }, { url = "https://files.pythonhosted.org/packages/57/b8/d6fd17d1a8e2b0e1a4e8b9cb1f0f261afd422570735899759c0584236916/coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002", size = 237237 },
{ url = "https://files.pythonhosted.org/packages/7f/f8/4436a643631a2fbab4b44d54f515028f6099bfb1cd95b13cfbf701e7f2f2/coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", size = 238950 }, { url = "https://files.pythonhosted.org/packages/d4/e4/a91e9bb46809c8b63e68fc5db5c4d567d3423b6691d049a4f950e38fbe9d/coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3", size = 239225 },
{ url = "https://files.pythonhosted.org/packages/49/50/1571810ddd01f99a0a8be464a4ac8b147f322cd1e8e296a1528984fc560b/coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", size = 238610 }, { url = "https://files.pythonhosted.org/packages/31/9c/9b99b0591ec4555b7292d271e005f27b465388ce166056c435b288db6a69/coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022", size = 238888 },
{ url = "https://files.pythonhosted.org/packages/f3/8c/6312d241fe7cbd1f0cade34a62fea6f333d1a261255d76b9a87074d8703c/coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", size = 236697 }, { url = "https://files.pythonhosted.org/packages/a6/85/285c2df9a04bc7c31f21fd9d4a24d19e040ec5e2ff06e572af1f6514c9e7/coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e", size = 236974 },
{ url = "https://files.pythonhosted.org/packages/ce/5f/fef33dfd05d87ee9030f614c857deb6df6556b8f6a1c51bbbb41e24ee5ac/coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", size = 238541 }, { url = "https://files.pythonhosted.org/packages/cb/a1/95ec8522206f76cdca033bf8bb61fff56429fb414835fc4d34651dfd29fc/coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b", size = 238815 },
{ url = "https://files.pythonhosted.org/packages/a9/64/6a984b6e92e1ea1353b7ffa08e27f707a5e29b044622445859200f541e8c/coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", size = 209707 }, { url = "https://files.pythonhosted.org/packages/8d/ac/687e9ba5e6d0979e9dab5c02e01c4f24ac58260ef82d88d3b433b3f84f1e/coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146", size = 209957 },
{ url = "https://files.pythonhosted.org/packages/5c/60/ce5a9e942e9543783b3db5d942e0578b391c25cdd5e7f342d854ea83d6b7/coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", size = 210439 }, { url = "https://files.pythonhosted.org/packages/2f/a3/b61cc8e3fcf075293fb0f3dee405748453c5ba28ac02ceb4a87f52bdb105/coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28", size = 210711 },
{ url = "https://files.pythonhosted.org/packages/78/53/6719677e92c308207e7f10561a1b16ab8b5c00e9328efc9af7cfd6fb703e/coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", size = 207784 }, { url = "https://files.pythonhosted.org/packages/ee/4b/891c8b9acf1b62c85e4a71dac142ab9284e8347409b7355de02e3f38306f/coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d", size = 208053 },
{ url = "https://files.pythonhosted.org/packages/fa/dd/7054928930671fcb39ae6a83bb71d9ab5f0afb733172543ced4b09a115ca/coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", size = 208058 }, { url = "https://files.pythonhosted.org/packages/18/a9/9e330409b291cc002723d339346452800e78df1ce50774ca439ade1d374f/coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451", size = 208329 },
{ url = "https://files.pythonhosted.org/packages/b5/7d/fd656ddc2b38301927b9eb3aae3fe827e7aa82e691923ed43721fd9423c9/coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", size = 250772 }, { url = "https://files.pythonhosted.org/packages/9c/0d/33635fd429f6589c6e1cdfc7bf581aefe4c1792fbff06383f9d37f59db60/coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764", size = 251052 },
{ url = "https://files.pythonhosted.org/packages/90/d0/eb9a3cc2100b83064bb086f18aedde3afffd7de6ead28f69736c00b7f302/coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", size = 246490 }, { url = "https://files.pythonhosted.org/packages/23/32/8a08da0e46f3830bbb9a5b40614241b2e700f27a9c2889f53122486443ed/coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf", size = 246765 },
{ url = "https://files.pythonhosted.org/packages/45/44/3f64f38f6faab8a0cfd2c6bc6eb4c6daead246b97cf5f8fc23bf3788f841/coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", size = 248848 }, { url = "https://files.pythonhosted.org/packages/56/3f/3b86303d2c14350fdb1c6c4dbf9bc76000af2382f42ca1d4d99c6317666e/coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5", size = 249125 },
{ url = "https://files.pythonhosted.org/packages/5d/11/4c465a5f98656821e499f4b4619929bd5a34639c466021740ecdca42aa30/coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", size = 248340 }, { url = "https://files.pythonhosted.org/packages/36/cb/c4f081b9023f9fd8646dbc4ef77be0df090263e8f66f4ea47681e0dc2cff/coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4", size = 248615 },
{ url = "https://files.pythonhosted.org/packages/f1/96/ebecda2d016cce9da812f404f720ca5df83c6b29f65dc80d2000d0078741/coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", size = 246229 }, { url = "https://files.pythonhosted.org/packages/32/ee/53bdbf67760928c44b57b2c28a8c0a4bf544f85a9ee129a63ba5c78fdee4/coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83", size = 246507 },
{ url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510 }, { url = "https://files.pythonhosted.org/packages/57/49/5a57910bd0af6d8e802b4ca65292576d19b54b49f81577fd898505dee075/coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b", size = 247785 },
{ url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353 }, { url = "https://files.pythonhosted.org/packages/bd/37/e450c9f6b297c79bb9858407396ed3e084dcc22990dd110ab01d5ceb9770/coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71", size = 210605 },
{ url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502 }, { url = "https://files.pythonhosted.org/packages/44/79/7d0c7dd237c6905018e2936cd1055fe1d42e7eba2ebab3c00f4aad2a27d7/coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc", size = 211777 },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@ -381,11 +378,11 @@ wheels = [
[[package]] [[package]]
name = "docutils" name = "docutils"
version = "0.19" version = "0.20.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/330ea8d383eb2ce973df34d1239b3b21e91cd8c865d21ff82902d952f91f/docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6", size = 2056383 } sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/69/e391bd51bc08ed9141ecd899a0ddb61ab6465309f1eb470905c0c8868081/docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc", size = 570472 }, { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666 },
] ]
[[package]] [[package]]
@ -474,11 +471,11 @@ wheels = [
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.6.1" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/bb/25024dbcc93516c492b75919e76f389bac754a3e4248682fba32b250c880/identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98", size = 99097 } sdist = { url = "https://files.pythonhosted.org/packages/1a/5f/05f0d167be94585d502b4adf8c7af31f1dc0b1c7e14f9938a88fdbbcf4a7/identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02", size = 99179 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/0c/4ef72754c050979fdcc06c744715ae70ea37e734816bb6514f79df77a42f/identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", size = 98972 }, { url = "https://files.pythonhosted.org/packages/c9/f5/09644a3ad803fae9eca8efa17e1f2aef380c7f0b02f7ec4e8d446e51d64a/identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd", size = 99049 },
] ]
[[package]] [[package]]
@ -510,14 +507,14 @@ wheels = [
[[package]] [[package]]
name = "jedi" name = "jedi"
version = "0.19.1" version = "0.19.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "parso" }, { name = "parso" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d6/99/99b493cec4bf43176b678de30f81ed003fd6a647a301b9c927280c600f0a/jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd", size = 1227821 } sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/9f/bc63f0f0737ad7a60800bfd472a4836661adae21f9c2535f3957b1e54ceb/jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0", size = 1569361 }, { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 },
] ]
[[package]] [[package]]
@ -556,14 +553,14 @@ wheels = [
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "2.2.0" version = "3.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "mdurl" }, { name = "mdurl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e4/c0/59bd6d0571986f72899288a95d9d6178d0eebd70b6650f1bb3f0da90f8f7/markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1", size = 67120 } sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/25/2d88e8feee8e055d015343f9b86e370a1ccbec546f2865c98397aaef24af/markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30", size = 84466 }, { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
] ]
[[package]] [[package]]
@ -616,26 +613,26 @@ wheels = [
[[package]] [[package]]
name = "mashumaro" name = "mashumaro"
version = "3.14" version = "3.15"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/eb/47/0a450b281bef2d7e97ec02c8e1168d821e283f58e02e6c403b2bb4d73c1c/mashumaro-3.14.tar.gz", hash = "sha256:5ef6f2b963892cbe9a4ceb3441dfbea37f8c3412523f25d42e9b3a7186555f1d", size = 166160 } sdist = { url = "https://files.pythonhosted.org/packages/f3/c1/7b687c8b993202e2eb49e559b25599d8e85f1b6d92ce676c8801226b8bdf/mashumaro-3.15.tar.gz", hash = "sha256:32a2a38a1e942a07f2cbf9c3061cb2a247714ee53e36a5958548b66bd116d0a9", size = 188646 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/35/8d63733a2c12149d0c7663c29bf626bdbeea5f0ff963afe58a42b4810981/mashumaro-3.14-py3-none-any.whl", hash = "sha256:c12a649599a8f7b1a0b35d18f12e678423c3066189f7bc7bd8dd431c5c8132c3", size = 92183 }, { url = "https://files.pythonhosted.org/packages/f9/59/595eabaa779c87a72d65864351e0fdd2359d7d73967d5ed9f2f0c6186fa3/mashumaro-3.15-py3-none-any.whl", hash = "sha256:cdd45ef5a4d09860846a3ee37a4c2f5f4bc70eb158caa55648c4c99451ca6c4c", size = 93761 },
] ]
[[package]] [[package]]
name = "mdit-py-plugins" name = "mdit-py-plugins"
version = "0.3.5" version = "0.4.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/49/e7/cc2720da8a32724b36d04c6dba5644154cdf883a1482b3bbb81959a642ed/mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a", size = 39871 } sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/4c/a9b222f045f98775034d243198212cbea36d3524c3ee1e8ab8c0346d6953/mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e", size = 52087 }, { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316 },
] ]
[[package]] [[package]]
@ -740,7 +737,7 @@ wheels = [
[[package]] [[package]]
name = "myst-parser" name = "myst-parser"
version = "1.0.0" version = "4.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "docutils" }, { name = "docutils" },
@ -750,9 +747,9 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "sphinx" }, { name = "sphinx" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5f/69/fbddb50198c6b0901a981e72ae30f1b7769d2dfac88071f7df41c946d133/myst-parser-1.0.0.tar.gz", hash = "sha256:502845659313099542bd38a2ae62f01360e7dd4b1310f025dd014dfc0439cdae", size = 84224 } sdist = { url = "https://files.pythonhosted.org/packages/85/55/6d1741a1780e5e65038b74bce6689da15f620261c490c3511eb4c12bac4b/myst_parser-4.0.0.tar.gz", hash = "sha256:851c9dfb44e36e56d15d05e72f02b80da21a9e0d07cba96baf5e2d476bb91531", size = 93858 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/1f/1621ef434ac5da26c30d31fcca6d588e3383344902941713640ba717fa87/myst_parser-1.0.0-py3-none-any.whl", hash = "sha256:69fb40a586c6fa68995e6521ac0a525793935db7e724ca9bac1d33be51be9a4c", size = 77312 }, { url = "https://files.pythonhosted.org/packages/ca/b4/b036f8fdb667587bb37df29dc6644681dd78b7a2a6321a34684b79412b28/myst_parser-4.0.0-py3-none-any.whl", hash = "sha256:b9317997552424448c6096c2558872fdb6f81d3ecb3a40ce84a7518798f3f28d", size = 84563 },
] ]
[[package]] [[package]]
@ -766,46 +763,54 @@ wheels = [
[[package]] [[package]]
name = "orjson" name = "orjson"
version = "3.10.11" version = "3.10.12"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/3a/10320029954badc7eaa338a15ee279043436f396e965dafc169610e4933f/orjson-3.10.11.tar.gz", hash = "sha256:e35b6d730de6384d5b2dab5fd23f0d76fae8bbc8c353c2f78210aa5fa4beb3ef", size = 5444879 } sdist = { url = "https://files.pythonhosted.org/packages/e0/04/bb9f72987e7f62fb591d6c880c0caaa16238e4e530cbc3bdc84a7372d75f/orjson-3.10.12.tar.gz", hash = "sha256:0a78bbda3aea0f9f079057ee1ee8a1ecf790d4f1af88dd67493c6b8ee52506ff", size = 5438647 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/25/c869a1fbd481dcb02c70032fd6a7243de7582bc48c7cae03d6f0985a11c0/orjson-3.10.11-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1444f9cb7c14055d595de1036f74ecd6ce15f04a715e73f33bb6326c9cef01b6", size = 266432 }, { url = "https://files.pythonhosted.org/packages/d3/48/7c3cd094488f5a3bc58488555244609a8c4d105bc02f2b77e509debf0450/orjson-3.10.12-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a734c62efa42e7df94926d70fe7d37621c783dea9f707a98cdea796964d4cf74", size = 248687 },
{ url = "https://files.pythonhosted.org/packages/6a/a4/2307155ee92457d28345308f7d8c0e712348404723025613adeffcb531d0/orjson-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdec57fe3b4bdebcc08a946db3365630332dbe575125ff3d80a3272ebd0ddafe", size = 151884 }, { url = "https://files.pythonhosted.org/packages/ff/90/e55f0e25c7fdd1f82551fe787f85df6f378170caca863c04c810cd8f2730/orjson-3.10.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:750f8b27259d3409eda8350c2919a58b0cfcd2054ddc1bd317a643afc646ef23", size = 136953 },
{ url = "https://files.pythonhosted.org/packages/aa/82/daf1b2596dd49fe44a1bd92367568faf6966dcb5d7f99fd437c3d0dc2de6/orjson-3.10.11-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4eed32f33a0ea6ef36ccc1d37f8d17f28a1d6e8eefae5928f76aff8f1df85e67", size = 167371 }, { url = "https://files.pythonhosted.org/packages/2a/b3/109c020cf7fee747d400de53b43b183ca9d3ebda3906ad0b858eb5479718/orjson-3.10.12-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb52c22bfffe2857e7aa13b4622afd0dd9d16ea7cc65fd2bf318d3223b1b6252", size = 149090 },
{ url = "https://files.pythonhosted.org/packages/63/a8/680578e4589be5fdcfe0186bdd7dc6fe4a39d30e293a9da833cbedd5a56e/orjson-3.10.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80df27dd8697242b904f4ea54820e2d98d3f51f91e97e358fc13359721233e4b", size = 154368 }, { url = "https://files.pythonhosted.org/packages/96/d4/35c0275dc1350707d182a1b5da16d1184b9439848060af541285407f18f9/orjson-3.10.12-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:440d9a337ac8c199ff8251e100c62e9488924c92852362cd27af0e67308c16ef", size = 140480 },
{ url = "https://files.pythonhosted.org/packages/6e/ce/9cb394b5b01ef34579eeca6d704b21f97248f607067ce95a24ba9ea2698e/orjson-3.10.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:705f03cee0cb797256d54de6695ef219e5bc8c8120b6654dd460848d57a9af3d", size = 165725 }, { url = "https://files.pythonhosted.org/packages/3b/79/f863ff460c291ad2d882cc3b580cc444bd4ec60c9df55f6901e6c9a3f519/orjson-3.10.12-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e15c06491c69997dfa067369baab3bf094ecb74be9912bdc4339972323f252", size = 156564 },
{ url = "https://files.pythonhosted.org/packages/49/24/55eeb05cfb36b9e950d05743e6f6fdb7d5f33ca951a27b06ea6d03371aed/orjson-3.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03246774131701de8e7059b2e382597da43144a9a7400f178b2a32feafc54bd5", size = 142522 }, { url = "https://files.pythonhosted.org/packages/98/7e/8d5835449ddd873424ee7b1c4ba73a0369c1055750990d824081652874d6/orjson-3.10.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:362d204ad4b0b8724cf370d0cd917bb2dc913c394030da748a3bb632445ce7c4", size = 131279 },
{ url = "https://files.pythonhosted.org/packages/94/0c/3a6a289e56dcc9fe67dc6b6d33c91dc5491f9ec4a03745efd739d2acf0ff/orjson-3.10.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8b5759063a6c940a69c728ea70d7c33583991c6982915a839c8da5f957e0103a", size = 146934 }, { url = "https://files.pythonhosted.org/packages/46/f5/d34595b6d7f4f984c6fef289269a7f98abcdc2445ebdf90e9273487dda6b/orjson-3.10.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b57cbb4031153db37b41622eac67329c7810e5f480fda4cfd30542186f006ae", size = 139764 },
{ url = "https://files.pythonhosted.org/packages/1d/5c/a08c0e90a91e2526029a4681ff8c6fc4495b8bab77d48801144e378c7da9/orjson-3.10.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:677f23e32491520eebb19c99bb34675daf5410c449c13416f7f0d93e2cf5f981", size = 142904 }, { url = "https://files.pythonhosted.org/packages/b3/5b/ee6e9ddeab54a7b7806768151c2090a2d36025bc346a944f51cf172ef7f7/orjson-3.10.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:165c89b53ef03ce0d7c59ca5c82fa65fe13ddf52eeb22e859e58c237d4e33b9b", size = 131915 },
{ url = "https://files.pythonhosted.org/packages/2c/c9/710286a60b14e88288ca014d43befb08bb0a4a6a0f51b875f8c2f05e8205/orjson-3.10.11-cp311-none-win32.whl", hash = "sha256:a11225d7b30468dcb099498296ffac36b4673a8398ca30fdaec1e6c20df6aa55", size = 144459 }, { url = "https://files.pythonhosted.org/packages/c4/45/febee5951aef6db5cd8cdb260548101d7ece0ca9d4ddadadf1766306b7a4/orjson-3.10.12-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5dee91b8dfd54557c1a1596eb90bcd47dbcd26b0baaed919e6861f076583e9da", size = 415783 },
{ url = "https://files.pythonhosted.org/packages/7d/68/ef7b920e0a09e02b1a30daca1b4864938463797995c2fabe457c1500220a/orjson-3.10.11-cp311-none-win_amd64.whl", hash = "sha256:df8c677df2f9f385fcc85ab859704045fa88d4668bc9991a527c86e710392bec", size = 136444 }, { url = "https://files.pythonhosted.org/packages/27/a5/5a8569e49f3a6c093bee954a3de95062a231196f59e59df13a48e2420081/orjson-3.10.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a4e1cfb72de6f905bdff061172adfb3caf7a4578ebf481d8f0530879476c07", size = 142387 },
{ url = "https://files.pythonhosted.org/packages/78/f2/a712dbcef6d84ff53e13056e7dc69d9d4844bd1e35e51b7431679ddd154d/orjson-3.10.11-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:360a4e2c0943da7c21505e47cf6bd725588962ff1d739b99b14e2f7f3545ba51", size = 266505 }, { url = "https://files.pythonhosted.org/packages/6e/05/02550fb38c5bf758f3994f55401233a2ef304e175f473f2ac6dbf464cc8b/orjson-3.10.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:038d42c7bc0606443459b8fe2d1f121db474c49067d8d14c6a075bbea8bf14dd", size = 130664 },
{ url = "https://files.pythonhosted.org/packages/94/54/53970831786d71f98fdc13c0f80451324c9b5c20fbf42f42ef6147607ee7/orjson-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:496e2cb45de21c369079ef2d662670a4892c81573bcc143c4205cae98282ba97", size = 151745 }, { url = "https://files.pythonhosted.org/packages/8c/f4/ba31019d0646ce51f7ac75af6dabf98fd89dbf8ad87a9086da34710738e7/orjson-3.10.12-cp311-none-win32.whl", hash = "sha256:03b553c02ab39bed249bedd4abe37b2118324d1674e639b33fab3d1dafdf4d79", size = 143623 },
{ url = "https://files.pythonhosted.org/packages/35/38/482667da1ca7ef95d44d4d2328257a144fd2752383e688637c53ed474d2a/orjson-3.10.11-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7dfa8db55c9792d53c5952900c6a919cfa377b4f4534c7a786484a6a4a350c19", size = 167274 }, { url = "https://files.pythonhosted.org/packages/83/fe/babf08842b989acf4c46103fefbd7301f026423fab47e6f3ba07b54d7837/orjson-3.10.12-cp311-none-win_amd64.whl", hash = "sha256:8b8713b9e46a45b2af6b96f559bfb13b1e02006f4242c156cbadef27800a55a8", size = 135074 },
{ url = "https://files.pythonhosted.org/packages/23/2f/5bb0a03e819781d82dadb733fde8ebbe20d1777d1a33715d45ada4d82ce8/orjson-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51f3382415747e0dbda9dade6f1e1a01a9d37f630d8c9049a8ed0e385b7a90c0", size = 154605 }, { url = "https://files.pythonhosted.org/packages/a1/2f/989adcafad49afb535da56b95d8f87d82e748548b2a86003ac129314079c/orjson-3.10.12-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:53206d72eb656ca5ac7d3a7141e83c5bbd3ac30d5eccfe019409177a57634b0d", size = 248678 },
{ url = "https://files.pythonhosted.org/packages/49/e9/14cc34d45c7bd51665aff9b1bb6b83475a61c52edb0d753fffe1adc97764/orjson-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35a1b9f50a219f470e0e497ca30b285c9f34948d3c8160d5ad3a755d9299433", size = 165874 }, { url = "https://files.pythonhosted.org/packages/69/b9/8c075e21a50c387649db262b618ebb7e4d40f4197b949c146fc225dd23da/orjson-3.10.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac8010afc2150d417ebda810e8df08dd3f544e0dd2acab5370cfa6bcc0662f8f", size = 136763 },
{ url = "https://files.pythonhosted.org/packages/7b/61/c2781ecf90f99623e97c67a31e8553f38a1ecebaf3189485726ac8641576/orjson-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f3b7c5803138e67028dde33450e054c87e0703afbe730c105f1fcd873496d5", size = 142813 }, { url = "https://files.pythonhosted.org/packages/87/d3/78edf10b4ab14c19f6d918cf46a145818f4aca2b5a1773c894c5490d3a4c/orjson-3.10.12-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed459b46012ae950dd2e17150e838ab08215421487371fa79d0eced8d1461d70", size = 149137 },
{ url = "https://files.pythonhosted.org/packages/4d/4f/18c83f78b501b6608569b1610fcb5a25c9bb9ab6a7eb4b3a55131e0fba37/orjson-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f91d9eb554310472bd09f5347950b24442600594c2edc1421403d7610a0998fd", size = 146762 }, { url = "https://files.pythonhosted.org/packages/16/81/5db8852bdf990a0ddc997fa8f16b80895b8cc77c0fe3701569ed2b4b9e78/orjson-3.10.12-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dcb9673f108a93c1b52bfc51b0af422c2d08d4fc710ce9c839faad25020bb69", size = 140567 },
{ url = "https://files.pythonhosted.org/packages/ba/19/ea80d5b575abd3f76a790409c2b7b8a60f3fc9447965c27d09613b8bddf4/orjson-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dfbb2d460a855c9744bbc8e36f9c3a997c4b27d842f3d5559ed54326e6911f9b", size = 143186 }, { url = "https://files.pythonhosted.org/packages/fa/a6/9ce1e3e3db918512efadad489630c25841eb148513d21dab96f6b4157fa1/orjson-3.10.12-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22a51ae77680c5c4652ebc63a83d5255ac7d65582891d9424b566fb3b5375ee9", size = 156620 },
{ url = "https://files.pythonhosted.org/packages/2c/f5/d835fee01a0284d4b78effc24d16e7609daac2ff6b6851ca1bdd3b6194fc/orjson-3.10.11-cp312-none-win32.whl", hash = "sha256:d4a62c49c506d4d73f59514986cadebb7e8d186ad510c518f439176cf8d5359d", size = 144489 }, { url = "https://files.pythonhosted.org/packages/47/d4/05133d6bea24e292d2f7628b1e19986554f7d97b6412b3e51d812e38db2d/orjson-3.10.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:910fdf2ac0637b9a77d1aad65f803bac414f0b06f720073438a7bd8906298192", size = 131555 },
{ url = "https://files.pythonhosted.org/packages/03/60/748e0e205060dec74328dfd835e47902eb5522ae011766da76bfff64e2f4/orjson-3.10.11-cp312-none-win_amd64.whl", hash = "sha256:f1eec3421a558ff7a9b010a6c7effcfa0ade65327a71bb9b02a1c3b77a247284", size = 136614 }, { url = "https://files.pythonhosted.org/packages/b9/7a/b3fbffda8743135c7811e95dc2ab7cdbc5f04999b83c2957d046f1b3fac9/orjson-3.10.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:24ce85f7100160936bc2116c09d1a8492639418633119a2224114f67f63a4559", size = 139743 },
{ url = "https://files.pythonhosted.org/packages/13/92/400970baf46b987c058469e9e779fb7a40d54a5754914d3634cca417e054/orjson-3.10.11-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c46294faa4e4d0eb73ab68f1a794d2cbf7bab33b1dda2ac2959ffb7c61591899", size = 266402 }, { url = "https://files.pythonhosted.org/packages/b5/13/95bbcc9a6584aa083da5ce5004ce3d59ea362a542a0b0938d884fd8790b6/orjson-3.10.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a76ba5fc8dd9c913640292df27bff80a685bed3a3c990d59aa6ce24c352f8fc", size = 131733 },
{ url = "https://files.pythonhosted.org/packages/3c/fa/f126fc2d817552bd1f67466205abdcbff64eab16f6844fe6df2853528675/orjson-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52e5834d7d6e58a36846e059d00559cb9ed20410664f3ad156cd2cc239a11230", size = 140826 }, { url = "https://files.pythonhosted.org/packages/e8/29/dddbb2ea6e7af426fcc3da65a370618a88141de75c6603313d70768d1df1/orjson-3.10.12-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ff70ef093895fd53f4055ca75f93f047e088d1430888ca1229393a7c0521100f", size = 415788 },
{ url = "https://files.pythonhosted.org/packages/ad/18/9b9664d7d4af5b4fe9fe6600b7654afc0684bba528260afdde10c4a530aa/orjson-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2fc947e5350fdce548bfc94f434e8760d5cafa97fb9c495d2fef6757aa02ec0", size = 142593 }, { url = "https://files.pythonhosted.org/packages/53/df/4aea59324ac539975919b4705ee086aced38e351a6eb3eea0f5071dd5661/orjson-3.10.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f4244b7018b5753ecd10a6d324ec1f347da130c953a9c88432c7fbc8875d13be", size = 142347 },
{ url = "https://files.pythonhosted.org/packages/20/f9/a30c68f12778d5e58e6b5cdd26f86ee2d0babce1a475073043f46fdd8402/orjson-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0efabbf839388a1dab5b72b5d3baedbd6039ac83f3b55736eb9934ea5494d258", size = 146777 }, { url = "https://files.pythonhosted.org/packages/55/55/a52d83d7c49f8ff44e0daab10554490447d6c658771569e1c662aa7057fe/orjson-3.10.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16135ccca03445f37921fa4b585cff9a58aa8d81ebcb27622e69bfadd220b32c", size = 130829 },
{ url = "https://files.pythonhosted.org/packages/f2/97/12047b0c0e9b391d589fb76eb40538f522edc664f650f8e352fdaaf77ff5/orjson-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3f29634260708c200c4fe148e42b4aae97d7b9fee417fbdd74f8cfc265f15b0", size = 142961 }, { url = "https://files.pythonhosted.org/packages/a1/8b/b1beb1624dd4adf7d72e2d9b73c4b529e7851c0c754f17858ea13e368b33/orjson-3.10.12-cp312-none-win32.whl", hash = "sha256:2d879c81172d583e34153d524fcba5d4adafbab8349a7b9f16ae511c2cee8708", size = 143659 },
{ url = "https://files.pythonhosted.org/packages/a4/97/d904e26c1cabf2dd6ab1b0909e9b790af28a7f0fcb9d8378d7320d4869eb/orjson-3.10.11-cp313-none-win32.whl", hash = "sha256:1a1222ffcee8a09476bbdd5d4f6f33d06d0d6642df2a3d78b7a195ca880d669b", size = 144486 }, { url = "https://files.pythonhosted.org/packages/13/91/634c9cd0bfc6a857fc8fab9bf1a1bd9f7f3345e0d6ca5c3d4569ceb6dcfa/orjson-3.10.12-cp312-none-win_amd64.whl", hash = "sha256:fc23f691fa0f5c140576b8c365bc942d577d861a9ee1142e4db468e4e17094fb", size = 135221 },
{ url = "https://files.pythonhosted.org/packages/42/62/3760bd1e6e949321d99bab238d08db2b1266564d2f708af668f57109bb36/orjson-3.10.11-cp313-none-win_amd64.whl", hash = "sha256:bc274ac261cc69260913b2d1610760e55d3c0801bb3457ba7b9004420b6b4270", size = 136361 }, { url = "https://files.pythonhosted.org/packages/1b/bb/3f560735f46fa6f875a9d7c4c2171a58cfb19f56a633d5ad5037a924f35f/orjson-3.10.12-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:47962841b2a8aa9a258b377f5188db31ba49af47d4003a32f55d6f8b19006543", size = 248662 },
{ url = "https://files.pythonhosted.org/packages/a3/df/54817902350636cc9270db20486442ab0e4db33b38555300a1159b439d16/orjson-3.10.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6334730e2532e77b6054e87ca84f3072bee308a45a452ea0bffbbbc40a67e296", size = 126055 },
{ url = "https://files.pythonhosted.org/packages/2e/77/55835914894e00332601a74540840f7665e81f20b3e2b9a97614af8565ed/orjson-3.10.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:accfe93f42713c899fdac2747e8d0d5c659592df2792888c6c5f829472e4f85e", size = 131507 },
{ url = "https://files.pythonhosted.org/packages/33/9e/b91288361898e3158062a876b5013c519a5d13e692ac7686e3486c4133ab/orjson-3.10.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7974c490c014c48810d1dede6c754c3cc46598da758c25ca3b4001ac45b703f", size = 131686 },
{ url = "https://files.pythonhosted.org/packages/b2/15/08ce117d60a4d2d3fd24e6b21db463139a658e9f52d22c9c30af279b4187/orjson-3.10.12-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3f250ce7727b0b2682f834a3facff88e310f52f07a5dcfd852d99637d386e79e", size = 415710 },
{ url = "https://files.pythonhosted.org/packages/71/af/c09da5ed58f9c002cf83adff7a4cdf3e6cee742aa9723395f8dcdb397233/orjson-3.10.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f31422ff9486ae484f10ffc51b5ab2a60359e92d0716fcce1b3593d7bb8a9af6", size = 142305 },
{ url = "https://files.pythonhosted.org/packages/17/d1/8612038d44f33fae231e9ba480d273bac2b0383ce9e77cb06bede1224ae3/orjson-3.10.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5f29c5d282bb2d577c2a6bbde88d8fdcc4919c593f806aac50133f01b733846e", size = 130815 },
{ url = "https://files.pythonhosted.org/packages/67/2c/d5f87834be3591555cfaf9aecdf28f480a6f0b4afeaac53bad534bf9518f/orjson-3.10.12-cp313-none-win32.whl", hash = "sha256:f45653775f38f63dc0e6cd4f14323984c3149c05d6007b58cb154dd080ddc0dc", size = 143664 },
{ url = "https://files.pythonhosted.org/packages/6a/05/7d768fa3ca23c9b3e1e09117abeded1501119f1d8de0ab722938c91ab25d/orjson-3.10.12-cp313-none-win_amd64.whl", hash = "sha256:229994d0c376d5bdc91d92b3c9e6be2f1fbabd4cc1b59daae1443a46ee5e9825", size = 134944 },
] ]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "24.1" version = "24.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
] ]
[[package]] [[package]]
@ -1083,7 +1088,7 @@ wheels = [
[[package]] [[package]]
name = "python-kasa" name = "python-kasa"
version = "0.7.7" version = "0.8.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
@ -1143,7 +1148,7 @@ requires-dist = [
{ name = "orjson", marker = "extra == 'speedups'", specifier = ">=3.9.1" }, { name = "orjson", marker = "extra == 'speedups'", specifier = ">=3.9.1" },
{ name = "ptpython", marker = "extra == 'shell'" }, { name = "ptpython", marker = "extra == 'shell'" },
{ name = "rich", marker = "extra == 'shell'" }, { name = "rich", marker = "extra == 'shell'" },
{ name = "sphinx", marker = "extra == 'docs'", specifier = "~=6.2" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.4.7" },
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" },
{ name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" },
{ name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" }, { name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" },
@ -1287,7 +1292,7 @@ wheels = [
[[package]] [[package]]
name = "sphinx" name = "sphinx"
version = "6.2.1" version = "7.4.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "alabaster" }, { name = "alabaster" },
@ -1307,9 +1312,9 @@ dependencies = [
{ name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-qthelp" },
{ name = "sphinxcontrib-serializinghtml" }, { name = "sphinxcontrib-serializinghtml" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0f/6d/392defcc95ca48daf62aecb89550143e97a4651275e62a3d7755efe35a3a/Sphinx-6.2.1.tar.gz", hash = "sha256:6d56a34697bb749ffa0152feafc4b19836c755d90a7c59b72bc7dfd371b9cc6b", size = 6681092 } sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/d8/45ba6097c39ba44d9f0e1462fb232e13ca4ddb5aea93a385dcfa964687da/sphinx-6.2.1-py3-none-any.whl", hash = "sha256:97787ff1fa3256a3eef9eda523a63dbf299f7b47e053cfcf684a1c2a8380c912", size = 3024615 }, { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624 },
] ]
[[package]] [[package]]
@ -1424,11 +1429,11 @@ wheels = [
[[package]] [[package]]
name = "tomli" name = "tomli"
version = "2.0.2" version = "2.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096 } sdist = { url = "https://files.pythonhosted.org/packages/1e/e4/1b6cbcc82d8832dd0ce34767d5c560df8a3547ad8cbc427f34601415930a/tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8", size = 16622 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 }, { url = "https://files.pythonhosted.org/packages/de/f7/4da0ffe1892122c9ea096c57f64c2753ae5dd3ce85488802d11b0992cc6d/tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391", size = 13750 },
] ]
[[package]] [[package]]
@ -1460,16 +1465,16 @@ wheels = [
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.27.1" version = "20.28.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "distlib" }, { name = "distlib" },
{ name = "filelock" }, { name = "filelock" },
{ name = "platformdirs" }, { name = "platformdirs" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/8c/b3/7b6a79c5c8cf6d90ea681310e169cf2db2884f4d583d16c6e1d5a75a4e04/virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba", size = 6491145 } sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/92/78324ff89391e00c8f4cf6b8526c41c6ef36b4ea2d2c132250b1a6fc2b8d/virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4", size = 3117838 }, { url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702 },
] ]
[[package]] [[package]]
@ -1501,62 +1506,62 @@ wheels = [
[[package]] [[package]]
name = "yarl" name = "yarl"
version = "1.17.1" version = "1.18.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "multidict" }, { name = "multidict" },
{ name = "propcache" }, { name = "propcache" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/54/9c/9c0a9bfa683fc1be7fdcd9687635151544d992cccd48892dc5e0a5885a29/yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47", size = 178163 } sdist = { url = "https://files.pythonhosted.org/packages/5e/4b/53db4ecad4d54535aff3dfda1f00d6363d79455f62b11b8ca97b82746bd2/yarl-1.18.0.tar.gz", hash = "sha256:20d95535e7d833889982bfe7cc321b7f63bf8879788fee982c76ae2b24cfb715", size = 180098 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/0f/ce6a2c8aab9946446fb27f1e28f0fd89ce84ae913ab18a92d18078a1c7ed/yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217", size = 140727 }, { url = "https://files.pythonhosted.org/packages/06/45/6ad7135d1c4ad3a6a49e2c37dc78a1805a7871879c03c3495d64c9605d49/yarl-1.18.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b8e8c516dc4e1a51d86ac975b0350735007e554c962281c432eaa5822aa9765c", size = 141283 },
{ url = "https://files.pythonhosted.org/packages/9d/df/204f7a502bdc3973cd9fc29e7dfad18ae48b3acafdaaf1ae07c0f41025aa/yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988", size = 93560 }, { url = "https://files.pythonhosted.org/packages/45/6d/24b70ae33107d6eba303ed0ebfdf1164fe2219656e7594ca58628ebc0f1d/yarl-1.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e6b4466714a73f5251d84b471475850954f1fa6acce4d3f404da1d55d644c34", size = 94082 },
{ url = "https://files.pythonhosted.org/packages/a2/e1/f4d522ae0560c91a4ea31113a50f00f85083be885e1092fc6e74eb43cb1d/yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75", size = 91497 }, { url = "https://files.pythonhosted.org/packages/8a/0e/da720989be11b662ca847ace58f468b52310a9b03e52ac62c144755f9d75/yarl-1.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c893f8c1a6d48b25961e00922724732d00b39de8bb0b451307482dc87bddcd74", size = 92017 },
{ url = "https://files.pythonhosted.org/packages/f1/82/783d97bf4a226f1a2e59b1966f2752244c2bf4dc89bc36f61d597b8e34e5/yarl-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca", size = 339446 }, { url = "https://files.pythonhosted.org/packages/f5/76/e5c91681fa54658943cb88673fb19b3355c3a8ae911a33a2621b6320990d/yarl-1.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13aaf2bdbc8c86ddce48626b15f4987f22e80d898818d735b20bd58f17292ee8", size = 340359 },
{ url = "https://files.pythonhosted.org/packages/e5/ff/615600647048d81289c80907165de713fbc566d1e024789863a2f6563ba3/yarl-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74", size = 354616 }, { url = "https://files.pythonhosted.org/packages/cf/77/02cf72f09dea20980dea4ebe40dfb2c24916b864aec869a19f715428e0f0/yarl-1.18.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd21c0128e301851de51bc607b0a6da50e82dc34e9601f4b508d08cc89ee7929", size = 356336 },
{ url = "https://files.pythonhosted.org/packages/a5/04/bfb7adb452bd19dfe0c35354ffce8ebc3086e028e5f8270e409d17da5466/yarl-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f", size = 351801 }, { url = "https://files.pythonhosted.org/packages/17/66/83a88d04e4fc243dd26109f3e3d6412f67819ab1142dadbce49706ef4df4/yarl-1.18.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:205de377bd23365cd85562c9c6c33844050a93661640fda38e0567d2826b50df", size = 353730 },
{ url = "https://files.pythonhosted.org/packages/10/e0/efe21edacdc4a638ce911f8cabf1c77cac3f60e9819ba7d891b9ceb6e1d4/yarl-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d", size = 343381 }, { url = "https://files.pythonhosted.org/packages/76/77/0b205a532d22756ab250ab21924d362f910a23d641c82faec1c4ad7f6077/yarl-1.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed69af4fe2a0949b1ea1d012bf065c77b4c7822bad4737f17807af2adb15a73c", size = 343882 },
{ url = "https://files.pythonhosted.org/packages/63/f9/7bc7e69857d6fc3920ecd173592f921d5701f4a0dd3f2ae293b386cfa3bf/yarl-1.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11", size = 337093 }, { url = "https://files.pythonhosted.org/packages/0b/47/2081ddce3da6096889c3947bdc21907d0fa15939909b10219254fe116841/yarl-1.18.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e1c18890091aa3cc8a77967943476b729dc2016f4cfe11e45d89b12519d4a93", size = 335873 },
{ url = "https://files.pythonhosted.org/packages/93/52/99da61947466275ff17d7bc04b0ac31dfb7ec699bd8d8985dffc34c3a913/yarl-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0", size = 346619 }, { url = "https://files.pythonhosted.org/packages/25/3c/437304394494e757ae927c9a81bacc4bcdf7351a1d4e811d95b02cb6dbae/yarl-1.18.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:91b8fb9427e33f83ca2ba9501221ffaac1ecf0407f758c4d2f283c523da185ee", size = 347725 },
{ url = "https://files.pythonhosted.org/packages/91/8a/8aaad86a35a16e485ba0e5de0d2ae55bf8dd0c9f1cccac12be4c91366b1d/yarl-1.17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3", size = 344347 }, { url = "https://files.pythonhosted.org/packages/c6/fb/fa6c642bc052fbe6370ed5da765579650510157dea354fe9e8177c3bc34a/yarl-1.18.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:536a7a8a53b75b2e98ff96edb2dfb91a26b81c4fed82782035767db5a465be46", size = 346161 },
{ url = "https://files.pythonhosted.org/packages/af/b6/97f29f626b4a1768ffc4b9b489533612cfcb8905c90f745aade7b2eaf75e/yarl-1.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe", size = 350316 }, { url = "https://files.pythonhosted.org/packages/b0/09/8c0cf68a0fcfe3b060c9e5857bb35735bc72a4cf4075043632c636d007e9/yarl-1.18.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a64619a9c47c25582190af38e9eb382279ad42e1f06034f14d794670796016c0", size = 349924 },
{ url = "https://files.pythonhosted.org/packages/d7/98/8e0e8b812479569bdc34d66dd3e2471176ca33be4ff5c272a01333c4b269/yarl-1.17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860", size = 361336 }, { url = "https://files.pythonhosted.org/packages/bf/4b/1efe10fd51e2cedf53195d688fa270efbcd64a015c61d029d49c20bf0af7/yarl-1.18.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c73a6bbc97ba1b5a0c3c992ae93d721c395bdbb120492759b94cc1ac71bc6350", size = 361865 },
{ url = "https://files.pythonhosted.org/packages/9e/d3/d1507efa0a85c25285f8eb51df9afa1ba1b6e446dda781d074d775b6a9af/yarl-1.17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4", size = 365350 }, { url = "https://files.pythonhosted.org/packages/0b/1b/2b5efd6df06bf938f7e154dee8e2ab22d148f3311a92bf4da642aaaf2fc5/yarl-1.18.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a173401d7821a2a81c7b47d4e7d5c4021375a1441af0c58611c1957445055056", size = 366030 },
{ url = "https://files.pythonhosted.org/packages/22/ba/ee7f1830449c96bae6f33210b7d89e8aaf3079fbdaf78ac398e50a9da404/yarl-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4", size = 357689 }, { url = "https://files.pythonhosted.org/packages/f8/db/786a5684f79278e62271038a698f56a51960f9e643be5d3eff82712f0b1c/yarl-1.18.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7520e799b1f84e095cce919bd6c23c9d49472deeef25fe1ef960b04cca51c3fc", size = 358902 },
{ url = "https://files.pythonhosted.org/packages/a0/85/321c563dc5afe1661108831b965c512d185c61785400f5606006507d2e18/yarl-1.17.1-cp311-cp311-win32.whl", hash = "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7", size = 83635 }, { url = "https://files.pythonhosted.org/packages/91/2f/437d0de062f1a3e3cb17573971b3832232443241133580c2ba3da5001d06/yarl-1.18.0-cp311-cp311-win32.whl", hash = "sha256:c4cb992d8090d5ae5f7afa6754d7211c578be0c45f54d3d94f7781c495d56716", size = 84138 },
{ url = "https://files.pythonhosted.org/packages/bc/da/543a32c00860588ff1235315b68f858cea30769099c32cd22b7bb266411b/yarl-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3", size = 90218 }, { url = "https://files.pythonhosted.org/packages/9d/85/035719a9266bce85ecde820aa3f8c46f3b18c3d7ba9ff51367b2fa4ae2a2/yarl-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:52c136f348605974c9b1c878addd6b7a60e3bf2245833e370862009b86fa4689", size = 90765 },
{ url = "https://files.pythonhosted.org/packages/5d/af/e25615c7920396219b943b9ff8b34636ae3e1ad30777649371317d7f05f8/yarl-1.17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61", size = 141839 }, { url = "https://files.pythonhosted.org/packages/23/36/c579b80a5c76c0d41c8e08baddb3e6940dfc20569db579a5691392c52afa/yarl-1.18.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1ece25e2251c28bab737bdf0519c88189b3dd9492dc086a1d77336d940c28ced", size = 142376 },
{ url = "https://files.pythonhosted.org/packages/83/5e/363d9de3495c7c66592523f05d21576a811015579e0c87dd38c7b5788afd/yarl-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d", size = 94125 }, { url = "https://files.pythonhosted.org/packages/0c/5f/e247dc7c0607a0c505fea6c839721844bee55686dfb183c7d7b8ef8a9cb1/yarl-1.18.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:454902dc1830d935c90b5b53c863ba2a98dcde0fbaa31ca2ed1ad33b2a7171c6", size = 94692 },
{ url = "https://files.pythonhosted.org/packages/e3/a2/b65447626227ebe36f18f63ac551790068bf42c69bb22dfa3ae986170728/yarl-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139", size = 92048 }, { url = "https://files.pythonhosted.org/packages/eb/e1/3081b578a6f21961711b9a1c49c2947abb3b0d0dd9537378fb06777ce8ee/yarl-1.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01be8688fc211dc237e628fcc209dda412d35de7642453059a0553747018d075", size = 92527 },
{ url = "https://files.pythonhosted.org/packages/a1/f5/2ef86458446f85cde10582054fd5113495ef8ce8477da35aaaf26d2970ef/yarl-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5", size = 331472 }, { url = "https://files.pythonhosted.org/packages/2f/fa/d9e1b9fbafa4cc82cd3980b5314741b33c2fe16308d725449a23aed32021/yarl-1.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d26f1fa9fa2167bb238f6f4b20218eb4e88dd3ef21bb8f97439fa6b5313e30d", size = 332096 },
{ url = "https://files.pythonhosted.org/packages/f3/6b/1ba79758ba352cdf2ad4c20cab1b982dd369aa595bb0d7601fc89bf82bee/yarl-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac", size = 341260 }, { url = "https://files.pythonhosted.org/packages/93/b6/dd27165114317875838e216214fb86338dc63d2e50855a8f2a12de2a7fe5/yarl-1.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b234a4a9248a9f000b7a5dfe84b8cb6210ee5120ae70eb72a4dcbdb4c528f72f", size = 342047 },
{ url = "https://files.pythonhosted.org/packages/2d/41/4e07c2afca3f9ed3da5b0e38d43d0280d9b624a3d5c478c425e5ce17775c/yarl-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463", size = 340882 }, { url = "https://files.pythonhosted.org/packages/fc/9f/bad434b5279ae7a356844e14dc771c3d29eb928140bbc01621af811c8a27/yarl-1.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe94d1de77c4cd8caff1bd5480e22342dbd54c93929f5943495d9c1e8abe9f42", size = 341712 },
{ url = "https://files.pythonhosted.org/packages/c3/c0/cd8e94618983c1b811af082e1a7ad7764edb3a6af2bc6b468e0e686238ba/yarl-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147", size = 336648 }, { url = "https://files.pythonhosted.org/packages/9a/9f/63864f43d131ba8c8cdf1bde5dd3f02f0eff8a7c883a5d7fad32f204fda5/yarl-1.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4c90c5363c6b0a54188122b61edb919c2cd1119684999d08cd5e538813a28e", size = 336654 },
{ url = "https://files.pythonhosted.org/packages/ac/fc/73ec4340d391ffbb8f34eb4c55429784ec9f5bd37973ce86d52d67135418/yarl-1.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7", size = 325019 }, { url = "https://files.pythonhosted.org/packages/20/30/b4542bbd9be73de155213207eec019f6fe6495885f7dd59aa1ff705a041b/yarl-1.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a98ecadc5a241c9ba06de08127ee4796e1009555efd791bac514207862b43d", size = 325484 },
{ url = "https://files.pythonhosted.org/packages/57/48/da3ebf418fc239d0a156b3bdec6b17a5446f8d2dea752299c6e47b143a85/yarl-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685", size = 342841 }, { url = "https://files.pythonhosted.org/packages/69/bc/e2a9808ec26989cf0d1b98fe7b3cc45c1c6506b5ea4fe43ece5991f28f34/yarl-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9106025c7f261f9f5144f9aa7681d43867eed06349a7cfb297a1bc804de2f0d1", size = 344213 },
{ url = "https://files.pythonhosted.org/packages/5d/79/107272745a470a8167924e353a5312eb52b5a9bb58e22686adc46c94f7ec/yarl-1.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172", size = 341433 }, { url = "https://files.pythonhosted.org/packages/e2/17/0ee5a68886aca1a8071b0d24a1e1c0fd9970dead2ef2d5e26e027fb7ce88/yarl-1.18.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f275ede6199d0f1ed4ea5d55a7b7573ccd40d97aee7808559e1298fe6efc8dbd", size = 340517 },
{ url = "https://files.pythonhosted.org/packages/30/9c/6459668b3b8dcc11cd061fc53e12737e740fb6b1575b49c84cbffb387b3a/yarl-1.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7", size = 344927 }, { url = "https://files.pythonhosted.org/packages/fd/db/1fe4ef38ee852bff5ec8f5367d718b3a7dac7520f344b8e50306f68a2940/yarl-1.18.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f7edeb1dcc7f50a2c8e08b9dc13a413903b7817e72273f00878cb70e766bdb3b", size = 346234 },
{ url = "https://files.pythonhosted.org/packages/c5/0b/93a17ed733aca8164fc3a01cb7d47b3f08854ce4f957cce67a6afdb388a0/yarl-1.17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da", size = 355732 }, { url = "https://files.pythonhosted.org/packages/b4/ee/5e5bccdb821eb9949ba66abb4d19e3299eee00282e37b42f65236120e892/yarl-1.18.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c083f6dd6951b86e484ebfc9c3524b49bcaa9c420cb4b2a78ef9f7a512bfcc85", size = 359625 },
{ url = "https://files.pythonhosted.org/packages/9a/63/ead2ed6aec3c59397e135cadc66572330325a0c24cd353cd5c94f5e63463/yarl-1.17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c", size = 362123 }, { url = "https://files.pythonhosted.org/packages/3f/43/95a64d9e7ab4aa1c34fc5ea0edb35b581bc6ad33fd960a8ae34c2040b319/yarl-1.18.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:80741ec5b471fbdfb997821b2842c59660a1c930ceb42f8a84ba8ca0f25a66aa", size = 364239 },
{ url = "https://files.pythonhosted.org/packages/89/bf/f6b75b4c2fcf0e7bb56edc0ed74e33f37fac45dc40e5a52a3be66b02587a/yarl-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199", size = 356355 }, { url = "https://files.pythonhosted.org/packages/40/19/09ce976c624c9d3cc898f0be5035ddef0c0759d85b2313321cfe77b69915/yarl-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1a3297b9cad594e1ff0c040d2881d7d3a74124a3c73e00c3c71526a1234a9f7", size = 357599 },
{ url = "https://files.pythonhosted.org/packages/45/1f/50a0257cd07eef65c8c65ad6a21f5fb230012d659e021aeb6ac8a7897bf6/yarl-1.17.1-cp312-cp312-win32.whl", hash = "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96", size = 83279 }, { url = "https://files.pythonhosted.org/packages/7d/35/6f33fd29791af2ec161aebe8abe63e788c2b74a6c7e8f29c92e5f5e96849/yarl-1.18.0-cp312-cp312-win32.whl", hash = "sha256:cd6ab7d6776c186f544f893b45ee0c883542b35e8a493db74665d2e594d3ca75", size = 83832 },
{ url = "https://files.pythonhosted.org/packages/bc/82/fafb2c1268d63d54ec08b3a254fbe51f4ef098211501df646026717abee3/yarl-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df", size = 89590 }, { url = "https://files.pythonhosted.org/packages/4e/8e/cdb40ef98597be107de67b11e2f1f23f911e0f1416b938885d17a338e304/yarl-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:039c299a0864d1f43c3e31570045635034ea7021db41bf4842693a72aca8df3a", size = 90132 },
{ url = "https://files.pythonhosted.org/packages/06/1e/5a93e3743c20eefbc68bd89334d9c9f04f3f2334380f7bbf5e950f29511b/yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488", size = 139974 }, { url = "https://files.pythonhosted.org/packages/2b/77/2196b657c66f97adaef0244e9e015f30eac0df59c31ad540f79ce328feed/yarl-1.18.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6fb64dd45453225f57d82c4764818d7a205ee31ce193e9f0086e493916bd4f72", size = 140512 },
{ url = "https://files.pythonhosted.org/packages/a1/be/4e0f6919013c7c5eaea5c31811c551ccd599d2fc80aa3dd6962f1bbdcddd/yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374", size = 93364 }, { url = "https://files.pythonhosted.org/packages/0e/d8/2bb6e26fddba5c01bad284e4571178c651b97e8e06318efcaa16e07eb9fd/yarl-1.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3adaaf9c6b1b4fc258584f4443f24d775a2086aee82d1387e48a8b4f3d6aecf6", size = 93875 },
{ url = "https://files.pythonhosted.org/packages/73/f0/650f994bc491d0cb85df8bb45392780b90eab1e175f103a5edc61445ff67/yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac", size = 91177 }, { url = "https://files.pythonhosted.org/packages/54/e4/99fbb884dd9f814fb0037dc1783766bb9edcd57b32a76f3ec5ac5c5772d7/yarl-1.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:da206d1ec78438a563c5429ab808a2b23ad7bc025c8adbf08540dde202be37d5", size = 91705 },
{ url = "https://files.pythonhosted.org/packages/f3/e8/9945ed555d14b43ede3ae8b1bd73e31068a694cad2b9d3cad0a28486c2eb/yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170", size = 333086 }, { url = "https://files.pythonhosted.org/packages/3b/a2/5bd86eca9449e6b15d3b08005cf4e58e3da972240c2bee427b358c311549/yarl-1.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:576d258b21c1db4c6449b1c572c75d03f16a482eb380be8003682bdbe7db2f28", size = 333325 },
{ url = "https://files.pythonhosted.org/packages/a6/c0/7d167e48e14d26639ca066825af8da7df1d2fcdba827e3fd6341aaf22a3b/yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8", size = 343661 }, { url = "https://files.pythonhosted.org/packages/94/50/a218da5f159cd985685bc72c500bb1a7fd2d60035d2339b8a9d9e1f99194/yarl-1.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e547c0a375c4bfcdd60eef82e7e0e8698bf84c239d715f5c1278a73050393", size = 344121 },
{ url = "https://files.pythonhosted.org/packages/fa/81/80a266517531d4e3553aecd141800dbf48d02e23ebd52909e63598a80134/yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938", size = 345196 }, { url = "https://files.pythonhosted.org/packages/a4/e3/830ae465811198b4b5ebecd674b5b3dca4d222af2155eb2144bfe190bbb8/yarl-1.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3818eabaefb90adeb5e0f62f047310079d426387991106d4fbf3519eec7d90a", size = 345163 },
{ url = "https://files.pythonhosted.org/packages/b0/77/6adc482ba7f2dc6c0d9b3b492e7cd100edfac4cfc3849c7ffa26fd7beb1a/yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e", size = 338743 }, { url = "https://files.pythonhosted.org/packages/7a/74/05c4326877ca541eee77b1ef74b7ac8081343d3957af8f9291ca6eca6fec/yarl-1.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f72421246c21af6a92fbc8c13b6d4c5427dfd949049b937c3b731f2f9076bd", size = 339130 },
{ url = "https://files.pythonhosted.org/packages/6d/cc/f0c4c0b92ff3ada517ffde2b127406c001504b225692216d969879ada89a/yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556", size = 326719 }, { url = "https://files.pythonhosted.org/packages/29/42/842f35aa1dae25d132119ee92185e8c75d8b9b7c83346506bd31e9fa217f/yarl-1.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fa7d37f2ada0f42e0723632993ed422f2a679af0e200874d9d861720a54f53e", size = 326418 },
{ url = "https://files.pythonhosted.org/packages/18/3b/7bfc80d3376b5fa162189993a87a5a6a58057f88315bd0ea00610055b57a/yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67", size = 345826 }, { url = "https://files.pythonhosted.org/packages/f9/ed/65c0514f2d1e8b92a61f564c914381d078766cab38b5fbde355b3b3af1fb/yarl-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:42ba84e2ac26a3f252715f8ec17e6fdc0cbf95b9617c5367579fafcd7fba50eb", size = 345204 },
{ url = "https://files.pythonhosted.org/packages/2e/66/cf0b0338107a5c370205c1a572432af08f36ca12ecce127f5b558398b4fd/yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8", size = 340335 }, { url = "https://files.pythonhosted.org/packages/23/31/351f64f0530c372fa01160f38330f44478e7bf3092f5ce2bfcb91605561d/yarl-1.18.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6a49ad0102c0f0ba839628d0bf45973c86ce7b590cdedf7540d5b1833ddc6f00", size = 341652 },
{ url = "https://files.pythonhosted.org/packages/2f/52/b084b0eec0fd4d2490e1d33ace3320fad704c5f1f3deaa709f929d2d87fc/yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3", size = 345301 }, { url = "https://files.pythonhosted.org/packages/49/aa/0c6e666c218d567727c1d040d01575685e7f9b18052fd68a59c9f61fe5d9/yarl-1.18.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:96404e8d5e1bbe36bdaa84ef89dc36f0e75939e060ca5cd45451aba01db02902", size = 347257 },
{ url = "https://files.pythonhosted.org/packages/ef/38/9e2036d948efd3bafcdb4976cb212166fded76615f0dfc6c1492c4ce4784/yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0", size = 354205 }, { url = "https://files.pythonhosted.org/packages/36/0b/33a093b0e13bb8cd0f27301779661ff325270b6644929001f8f33307357d/yarl-1.18.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a0509475d714df8f6d498935b3f307cd122c4ca76f7d426c7e1bb791bcd87eda", size = 359735 },
{ url = "https://files.pythonhosted.org/packages/81/c1/13dfe1e70b86811733316221c696580725ceb1c46d4e4db852807e134310/yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299", size = 360501 }, { url = "https://files.pythonhosted.org/packages/a8/92/dcc0b37c48632e71ffc2b5f8b0509347a0bde55ab5862ff755dce9dd56c4/yarl-1.18.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ff116f0285b5c8b3b9a2680aeca29a858b3b9e0402fc79fd850b32c2bcb9f8b", size = 365982 },
{ url = "https://files.pythonhosted.org/packages/91/87/756e05c74cd8bf9e71537df4a2cae7e8211a9ebe0d2350a3e26949e1e41c/yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258", size = 359452 }, { url = "https://files.pythonhosted.org/packages/0e/39/30e2a24a7a6c628dccb13eb6c4a03db5f6cd1eb2c6cda56a61ddef764c11/yarl-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2580c1d7e66e6d29d6e11855e3b1c6381971e0edd9a5066e6c14d79bc8967af", size = 360128 },
{ url = "https://files.pythonhosted.org/packages/06/b2/b2bb09c1e6d59e1c9b1b36a86caa473e22c3dbf26d1032c030e9bfb554dc/yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2", size = 308904 }, { url = "https://files.pythonhosted.org/packages/76/13/12b65dca23b1fb8ae44269a4d24048fd32ac90b445c985b0a46fdfa30cfe/yarl-1.18.0-cp313-cp313-win32.whl", hash = "sha256:14408cc4d34e202caba7b5ac9cc84700e3421a9e2d1b157d744d101b061a4a88", size = 309888 },
{ url = "https://files.pythonhosted.org/packages/f3/27/f084d9a5668853c1f3b246620269b14ee871ef3c3cc4f3a1dd53645b68ec/yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda", size = 314637 }, { url = "https://files.pythonhosted.org/packages/f6/60/478d3d41a4bf0b9e7dca74d870d114e775d1ff7156b7d1e0e9972e8f97fd/yarl-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db1537e9cb846eb0ff206eac667f627794be8b71368c1ab3207ec7b6f8c5afc", size = 315459 },
{ url = "https://files.pythonhosted.org/packages/52/ad/1fe7ff5f3e8869d4c5070f47b96bac2b4d15e67c100a8278d8e7876329fc/yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06", size = 44352 }, { url = "https://files.pythonhosted.org/packages/30/9c/3f7ab894a37b1520291247cbc9ea6756228d098dae5b37eec848d404a204/yarl-1.18.0-py3-none-any.whl", hash = "sha256:dbf53db46f7cf176ee01d8d98c39381440776fcda13779d269a8ba664f69bec0", size = 44840 },
] ]