mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-01-08 22:07:06 +00:00
Merge remote-tracking branch 'upstream/master' into feat/parent_child_updates
This commit is contained in:
commit
5735f37c58
@ -1,5 +1,11 @@
|
|||||||
breaking_labels=breaking change
|
output=CHANGELOG.md
|
||||||
add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}}
|
base=HISTORY.md
|
||||||
|
user=python-kasa
|
||||||
|
project=python-kasa
|
||||||
|
since-tag=0.3.5
|
||||||
release_branch=master
|
release_branch=master
|
||||||
usernames-as-github-logins=true
|
usernames-as-github-logins=true
|
||||||
|
breaking_labels=breaking change
|
||||||
|
add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}}
|
||||||
exclude-labels=duplicate,question,invalid,wontfix,release-prep
|
exclude-labels=duplicate,question,invalid,wontfix,release-prep
|
||||||
|
issues-wo-labels=false
|
||||||
|
48
CHANGELOG.md
48
CHANGELOG.md
@ -1,12 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01)
|
||||||
|
|
||||||
## [0.7.0.1](https://github.com/python-kasa/python-kasa/tree/0.7.0.1) (2024-06-25)
|
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.1...0.7.0.2)
|
||||||
|
|
||||||
This patch release fixes some minor issues found out during testing against all new homeassistant platforms.
|
This patch release fixes some minor issues found out during testing against all new homeassistant platforms.
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- Disable multi-request on unknown errors [\#1027](https://github.com/python-kasa/python-kasa/pull/1027) (@sdb9696)
|
||||||
|
- Disable multi requests on json decode error during multi-request [\#1025](https://github.com/python-kasa/python-kasa/pull/1025) (@sdb9696)
|
||||||
|
- Fix changing brightness when effect is active [\#1019](https://github.com/python-kasa/python-kasa/pull/1019) (@rytilahti)
|
||||||
|
- Update light transition module to work with child devices [\#1017](https://github.com/python-kasa/python-kasa/pull/1017) (@sdb9696)
|
||||||
|
- Handle unknown error codes gracefully [\#1016](https://github.com/python-kasa/python-kasa/pull/1016) (@rytilahti)
|
||||||
|
|
||||||
|
**Project maintenance:**
|
||||||
|
|
||||||
|
- Make parent attribute on device consistent across iot and smart [\#1023](https://github.com/python-kasa/python-kasa/pull/1023) (@sdb9696)
|
||||||
|
- Cache SmartErrorCode creation [\#1022](https://github.com/python-kasa/python-kasa/pull/1022) (@bdraco)
|
||||||
|
|
||||||
|
## [0.7.0.1](https://github.com/python-kasa/python-kasa/tree/0.7.0.1) (2024-06-25)
|
||||||
|
|
||||||
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0...0.7.0.1)
|
[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0...0.7.0.1)
|
||||||
|
|
||||||
|
This patch release fixes some minor issues found out during testing against all new homeassistant platforms.
|
||||||
|
|
||||||
**Fixed bugs:**
|
**Fixed bugs:**
|
||||||
|
|
||||||
- Disable lighttransition module on child devices [\#1013](https://github.com/python-kasa/python-kasa/pull/1013) (@sdb9696)
|
- Disable lighttransition module on child devices [\#1013](https://github.com/python-kasa/python-kasa/pull/1013) (@sdb9696)
|
||||||
@ -54,24 +72,19 @@ For more information on the changes please checkout our [documentation on the AP
|
|||||||
**Implemented enhancements:**
|
**Implemented enhancements:**
|
||||||
|
|
||||||
- Cleanup cli output [\#1000](https://github.com/python-kasa/python-kasa/pull/1000) (@rytilahti)
|
- Cleanup cli output [\#1000](https://github.com/python-kasa/python-kasa/pull/1000) (@rytilahti)
|
||||||
- Improve autooff name and unit [\#997](https://github.com/python-kasa/python-kasa/pull/997) (@rytilahti)
|
|
||||||
- Update mode, time, rssi and report\_interval feature names/units [\#995](https://github.com/python-kasa/python-kasa/pull/995) (@sdb9696)
|
- Update mode, time, rssi and report\_interval feature names/units [\#995](https://github.com/python-kasa/python-kasa/pull/995) (@sdb9696)
|
||||||
- Add unit\_getter for feature [\#993](https://github.com/python-kasa/python-kasa/pull/993) (@rytilahti)
|
|
||||||
- Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696)
|
- Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696)
|
||||||
- Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696)
|
- Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696)
|
||||||
- Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696)
|
- Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696)
|
||||||
- Support smart child modules queries [\#967](https://github.com/python-kasa/python-kasa/pull/967) (@sdb9696)
|
- Support smart child modules queries [\#967](https://github.com/python-kasa/python-kasa/pull/967) (@sdb9696)
|
||||||
- Do not expose child modules on parent devices [\#964](https://github.com/python-kasa/python-kasa/pull/964) (@sdb9696)
|
- Do not expose child modules on parent devices [\#964](https://github.com/python-kasa/python-kasa/pull/964) (@sdb9696)
|
||||||
- Do not add parent only modules to strip sockets [\#963](https://github.com/python-kasa/python-kasa/pull/963) (@sdb9696)
|
- Do not add parent only modules to strip sockets [\#963](https://github.com/python-kasa/python-kasa/pull/963) (@sdb9696)
|
||||||
- Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti)
|
|
||||||
- Make device initialisation easier by reducing required imports [\#936](https://github.com/python-kasa/python-kasa/pull/936) (@sdb9696)
|
- Make device initialisation easier by reducing required imports [\#936](https://github.com/python-kasa/python-kasa/pull/936) (@sdb9696)
|
||||||
- Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696)
|
- Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696)
|
||||||
- Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti)
|
- Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti)
|
||||||
- Add post update hook to module and use in smart LightEffect [\#921](https://github.com/python-kasa/python-kasa/pull/921) (@sdb9696)
|
- Add post update hook to module and use in smart LightEffect [\#921](https://github.com/python-kasa/python-kasa/pull/921) (@sdb9696)
|
||||||
- Add LightEffect module for smart light strips [\#918](https://github.com/python-kasa/python-kasa/pull/918) (@sdb9696)
|
- Add LightEffect module for smart light strips [\#918](https://github.com/python-kasa/python-kasa/pull/918) (@sdb9696)
|
||||||
- Add light presets common module to devices. [\#907](https://github.com/python-kasa/python-kasa/pull/907) (@sdb9696)
|
|
||||||
- Improve categorization of features [\#904](https://github.com/python-kasa/python-kasa/pull/904) (@rytilahti)
|
- Improve categorization of features [\#904](https://github.com/python-kasa/python-kasa/pull/904) (@rytilahti)
|
||||||
- Create common interfaces for remaining device types [\#895](https://github.com/python-kasa/python-kasa/pull/895) (@sdb9696)
|
|
||||||
- Make get\_module return typed module [\#892](https://github.com/python-kasa/python-kasa/pull/892) (@sdb9696)
|
- Make get\_module return typed module [\#892](https://github.com/python-kasa/python-kasa/pull/892) (@sdb9696)
|
||||||
- Add LightEffectModule for dynamic light effects on SMART bulbs [\#887](https://github.com/python-kasa/python-kasa/pull/887) (@sdb9696)
|
- Add LightEffectModule for dynamic light effects on SMART bulbs [\#887](https://github.com/python-kasa/python-kasa/pull/887) (@sdb9696)
|
||||||
- Implement choice feature type [\#880](https://github.com/python-kasa/python-kasa/pull/880) (@rytilahti)
|
- Implement choice feature type [\#880](https://github.com/python-kasa/python-kasa/pull/880) (@rytilahti)
|
||||||
@ -100,6 +113,11 @@ For more information on the changes please checkout our [documentation on the AP
|
|||||||
- Add --child option to feature command [\#789](https://github.com/python-kasa/python-kasa/pull/789) (@rytilahti)
|
- Add --child option to feature command [\#789](https://github.com/python-kasa/python-kasa/pull/789) (@rytilahti)
|
||||||
- Add temperature\_unit feature to t315 [\#788](https://github.com/python-kasa/python-kasa/pull/788) (@rytilahti)
|
- Add temperature\_unit feature to t315 [\#788](https://github.com/python-kasa/python-kasa/pull/788) (@rytilahti)
|
||||||
- Add feature for ambient light sensor [\#787](https://github.com/python-kasa/python-kasa/pull/787) (@shifty35)
|
- Add feature for ambient light sensor [\#787](https://github.com/python-kasa/python-kasa/pull/787) (@shifty35)
|
||||||
|
- Improve autooff name and unit [\#997](https://github.com/python-kasa/python-kasa/pull/997) (@rytilahti)
|
||||||
|
- Add unit\_getter for feature [\#993](https://github.com/python-kasa/python-kasa/pull/993) (@rytilahti)
|
||||||
|
- Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti)
|
||||||
|
- Add light presets common module to devices. [\#907](https://github.com/python-kasa/python-kasa/pull/907) (@sdb9696)
|
||||||
|
- Create common interfaces for remaining device types [\#895](https://github.com/python-kasa/python-kasa/pull/895) (@sdb9696)
|
||||||
- Add initial support for H100 and T315 [\#776](https://github.com/python-kasa/python-kasa/pull/776) (@rytilahti)
|
- Add initial support for H100 and T315 [\#776](https://github.com/python-kasa/python-kasa/pull/776) (@rytilahti)
|
||||||
- Generalize smartdevice child support [\#775](https://github.com/python-kasa/python-kasa/pull/775) (@rytilahti)
|
- Generalize smartdevice child support [\#775](https://github.com/python-kasa/python-kasa/pull/775) (@rytilahti)
|
||||||
- Raise CLI errors in debug mode [\#771](https://github.com/python-kasa/python-kasa/pull/771) (@sdb9696)
|
- Raise CLI errors in debug mode [\#771](https://github.com/python-kasa/python-kasa/pull/771) (@sdb9696)
|
||||||
@ -117,18 +135,13 @@ For more information on the changes please checkout our [documentation on the AP
|
|||||||
|
|
||||||
- Fix smart led status to report rule status [\#1002](https://github.com/python-kasa/python-kasa/pull/1002) (@sdb9696)
|
- Fix smart led status to report rule status [\#1002](https://github.com/python-kasa/python-kasa/pull/1002) (@sdb9696)
|
||||||
- Demote device\_time back to debug [\#1001](https://github.com/python-kasa/python-kasa/pull/1001) (@rytilahti)
|
- Demote device\_time back to debug [\#1001](https://github.com/python-kasa/python-kasa/pull/1001) (@rytilahti)
|
||||||
- Fix to call update when only --device-family passed to cli [\#987](https://github.com/python-kasa/python-kasa/pull/987) (@sdb9696)
|
|
||||||
- Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti)
|
|
||||||
- Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696)
|
- Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696)
|
||||||
- Fix switching off light effects for iot lights strips [\#961](https://github.com/python-kasa/python-kasa/pull/961) (@sdb9696)
|
- Fix switching off light effects for iot lights strips [\#961](https://github.com/python-kasa/python-kasa/pull/961) (@sdb9696)
|
||||||
- Add state features to iot strip sockets [\#960](https://github.com/python-kasa/python-kasa/pull/960) (@sdb9696)
|
- Add state features to iot strip sockets [\#960](https://github.com/python-kasa/python-kasa/pull/960) (@sdb9696)
|
||||||
- Ensure http delay logic works during default login attempt [\#959](https://github.com/python-kasa/python-kasa/pull/959) (@sdb9696)
|
- Ensure http delay logic works during default login attempt [\#959](https://github.com/python-kasa/python-kasa/pull/959) (@sdb9696)
|
||||||
- Fix fan speed level when off and derive smart fan module from common fan interface [\#957](https://github.com/python-kasa/python-kasa/pull/957) (@sdb9696)
|
- Fix fan speed level when off and derive smart fan module from common fan interface [\#957](https://github.com/python-kasa/python-kasa/pull/957) (@sdb9696)
|
||||||
- Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti)
|
|
||||||
- Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti)
|
|
||||||
- Do not show a zero error code when cli exits from showing help [\#935](https://github.com/python-kasa/python-kasa/pull/935) (@rytilahti)
|
- Do not show a zero error code when cli exits from showing help [\#935](https://github.com/python-kasa/python-kasa/pull/935) (@rytilahti)
|
||||||
- Initialize autooff features only when data is available [\#933](https://github.com/python-kasa/python-kasa/pull/933) (@rytilahti)
|
- Initialize autooff features only when data is available [\#933](https://github.com/python-kasa/python-kasa/pull/933) (@rytilahti)
|
||||||
- Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696)
|
|
||||||
- Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696)
|
- Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696)
|
||||||
- Add 'battery\_percentage' only when it's available [\#906](https://github.com/python-kasa/python-kasa/pull/906) (@rytilahti)
|
- Add 'battery\_percentage' only when it's available [\#906](https://github.com/python-kasa/python-kasa/pull/906) (@rytilahti)
|
||||||
- Add missing alarm volume 'normal' [\#899](https://github.com/python-kasa/python-kasa/pull/899) (@rytilahti)
|
- Add missing alarm volume 'normal' [\#899](https://github.com/python-kasa/python-kasa/pull/899) (@rytilahti)
|
||||||
@ -140,6 +153,11 @@ For more information on the changes please checkout our [documentation on the AP
|
|||||||
- smartbulb: Limit brightness range to 1-100 [\#829](https://github.com/python-kasa/python-kasa/pull/829) (@rytilahti)
|
- smartbulb: Limit brightness range to 1-100 [\#829](https://github.com/python-kasa/python-kasa/pull/829) (@rytilahti)
|
||||||
- Fix energy module calling get\_current\_power [\#798](https://github.com/python-kasa/python-kasa/pull/798) (@sdb9696)
|
- Fix energy module calling get\_current\_power [\#798](https://github.com/python-kasa/python-kasa/pull/798) (@sdb9696)
|
||||||
- Fix auto update switch [\#786](https://github.com/python-kasa/python-kasa/pull/786) (@rytilahti)
|
- Fix auto update switch [\#786](https://github.com/python-kasa/python-kasa/pull/786) (@rytilahti)
|
||||||
|
- Fix to call update when only --device-family passed to cli [\#987](https://github.com/python-kasa/python-kasa/pull/987) (@sdb9696)
|
||||||
|
- Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti)
|
||||||
|
- Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti)
|
||||||
|
- Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti)
|
||||||
|
- Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696)
|
||||||
- Retry query on 403 after successful handshake [\#785](https://github.com/python-kasa/python-kasa/pull/785) (@sdb9696)
|
- Retry query on 403 after successful handshake [\#785](https://github.com/python-kasa/python-kasa/pull/785) (@sdb9696)
|
||||||
- Ensure connections are closed when cli is finished [\#752](https://github.com/python-kasa/python-kasa/pull/752) (@sdb9696)
|
- Ensure connections are closed when cli is finished [\#752](https://github.com/python-kasa/python-kasa/pull/752) (@sdb9696)
|
||||||
- Fix for P100 on fw 1.1.3 login\_version none [\#751](https://github.com/python-kasa/python-kasa/pull/751) (@sdb9696)
|
- Fix for P100 on fw 1.1.3 login\_version none [\#751](https://github.com/python-kasa/python-kasa/pull/751) (@sdb9696)
|
||||||
@ -172,19 +190,18 @@ For more information on the changes please checkout our [documentation on the AP
|
|||||||
|
|
||||||
- Cleanup README to use the new cli format [\#999](https://github.com/python-kasa/python-kasa/pull/999) (@rytilahti)
|
- Cleanup README to use the new cli format [\#999](https://github.com/python-kasa/python-kasa/pull/999) (@rytilahti)
|
||||||
- Add 0.7 api changes section to docs [\#996](https://github.com/python-kasa/python-kasa/pull/996) (@sdb9696)
|
- Add 0.7 api changes section to docs [\#996](https://github.com/python-kasa/python-kasa/pull/996) (@sdb9696)
|
||||||
- Update README to be more approachable for new users [\#994](https://github.com/python-kasa/python-kasa/pull/994) (@rytilahti)
|
|
||||||
- Update docs with more howto examples [\#968](https://github.com/python-kasa/python-kasa/pull/968) (@sdb9696)
|
- Update docs with more howto examples [\#968](https://github.com/python-kasa/python-kasa/pull/968) (@sdb9696)
|
||||||
- Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696)
|
- Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696)
|
||||||
- Add tutorial doctest module and enable top level await [\#919](https://github.com/python-kasa/python-kasa/pull/919) (@sdb9696)
|
- Add tutorial doctest module and enable top level await [\#919](https://github.com/python-kasa/python-kasa/pull/919) (@sdb9696)
|
||||||
- Add warning about tapo watchdog [\#902](https://github.com/python-kasa/python-kasa/pull/902) (@rytilahti)
|
- Add warning about tapo watchdog [\#902](https://github.com/python-kasa/python-kasa/pull/902) (@rytilahti)
|
||||||
- Move contribution instructions into docs [\#901](https://github.com/python-kasa/python-kasa/pull/901) (@rytilahti)
|
- Move contribution instructions into docs [\#901](https://github.com/python-kasa/python-kasa/pull/901) (@rytilahti)
|
||||||
- Add rust tapo link to README [\#857](https://github.com/python-kasa/python-kasa/pull/857) (@rytilahti)
|
- Add rust tapo link to README [\#857](https://github.com/python-kasa/python-kasa/pull/857) (@rytilahti)
|
||||||
|
- Update README to be more approachable for new users [\#994](https://github.com/python-kasa/python-kasa/pull/994) (@rytilahti)
|
||||||
- Enable shell extra for installing ptpython and rich [\#782](https://github.com/python-kasa/python-kasa/pull/782) (@sdb9696)
|
- Enable shell extra for installing ptpython and rich [\#782](https://github.com/python-kasa/python-kasa/pull/782) (@sdb9696)
|
||||||
- Add WallSwitch device type and autogenerate supported devices docs [\#758](https://github.com/python-kasa/python-kasa/pull/758) (@sdb9696)
|
- Add WallSwitch device type and autogenerate supported devices docs [\#758](https://github.com/python-kasa/python-kasa/pull/758) (@sdb9696)
|
||||||
|
|
||||||
**Project maintenance:**
|
**Project maintenance:**
|
||||||
|
|
||||||
- Drop python3.8 support [\#992](https://github.com/python-kasa/python-kasa/pull/992) (@rytilahti)
|
|
||||||
- Remove anyio dependency from pyproject.toml [\#990](https://github.com/python-kasa/python-kasa/pull/990) (@sdb9696)
|
- Remove anyio dependency from pyproject.toml [\#990](https://github.com/python-kasa/python-kasa/pull/990) (@sdb9696)
|
||||||
- Configure mypy to run in virtual environment and fix resulting issues [\#989](https://github.com/python-kasa/python-kasa/pull/989) (@sdb9696)
|
- Configure mypy to run in virtual environment and fix resulting issues [\#989](https://github.com/python-kasa/python-kasa/pull/989) (@sdb9696)
|
||||||
- Better checking of child modules not supported by parent device [\#966](https://github.com/python-kasa/python-kasa/pull/966) (@sdb9696)
|
- Better checking of child modules not supported by parent device [\#966](https://github.com/python-kasa/python-kasa/pull/966) (@sdb9696)
|
||||||
@ -194,7 +211,6 @@ For more information on the changes please checkout our [documentation on the AP
|
|||||||
- Deprecate device level light, effect and led attributes [\#916](https://github.com/python-kasa/python-kasa/pull/916) (@sdb9696)
|
- Deprecate device level light, effect and led attributes [\#916](https://github.com/python-kasa/python-kasa/pull/916) (@sdb9696)
|
||||||
- Update cli to use common modules and remove iot specific cli testing [\#913](https://github.com/python-kasa/python-kasa/pull/913) (@sdb9696)
|
- Update cli to use common modules and remove iot specific cli testing [\#913](https://github.com/python-kasa/python-kasa/pull/913) (@sdb9696)
|
||||||
- Deprecate is\_something attributes [\#912](https://github.com/python-kasa/python-kasa/pull/912) (@sdb9696)
|
- Deprecate is\_something attributes [\#912](https://github.com/python-kasa/python-kasa/pull/912) (@sdb9696)
|
||||||
- Make Light and Fan a common module interface [\#911](https://github.com/python-kasa/python-kasa/pull/911) (@sdb9696)
|
|
||||||
- Rename bulb interface to light and move fan and light interface to interfaces [\#910](https://github.com/python-kasa/python-kasa/pull/910) (@sdb9696)
|
- Rename bulb interface to light and move fan and light interface to interfaces [\#910](https://github.com/python-kasa/python-kasa/pull/910) (@sdb9696)
|
||||||
- Make module names consistent and remove redundant module casting [\#909](https://github.com/python-kasa/python-kasa/pull/909) (@sdb9696)
|
- Make module names consistent and remove redundant module casting [\#909](https://github.com/python-kasa/python-kasa/pull/909) (@sdb9696)
|
||||||
- Add child devices from hubs to generated list of supported devices [\#898](https://github.com/python-kasa/python-kasa/pull/898) (@sdb9696)
|
- Add child devices from hubs to generated list of supported devices [\#898](https://github.com/python-kasa/python-kasa/pull/898) (@sdb9696)
|
||||||
@ -229,10 +245,12 @@ For more information on the changes please checkout our [documentation on the AP
|
|||||||
- Do not fail fast on pypy CI jobs [\#799](https://github.com/python-kasa/python-kasa/pull/799) (@sdb9696)
|
- Do not fail fast on pypy CI jobs [\#799](https://github.com/python-kasa/python-kasa/pull/799) (@sdb9696)
|
||||||
- Update dump\_devinfo to collect child device info [\#796](https://github.com/python-kasa/python-kasa/pull/796) (@sdb9696)
|
- Update dump\_devinfo to collect child device info [\#796](https://github.com/python-kasa/python-kasa/pull/796) (@sdb9696)
|
||||||
- Refactor test framework [\#794](https://github.com/python-kasa/python-kasa/pull/794) (@sdb9696)
|
- Refactor test framework [\#794](https://github.com/python-kasa/python-kasa/pull/794) (@sdb9696)
|
||||||
|
- Refactor devices into subpackages and deprecate old names [\#716](https://github.com/python-kasa/python-kasa/pull/716) (@sdb9696)
|
||||||
|
- Drop python3.8 support [\#992](https://github.com/python-kasa/python-kasa/pull/992) (@rytilahti)
|
||||||
|
- Make Light and Fan a common module interface [\#911](https://github.com/python-kasa/python-kasa/pull/911) (@sdb9696)
|
||||||
- Add missing firmware module import [\#774](https://github.com/python-kasa/python-kasa/pull/774) (@rytilahti)
|
- Add missing firmware module import [\#774](https://github.com/python-kasa/python-kasa/pull/774) (@rytilahti)
|
||||||
- Fix dump\_devinfo scrubbing for ks240 [\#765](https://github.com/python-kasa/python-kasa/pull/765) (@rytilahti)
|
- Fix dump\_devinfo scrubbing for ks240 [\#765](https://github.com/python-kasa/python-kasa/pull/765) (@rytilahti)
|
||||||
- Rename and deprecate exception classes [\#739](https://github.com/python-kasa/python-kasa/pull/739) (@sdb9696)
|
- Rename and deprecate exception classes [\#739](https://github.com/python-kasa/python-kasa/pull/739) (@sdb9696)
|
||||||
- Refactor devices into subpackages and deprecate old names [\#716](https://github.com/python-kasa/python-kasa/pull/716) (@sdb9696)
|
|
||||||
|
|
||||||
## [0.6.2.1](https://github.com/python-kasa/python-kasa/tree/0.6.2.1) (2024-02-02)
|
## [0.6.2.1](https://github.com/python-kasa/python-kasa/tree/0.6.2.1) (2024-02-02)
|
||||||
|
|
||||||
|
@ -73,6 +73,8 @@ gh issue close ISSUE_NUMBER
|
|||||||
|
|
||||||
## Generate changelog
|
## Generate changelog
|
||||||
|
|
||||||
|
Configuration settings are in `.github_changelog_generator`
|
||||||
|
|
||||||
### For pre-release
|
### For pre-release
|
||||||
|
|
||||||
EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags.
|
EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags.
|
||||||
@ -82,13 +84,13 @@ Regex should be something like this `^((?!0\.7\.0)(.*dev\d))+`. The first match
|
|||||||
```bash
|
```bash
|
||||||
EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+"
|
EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+"
|
||||||
echo "$EXCLUDE_TAGS"
|
echo "$EXCLUDE_TAGS"
|
||||||
github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex "$EXCLUDE_TAGS"
|
github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex "$EXCLUDE_TAGS"
|
||||||
```
|
```
|
||||||
|
|
||||||
### For production
|
### For production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex 'dev\d$'
|
github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex 'dev\d$'
|
||||||
```
|
```
|
||||||
|
|
||||||
You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master:
|
You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master:
|
||||||
|
@ -209,6 +209,7 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
|
|||||||
- Hardware: 1.0 (EU) / Firmware: 1.1.0
|
- Hardware: 1.0 (EU) / Firmware: 1.1.0
|
||||||
- **L920-5**
|
- **L920-5**
|
||||||
- Hardware: 1.0 (EU) / Firmware: 1.0.7
|
- Hardware: 1.0 (EU) / Firmware: 1.0.7
|
||||||
|
- Hardware: 1.0 (EU) / Firmware: 1.1.3
|
||||||
- Hardware: 1.0 (US) / Firmware: 1.1.0
|
- Hardware: 1.0 (US) / Firmware: 1.1.0
|
||||||
- Hardware: 1.0 (US) / Firmware: 1.1.3
|
- Hardware: 1.0 (US) / Firmware: 1.1.3
|
||||||
- **L930-5**
|
- **L930-5**
|
||||||
|
@ -117,8 +117,10 @@ class AesTransport(BaseTransport):
|
|||||||
return self.DEFAULT_PORT
|
return self.DEFAULT_PORT
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def credentials_hash(self) -> str:
|
def credentials_hash(self) -> str | None:
|
||||||
"""The hashed credentials used by the transport."""
|
"""The hashed credentials used by the transport."""
|
||||||
|
if self._credentials == Credentials():
|
||||||
|
return None
|
||||||
return base64.b64encode(json_dumps(self._login_params).encode()).decode()
|
return base64.b64encode(json_dumps(self._login_params).encode()).decode()
|
||||||
|
|
||||||
def _get_login_params(self, credentials: Credentials) -> dict[str, str]:
|
def _get_login_params(self, credentials: Credentials) -> dict[str, str]:
|
||||||
|
316
kasa/cli.py
316
kasa/cli.py
@ -8,11 +8,11 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import singledispatch, wraps
|
from functools import singledispatch, update_wrapper, wraps
|
||||||
from pprint import pformat as pf
|
from pprint import pformat as pf
|
||||||
from typing import Any, cast
|
from typing import Any, Final, cast
|
||||||
|
|
||||||
import asyncclick as click
|
import asyncclick as click
|
||||||
from pydantic.v1 import ValidationError
|
from pydantic.v1 import ValidationError
|
||||||
@ -41,6 +41,7 @@ from kasa.iot import (
|
|||||||
IotStrip,
|
IotStrip,
|
||||||
IotWallSwitch,
|
IotWallSwitch,
|
||||||
)
|
)
|
||||||
|
from kasa.iot.iotstrip import IotStripPlug
|
||||||
from kasa.iot.modules import Usage
|
from kasa.iot.modules import Usage
|
||||||
from kasa.smart import SmartDevice
|
from kasa.smart import SmartDevice
|
||||||
|
|
||||||
@ -77,6 +78,9 @@ def error(msg: str):
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Value for optional options if passed without a value
|
||||||
|
OPTIONAL_VALUE_FLAG: Final = "_FLAG_"
|
||||||
|
|
||||||
TYPE_TO_CLASS = {
|
TYPE_TO_CLASS = {
|
||||||
"plug": IotPlug,
|
"plug": IotPlug,
|
||||||
"switch": IotWallSwitch,
|
"switch": IotWallSwitch,
|
||||||
@ -169,6 +173,112 @@ def json_formatter_cb(result, **kwargs):
|
|||||||
print(json_content)
|
print(json_content)
|
||||||
|
|
||||||
|
|
||||||
|
def pass_dev_or_child(wrapped_function):
|
||||||
|
"""Pass the device or child to the click command based on the child options."""
|
||||||
|
child_help = (
|
||||||
|
"Child ID or alias for controlling sub-devices. "
|
||||||
|
"If no value provided will show an interactive prompt allowing you to "
|
||||||
|
"select a child."
|
||||||
|
)
|
||||||
|
child_index_help = "Child index controlling sub-devices"
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def patched_device_update(parent: Device, child: Device):
|
||||||
|
try:
|
||||||
|
orig_update = child.update
|
||||||
|
# patch child update method. Can be removed once update can be called
|
||||||
|
# directly on child devices
|
||||||
|
child.update = parent.update # type: ignore[method-assign]
|
||||||
|
yield child
|
||||||
|
finally:
|
||||||
|
child.update = orig_update # type: ignore[method-assign]
|
||||||
|
|
||||||
|
@click.pass_obj
|
||||||
|
@click.pass_context
|
||||||
|
@click.option(
|
||||||
|
"--child",
|
||||||
|
"--name",
|
||||||
|
is_flag=False,
|
||||||
|
flag_value=OPTIONAL_VALUE_FLAG,
|
||||||
|
default=None,
|
||||||
|
required=False,
|
||||||
|
type=click.STRING,
|
||||||
|
help=child_help,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--child-index",
|
||||||
|
"--index",
|
||||||
|
required=False,
|
||||||
|
default=None,
|
||||||
|
type=click.INT,
|
||||||
|
help=child_index_help,
|
||||||
|
)
|
||||||
|
async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs):
|
||||||
|
if child := await _get_child_device(dev, child, child_index, ctx.info_name):
|
||||||
|
ctx.obj = ctx.with_resource(patched_device_update(dev, child))
|
||||||
|
dev = child
|
||||||
|
return await ctx.invoke(wrapped_function, dev, *args, **kwargs)
|
||||||
|
|
||||||
|
# Update wrapper function to look like wrapped function
|
||||||
|
return update_wrapper(wrapper, wrapped_function)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_child_device(
|
||||||
|
device: Device, child_option, child_index_option, info_command
|
||||||
|
) -> Device | None:
|
||||||
|
def _list_children():
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
f"{idx}: {child.device_id} ({child.alias})"
|
||||||
|
for idx, child in enumerate(device.children)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if child_option is None and child_index_option is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if info_command in SKIP_UPDATE_COMMANDS:
|
||||||
|
# The device hasn't had update called (e.g. for cmd_command)
|
||||||
|
# The way child devices are accessed requires a ChildDevice to
|
||||||
|
# wrap the communications. Doing this properly would require creating
|
||||||
|
# a common interfaces for both IOT and SMART child devices.
|
||||||
|
# As a stop-gap solution, we perform an update instead.
|
||||||
|
await device.update()
|
||||||
|
|
||||||
|
if not device.children:
|
||||||
|
error(f"Device: {device.host} does not have children")
|
||||||
|
|
||||||
|
if child_option is not None and child_index_option is not None:
|
||||||
|
raise click.BadOptionUsage(
|
||||||
|
"child", "Use either --child or --child-index, not both."
|
||||||
|
)
|
||||||
|
|
||||||
|
if child_option is not None:
|
||||||
|
if child_option is OPTIONAL_VALUE_FLAG:
|
||||||
|
msg = _list_children()
|
||||||
|
child_index_option = click.prompt(
|
||||||
|
f"\n{msg}\nEnter the index number of the child device",
|
||||||
|
type=click.IntRange(0, len(device.children) - 1),
|
||||||
|
)
|
||||||
|
elif child := device.get_child_device(child_option):
|
||||||
|
echo(f"Targeting child device {child.alias}")
|
||||||
|
return child
|
||||||
|
else:
|
||||||
|
error(
|
||||||
|
"No child device found with device_id or name: "
|
||||||
|
f"{child_option} children are:\n{_list_children()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if child_index_option + 1 > len(device.children) or child_index_option < 0:
|
||||||
|
error(
|
||||||
|
f"Invalid index {child_index_option}, "
|
||||||
|
f"device has {len(device.children)} children"
|
||||||
|
)
|
||||||
|
child_by_index = device.children[child_index_option]
|
||||||
|
echo(f"Targeting child device {child_by_index.alias}")
|
||||||
|
return child_by_index
|
||||||
|
|
||||||
|
|
||||||
@click.group(
|
@click.group(
|
||||||
invoke_without_command=True,
|
invoke_without_command=True,
|
||||||
cls=CatchAllExceptions(click.Group),
|
cls=CatchAllExceptions(click.Group),
|
||||||
@ -232,6 +342,7 @@ def json_formatter_cb(result, **kwargs):
|
|||||||
help="Output raw device response as JSON.",
|
help="Output raw device response as JSON.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
|
"-e",
|
||||||
"--encrypt-type",
|
"--encrypt-type",
|
||||||
envvar="KASA_ENCRYPT_TYPE",
|
envvar="KASA_ENCRYPT_TYPE",
|
||||||
default=None,
|
default=None,
|
||||||
@ -240,13 +351,14 @@ def json_formatter_cb(result, **kwargs):
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--device-family",
|
"--device-family",
|
||||||
envvar="KASA_DEVICE_FAMILY",
|
envvar="KASA_DEVICE_FAMILY",
|
||||||
default=None,
|
default="SMART.TAPOPLUG",
|
||||||
type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False),
|
type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False),
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
|
"-lv",
|
||||||
"--login-version",
|
"--login-version",
|
||||||
envvar="KASA_LOGIN_VERSION",
|
envvar="KASA_LOGIN_VERSION",
|
||||||
default=None,
|
default=2,
|
||||||
type=int,
|
type=int,
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
@ -379,7 +491,8 @@ async def cli(
|
|||||||
|
|
||||||
device_updated = False
|
device_updated = False
|
||||||
if type is not None:
|
if type is not None:
|
||||||
dev = TYPE_TO_CLASS[type](host)
|
config = DeviceConfig(host=host, port_override=port, timeout=timeout)
|
||||||
|
dev = TYPE_TO_CLASS[type](host, config=config)
|
||||||
elif device_family and encrypt_type:
|
elif device_family and encrypt_type:
|
||||||
ctype = DeviceConnectionParameters(
|
ctype = DeviceConnectionParameters(
|
||||||
DeviceFamily(device_family),
|
DeviceFamily(device_family),
|
||||||
@ -397,12 +510,6 @@ async def cli(
|
|||||||
dev = await Device.connect(config=config)
|
dev = await Device.connect(config=config)
|
||||||
device_updated = True
|
device_updated = True
|
||||||
else:
|
else:
|
||||||
if device_family or encrypt_type:
|
|
||||||
echo(
|
|
||||||
"--device-family and --encrypt-type options must both be "
|
|
||||||
"provided or they are ignored\n"
|
|
||||||
f"discovering for {discovery_timeout} seconds.."
|
|
||||||
)
|
|
||||||
dev = await Discover.discover_single(
|
dev = await Discover.discover_single(
|
||||||
host,
|
host,
|
||||||
port=port,
|
port=port,
|
||||||
@ -587,7 +694,7 @@ async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attem
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def sysinfo(dev):
|
async def sysinfo(dev):
|
||||||
"""Print out full system information."""
|
"""Print out full system information."""
|
||||||
echo("== System info ==")
|
echo("== System info ==")
|
||||||
@ -624,6 +731,7 @@ def _echo_all_features(features, *, verbose=False, title_prefix=None, indent="")
|
|||||||
"""Print out all features by category."""
|
"""Print out all features by category."""
|
||||||
if title_prefix is not None:
|
if title_prefix is not None:
|
||||||
echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]")
|
echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]")
|
||||||
|
echo()
|
||||||
_echo_features(
|
_echo_features(
|
||||||
features,
|
features,
|
||||||
title="== Primary features ==",
|
title="== Primary features ==",
|
||||||
@ -658,7 +766,7 @@ def _echo_all_features(features, *, verbose=False, title_prefix=None, indent="")
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
async def state(ctx, dev: Device):
|
async def state(ctx, dev: Device):
|
||||||
"""Print out device state and versions."""
|
"""Print out device state and versions."""
|
||||||
@ -676,11 +784,16 @@ async def state(ctx, dev: Device):
|
|||||||
if verbose:
|
if verbose:
|
||||||
echo(f"Location: {dev.location}")
|
echo(f"Location: {dev.location}")
|
||||||
|
|
||||||
_echo_all_features(dev.features, verbose=verbose)
|
|
||||||
echo()
|
echo()
|
||||||
|
_echo_all_features(dev.features, verbose=verbose)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
echo("\n[bold]== Modules ==[/bold]")
|
||||||
|
for module in dev.modules.values():
|
||||||
|
echo(f"[green]+ {module}[/green]")
|
||||||
|
|
||||||
if dev.children:
|
if dev.children:
|
||||||
echo("[bold]== Children ==[/bold]")
|
echo("\n[bold]== Children ==[/bold]")
|
||||||
for child in dev.children:
|
for child in dev.children:
|
||||||
_echo_all_features(
|
_echo_all_features(
|
||||||
child.features,
|
child.features,
|
||||||
@ -688,14 +801,13 @@ async def state(ctx, dev: Device):
|
|||||||
verbose=verbose,
|
verbose=verbose,
|
||||||
indent="\t",
|
indent="\t",
|
||||||
)
|
)
|
||||||
|
if verbose:
|
||||||
|
echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]")
|
||||||
|
for module in child.modules.values():
|
||||||
|
echo(f"\t[green]+ {module}[/green]")
|
||||||
echo()
|
echo()
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
echo("\n\t[bold]== Modules ==[/bold]")
|
|
||||||
for module in dev.modules.values():
|
|
||||||
echo(f"\t[green]+ {module}[/green]")
|
|
||||||
|
|
||||||
echo("\n\t[bold]== Protocol information ==[/bold]")
|
echo("\n\t[bold]== Protocol information ==[/bold]")
|
||||||
echo(f"\tCredentials hash: {dev.credentials_hash}")
|
echo(f"\tCredentials hash: {dev.credentials_hash}")
|
||||||
echo()
|
echo()
|
||||||
@ -705,24 +817,19 @@ async def state(ctx, dev: Device):
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
|
||||||
@click.argument("new_alias", required=False, default=None)
|
@click.argument("new_alias", required=False, default=None)
|
||||||
@click.option("--index", type=int)
|
@pass_dev_or_child
|
||||||
async def alias(dev, new_alias, index):
|
async def alias(dev, new_alias):
|
||||||
"""Get or set the device (or plug) alias."""
|
"""Get or set the device (or plug) alias."""
|
||||||
if index is not None:
|
|
||||||
if not dev.is_strip:
|
|
||||||
echo("Index can only used for power strips!")
|
|
||||||
return
|
|
||||||
dev = dev.get_plug_by_index(index)
|
|
||||||
|
|
||||||
if new_alias is not None:
|
if new_alias is not None:
|
||||||
echo(f"Setting alias to {new_alias}")
|
echo(f"Setting alias to {new_alias}")
|
||||||
res = await dev.set_alias(new_alias)
|
res = await dev.set_alias(new_alias)
|
||||||
|
await dev.update()
|
||||||
|
echo(f"Alias set to: {dev.alias}")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
echo(f"Alias: {dev.alias}")
|
echo(f"Alias: {dev.alias}")
|
||||||
if dev.is_strip:
|
if dev.children:
|
||||||
for plug in dev.children:
|
for plug in dev.children:
|
||||||
echo(f" * {plug.alias}")
|
echo(f" * {plug.alias}")
|
||||||
|
|
||||||
@ -730,36 +837,26 @@ async def alias(dev, new_alias, index):
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@click.argument("module")
|
@click.argument("module")
|
||||||
@click.argument("command")
|
@click.argument("command")
|
||||||
@click.argument("parameters", default=None, required=False)
|
@click.argument("parameters", default=None, required=False)
|
||||||
async def raw_command(ctx, dev: Device, module, command, parameters):
|
async def raw_command(ctx, module, command, parameters):
|
||||||
"""Run a raw command on the device."""
|
"""Run a raw command on the device."""
|
||||||
logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command)
|
logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command)
|
||||||
return await ctx.forward(cmd_command)
|
return await ctx.forward(cmd_command)
|
||||||
|
|
||||||
|
|
||||||
@cli.command(name="command")
|
@cli.command(name="command")
|
||||||
@pass_dev
|
|
||||||
@click.option("--module", required=False, help="Module for IOT protocol.")
|
@click.option("--module", required=False, help="Module for IOT protocol.")
|
||||||
@click.option("--child", required=False, help="Child ID for controlling sub-devices")
|
|
||||||
@click.argument("command")
|
@click.argument("command")
|
||||||
@click.argument("parameters", default=None, required=False)
|
@click.argument("parameters", default=None, required=False)
|
||||||
async def cmd_command(dev: Device, module, child, command, parameters):
|
@pass_dev_or_child
|
||||||
|
async def cmd_command(dev: Device, module, command, parameters):
|
||||||
"""Run a raw command on the device."""
|
"""Run a raw command on the device."""
|
||||||
if parameters is not None:
|
if parameters is not None:
|
||||||
parameters = ast.literal_eval(parameters)
|
parameters = ast.literal_eval(parameters)
|
||||||
|
|
||||||
if child:
|
|
||||||
# The way child devices are accessed requires a ChildDevice to
|
|
||||||
# wrap the communications. Doing this properly would require creating
|
|
||||||
# a common interfaces for both IOT and SMART child devices.
|
|
||||||
# As a stop-gap solution, we perform an update instead.
|
|
||||||
await dev.update()
|
|
||||||
dev = dev.get_child_device(child)
|
|
||||||
|
|
||||||
if isinstance(dev, IotDevice):
|
if isinstance(dev, IotDevice):
|
||||||
res = await dev._query_helper(module, command, parameters)
|
res = await dev._query_helper(module, command, parameters)
|
||||||
elif isinstance(dev, SmartDevice):
|
elif isinstance(dev, SmartDevice):
|
||||||
@ -771,27 +868,30 @@ async def cmd_command(dev: Device, module, child, command, parameters):
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
|
||||||
@click.option("--index", type=int, required=False)
|
@click.option("--index", type=int, required=False)
|
||||||
@click.option("--name", type=str, required=False)
|
@click.option("--name", type=str, required=False)
|
||||||
@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)
|
||||||
@click.option("--erase", is_flag=True)
|
@click.option("--erase", is_flag=True)
|
||||||
async def emeter(dev: Device, index: int, name: str, year, month, erase):
|
@click.pass_context
|
||||||
"""Query emeter for historical consumption.
|
async def emeter(ctx: click.Context, index, name, year, month, erase):
|
||||||
|
"""Query emeter for historical consumption."""
|
||||||
|
logging.warning("Deprecated, use 'kasa energy'")
|
||||||
|
return await ctx.invoke(
|
||||||
|
energy, child_index=index, child=name, year=year, month=month, erase=erase
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
|
||||||
|
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
|
||||||
|
@click.option("--erase", is_flag=True)
|
||||||
|
@pass_dev_or_child
|
||||||
|
async def energy(dev: Device, year, month, erase):
|
||||||
|
"""Query energy module for historical consumption.
|
||||||
|
|
||||||
Daily and monthly data provided in CSV format.
|
Daily and monthly data provided in CSV format.
|
||||||
"""
|
"""
|
||||||
if index is not None or name is not None:
|
|
||||||
if not dev.is_strip:
|
|
||||||
error("Index and name are only for power strips!")
|
|
||||||
return
|
|
||||||
|
|
||||||
if index is not None:
|
|
||||||
dev = dev.get_plug_by_index(index)
|
|
||||||
elif name:
|
|
||||||
dev = dev.get_plug_by_name(name)
|
|
||||||
|
|
||||||
echo("[bold]== Emeter ==[/bold]")
|
echo("[bold]== Emeter ==[/bold]")
|
||||||
if not dev.has_emeter:
|
if not dev.has_emeter:
|
||||||
error("Device has no emeter")
|
error("Device has no emeter")
|
||||||
@ -817,7 +917,7 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase):
|
|||||||
usage_data = await dev.get_emeter_daily(year=month.year, month=month.month)
|
usage_data = await dev.get_emeter_daily(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
|
||||||
if index is not None or name is not None:
|
if isinstance(dev, IotStripPlug):
|
||||||
emeter_status = await dev.get_emeter_realtime()
|
emeter_status = await dev.get_emeter_realtime()
|
||||||
else:
|
else:
|
||||||
emeter_status = dev.emeter_realtime
|
emeter_status = dev.emeter_realtime
|
||||||
@ -840,10 +940,10 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase):
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
|
||||||
@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)
|
||||||
@click.option("--erase", is_flag=True)
|
@click.option("--erase", is_flag=True)
|
||||||
|
@pass_dev_or_child
|
||||||
async def usage(dev: Device, year, month, erase):
|
async def usage(dev: Device, year, month, erase):
|
||||||
"""Query usage for historical consumption.
|
"""Query usage for historical consumption.
|
||||||
|
|
||||||
@ -881,7 +981,7 @@ async def usage(dev: Device, year, month, erase):
|
|||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
|
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
|
||||||
@click.option("--transition", type=int, required=False)
|
@click.option("--transition", type=int, required=False)
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def brightness(dev: Device, brightness: int, transition: int):
|
async def brightness(dev: Device, brightness: int, transition: int):
|
||||||
"""Get or set brightness."""
|
"""Get or set brightness."""
|
||||||
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
|
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
|
||||||
@ -901,7 +1001,7 @@ async def brightness(dev: Device, brightness: int, transition: int):
|
|||||||
"temperature", type=click.IntRange(2500, 9000), default=None, required=False
|
"temperature", type=click.IntRange(2500, 9000), default=None, required=False
|
||||||
)
|
)
|
||||||
@click.option("--transition", type=int, required=False)
|
@click.option("--transition", type=int, required=False)
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def temperature(dev: Device, temperature: int, transition: int):
|
async def temperature(dev: Device, temperature: int, transition: int):
|
||||||
"""Get or set color temperature."""
|
"""Get or set color temperature."""
|
||||||
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
|
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
|
||||||
@ -927,7 +1027,7 @@ async def temperature(dev: Device, temperature: int, transition: int):
|
|||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument("effect", type=click.STRING, default=None, required=False)
|
@click.argument("effect", type=click.STRING, default=None, required=False)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def effect(dev: Device, ctx, effect):
|
async def effect(dev: Device, ctx, effect):
|
||||||
"""Set an effect."""
|
"""Set an effect."""
|
||||||
if not (light_effect := dev.modules.get(Module.LightEffect)):
|
if not (light_effect := dev.modules.get(Module.LightEffect)):
|
||||||
@ -955,7 +1055,7 @@ async def effect(dev: Device, ctx, effect):
|
|||||||
@click.argument("v", type=click.IntRange(0, 100), default=None, required=False)
|
@click.argument("v", type=click.IntRange(0, 100), default=None, required=False)
|
||||||
@click.option("--transition", type=int, required=False)
|
@click.option("--transition", type=int, required=False)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def hsv(dev: Device, ctx, h, s, v, transition):
|
async def hsv(dev: Device, ctx, h, s, v, transition):
|
||||||
"""Get or set color in HSV."""
|
"""Get or set color in HSV."""
|
||||||
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
|
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
|
||||||
@ -974,7 +1074,7 @@ async def hsv(dev: Device, ctx, h, s, v, transition):
|
|||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument("state", type=bool, required=False)
|
@click.argument("state", type=bool, required=False)
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def led(dev: Device, state):
|
async def led(dev: Device, state):
|
||||||
"""Get or set (Plug's) led state."""
|
"""Get or set (Plug's) led state."""
|
||||||
if not (led := dev.modules.get(Module.Led)):
|
if not (led := dev.modules.get(Module.Led)):
|
||||||
@ -1026,64 +1126,28 @@ async def time_sync(dev: Device):
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option("--index", type=int, required=False)
|
|
||||||
@click.option("--name", type=str, required=False)
|
|
||||||
@click.option("--transition", type=int, required=False)
|
@click.option("--transition", type=int, required=False)
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def on(dev: Device, index: int, name: str, transition: int):
|
async def on(dev: Device, transition: int):
|
||||||
"""Turn the device on."""
|
"""Turn the device on."""
|
||||||
if index is not None or name is not None:
|
|
||||||
if not dev.children:
|
|
||||||
error("Index and name are only for devices with children.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if index is not None:
|
|
||||||
dev = dev.get_plug_by_index(index)
|
|
||||||
elif name:
|
|
||||||
dev = dev.get_plug_by_name(name)
|
|
||||||
|
|
||||||
echo(f"Turning on {dev.alias}")
|
echo(f"Turning on {dev.alias}")
|
||||||
return await dev.turn_on(transition=transition)
|
return await dev.turn_on(transition=transition)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command
|
||||||
@click.option("--index", type=int, required=False)
|
|
||||||
@click.option("--name", type=str, required=False)
|
|
||||||
@click.option("--transition", type=int, required=False)
|
@click.option("--transition", type=int, required=False)
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def off(dev: Device, index: int, name: str, transition: int):
|
async def off(dev: Device, transition: int):
|
||||||
"""Turn the device off."""
|
"""Turn the device off."""
|
||||||
if index is not None or name is not None:
|
|
||||||
if not dev.children:
|
|
||||||
error("Index and name are only for devices with children.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if index is not None:
|
|
||||||
dev = dev.get_plug_by_index(index)
|
|
||||||
elif name:
|
|
||||||
dev = dev.get_plug_by_name(name)
|
|
||||||
|
|
||||||
echo(f"Turning off {dev.alias}")
|
echo(f"Turning off {dev.alias}")
|
||||||
return await dev.turn_off(transition=transition)
|
return await dev.turn_off(transition=transition)
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option("--index", type=int, required=False)
|
|
||||||
@click.option("--name", type=str, required=False)
|
|
||||||
@click.option("--transition", type=int, required=False)
|
@click.option("--transition", type=int, required=False)
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def toggle(dev: Device, index: int, name: str, transition: int):
|
async def toggle(dev: Device, transition: int):
|
||||||
"""Toggle the device on/off."""
|
"""Toggle the device on/off."""
|
||||||
if index is not None or name is not None:
|
|
||||||
if not dev.children:
|
|
||||||
error("Index and name are only for devices with children.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if index is not None:
|
|
||||||
dev = dev.get_plug_by_index(index)
|
|
||||||
elif name:
|
|
||||||
dev = dev.get_plug_by_name(name)
|
|
||||||
|
|
||||||
if dev.is_on:
|
if dev.is_on:
|
||||||
echo(f"Turning off {dev.alias}")
|
echo(f"Turning off {dev.alias}")
|
||||||
return await dev.turn_off(transition=transition)
|
return await dev.turn_off(transition=transition)
|
||||||
@ -1108,9 +1172,9 @@ async def schedule(dev):
|
|||||||
|
|
||||||
|
|
||||||
@schedule.command(name="list")
|
@schedule.command(name="list")
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
@click.argument("type", default="schedule")
|
@click.argument("type", default="schedule")
|
||||||
def _schedule_list(dev, type):
|
async def _schedule_list(dev, type):
|
||||||
"""Return the list of schedule actions for the given type."""
|
"""Return the list of schedule actions for the given type."""
|
||||||
sched = dev.modules[type]
|
sched = dev.modules[type]
|
||||||
for rule in sched.rules:
|
for rule in sched.rules:
|
||||||
@ -1122,7 +1186,7 @@ def _schedule_list(dev, type):
|
|||||||
|
|
||||||
|
|
||||||
@schedule.command(name="delete")
|
@schedule.command(name="delete")
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
@click.option("--id", type=str, required=True)
|
@click.option("--id", type=str, required=True)
|
||||||
async def delete_rule(dev, id):
|
async def delete_rule(dev, id):
|
||||||
"""Delete rule from device."""
|
"""Delete rule from device."""
|
||||||
@ -1136,25 +1200,26 @@ async def delete_rule(dev, id):
|
|||||||
|
|
||||||
|
|
||||||
@cli.group(invoke_without_command=True)
|
@cli.group(invoke_without_command=True)
|
||||||
|
@pass_dev_or_child
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
async def presets(ctx):
|
async def presets(ctx, dev):
|
||||||
"""List and modify bulb setting presets."""
|
"""List and modify bulb setting presets."""
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
return await ctx.invoke(presets_list)
|
return await ctx.invoke(presets_list)
|
||||||
|
|
||||||
|
|
||||||
@presets.command(name="list")
|
@presets.command(name="list")
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
def presets_list(dev: Device):
|
def presets_list(dev: Device):
|
||||||
"""List presets."""
|
"""List presets."""
|
||||||
if not dev.is_bulb or not isinstance(dev, IotBulb):
|
if not (light_preset := dev.modules.get(Module.LightPreset)):
|
||||||
error("Presets only supported on iot bulbs")
|
error("Presets not supported on device")
|
||||||
return
|
return
|
||||||
|
|
||||||
for preset in dev.presets:
|
for preset in light_preset.preset_states_list:
|
||||||
echo(preset)
|
echo(preset)
|
||||||
|
|
||||||
return dev.presets
|
return light_preset.preset_states_list
|
||||||
|
|
||||||
|
|
||||||
@presets.command(name="modify")
|
@presets.command(name="modify")
|
||||||
@ -1163,7 +1228,7 @@ def presets_list(dev: Device):
|
|||||||
@click.option("--hue", type=int)
|
@click.option("--hue", type=int)
|
||||||
@click.option("--saturation", type=int)
|
@click.option("--saturation", type=int)
|
||||||
@click.option("--temperature", type=int)
|
@click.option("--temperature", type=int)
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature):
|
async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature):
|
||||||
"""Modify a preset."""
|
"""Modify a preset."""
|
||||||
for preset in dev.presets:
|
for preset in dev.presets:
|
||||||
@ -1188,7 +1253,7 @@ async def presets_modify(dev: Device, index, brightness, hue, saturation, temper
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False))
|
@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False))
|
||||||
@click.option("--last", is_flag=True)
|
@click.option("--last", is_flag=True)
|
||||||
@click.option("--preset", type=int)
|
@click.option("--preset", type=int)
|
||||||
@ -1240,7 +1305,7 @@ async def update_credentials(dev, username, password):
|
|||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@pass_dev
|
@pass_dev_or_child
|
||||||
async def shell(dev: Device):
|
async def shell(dev: Device):
|
||||||
"""Open interactive shell."""
|
"""Open interactive shell."""
|
||||||
echo("Opening shell for %s" % dev)
|
echo("Opening shell for %s" % dev)
|
||||||
@ -1263,10 +1328,14 @@ async def shell(dev: Device):
|
|||||||
@cli.command(name="feature")
|
@cli.command(name="feature")
|
||||||
@click.argument("name", required=False)
|
@click.argument("name", required=False)
|
||||||
@click.argument("value", required=False)
|
@click.argument("value", required=False)
|
||||||
@click.option("--child", required=False)
|
@pass_dev_or_child
|
||||||
@pass_dev
|
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
async def feature(ctx: click.Context, dev: Device, child: str, name: str, value):
|
async def feature(
|
||||||
|
ctx: click.Context,
|
||||||
|
dev: Device,
|
||||||
|
name: str,
|
||||||
|
value,
|
||||||
|
):
|
||||||
"""Access and modify features.
|
"""Access and modify features.
|
||||||
|
|
||||||
If no *name* is given, lists available features and their values.
|
If no *name* is given, lists available features and their values.
|
||||||
@ -1275,9 +1344,6 @@ async def feature(ctx: click.Context, dev: Device, child: str, name: str, value)
|
|||||||
"""
|
"""
|
||||||
verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False
|
verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False
|
||||||
|
|
||||||
if child is not None:
|
|
||||||
echo(f"Targeting child device {child}")
|
|
||||||
dev = dev.get_child_device(child)
|
|
||||||
if not name:
|
if not name:
|
||||||
_echo_all_features(dev.features, verbose=verbose, indent="")
|
_echo_all_features(dev.features, verbose=verbose, indent="")
|
||||||
|
|
||||||
|
@ -347,14 +347,25 @@ class Device(ABC):
|
|||||||
"""Send a raw query to the device."""
|
"""Send a raw query to the device."""
|
||||||
return await self.protocol.query(request=request)
|
return await self.protocol.query(request=request)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parent(self) -> Device | None:
|
||||||
|
"""Return the parent on child devices."""
|
||||||
|
return self._parent
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def children(self) -> Sequence[Device]:
|
def children(self) -> Sequence[Device]:
|
||||||
"""Returns the child devices."""
|
"""Returns the child devices."""
|
||||||
return list(self._children.values())
|
return list(self._children.values())
|
||||||
|
|
||||||
def get_child_device(self, id_: str) -> Device:
|
def get_child_device(self, name_or_id: str) -> Device | None:
|
||||||
"""Return child device by its ID."""
|
"""Return child device by its device_id or alias."""
|
||||||
return self._children[id_]
|
if name_or_id in self._children:
|
||||||
|
return self._children[name_or_id]
|
||||||
|
name_lower = name_or_id.lower()
|
||||||
|
for child in self.children:
|
||||||
|
if child.alias and child.alias.lower() == name_lower:
|
||||||
|
return child
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
@ -333,10 +333,12 @@ class IotStripPlug(IotPlug):
|
|||||||
The plug inherits (most of) the system information from the parent.
|
The plug inherits (most of) the system information from the parent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_parent: IotStrip
|
||||||
|
|
||||||
def __init__(self, host: str, parent: IotStrip, child_id: str) -> None:
|
def __init__(self, host: str, parent: IotStrip, child_id: str) -> None:
|
||||||
super().__init__(host)
|
super().__init__(host)
|
||||||
|
|
||||||
self.parent = parent
|
self._parent = parent
|
||||||
self.child_id = child_id
|
self.child_id = child_id
|
||||||
self._last_update = parent._last_update
|
self._last_update = parent._last_update
|
||||||
self._set_sys_info(parent.sys_info)
|
self._set_sys_info(parent.sys_info)
|
||||||
@ -400,14 +402,15 @@ class IotStripPlug(IotPlug):
|
|||||||
update_children_or_parent = False
|
update_children_or_parent = False
|
||||||
|
|
||||||
if update_children_or_parent:
|
if update_children_or_parent:
|
||||||
await self.parent._update(called_from_child=self)
|
await self._parent._update(called_from_child=self)
|
||||||
else:
|
else:
|
||||||
await self._update()
|
await self._update()
|
||||||
|
|
||||||
async def _update(self):
|
async def _update(self):
|
||||||
"""Query the device to update the data.
|
"""Query the device to update the data.
|
||||||
|
|
||||||
Needed for properties that are decorated with `requires_update`.
|
Internal implementation to allow patching of public update in the cli
|
||||||
|
or test framework.
|
||||||
"""
|
"""
|
||||||
await self._modular_update({})
|
await self._modular_update({})
|
||||||
for module in self._modules.values():
|
for module in self._modules.values():
|
||||||
@ -429,7 +432,7 @@ class IotStripPlug(IotPlug):
|
|||||||
self, target: str, cmd: str, arg: dict | None = None, child_ids=None
|
self, target: str, cmd: str, arg: dict | None = None, child_ids=None
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Override query helper to include the child_ids."""
|
"""Override query helper to include the child_ids."""
|
||||||
return await self.parent._query_helper(
|
return await self._parent._query_helper(
|
||||||
target, cmd, arg, child_ids=[self.child_id]
|
target, cmd, arg, child_ids=[self.child_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -490,13 +493,15 @@ class IotStripPlug(IotPlug):
|
|||||||
@requires_update
|
@requires_update
|
||||||
def model(self) -> str:
|
def model(self) -> str:
|
||||||
"""Return device model for a child socket."""
|
"""Return device model for a child socket."""
|
||||||
sys_info = self.parent.sys_info
|
sys_info = self._parent.sys_info
|
||||||
return f"Socket for {sys_info['model']}"
|
return f"Socket for {sys_info['model']}"
|
||||||
|
|
||||||
def _get_child_info(self) -> dict:
|
def _get_child_info(self) -> dict:
|
||||||
"""Return the subdevice information for this device."""
|
"""Return the subdevice information for this device."""
|
||||||
for plug in self.parent.sys_info["children"]:
|
for plug in self._parent.sys_info["children"]:
|
||||||
if plug["id"] == self.child_id:
|
if plug["id"] == self.child_id:
|
||||||
return plug
|
return plug
|
||||||
|
|
||||||
raise KasaException(f"Unable to find children {self.child_id}")
|
raise KasaException(
|
||||||
|
f"Unable to find children {self.child_id}"
|
||||||
|
) # pragma: no cover
|
||||||
|
@ -132,8 +132,10 @@ class KlapTransport(BaseTransport):
|
|||||||
return self.DEFAULT_PORT
|
return self.DEFAULT_PORT
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def credentials_hash(self) -> str:
|
def credentials_hash(self) -> str | None:
|
||||||
"""The hashed credentials used by the transport."""
|
"""The hashed credentials used by the transport."""
|
||||||
|
if self._credentials == Credentials():
|
||||||
|
return None
|
||||||
return base64.b64encode(self._local_auth_hash).decode()
|
return base64.b64encode(self._local_auth_hash).decode()
|
||||||
|
|
||||||
async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]:
|
async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]:
|
||||||
|
@ -112,6 +112,9 @@ class Module(ABC):
|
|||||||
"LightTransition"
|
"LightTransition"
|
||||||
)
|
)
|
||||||
ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode")
|
ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode")
|
||||||
|
SmartLightEffect: Final[ModuleName[smart.SmartLightEffect]] = ModuleName(
|
||||||
|
"LightEffect"
|
||||||
|
)
|
||||||
TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName(
|
TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName(
|
||||||
"TemperatureSensor"
|
"TemperatureSensor"
|
||||||
)
|
)
|
||||||
|
@ -59,7 +59,7 @@ class BaseTransport(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def credentials_hash(self) -> str:
|
def credentials_hash(self) -> str | None:
|
||||||
"""The hashed credentials used by the transport."""
|
"""The hashed credentials used by the transport."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
@ -2,8 +2,33 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
from ..interfaces.lighteffect import LightEffect as LightEffectInterface
|
||||||
|
|
||||||
|
|
||||||
|
class SmartLightEffect(LightEffectInterface, ABC):
|
||||||
|
"""Abstract interface for smart light effects.
|
||||||
|
|
||||||
|
This interface extends lighteffect interface to add brightness controls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def set_brightness(self, brightness: int, *, transition: int | None = None):
|
||||||
|
"""Set effect brightness."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def brightness(self) -> int:
|
||||||
|
"""Return effect brightness."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""Return True if effect is active."""
|
||||||
|
|
||||||
|
|
||||||
EFFECT_AURORA = {
|
EFFECT_AURORA = {
|
||||||
"custom": 0,
|
"custom": 0,
|
||||||
"id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP",
|
"id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP",
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""Modules for SMART devices."""
|
"""Modules for SMART devices."""
|
||||||
|
|
||||||
|
from ..effects import SmartLightEffect
|
||||||
from .alarm import Alarm
|
from .alarm import Alarm
|
||||||
from .autooff import AutoOff
|
from .autooff import AutoOff
|
||||||
from .batterysensor import BatterySensor
|
from .batterysensor import BatterySensor
|
||||||
@ -54,4 +55,5 @@ __all__ = [
|
|||||||
"WaterleakSensor",
|
"WaterleakSensor",
|
||||||
"ContactSensor",
|
"ContactSensor",
|
||||||
"FrostProtection",
|
"FrostProtection",
|
||||||
|
"SmartLightEffect",
|
||||||
]
|
]
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ...feature import Feature
|
from ...feature import Feature
|
||||||
from ..smartmodule import SmartModule
|
from ..smartmodule import Module, SmartModule
|
||||||
|
|
||||||
BRIGHTNESS_MIN = 0
|
BRIGHTNESS_MIN = 0
|
||||||
BRIGHTNESS_MAX = 100
|
BRIGHTNESS_MAX = 100
|
||||||
@ -42,6 +42,12 @@ class Brightness(SmartModule):
|
|||||||
@property
|
@property
|
||||||
def brightness(self):
|
def brightness(self):
|
||||||
"""Return current brightness."""
|
"""Return current brightness."""
|
||||||
|
# If the device supports effects and one is active, use its brightness
|
||||||
|
if (
|
||||||
|
light_effect := self._device.modules.get(Module.SmartLightEffect)
|
||||||
|
) is not None and light_effect.is_active:
|
||||||
|
return light_effect.brightness
|
||||||
|
|
||||||
return self.data["brightness"]
|
return self.data["brightness"]
|
||||||
|
|
||||||
async def set_brightness(self, brightness: int, *, transition: int | None = None):
|
async def set_brightness(self, brightness: int, *, transition: int | None = None):
|
||||||
@ -59,6 +65,13 @@ class Brightness(SmartModule):
|
|||||||
|
|
||||||
if brightness == 0:
|
if brightness == 0:
|
||||||
return await self._device.turn_off()
|
return await self._device.turn_off()
|
||||||
|
|
||||||
|
# If the device supports effects and one is active, we adjust its brightness
|
||||||
|
if (
|
||||||
|
light_effect := self._device.modules.get(Module.SmartLightEffect)
|
||||||
|
) is not None and light_effect.is_active:
|
||||||
|
return await light_effect.set_brightness(brightness)
|
||||||
|
|
||||||
return await self.call("set_device_info", {"brightness": brightness})
|
return await self.call("set_device_info", {"brightness": brightness})
|
||||||
|
|
||||||
async def _check_supported(self):
|
async def _check_supported(self):
|
||||||
|
@ -3,14 +3,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import binascii
|
||||||
|
import contextlib
|
||||||
import copy
|
import copy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ...interfaces.lighteffect import LightEffect as LightEffectInterface
|
from ..effects import SmartLightEffect
|
||||||
from ..smartmodule import SmartModule
|
from ..smartmodule import Module, SmartModule
|
||||||
|
|
||||||
|
|
||||||
class LightEffect(SmartModule, LightEffectInterface):
|
class LightEffect(SmartModule, SmartLightEffect):
|
||||||
"""Implementation of dynamic light effects."""
|
"""Implementation of dynamic light effects."""
|
||||||
|
|
||||||
REQUIRED_COMPONENT = "light_effect"
|
REQUIRED_COMPONENT = "light_effect"
|
||||||
@ -36,8 +38,11 @@ class LightEffect(SmartModule, LightEffectInterface):
|
|||||||
# If the name has not been edited scene_name will be an empty string
|
# If the name has not been edited scene_name will be an empty string
|
||||||
effect["scene_name"] = self.AVAILABLE_BULB_EFFECTS[effect["id"]]
|
effect["scene_name"] = self.AVAILABLE_BULB_EFFECTS[effect["id"]]
|
||||||
else:
|
else:
|
||||||
# Otherwise it will be b64 encoded
|
# Otherwise it might be b64 encoded or raw string
|
||||||
effect["scene_name"] = base64.b64decode(effect["scene_name"]).decode()
|
with contextlib.suppress(binascii.Error):
|
||||||
|
effect["scene_name"] = base64.b64decode(
|
||||||
|
effect["scene_name"]
|
||||||
|
).decode()
|
||||||
|
|
||||||
self._effect_state_list = effects
|
self._effect_state_list = effects
|
||||||
self._effect_list = [self.LIGHT_EFFECTS_OFF]
|
self._effect_list = [self.LIGHT_EFFECTS_OFF]
|
||||||
@ -77,6 +82,8 @@ class LightEffect(SmartModule, LightEffectInterface):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set an effect for the device.
|
"""Set an effect for the device.
|
||||||
|
|
||||||
|
Calling this will modify the brightness of the effect on the device.
|
||||||
|
|
||||||
The device doesn't store an active effect while not enabled so store locally.
|
The device doesn't store an active effect while not enabled so store locally.
|
||||||
"""
|
"""
|
||||||
if effect != self.LIGHT_EFFECTS_OFF and effect not in self._scenes_names_to_id:
|
if effect != self.LIGHT_EFFECTS_OFF and effect not in self._scenes_names_to_id:
|
||||||
@ -90,7 +97,64 @@ class LightEffect(SmartModule, LightEffectInterface):
|
|||||||
if enable:
|
if enable:
|
||||||
effect_id = self._scenes_names_to_id[effect]
|
effect_id = self._scenes_names_to_id[effect]
|
||||||
params["id"] = effect_id
|
params["id"] = effect_id
|
||||||
return await self.call("set_dynamic_light_effect_rule_enable", params)
|
|
||||||
|
# We set the wanted brightness before activating the effect
|
||||||
|
brightness_module = self._device.modules[Module.Brightness]
|
||||||
|
brightness = (
|
||||||
|
brightness if brightness is not None else brightness_module.brightness
|
||||||
|
)
|
||||||
|
await self.set_brightness(brightness, effect_id=effect_id)
|
||||||
|
|
||||||
|
await self.call("set_dynamic_light_effect_rule_enable", params)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""Return True if effect is active."""
|
||||||
|
return bool(self._device._info["dynamic_light_effect_enable"])
|
||||||
|
|
||||||
|
def _get_effect_data(self, effect_id: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Return effect data for the *effect_id*.
|
||||||
|
|
||||||
|
If *effect_id* is None, return the data for active effect.
|
||||||
|
"""
|
||||||
|
if effect_id is None:
|
||||||
|
effect_id = self.data["current_rule_id"]
|
||||||
|
|
||||||
|
return self._effect_state_list[effect_id]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> int:
|
||||||
|
"""Return effect brightness."""
|
||||||
|
first_color_status = self._get_effect_data()["color_status_list"][0]
|
||||||
|
brightness = first_color_status[0]
|
||||||
|
|
||||||
|
return brightness
|
||||||
|
|
||||||
|
async def set_brightness(
|
||||||
|
self,
|
||||||
|
brightness: int,
|
||||||
|
*,
|
||||||
|
transition: int | None = None,
|
||||||
|
effect_id: str | None = None,
|
||||||
|
):
|
||||||
|
"""Set effect brightness."""
|
||||||
|
new_effect = self._get_effect_data(effect_id=effect_id).copy()
|
||||||
|
|
||||||
|
def _replace_brightness(data, new_brightness):
|
||||||
|
"""Replace brightness.
|
||||||
|
|
||||||
|
The first element is the brightness, the rest are unknown.
|
||||||
|
[[33, 0, 0, 2700], [33, 321, 99, 0], [33, 196, 99, 0], .. ]
|
||||||
|
"""
|
||||||
|
return [new_brightness, data[1], data[2], data[3]]
|
||||||
|
|
||||||
|
new_color_status_list = [
|
||||||
|
_replace_brightness(state, brightness)
|
||||||
|
for state in new_effect["color_status_list"]
|
||||||
|
]
|
||||||
|
new_effect["color_status_list"] = new_color_status_list
|
||||||
|
|
||||||
|
return await self.call("edit_dynamic_light_effect_rule", new_effect)
|
||||||
|
|
||||||
async def set_custom_effect(
|
async def set_custom_effect(
|
||||||
self,
|
self,
|
||||||
|
@ -4,15 +4,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from ...interfaces.lighteffect import LightEffect as LightEffectInterface
|
from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect
|
||||||
from ..effects import EFFECT_MAPPING, EFFECT_NAMES
|
from ..smartmodule import Module, SmartModule
|
||||||
from ..smartmodule import SmartModule
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..smartdevice import SmartDevice
|
from ..smartdevice import SmartDevice
|
||||||
|
|
||||||
|
|
||||||
class LightStripEffect(SmartModule, LightEffectInterface):
|
class LightStripEffect(SmartModule, SmartLightEffect):
|
||||||
"""Implementation of dynamic light effects."""
|
"""Implementation of dynamic light effects."""
|
||||||
|
|
||||||
REQUIRED_COMPONENT = "light_strip_lighting_effect"
|
REQUIRED_COMPONENT = "light_strip_lighting_effect"
|
||||||
@ -22,6 +21,7 @@ class LightStripEffect(SmartModule, LightEffectInterface):
|
|||||||
effect_list = [self.LIGHT_EFFECTS_OFF]
|
effect_list = [self.LIGHT_EFFECTS_OFF]
|
||||||
effect_list.extend(EFFECT_NAMES)
|
effect_list.extend(EFFECT_NAMES)
|
||||||
self._effect_list = effect_list
|
self._effect_list = effect_list
|
||||||
|
self._effect_mapping = EFFECT_MAPPING
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -53,6 +53,28 @@ class LightStripEffect(SmartModule, LightEffectInterface):
|
|||||||
return name
|
return name
|
||||||
return self.LIGHT_EFFECTS_OFF
|
return self.LIGHT_EFFECTS_OFF
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""Return if effect is active."""
|
||||||
|
eff = self.data["lighting_effect"]
|
||||||
|
# softAP has enable=1, but brightness 0 which fails on tests
|
||||||
|
return bool(eff["enable"]) and eff["name"] in self._effect_list
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> int:
|
||||||
|
"""Return effect brightness."""
|
||||||
|
eff = self.data["lighting_effect"]
|
||||||
|
return eff["brightness"]
|
||||||
|
|
||||||
|
async def set_brightness(self, brightness: int, *, transition: int | None = None):
|
||||||
|
"""Set effect brightness."""
|
||||||
|
if brightness <= 0:
|
||||||
|
return await self.set_effect(self.LIGHT_EFFECTS_OFF)
|
||||||
|
|
||||||
|
# Need to pass bAdjusted to keep the existing effect running
|
||||||
|
eff = {"brightness": brightness, "bAdjusted": True}
|
||||||
|
return await self.set_custom_effect(eff)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def effect_list(self) -> list[str]:
|
def effect_list(self) -> list[str]:
|
||||||
"""Return built-in effects list.
|
"""Return built-in effects list.
|
||||||
@ -81,16 +103,24 @@ class LightStripEffect(SmartModule, LightEffectInterface):
|
|||||||
:param int brightness: The wanted brightness
|
:param int brightness: The wanted brightness
|
||||||
:param int transition: The wanted transition time
|
:param int transition: The wanted transition time
|
||||||
"""
|
"""
|
||||||
|
brightness_module = self._device.modules[Module.Brightness]
|
||||||
if effect == self.LIGHT_EFFECTS_OFF:
|
if effect == self.LIGHT_EFFECTS_OFF:
|
||||||
effect_dict = dict(self.data["lighting_effect"])
|
state = self._device.modules[Module.Light].state
|
||||||
effect_dict["enable"] = 0
|
await self._device.modules[Module.Light].set_state(state)
|
||||||
elif effect not in EFFECT_MAPPING:
|
return
|
||||||
|
|
||||||
|
if effect not in self._effect_mapping:
|
||||||
raise ValueError(f"The effect {effect} is not a built in effect.")
|
raise ValueError(f"The effect {effect} is not a built in effect.")
|
||||||
else:
|
else:
|
||||||
effect_dict = EFFECT_MAPPING[effect]
|
effect_dict = self._effect_mapping[effect]
|
||||||
|
|
||||||
|
# Use explicitly given brightness
|
||||||
if brightness is not None:
|
if brightness is not None:
|
||||||
effect_dict["brightness"] = brightness
|
effect_dict["brightness"] = brightness
|
||||||
|
# Fall back to brightness reported by the brightness module
|
||||||
|
elif brightness_module.brightness:
|
||||||
|
effect_dict["brightness"] = brightness_module.brightness
|
||||||
|
|
||||||
if transition is not None:
|
if transition is not None:
|
||||||
effect_dict["transition"] = transition
|
effect_dict["transition"] = transition
|
||||||
|
|
||||||
|
@ -64,12 +64,13 @@ class SmartChildDevice(SmartDevice):
|
|||||||
async def _update(self):
|
async def _update(self):
|
||||||
"""Update child module info.
|
"""Update child module info.
|
||||||
|
|
||||||
The parent updates our internal info so just update modules with
|
Internal implementation to allow patching of public update in the cli
|
||||||
their own queries.
|
or test framework.
|
||||||
"""
|
"""
|
||||||
# Hubs attached devices only update via the parent hub
|
# Hubs attached devices only update via the parent hub
|
||||||
if self._parent.device_type == DeviceType.Hub:
|
if self._parent.device_type is DeviceType.Hub:
|
||||||
return
|
return
|
||||||
|
|
||||||
req: dict[str, Any] = {}
|
req: dict[str, Any] = {}
|
||||||
for module in self.modules.values():
|
for module in self.modules.values():
|
||||||
if mod_query := module.query():
|
if mod_query := module.query():
|
||||||
|
@ -47,6 +47,9 @@ class SmartProtocol(BaseProtocol):
|
|||||||
self._terminal_uuid: str = base64.b64encode(md5(uuid.uuid4().bytes)).decode()
|
self._terminal_uuid: str = base64.b64encode(md5(uuid.uuid4().bytes)).decode()
|
||||||
self._request_id_generator = SnowflakeId(1, 1)
|
self._request_id_generator = SnowflakeId(1, 1)
|
||||||
self._query_lock = asyncio.Lock()
|
self._query_lock = asyncio.Lock()
|
||||||
|
self._multi_request_batch_size = (
|
||||||
|
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
def get_smart_request(self, method, params=None) -> str:
|
def get_smart_request(self, method, params=None) -> str:
|
||||||
"""Get a request message as a string."""
|
"""Get a request message as a string."""
|
||||||
@ -117,9 +120,16 @@ class SmartProtocol(BaseProtocol):
|
|||||||
|
|
||||||
end = len(multi_requests)
|
end = len(multi_requests)
|
||||||
# Break the requests down as there can be a size limit
|
# Break the requests down as there can be a size limit
|
||||||
step = (
|
step = self._multi_request_batch_size
|
||||||
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
|
if step == 1:
|
||||||
)
|
# If step is 1 do not send request batches
|
||||||
|
for request in multi_requests:
|
||||||
|
method = request["method"]
|
||||||
|
req = self.get_smart_request(method, request["params"])
|
||||||
|
resp = await self._transport.send(req)
|
||||||
|
self._handle_response_error_code(resp, method, raise_on_error=False)
|
||||||
|
multi_result[method] = resp["result"]
|
||||||
|
return multi_result
|
||||||
for i in range(0, end, step):
|
for i in range(0, end, step):
|
||||||
requests_step = multi_requests[i : i + step]
|
requests_step = multi_requests[i : i + step]
|
||||||
|
|
||||||
@ -141,7 +151,25 @@ class SmartProtocol(BaseProtocol):
|
|||||||
batch_name,
|
batch_name,
|
||||||
pf(response_step),
|
pf(response_step),
|
||||||
)
|
)
|
||||||
self._handle_response_error_code(response_step, batch_name)
|
try:
|
||||||
|
self._handle_response_error_code(response_step, batch_name)
|
||||||
|
except DeviceError as ex:
|
||||||
|
# P100 sometimes raises JSON_DECODE_FAIL_ERROR or INTERNAL_UNKNOWN_ERROR
|
||||||
|
# on batched request so disable batching
|
||||||
|
if (
|
||||||
|
ex.error_code
|
||||||
|
in {
|
||||||
|
SmartErrorCode.JSON_DECODE_FAIL_ERROR,
|
||||||
|
SmartErrorCode.INTERNAL_UNKNOWN_ERROR,
|
||||||
|
}
|
||||||
|
and self._multi_request_batch_size != 1
|
||||||
|
):
|
||||||
|
self._multi_request_batch_size = 1
|
||||||
|
raise _RetryableError(
|
||||||
|
"JSON Decode failure, multi requests disabled"
|
||||||
|
) from ex
|
||||||
|
raise ex
|
||||||
|
|
||||||
responses = response_step["result"]["responses"]
|
responses = response_step["result"]["responses"]
|
||||||
for response in responses:
|
for response in responses:
|
||||||
method = response["method"]
|
method = response["method"]
|
||||||
|
@ -234,8 +234,8 @@ class FakeIotTransport(BaseTransport):
|
|||||||
return 9999
|
return 9999
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def credentials_hash(self) -> str:
|
def credentials_hash(self) -> None:
|
||||||
return ""
|
return None
|
||||||
|
|
||||||
def set_alias(self, x, child_ids=None):
|
def set_alias(self, x, child_ids=None):
|
||||||
if child_ids is None:
|
if child_ids is None:
|
||||||
|
@ -250,18 +250,31 @@ class FakeSmartTransport(BaseTransport):
|
|||||||
info["get_dynamic_light_effect_rules"]["enable"] = params["enable"]
|
info["get_dynamic_light_effect_rules"]["enable"] = params["enable"]
|
||||||
if params["enable"]:
|
if params["enable"]:
|
||||||
info["get_device_info"]["dynamic_light_effect_id"] = params["id"]
|
info["get_device_info"]["dynamic_light_effect_id"] = params["id"]
|
||||||
info["get_dynamic_light_effect_rules"]["current_rule_id"] = params["enable"]
|
info["get_dynamic_light_effect_rules"]["current_rule_id"] = params["id"]
|
||||||
else:
|
else:
|
||||||
if "dynamic_light_effect_id" in info["get_device_info"]:
|
if "dynamic_light_effect_id" in info["get_device_info"]:
|
||||||
del info["get_device_info"]["dynamic_light_effect_id"]
|
del info["get_device_info"]["dynamic_light_effect_id"]
|
||||||
if "current_rule_id" in info["get_dynamic_light_effect_rules"]:
|
if "current_rule_id" in info["get_dynamic_light_effect_rules"]:
|
||||||
del info["get_dynamic_light_effect_rules"]["current_rule_id"]
|
del info["get_dynamic_light_effect_rules"]["current_rule_id"]
|
||||||
|
|
||||||
|
def _set_edit_dynamic_light_effect_rule(self, info, params):
|
||||||
|
"""Edit dynamic light effect rule."""
|
||||||
|
rules = info["get_dynamic_light_effect_rules"]["rule_list"]
|
||||||
|
for rule in rules:
|
||||||
|
if rule["id"] == params["id"]:
|
||||||
|
rule.update(params)
|
||||||
|
return
|
||||||
|
|
||||||
|
raise Exception("Unable to find rule with id")
|
||||||
|
|
||||||
def _set_light_strip_effect(self, info, params):
|
def _set_light_strip_effect(self, info, params):
|
||||||
"""Set or remove values as per the device behaviour."""
|
"""Set or remove values as per the device behaviour."""
|
||||||
info["get_device_info"]["lighting_effect"]["enable"] = params["enable"]
|
info["get_device_info"]["lighting_effect"]["enable"] = params["enable"]
|
||||||
info["get_device_info"]["lighting_effect"]["name"] = params["name"]
|
info["get_device_info"]["lighting_effect"]["name"] = params["name"]
|
||||||
info["get_device_info"]["lighting_effect"]["id"] = params["id"]
|
info["get_device_info"]["lighting_effect"]["id"] = params["id"]
|
||||||
|
# Brightness is not always available
|
||||||
|
if (brightness := params.get("brightness")) is not None:
|
||||||
|
info["get_device_info"]["lighting_effect"]["brightness"] = brightness
|
||||||
info["get_lighting_effect"] = copy.deepcopy(params)
|
info["get_lighting_effect"] = copy.deepcopy(params)
|
||||||
|
|
||||||
def _set_led_info(self, info, params):
|
def _set_led_info(self, info, params):
|
||||||
@ -365,6 +378,9 @@ class FakeSmartTransport(BaseTransport):
|
|||||||
elif method == "set_dynamic_light_effect_rule_enable":
|
elif method == "set_dynamic_light_effect_rule_enable":
|
||||||
self._set_dynamic_light_effect(info, params)
|
self._set_dynamic_light_effect(info, params)
|
||||||
return {"error_code": 0}
|
return {"error_code": 0}
|
||||||
|
elif method == "edit_dynamic_light_effect_rule":
|
||||||
|
self._set_edit_dynamic_light_effect_rule(info, params)
|
||||||
|
return {"error_code": 0}
|
||||||
elif method == "set_lighting_effect":
|
elif method == "set_lighting_effect":
|
||||||
self._set_light_strip_effect(info, params)
|
self._set_light_strip_effect(info, params)
|
||||||
return {"error_code": 0}
|
return {"error_code": 0}
|
||||||
|
436
kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json
vendored
Normal file
436
kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json
vendored
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
{
|
||||||
|
"component_nego": {
|
||||||
|
"component_list": [
|
||||||
|
{
|
||||||
|
"id": "device",
|
||||||
|
"ver_code": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "light_strip",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "light_strip_lighting_effect",
|
||||||
|
"ver_code": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "firmware",
|
||||||
|
"ver_code": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quick_setup",
|
||||||
|
"ver_code": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inherit",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "time",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wireless",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "schedule",
|
||||||
|
"ver_code": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "countdown",
|
||||||
|
"ver_code": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "antitheft",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "account",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "synchronize",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sunrise_sunset",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "brightness",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cloud_connect",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "color_temperature",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "default_states",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "preset",
|
||||||
|
"ver_code": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "on_off_gradually",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "device_local_time",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iot_cloud",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "music_rhythm",
|
||||||
|
"ver_code": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bulb_quick_control",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "localSmart",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "segment",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "segment_effect",
|
||||||
|
"ver_code": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"discovery_result": {
|
||||||
|
"device_id": "00000000000000000000000000000000",
|
||||||
|
"device_model": "L920-5(EU)",
|
||||||
|
"device_type": "SMART.TAPOBULB",
|
||||||
|
"factory_default": false,
|
||||||
|
"ip": "127.0.0.123",
|
||||||
|
"is_support_iot_cloud": true,
|
||||||
|
"mac": "1C-61-B4-00-00-00",
|
||||||
|
"mgt_encrypt_schm": {
|
||||||
|
"encrypt_type": "KLAP",
|
||||||
|
"http_port": 80,
|
||||||
|
"is_support_https": false,
|
||||||
|
"lv": 2
|
||||||
|
},
|
||||||
|
"obd_src": "tplink",
|
||||||
|
"owner": "00000000000000000000000000000000"
|
||||||
|
},
|
||||||
|
"get_antitheft_rules": {
|
||||||
|
"antitheft_rule_max_count": 1,
|
||||||
|
"enable": false,
|
||||||
|
"rule_list": []
|
||||||
|
},
|
||||||
|
"get_auto_update_info": {
|
||||||
|
"enable": false,
|
||||||
|
"random_range": 120,
|
||||||
|
"time": 180
|
||||||
|
},
|
||||||
|
"get_connect_cloud_state": {
|
||||||
|
"status": 1
|
||||||
|
},
|
||||||
|
"get_countdown_rules": {
|
||||||
|
"countdown_rule_max_count": 1,
|
||||||
|
"enable": false,
|
||||||
|
"rule_list": []
|
||||||
|
},
|
||||||
|
"get_device_info": {
|
||||||
|
"avatar": "light_strip",
|
||||||
|
"brightness": 65,
|
||||||
|
"color_temp": 0,
|
||||||
|
"color_temp_range": [
|
||||||
|
9000,
|
||||||
|
9000
|
||||||
|
],
|
||||||
|
"default_states": {
|
||||||
|
"state": {
|
||||||
|
"brightness": 65,
|
||||||
|
"color_temp": 0,
|
||||||
|
"hue": 9,
|
||||||
|
"saturation": 67
|
||||||
|
},
|
||||||
|
"type": "last_states"
|
||||||
|
},
|
||||||
|
"device_id": "0000000000000000000000000000000000000000",
|
||||||
|
"device_on": false,
|
||||||
|
"fw_id": "00000000000000000000000000000000",
|
||||||
|
"fw_ver": "1.1.3 Build 231229 Rel.164316",
|
||||||
|
"has_set_location_info": false,
|
||||||
|
"hue": 9,
|
||||||
|
"hw_id": "00000000000000000000000000000000",
|
||||||
|
"hw_ver": "1.0",
|
||||||
|
"ip": "127.0.0.123",
|
||||||
|
"lang": "de_DE",
|
||||||
|
"lighting_effect": {
|
||||||
|
"brightness": 65,
|
||||||
|
"custom": 0,
|
||||||
|
"display_colors": [
|
||||||
|
[
|
||||||
|
136,
|
||||||
|
98,
|
||||||
|
100
|
||||||
|
],
|
||||||
|
[
|
||||||
|
350,
|
||||||
|
97,
|
||||||
|
100
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"enable": 0,
|
||||||
|
"id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh",
|
||||||
|
"name": "Christmas"
|
||||||
|
},
|
||||||
|
"mac": "1C-61-B4-00-00-00",
|
||||||
|
"model": "L920",
|
||||||
|
"music_rhythm_enable": false,
|
||||||
|
"music_rhythm_mode": "single_lamp",
|
||||||
|
"nickname": "I01BU0tFRF9OQU1FIw==",
|
||||||
|
"oem_id": "00000000000000000000000000000000",
|
||||||
|
"overheated": false,
|
||||||
|
"region": "Europe/Berlin",
|
||||||
|
"rssi": -56,
|
||||||
|
"saturation": 67,
|
||||||
|
"segment_effect": {
|
||||||
|
"brightness": 97,
|
||||||
|
"custom": 0,
|
||||||
|
"display_colors": [],
|
||||||
|
"enable": 0,
|
||||||
|
"id": "",
|
||||||
|
"name": "Lightning"
|
||||||
|
},
|
||||||
|
"signal_level": 2,
|
||||||
|
"specs": "",
|
||||||
|
"ssid": "I01BU0tFRF9TU0lEIw==",
|
||||||
|
"time_diff": 60,
|
||||||
|
"type": "SMART.TAPOBULB"
|
||||||
|
},
|
||||||
|
"get_device_segment": {
|
||||||
|
"segment": 50
|
||||||
|
},
|
||||||
|
"get_device_time": {
|
||||||
|
"region": "Europe/Berlin",
|
||||||
|
"time_diff": 60,
|
||||||
|
"timestamp": 1719920893
|
||||||
|
},
|
||||||
|
"get_device_usage": {
|
||||||
|
"power_usage": {
|
||||||
|
"past30": 20,
|
||||||
|
"past7": 20,
|
||||||
|
"today": 0
|
||||||
|
},
|
||||||
|
"saved_power": {
|
||||||
|
"past30": 319,
|
||||||
|
"past7": 319,
|
||||||
|
"today": 0
|
||||||
|
},
|
||||||
|
"time_usage": {
|
||||||
|
"past30": 339,
|
||||||
|
"past7": 339,
|
||||||
|
"today": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"get_fw_download_state": {
|
||||||
|
"auto_upgrade": false,
|
||||||
|
"download_progress": 0,
|
||||||
|
"reboot_time": 5,
|
||||||
|
"status": 0,
|
||||||
|
"upgrade_time": 5
|
||||||
|
},
|
||||||
|
"get_inherit_info": null,
|
||||||
|
"get_lighting_effect": {
|
||||||
|
"backgrounds": [
|
||||||
|
[
|
||||||
|
136,
|
||||||
|
98,
|
||||||
|
75
|
||||||
|
],
|
||||||
|
[
|
||||||
|
136,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
350,
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
],
|
||||||
|
[
|
||||||
|
350,
|
||||||
|
97,
|
||||||
|
94
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"brightness": 65,
|
||||||
|
"brightness_range": [
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
],
|
||||||
|
"custom": 0,
|
||||||
|
"display_colors": [
|
||||||
|
[
|
||||||
|
136,
|
||||||
|
98,
|
||||||
|
100
|
||||||
|
],
|
||||||
|
[
|
||||||
|
350,
|
||||||
|
97,
|
||||||
|
100
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"duration": 5000,
|
||||||
|
"enable": 0,
|
||||||
|
"expansion_strategy": 1,
|
||||||
|
"fadeoff": 2000,
|
||||||
|
"hue_range": [
|
||||||
|
136,
|
||||||
|
146
|
||||||
|
],
|
||||||
|
"id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh",
|
||||||
|
"init_states": [
|
||||||
|
[
|
||||||
|
136,
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"name": "Christmas",
|
||||||
|
"random_seed": 100,
|
||||||
|
"saturation_range": [
|
||||||
|
90,
|
||||||
|
100
|
||||||
|
],
|
||||||
|
"segments": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"transition": 0,
|
||||||
|
"type": "random"
|
||||||
|
},
|
||||||
|
"get_next_event": {},
|
||||||
|
"get_on_off_gradually_info": {
|
||||||
|
"enable": true
|
||||||
|
},
|
||||||
|
"get_preset_rules": {
|
||||||
|
"start_index": 0,
|
||||||
|
"states": [
|
||||||
|
{
|
||||||
|
"brightness": 50,
|
||||||
|
"color_temp": 9000,
|
||||||
|
"hue": 0,
|
||||||
|
"saturation": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"brightness": 100,
|
||||||
|
"color_temp": 0,
|
||||||
|
"hue": 240,
|
||||||
|
"saturation": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"brightness": 100,
|
||||||
|
"color_temp": 0,
|
||||||
|
"hue": 0,
|
||||||
|
"saturation": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"brightness": 100,
|
||||||
|
"color_temp": 0,
|
||||||
|
"hue": 120,
|
||||||
|
"saturation": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"brightness": 100,
|
||||||
|
"color_temp": 0,
|
||||||
|
"hue": 277,
|
||||||
|
"saturation": 86
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"brightness": 100,
|
||||||
|
"color_temp": 0,
|
||||||
|
"hue": 60,
|
||||||
|
"saturation": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"brightness": 100,
|
||||||
|
"color_temp": 0,
|
||||||
|
"hue": 300,
|
||||||
|
"saturation": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sum": 7
|
||||||
|
},
|
||||||
|
"get_schedule_rules": {
|
||||||
|
"enable": false,
|
||||||
|
"rule_list": [],
|
||||||
|
"schedule_rule_max_count": 24,
|
||||||
|
"start_index": 0,
|
||||||
|
"sum": 0
|
||||||
|
},
|
||||||
|
"get_segment_effect_rule": {
|
||||||
|
"brightness": 97,
|
||||||
|
"custom": 0,
|
||||||
|
"display_colors": [],
|
||||||
|
"enable": 0,
|
||||||
|
"id": "",
|
||||||
|
"name": "Lightning"
|
||||||
|
},
|
||||||
|
"get_wireless_scan_info": {
|
||||||
|
"ap_list": [
|
||||||
|
{
|
||||||
|
"bssid": "000000000000",
|
||||||
|
"channel": 0,
|
||||||
|
"cipher_type": 2,
|
||||||
|
"key_type": "wpa2_psk",
|
||||||
|
"signal_level": 2,
|
||||||
|
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_index": 0,
|
||||||
|
"sum": 1,
|
||||||
|
"wep_supported": false
|
||||||
|
},
|
||||||
|
"qs_component_nego": {
|
||||||
|
"component_list": [
|
||||||
|
{
|
||||||
|
"id": "quick_setup",
|
||||||
|
"ver_code": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sunrise_sunset",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inherit",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iot_cloud",
|
||||||
|
"ver_code": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "firmware",
|
||||||
|
"ver_code": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extra_info": {
|
||||||
|
"device_model": "L920",
|
||||||
|
"device_type": "SMART.TAPOBULB",
|
||||||
|
"is_klap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -39,3 +39,45 @@ async def test_light_effect(dev: Device, mocker: MockerFixture):
|
|||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await light_effect.set_effect("foobar")
|
await light_effect.set_effect("foobar")
|
||||||
|
|
||||||
|
|
||||||
|
@light_effect
|
||||||
|
@pytest.mark.parametrize("effect_active", [True, False])
|
||||||
|
async def test_light_effect_brightness(
|
||||||
|
dev: Device, effect_active: bool, mocker: MockerFixture
|
||||||
|
):
|
||||||
|
"""Test that light module uses light_effect for brightness when active."""
|
||||||
|
light_module = dev.modules[Module.Light]
|
||||||
|
|
||||||
|
light_effect = dev.modules[Module.SmartLightEffect]
|
||||||
|
light_effect_set_brightness = mocker.spy(light_effect, "set_brightness")
|
||||||
|
mock_light_effect_call = mocker.patch.object(light_effect, "call")
|
||||||
|
|
||||||
|
brightness = dev.modules[Module.Brightness]
|
||||||
|
brightness_set_brightness = mocker.spy(brightness, "set_brightness")
|
||||||
|
mock_brightness_call = mocker.patch.object(brightness, "call")
|
||||||
|
|
||||||
|
mocker.patch.object(
|
||||||
|
type(light_effect),
|
||||||
|
"is_active",
|
||||||
|
new_callable=mocker.PropertyMock,
|
||||||
|
return_value=effect_active,
|
||||||
|
)
|
||||||
|
if effect_active: # Set the rule L1 active for testing
|
||||||
|
light_effect.data["current_rule_id"] = "L1"
|
||||||
|
|
||||||
|
await light_module.set_brightness(10)
|
||||||
|
|
||||||
|
if effect_active:
|
||||||
|
assert light_effect.is_active
|
||||||
|
assert light_effect.brightness == dev.brightness
|
||||||
|
|
||||||
|
light_effect_set_brightness.assert_called_with(10)
|
||||||
|
mock_light_effect_call.assert_called_with(
|
||||||
|
"edit_dynamic_light_effect_rule", mocker.ANY
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert not light_effect.is_active
|
||||||
|
|
||||||
|
brightness_set_brightness.assert_called_with(10)
|
||||||
|
mock_brightness_call.assert_called_with("set_device_info", {"brightness": 10})
|
||||||
|
101
kasa/tests/smart/modules/test_light_strip_effect.py
Normal file
101
kasa/tests/smart/modules/test_light_strip_effect.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
from kasa import Device, Feature, Module
|
||||||
|
from kasa.smart.modules import LightEffect, LightStripEffect
|
||||||
|
from kasa.tests.device_fixtures import parametrize
|
||||||
|
|
||||||
|
light_strip_effect = parametrize(
|
||||||
|
"has light strip effect",
|
||||||
|
component_filter="light_strip_lighting_effect",
|
||||||
|
protocol_filter={"SMART"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@light_strip_effect
|
||||||
|
async def test_light_strip_effect(dev: Device, mocker: MockerFixture):
|
||||||
|
"""Test light strip effect."""
|
||||||
|
light_effect = dev.modules.get(Module.LightEffect)
|
||||||
|
|
||||||
|
assert isinstance(light_effect, LightStripEffect)
|
||||||
|
|
||||||
|
brightness = dev.modules[Module.Brightness]
|
||||||
|
|
||||||
|
feature = dev.features["light_effect"]
|
||||||
|
assert feature.type == Feature.Type.Choice
|
||||||
|
|
||||||
|
call = mocker.spy(light_effect, "call")
|
||||||
|
|
||||||
|
light = dev.modules[Module.Light]
|
||||||
|
light_call = mocker.spy(light, "call")
|
||||||
|
|
||||||
|
assert feature.choices == light_effect.effect_list
|
||||||
|
assert feature.choices
|
||||||
|
for effect in chain(reversed(feature.choices), feature.choices):
|
||||||
|
await light_effect.set_effect(effect)
|
||||||
|
|
||||||
|
if effect == LightEffect.LIGHT_EFFECTS_OFF:
|
||||||
|
light_call.assert_called()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Start with the current effect data
|
||||||
|
params = light_effect.data["lighting_effect"]
|
||||||
|
enable = effect != LightEffect.LIGHT_EFFECTS_OFF
|
||||||
|
params["enable"] = enable
|
||||||
|
if enable:
|
||||||
|
params = light_effect._effect_mapping[effect]
|
||||||
|
params["enable"] = enable
|
||||||
|
params["brightness"] = brightness.brightness # use the existing brightness
|
||||||
|
|
||||||
|
call.assert_called_with("set_lighting_effect", params)
|
||||||
|
|
||||||
|
await dev.update()
|
||||||
|
assert light_effect.effect == effect
|
||||||
|
assert feature.value == effect
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await light_effect.set_effect("foobar")
|
||||||
|
|
||||||
|
|
||||||
|
@light_strip_effect
|
||||||
|
@pytest.mark.parametrize("effect_active", [True, False])
|
||||||
|
async def test_light_effect_brightness(
|
||||||
|
dev: Device, effect_active: bool, mocker: MockerFixture
|
||||||
|
):
|
||||||
|
"""Test that light module uses light_effect for brightness when active."""
|
||||||
|
light_module = dev.modules[Module.Light]
|
||||||
|
|
||||||
|
light_effect = dev.modules[Module.SmartLightEffect]
|
||||||
|
light_effect_set_brightness = mocker.spy(light_effect, "set_brightness")
|
||||||
|
mock_light_effect_call = mocker.patch.object(light_effect, "call")
|
||||||
|
|
||||||
|
brightness = dev.modules[Module.Brightness]
|
||||||
|
brightness_set_brightness = mocker.spy(brightness, "set_brightness")
|
||||||
|
mock_brightness_call = mocker.patch.object(brightness, "call")
|
||||||
|
|
||||||
|
mocker.patch.object(
|
||||||
|
type(light_effect),
|
||||||
|
"is_active",
|
||||||
|
new_callable=mocker.PropertyMock,
|
||||||
|
return_value=effect_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
await light_module.set_brightness(10)
|
||||||
|
|
||||||
|
if effect_active:
|
||||||
|
assert light_effect.is_active
|
||||||
|
assert light_effect.brightness == dev.brightness
|
||||||
|
|
||||||
|
light_effect_set_brightness.assert_called_with(10)
|
||||||
|
mock_light_effect_call.assert_called_with(
|
||||||
|
"set_lighting_effect", {"brightness": 10, "bAdjusted": True}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert not light_effect.is_active
|
||||||
|
|
||||||
|
brightness_set_brightness.assert_called_with(10)
|
||||||
|
mock_brightness_call.assert_called_with("set_device_info", {"brightness": 10})
|
@ -238,3 +238,14 @@ async def test_device_updates_deprecated(
|
|||||||
child_spy.assert_called_once()
|
child_spy.assert_called_once()
|
||||||
else:
|
else:
|
||||||
child_spy.assert_not_called()
|
child_spy.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@has_children
|
||||||
|
async def test_parent_property(dev: Device):
|
||||||
|
"""Test a child device exposes it's parent."""
|
||||||
|
if not dev.children:
|
||||||
|
pytest.skip(f"Device {dev} fixture does not have any children")
|
||||||
|
|
||||||
|
assert dev.parent is None
|
||||||
|
for child in dev.children:
|
||||||
|
assert child.parent == dev
|
||||||
|
@ -5,6 +5,7 @@ import re
|
|||||||
import asyncclick as click
|
import asyncclick as click
|
||||||
import pytest
|
import pytest
|
||||||
from asyncclick.testing import CliRunner
|
from asyncclick.testing import CliRunner
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from kasa import (
|
from kasa import (
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
@ -24,6 +25,7 @@ from kasa.cli import (
|
|||||||
cmd_command,
|
cmd_command,
|
||||||
effect,
|
effect,
|
||||||
emeter,
|
emeter,
|
||||||
|
energy,
|
||||||
hsv,
|
hsv,
|
||||||
led,
|
led,
|
||||||
raw_command,
|
raw_command,
|
||||||
@ -62,7 +64,6 @@ def runner():
|
|||||||
[
|
[
|
||||||
pytest.param(None, None, id="No connect params"),
|
pytest.param(None, None, id="No connect params"),
|
||||||
pytest.param("SMART.TAPOPLUG", None, id="Only device_family"),
|
pytest.param("SMART.TAPOPLUG", None, id="Only device_family"),
|
||||||
pytest.param(None, "KLAP", id="Only encrypt_type"),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_type):
|
async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_type):
|
||||||
@ -171,13 +172,16 @@ async def test_command_with_child(dev, mocker, runner):
|
|||||||
class DummyDevice(dev.__class__):
|
class DummyDevice(dev.__class__):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("127.0.0.1")
|
super().__init__("127.0.0.1")
|
||||||
|
# device_type and _info initialised for repr
|
||||||
|
self._device_type = Device.Type.StripSocket
|
||||||
|
self._info = {}
|
||||||
|
|
||||||
async def _query_helper(*_, **__):
|
async def _query_helper(*_, **__):
|
||||||
return {"dummy": "response"}
|
return {"dummy": "response"}
|
||||||
|
|
||||||
dummy_child = DummyDevice()
|
dummy_child = DummyDevice()
|
||||||
|
|
||||||
mocker.patch.object(dev, "_children", {"XYZ": dummy_child})
|
mocker.patch.object(dev, "_children", {"XYZ": [dummy_child]})
|
||||||
mocker.patch.object(dev, "get_child_device", return_value=dummy_child)
|
mocker.patch.object(dev, "get_child_device", return_value=dummy_child)
|
||||||
|
|
||||||
res = await runner.invoke(
|
res = await runner.invoke(
|
||||||
@ -314,9 +318,9 @@ async def test_emeter(dev: Device, mocker, runner):
|
|||||||
|
|
||||||
if not dev.is_strip:
|
if not dev.is_strip:
|
||||||
res = await runner.invoke(emeter, ["--index", "0"], obj=dev)
|
res = await runner.invoke(emeter, ["--index", "0"], obj=dev)
|
||||||
assert "Index and name are only for power strips!" in res.output
|
assert f"Device: {dev.host} does not have children" in res.output
|
||||||
res = await runner.invoke(emeter, ["--name", "mock"], obj=dev)
|
res = await runner.invoke(emeter, ["--name", "mock"], obj=dev)
|
||||||
assert "Index and name are only for power strips!" in res.output
|
assert f"Device: {dev.host} does not have children" in res.output
|
||||||
|
|
||||||
if dev.is_strip and len(dev.children) > 0:
|
if dev.is_strip and len(dev.children) > 0:
|
||||||
realtime_emeter = mocker.patch.object(dev.children[0], "get_emeter_realtime")
|
realtime_emeter = mocker.patch.object(dev.children[0], "get_emeter_realtime")
|
||||||
@ -930,3 +934,110 @@ async def test_feature_set_child(mocker, runner):
|
|||||||
assert f"Targeting child device {child_id}"
|
assert f"Targeting child device {child_id}"
|
||||||
assert "Changing state from False to True" in res.output
|
assert "Changing state from False to True" in res.output
|
||||||
assert res.exit_code == 0
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cli_child_commands(
|
||||||
|
dev: Device, runner: CliRunner, mocker: MockerFixture
|
||||||
|
):
|
||||||
|
if not dev.children:
|
||||||
|
res = await runner.invoke(alias, ["--child-index", "0"], obj=dev)
|
||||||
|
assert f"Device: {dev.host} does not have children" in res.output
|
||||||
|
assert res.exit_code == 1
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--index", "0"], obj=dev)
|
||||||
|
assert f"Device: {dev.host} does not have children" in res.output
|
||||||
|
assert res.exit_code == 1
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--child", "Plug 2"], obj=dev)
|
||||||
|
assert f"Device: {dev.host} does not have children" in res.output
|
||||||
|
assert res.exit_code == 1
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--name", "Plug 2"], obj=dev)
|
||||||
|
assert f"Device: {dev.host} does not have children" in res.output
|
||||||
|
assert res.exit_code == 1
|
||||||
|
|
||||||
|
if dev.children:
|
||||||
|
child_alias = dev.children[0].alias
|
||||||
|
assert child_alias
|
||||||
|
child_device_id = dev.children[0].device_id
|
||||||
|
child_count = len(dev.children)
|
||||||
|
child_update_method = dev.children[0].update
|
||||||
|
|
||||||
|
# Test child retrieval
|
||||||
|
res = await runner.invoke(alias, ["--child-index", "0"], obj=dev)
|
||||||
|
assert f"Targeting child device {child_alias}" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--index", "0"], obj=dev)
|
||||||
|
assert f"Targeting child device {child_alias}" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--child", child_alias], obj=dev)
|
||||||
|
assert f"Targeting child device {child_alias}" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--name", child_alias], obj=dev)
|
||||||
|
assert f"Targeting child device {child_alias}" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--child", child_device_id], obj=dev)
|
||||||
|
assert f"Targeting child device {child_alias}" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--name", child_device_id], obj=dev)
|
||||||
|
assert f"Targeting child device {child_alias}" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
# Test invalid name and index
|
||||||
|
res = await runner.invoke(alias, ["--child-index", "-1"], obj=dev)
|
||||||
|
assert f"Invalid index -1, device has {child_count} children" in res.output
|
||||||
|
assert res.exit_code == 1
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--child-index", str(child_count)], obj=dev)
|
||||||
|
assert (
|
||||||
|
f"Invalid index {child_count}, device has {child_count} children"
|
||||||
|
in res.output
|
||||||
|
)
|
||||||
|
assert res.exit_code == 1
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--child", "foobar"], obj=dev)
|
||||||
|
assert "No child device found with device_id or name: foobar" in res.output
|
||||||
|
assert res.exit_code == 1
|
||||||
|
|
||||||
|
# Test using both options:
|
||||||
|
|
||||||
|
res = await runner.invoke(
|
||||||
|
alias, ["--child", child_alias, "--child-index", "0"], obj=dev
|
||||||
|
)
|
||||||
|
assert "Use either --child or --child-index, not both." in res.output
|
||||||
|
assert res.exit_code == 2
|
||||||
|
|
||||||
|
# Test child with no parameter interactive prompt
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["--child"], obj=dev, input="0\n")
|
||||||
|
assert "Enter the index number of the child device:" in res.output
|
||||||
|
assert f"Alias: {child_alias}" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
# Test values and updates
|
||||||
|
|
||||||
|
res = await runner.invoke(alias, ["foo", "--child", child_device_id], obj=dev)
|
||||||
|
assert "Alias set to: foo" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
# Test help has command options plus child options
|
||||||
|
|
||||||
|
res = await runner.invoke(energy, ["--help"], obj=dev)
|
||||||
|
assert "--year" in res.output
|
||||||
|
assert "--child" in res.output
|
||||||
|
assert "--child-index" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
|
||||||
|
# Test child update patching calls parent and is undone on exit
|
||||||
|
|
||||||
|
parent_update_spy = mocker.spy(dev, "update")
|
||||||
|
res = await runner.invoke(alias, ["bar", "--child", child_device_id], obj=dev)
|
||||||
|
assert "Alias set to: bar" in res.output
|
||||||
|
assert res.exit_code == 0
|
||||||
|
parent_update_spy.assert_called_once()
|
||||||
|
assert dev.children[0].update == child_update_method
|
||||||
|
@ -89,35 +89,39 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture):
|
|||||||
assert light_effect_module.has_custom_effects is not None
|
assert light_effect_module.has_custom_effects is not None
|
||||||
|
|
||||||
await light_effect_module.set_effect("Off")
|
await light_effect_module.set_effect("Off")
|
||||||
assert call.call_count == 1
|
call.assert_called()
|
||||||
await dev.update()
|
await dev.update()
|
||||||
assert light_effect_module.effect == "Off"
|
assert light_effect_module.effect == "Off"
|
||||||
assert feat.value == "Off"
|
assert feat.value == "Off"
|
||||||
|
call.reset_mock()
|
||||||
|
|
||||||
second_effect = effect_list[1]
|
second_effect = effect_list[1]
|
||||||
await light_effect_module.set_effect(second_effect)
|
await light_effect_module.set_effect(second_effect)
|
||||||
assert call.call_count == 2
|
call.assert_called()
|
||||||
await dev.update()
|
await dev.update()
|
||||||
assert light_effect_module.effect == second_effect
|
assert light_effect_module.effect == second_effect
|
||||||
assert feat.value == second_effect
|
assert feat.value == second_effect
|
||||||
|
call.reset_mock()
|
||||||
|
|
||||||
last_effect = effect_list[len(effect_list) - 1]
|
last_effect = effect_list[len(effect_list) - 1]
|
||||||
await light_effect_module.set_effect(last_effect)
|
await light_effect_module.set_effect(last_effect)
|
||||||
assert call.call_count == 3
|
call.assert_called()
|
||||||
await dev.update()
|
await dev.update()
|
||||||
assert light_effect_module.effect == last_effect
|
assert light_effect_module.effect == last_effect
|
||||||
assert feat.value == last_effect
|
assert feat.value == last_effect
|
||||||
|
call.reset_mock()
|
||||||
|
|
||||||
# Test feature set
|
# Test feature set
|
||||||
await feat.set_value(second_effect)
|
await feat.set_value(second_effect)
|
||||||
assert call.call_count == 4
|
call.assert_called()
|
||||||
await dev.update()
|
await dev.update()
|
||||||
assert light_effect_module.effect == second_effect
|
assert light_effect_module.effect == second_effect
|
||||||
assert feat.value == second_effect
|
assert feat.value == second_effect
|
||||||
|
call.reset_mock()
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await light_effect_module.set_effect("foobar")
|
await light_effect_module.set_effect("foobar")
|
||||||
assert call.call_count == 4
|
call.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@dimmable
|
@dimmable
|
||||||
|
@ -13,6 +13,7 @@ import pytest
|
|||||||
|
|
||||||
from ..aestransport import AesTransport
|
from ..aestransport import AesTransport
|
||||||
from ..credentials import Credentials
|
from ..credentials import Credentials
|
||||||
|
from ..device import Device
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
from ..exceptions import KasaException
|
from ..exceptions import KasaException
|
||||||
from ..iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol
|
from ..iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol
|
||||||
@ -512,11 +513,72 @@ def test_transport_init_signature(class_name_obj):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("transport_class", "login_version", "expected_hash"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
AesTransport,
|
||||||
|
1,
|
||||||
|
"eyJwYXNzd29yZCI6IlFtRnkiLCJ1c2VybmFtZSI6Ik1qQXhZVFppTXpBMU0yTmpNVFF5TW1ReVl6TTJOekJpTmpJMk1UWXlNakZrTWpJNU1Ea3lPUT09In0=",
|
||||||
|
id="aes-lv-1",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
AesTransport,
|
||||||
|
2,
|
||||||
|
"eyJwYXNzd29yZDIiOiJaVFE1Tm1aa01qQXhNelprTkdKaU56Z3lPR1ZpWWpCaFlqa3lOV0l4WW1RNU56Y3lNRGhsTkE9PSIsInVzZXJuYW1lIjoiTWpBeFlUWmlNekExTTJOak1UUXlNbVF5WXpNMk56QmlOakkyTVRZeU1qRmtNakk1TURreU9RPT0ifQ==",
|
||||||
|
id="aes-lv-2",
|
||||||
|
),
|
||||||
|
pytest.param(KlapTransport, 1, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-1"),
|
||||||
|
pytest.param(KlapTransport, 2, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-2"),
|
||||||
|
pytest.param(
|
||||||
|
KlapTransportV2,
|
||||||
|
1,
|
||||||
|
"tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=",
|
||||||
|
id="klapv2-lv-1",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
KlapTransportV2,
|
||||||
|
2,
|
||||||
|
"tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=",
|
||||||
|
id="klapv2-lv-2",
|
||||||
|
),
|
||||||
|
pytest.param(XorTransport, None, None, id="xor"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("credentials", "expected_blank"),
|
||||||
|
[
|
||||||
|
pytest.param(Credentials("Foo", "Bar"), False, id="credentials"),
|
||||||
|
pytest.param(None, True, id="no-credentials"),
|
||||||
|
pytest.param(Credentials(None, "Bar"), True, id="no-username"), # type: ignore[arg-type]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_transport_credentials_hash(
|
||||||
|
mocker, transport_class, login_version, expected_hash, credentials, expected_blank
|
||||||
|
):
|
||||||
|
"""Test that the actual hashing doesn't break and empty credential returns an empty hash."""
|
||||||
|
host = "127.0.0.1"
|
||||||
|
|
||||||
|
params = Device.ConnectionParameters(
|
||||||
|
device_family=Device.Family.SmartTapoPlug,
|
||||||
|
encryption_type=Device.EncryptionType.Xor,
|
||||||
|
login_version=login_version,
|
||||||
|
)
|
||||||
|
config = DeviceConfig(host, credentials=credentials, connection_type=params)
|
||||||
|
transport = transport_class(config=config)
|
||||||
|
|
||||||
|
credentials_hash = transport.credentials_hash
|
||||||
|
|
||||||
|
expected = None if expected_blank else expected_hash
|
||||||
|
assert credentials_hash == expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"transport_class",
|
"transport_class",
|
||||||
[AesTransport, KlapTransport, KlapTransportV2, XorTransport, XorTransport],
|
[AesTransport, KlapTransport, KlapTransportV2, XorTransport, XorTransport],
|
||||||
)
|
)
|
||||||
async def test_transport_credentials_hash(mocker, transport_class):
|
async def test_transport_credentials_hash_from_config(mocker, transport_class):
|
||||||
|
"""Test that credentials_hash provided via config sets correctly."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
|
|
||||||
credentials = Credentials("Foo", "Bar")
|
credentials = Credentials("Foo", "Bar")
|
||||||
|
@ -2,10 +2,9 @@ import logging
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ..credentials import Credentials
|
|
||||||
from ..deviceconfig import DeviceConfig
|
|
||||||
from ..exceptions import (
|
from ..exceptions import (
|
||||||
SMART_RETRYABLE_ERRORS,
|
SMART_RETRYABLE_ERRORS,
|
||||||
|
DeviceError,
|
||||||
KasaException,
|
KasaException,
|
||||||
SmartErrorCode,
|
SmartErrorCode,
|
||||||
)
|
)
|
||||||
@ -93,7 +92,6 @@ async def test_smart_device_errors_in_multiple_request(
|
|||||||
async def test_smart_device_multiple_request(
|
async def test_smart_device_multiple_request(
|
||||||
dummy_protocol, mocker, request_size, batch_size
|
dummy_protocol, mocker, request_size, batch_size
|
||||||
):
|
):
|
||||||
host = "127.0.0.1"
|
|
||||||
requests = {}
|
requests = {}
|
||||||
mock_response = {
|
mock_response = {
|
||||||
"result": {"responses": []},
|
"result": {"responses": []},
|
||||||
@ -109,16 +107,101 @@ async def test_smart_device_multiple_request(
|
|||||||
send_mock = mocker.patch.object(
|
send_mock = mocker.patch.object(
|
||||||
dummy_protocol._transport, "send", return_value=mock_response
|
dummy_protocol._transport, "send", return_value=mock_response
|
||||||
)
|
)
|
||||||
config = DeviceConfig(
|
dummy_protocol._multi_request_batch_size = batch_size
|
||||||
host, credentials=Credentials("foo", "bar"), batch_size=batch_size
|
|
||||||
)
|
|
||||||
dummy_protocol._transport._config = config
|
|
||||||
|
|
||||||
await dummy_protocol.query(requests, retry_count=0)
|
await dummy_protocol.query(requests, retry_count=0)
|
||||||
expected_count = int(request_size / batch_size) + (request_size % batch_size > 0)
|
expected_count = int(request_size / batch_size) + (request_size % batch_size > 0)
|
||||||
assert send_mock.call_count == expected_count
|
assert send_mock.call_count == expected_count
|
||||||
|
|
||||||
|
|
||||||
|
async def test_smart_device_multiple_request_json_decode_failure(
|
||||||
|
dummy_protocol, mocker
|
||||||
|
):
|
||||||
|
"""Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR."""
|
||||||
|
requests = {}
|
||||||
|
mock_responses = []
|
||||||
|
|
||||||
|
mock_json_error = {
|
||||||
|
"result": {"responses": []},
|
||||||
|
"error_code": SmartErrorCode.JSON_DECODE_FAIL_ERROR.value,
|
||||||
|
}
|
||||||
|
for i in range(10):
|
||||||
|
method = f"get_method_{i}"
|
||||||
|
requests[method] = {"foo": "bar", "bar": "foo"}
|
||||||
|
mock_responses.append(
|
||||||
|
{"method": method, "result": {"great": "success"}, "error_code": 0}
|
||||||
|
)
|
||||||
|
|
||||||
|
send_mock = mocker.patch.object(
|
||||||
|
dummy_protocol._transport,
|
||||||
|
"send",
|
||||||
|
side_effect=[mock_json_error, *mock_responses],
|
||||||
|
)
|
||||||
|
dummy_protocol._multi_request_batch_size = 5
|
||||||
|
assert dummy_protocol._multi_request_batch_size == 5
|
||||||
|
await dummy_protocol.query(requests, retry_count=1)
|
||||||
|
assert dummy_protocol._multi_request_batch_size == 1
|
||||||
|
# Call count should be the first error + number of requests
|
||||||
|
assert send_mock.call_count == len(requests) + 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_smart_device_multiple_request_json_decode_failure_twice(
|
||||||
|
dummy_protocol, mocker
|
||||||
|
):
|
||||||
|
"""Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR."""
|
||||||
|
requests = {}
|
||||||
|
|
||||||
|
mock_json_error = {
|
||||||
|
"result": {"responses": []},
|
||||||
|
"error_code": SmartErrorCode.JSON_DECODE_FAIL_ERROR.value,
|
||||||
|
}
|
||||||
|
for i in range(10):
|
||||||
|
method = f"get_method_{i}"
|
||||||
|
requests[method] = {"foo": "bar", "bar": "foo"}
|
||||||
|
|
||||||
|
send_mock = mocker.patch.object(
|
||||||
|
dummy_protocol._transport,
|
||||||
|
"send",
|
||||||
|
side_effect=[mock_json_error, KasaException],
|
||||||
|
)
|
||||||
|
dummy_protocol._multi_request_batch_size = 5
|
||||||
|
with pytest.raises(KasaException):
|
||||||
|
await dummy_protocol.query(requests, retry_count=1)
|
||||||
|
assert dummy_protocol._multi_request_batch_size == 1
|
||||||
|
|
||||||
|
assert send_mock.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_smart_device_multiple_request_non_json_decode_failure(
|
||||||
|
dummy_protocol, mocker
|
||||||
|
):
|
||||||
|
"""Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR.
|
||||||
|
|
||||||
|
Ensure other exception types behave as expected.
|
||||||
|
"""
|
||||||
|
requests = {}
|
||||||
|
|
||||||
|
mock_json_error = {
|
||||||
|
"result": {"responses": []},
|
||||||
|
"error_code": SmartErrorCode.UNKNOWN_METHOD_ERROR.value,
|
||||||
|
}
|
||||||
|
for i in range(10):
|
||||||
|
method = f"get_method_{i}"
|
||||||
|
requests[method] = {"foo": "bar", "bar": "foo"}
|
||||||
|
|
||||||
|
send_mock = mocker.patch.object(
|
||||||
|
dummy_protocol._transport,
|
||||||
|
"send",
|
||||||
|
side_effect=[mock_json_error, KasaException],
|
||||||
|
)
|
||||||
|
dummy_protocol._multi_request_batch_size = 5
|
||||||
|
with pytest.raises(DeviceError):
|
||||||
|
await dummy_protocol.query(requests, retry_count=1)
|
||||||
|
assert dummy_protocol._multi_request_batch_size == 5
|
||||||
|
|
||||||
|
assert send_mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_childdevicewrapper_unwrapping(dummy_protocol, mocker):
|
async def test_childdevicewrapper_unwrapping(dummy_protocol, mocker):
|
||||||
"""Test that responseData gets unwrapped correctly."""
|
"""Test that responseData gets unwrapped correctly."""
|
||||||
wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol)
|
wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol)
|
||||||
|
@ -54,9 +54,9 @@ class XorTransport(BaseTransport):
|
|||||||
return self.DEFAULT_PORT
|
return self.DEFAULT_PORT
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def credentials_hash(self) -> str:
|
def credentials_hash(self) -> str | None:
|
||||||
"""The hashed credentials used by the transport."""
|
"""The hashed credentials used by the transport."""
|
||||||
return ""
|
return None
|
||||||
|
|
||||||
async def _connect(self, timeout: int) -> None:
|
async def _connect(self, timeout: int) -> None:
|
||||||
"""Try to connect or reconnect to the device."""
|
"""Try to connect or reconnect to the device."""
|
||||||
|
17
poetry.lock
generated
17
poetry.lock
generated
@ -1,4 +1,4 @@
|
|||||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
@ -1653,6 +1653,7 @@ files = [
|
|||||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
|
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
|
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
|
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
|
||||||
|
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
|
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
|
||||||
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
|
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
|
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
|
||||||
@ -1660,8 +1661,15 @@ files = [
|
|||||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
|
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
|
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
|
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
|
||||||
|
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
|
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
|
||||||
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
|
||||||
|
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
|
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
|
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
|
||||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
|
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
|
||||||
@ -1678,6 +1686,7 @@ files = [
|
|||||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
|
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
|
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
|
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
|
||||||
|
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
|
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
|
||||||
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
|
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
|
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
|
||||||
@ -1685,6 +1694,7 @@ files = [
|
|||||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
|
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
|
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
|
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
|
||||||
|
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
|
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
|
||||||
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
|
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
|
||||||
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
||||||
@ -2051,13 +2061,12 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voluptuous"
|
name = "voluptuous"
|
||||||
version = "0.15.0"
|
version = "0.15.1"
|
||||||
description = "Python data validation library"
|
description = "Python data validation library"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
files = [
|
files = [
|
||||||
{file = "voluptuous-0.15.0-py3-none-any.whl", hash = "sha256:ab8d0c3b74b83d062b72fde6ed120b9801d7acb7e504666b0f278dd214ae7ce5"},
|
{file = "voluptuous-0.15.1.tar.gz", hash = "sha256:4ba7f38f624379ecd02666e87e99cb24b6f5997a28258d3302c761d1a2c35d00"},
|
||||||
{file = "voluptuous-0.15.0.tar.gz", hash = "sha256:90fb449f6088f3985b24c0df79887e3823355639e0a6a220394ceac07258aea0"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "python-kasa"
|
name = "python-kasa"
|
||||||
version = "0.7.0.1"
|
version = "0.7.0.2"
|
||||||
description = "Python API for TP-Link Kasa Smarthome devices"
|
description = "Python API for TP-Link Kasa Smarthome devices"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
authors = ["python-kasa developers"]
|
authors = ["python-kasa developers"]
|
||||||
|
Loading…
Reference in New Issue
Block a user