mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +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
|
||||
add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}}
|
||||
output=CHANGELOG.md
|
||||
base=HISTORY.md
|
||||
user=python-kasa
|
||||
project=python-kasa
|
||||
since-tag=0.3.5
|
||||
release_branch=master
|
||||
usernames-as-github-logins=true
|
||||
breaking_labels=breaking change
|
||||
add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}}
|
||||
exclude-labels=duplicate,question,invalid,wontfix,release-prep
|
||||
issues-wo-labels=false
|
||||
|
48
CHANGELOG.md
48
CHANGELOG.md
@ -1,12 +1,30 @@
|
||||
# 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.
|
||||
|
||||
**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)
|
||||
|
||||
This patch release fixes some minor issues found out during testing against all new homeassistant platforms.
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- 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:**
|
||||
|
||||
- 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)
|
||||
- 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 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)
|
||||
- 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 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)
|
||||
- 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 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 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)
|
||||
- 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)
|
||||
- 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)
|
||||
@ -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 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)
|
||||
- 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)
|
||||
- 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)
|
||||
@ -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)
|
||||
- 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)
|
||||
- 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)
|
||||
- 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)
|
||||
- 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)
|
||||
- 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)
|
||||
- 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)
|
||||
@ -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)
|
||||
- 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 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)
|
||||
- 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)
|
||||
@ -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)
|
||||
- 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 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 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)
|
||||
- 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)
|
||||
- Add WallSwitch device type and autogenerate supported devices docs [\#758](https://github.com/python-kasa/python-kasa/pull/758) (@sdb9696)
|
||||
|
||||
**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)
|
||||
- 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)
|
||||
@ -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)
|
||||
- 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)
|
||||
- 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)
|
||||
- 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)
|
||||
@ -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)
|
||||
- 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 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)
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
|
@ -73,6 +73,8 @@ gh issue close ISSUE_NUMBER
|
||||
|
||||
## Generate changelog
|
||||
|
||||
Configuration settings are in `.github_changelog_generator`
|
||||
|
||||
### For pre-release
|
||||
|
||||
EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags.
|
||||
@ -82,13 +84,13 @@ Regex should be something like this `^((?!0\.7\.0)(.*dev\d))+`. The first match
|
||||
```bash
|
||||
EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+"
|
||||
echo "$EXCLUDE_TAGS"
|
||||
github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex "$EXCLUDE_TAGS"
|
||||
github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex "$EXCLUDE_TAGS"
|
||||
```
|
||||
|
||||
### For production
|
||||
|
||||
```bash
|
||||
github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md --no-issues --exclude-tags-regex 'dev\d$'
|
||||
github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex 'dev\d$'
|
||||
```
|
||||
|
||||
You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master:
|
||||
|
@ -209,6 +209,7 @@ All Tapo devices require authentication.<br>Hub-Connected Devices may work acros
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.1.0
|
||||
- **L920-5**
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.0.7
|
||||
- Hardware: 1.0 (EU) / Firmware: 1.1.3
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.0
|
||||
- Hardware: 1.0 (US) / Firmware: 1.1.3
|
||||
- **L930-5**
|
||||
|
@ -117,8 +117,10 @@ class AesTransport(BaseTransport):
|
||||
return self.DEFAULT_PORT
|
||||
|
||||
@property
|
||||
def credentials_hash(self) -> str:
|
||||
def credentials_hash(self) -> str | None:
|
||||
"""The hashed credentials used by the transport."""
|
||||
if self._credentials == Credentials():
|
||||
return None
|
||||
return base64.b64encode(json_dumps(self._login_params).encode()).decode()
|
||||
|
||||
def _get_login_params(self, credentials: Credentials) -> dict[str, str]:
|
||||
|
316
kasa/cli.py
316
kasa/cli.py
@ -8,11 +8,11 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from datetime import datetime
|
||||
from functools import singledispatch, wraps
|
||||
from functools import singledispatch, update_wrapper, wraps
|
||||
from pprint import pformat as pf
|
||||
from typing import Any, cast
|
||||
from typing import Any, Final, cast
|
||||
|
||||
import asyncclick as click
|
||||
from pydantic.v1 import ValidationError
|
||||
@ -41,6 +41,7 @@ from kasa.iot import (
|
||||
IotStrip,
|
||||
IotWallSwitch,
|
||||
)
|
||||
from kasa.iot.iotstrip import IotStripPlug
|
||||
from kasa.iot.modules import Usage
|
||||
from kasa.smart import SmartDevice
|
||||
|
||||
@ -77,6 +78,9 @@ def error(msg: str):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Value for optional options if passed without a value
|
||||
OPTIONAL_VALUE_FLAG: Final = "_FLAG_"
|
||||
|
||||
TYPE_TO_CLASS = {
|
||||
"plug": IotPlug,
|
||||
"switch": IotWallSwitch,
|
||||
@ -169,6 +173,112 @@ def json_formatter_cb(result, **kwargs):
|
||||
print(json_content)
|
||||
|
||||
|
||||
def pass_dev_or_child(wrapped_function):
|
||||
"""Pass the device or child to the click command based on the child options."""
|
||||
child_help = (
|
||||
"Child ID or alias for controlling sub-devices. "
|
||||
"If no value provided will show an interactive prompt allowing you to "
|
||||
"select a child."
|
||||
)
|
||||
child_index_help = "Child index controlling sub-devices"
|
||||
|
||||
@contextmanager
|
||||
def patched_device_update(parent: Device, child: Device):
|
||||
try:
|
||||
orig_update = child.update
|
||||
# patch child update method. Can be removed once update can be called
|
||||
# directly on child devices
|
||||
child.update = parent.update # type: ignore[method-assign]
|
||||
yield child
|
||||
finally:
|
||||
child.update = orig_update # type: ignore[method-assign]
|
||||
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--child",
|
||||
"--name",
|
||||
is_flag=False,
|
||||
flag_value=OPTIONAL_VALUE_FLAG,
|
||||
default=None,
|
||||
required=False,
|
||||
type=click.STRING,
|
||||
help=child_help,
|
||||
)
|
||||
@click.option(
|
||||
"--child-index",
|
||||
"--index",
|
||||
required=False,
|
||||
default=None,
|
||||
type=click.INT,
|
||||
help=child_index_help,
|
||||
)
|
||||
async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs):
|
||||
if child := await _get_child_device(dev, child, child_index, ctx.info_name):
|
||||
ctx.obj = ctx.with_resource(patched_device_update(dev, child))
|
||||
dev = child
|
||||
return await ctx.invoke(wrapped_function, dev, *args, **kwargs)
|
||||
|
||||
# Update wrapper function to look like wrapped function
|
||||
return update_wrapper(wrapper, wrapped_function)
|
||||
|
||||
|
||||
async def _get_child_device(
|
||||
device: Device, child_option, child_index_option, info_command
|
||||
) -> Device | None:
|
||||
def _list_children():
|
||||
return "\n".join(
|
||||
[
|
||||
f"{idx}: {child.device_id} ({child.alias})"
|
||||
for idx, child in enumerate(device.children)
|
||||
]
|
||||
)
|
||||
|
||||
if child_option is None and child_index_option is None:
|
||||
return None
|
||||
|
||||
if info_command in SKIP_UPDATE_COMMANDS:
|
||||
# The device hasn't had update called (e.g. for cmd_command)
|
||||
# The way child devices are accessed requires a ChildDevice to
|
||||
# wrap the communications. Doing this properly would require creating
|
||||
# a common interfaces for both IOT and SMART child devices.
|
||||
# As a stop-gap solution, we perform an update instead.
|
||||
await device.update()
|
||||
|
||||
if not device.children:
|
||||
error(f"Device: {device.host} does not have children")
|
||||
|
||||
if child_option is not None and child_index_option is not None:
|
||||
raise click.BadOptionUsage(
|
||||
"child", "Use either --child or --child-index, not both."
|
||||
)
|
||||
|
||||
if child_option is not None:
|
||||
if child_option is OPTIONAL_VALUE_FLAG:
|
||||
msg = _list_children()
|
||||
child_index_option = click.prompt(
|
||||
f"\n{msg}\nEnter the index number of the child device",
|
||||
type=click.IntRange(0, len(device.children) - 1),
|
||||
)
|
||||
elif child := device.get_child_device(child_option):
|
||||
echo(f"Targeting child device {child.alias}")
|
||||
return child
|
||||
else:
|
||||
error(
|
||||
"No child device found with device_id or name: "
|
||||
f"{child_option} children are:\n{_list_children()}"
|
||||
)
|
||||
|
||||
if child_index_option + 1 > len(device.children) or child_index_option < 0:
|
||||
error(
|
||||
f"Invalid index {child_index_option}, "
|
||||
f"device has {len(device.children)} children"
|
||||
)
|
||||
child_by_index = device.children[child_index_option]
|
||||
echo(f"Targeting child device {child_by_index.alias}")
|
||||
return child_by_index
|
||||
|
||||
|
||||
@click.group(
|
||||
invoke_without_command=True,
|
||||
cls=CatchAllExceptions(click.Group),
|
||||
@ -232,6 +342,7 @@ def json_formatter_cb(result, **kwargs):
|
||||
help="Output raw device response as JSON.",
|
||||
)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--encrypt-type",
|
||||
envvar="KASA_ENCRYPT_TYPE",
|
||||
default=None,
|
||||
@ -240,13 +351,14 @@ def json_formatter_cb(result, **kwargs):
|
||||
@click.option(
|
||||
"--device-family",
|
||||
envvar="KASA_DEVICE_FAMILY",
|
||||
default=None,
|
||||
default="SMART.TAPOPLUG",
|
||||
type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False),
|
||||
)
|
||||
@click.option(
|
||||
"-lv",
|
||||
"--login-version",
|
||||
envvar="KASA_LOGIN_VERSION",
|
||||
default=None,
|
||||
default=2,
|
||||
type=int,
|
||||
)
|
||||
@click.option(
|
||||
@ -379,7 +491,8 @@ async def cli(
|
||||
|
||||
device_updated = False
|
||||
if type is not None:
|
||||
dev = TYPE_TO_CLASS[type](host)
|
||||
config = DeviceConfig(host=host, port_override=port, timeout=timeout)
|
||||
dev = TYPE_TO_CLASS[type](host, config=config)
|
||||
elif device_family and encrypt_type:
|
||||
ctype = DeviceConnectionParameters(
|
||||
DeviceFamily(device_family),
|
||||
@ -397,12 +510,6 @@ async def cli(
|
||||
dev = await Device.connect(config=config)
|
||||
device_updated = True
|
||||
else:
|
||||
if device_family or encrypt_type:
|
||||
echo(
|
||||
"--device-family and --encrypt-type options must both be "
|
||||
"provided or they are ignored\n"
|
||||
f"discovering for {discovery_timeout} seconds.."
|
||||
)
|
||||
dev = await Discover.discover_single(
|
||||
host,
|
||||
port=port,
|
||||
@ -587,7 +694,7 @@ async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attem
|
||||
|
||||
|
||||
@cli.command()
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
async def sysinfo(dev):
|
||||
"""Print out full system information."""
|
||||
echo("== System info ==")
|
||||
@ -624,6 +731,7 @@ def _echo_all_features(features, *, verbose=False, title_prefix=None, indent="")
|
||||
"""Print out all features by category."""
|
||||
if title_prefix is not None:
|
||||
echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]")
|
||||
echo()
|
||||
_echo_features(
|
||||
features,
|
||||
title="== Primary features ==",
|
||||
@ -658,7 +766,7 @@ def _echo_all_features(features, *, verbose=False, title_prefix=None, indent="")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
@click.pass_context
|
||||
async def state(ctx, dev: Device):
|
||||
"""Print out device state and versions."""
|
||||
@ -676,11 +784,16 @@ async def state(ctx, dev: Device):
|
||||
if verbose:
|
||||
echo(f"Location: {dev.location}")
|
||||
|
||||
_echo_all_features(dev.features, verbose=verbose)
|
||||
echo()
|
||||
_echo_all_features(dev.features, verbose=verbose)
|
||||
|
||||
if verbose:
|
||||
echo("\n[bold]== Modules ==[/bold]")
|
||||
for module in dev.modules.values():
|
||||
echo(f"[green]+ {module}[/green]")
|
||||
|
||||
if dev.children:
|
||||
echo("[bold]== Children ==[/bold]")
|
||||
echo("\n[bold]== Children ==[/bold]")
|
||||
for child in dev.children:
|
||||
_echo_all_features(
|
||||
child.features,
|
||||
@ -688,14 +801,13 @@ async def state(ctx, dev: Device):
|
||||
verbose=verbose,
|
||||
indent="\t",
|
||||
)
|
||||
|
||||
if verbose:
|
||||
echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]")
|
||||
for module in child.modules.values():
|
||||
echo(f"\t[green]+ {module}[/green]")
|
||||
echo()
|
||||
|
||||
if verbose:
|
||||
echo("\n\t[bold]== Modules ==[/bold]")
|
||||
for module in dev.modules.values():
|
||||
echo(f"\t[green]+ {module}[/green]")
|
||||
|
||||
echo("\n\t[bold]== Protocol information ==[/bold]")
|
||||
echo(f"\tCredentials hash: {dev.credentials_hash}")
|
||||
echo()
|
||||
@ -705,24 +817,19 @@ async def state(ctx, dev: Device):
|
||||
|
||||
|
||||
@cli.command()
|
||||
@pass_dev
|
||||
@click.argument("new_alias", required=False, default=None)
|
||||
@click.option("--index", type=int)
|
||||
async def alias(dev, new_alias, index):
|
||||
@pass_dev_or_child
|
||||
async def alias(dev, new_alias):
|
||||
"""Get or set the device (or plug) alias."""
|
||||
if index is not None:
|
||||
if not dev.is_strip:
|
||||
echo("Index can only used for power strips!")
|
||||
return
|
||||
dev = dev.get_plug_by_index(index)
|
||||
|
||||
if new_alias is not None:
|
||||
echo(f"Setting alias to {new_alias}")
|
||||
res = await dev.set_alias(new_alias)
|
||||
await dev.update()
|
||||
echo(f"Alias set to: {dev.alias}")
|
||||
return res
|
||||
|
||||
echo(f"Alias: {dev.alias}")
|
||||
if dev.is_strip:
|
||||
if dev.children:
|
||||
for plug in dev.children:
|
||||
echo(f" * {plug.alias}")
|
||||
|
||||
@ -730,36 +837,26 @@ async def alias(dev, new_alias, index):
|
||||
|
||||
|
||||
@cli.command()
|
||||
@pass_dev
|
||||
@click.pass_context
|
||||
@click.argument("module")
|
||||
@click.argument("command")
|
||||
@click.argument("parameters", default=None, required=False)
|
||||
async def raw_command(ctx, dev: Device, module, command, parameters):
|
||||
async def raw_command(ctx, module, command, parameters):
|
||||
"""Run a raw command on the device."""
|
||||
logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command)
|
||||
return await ctx.forward(cmd_command)
|
||||
|
||||
|
||||
@cli.command(name="command")
|
||||
@pass_dev
|
||||
@click.option("--module", required=False, help="Module for IOT protocol.")
|
||||
@click.option("--child", required=False, help="Child ID for controlling sub-devices")
|
||||
@click.argument("command")
|
||||
@click.argument("parameters", default=None, required=False)
|
||||
async def cmd_command(dev: Device, module, child, command, parameters):
|
||||
@pass_dev_or_child
|
||||
async def cmd_command(dev: Device, module, command, parameters):
|
||||
"""Run a raw command on the device."""
|
||||
if parameters is not None:
|
||||
parameters = ast.literal_eval(parameters)
|
||||
|
||||
if child:
|
||||
# The way child devices are accessed requires a ChildDevice to
|
||||
# wrap the communications. Doing this properly would require creating
|
||||
# a common interfaces for both IOT and SMART child devices.
|
||||
# As a stop-gap solution, we perform an update instead.
|
||||
await dev.update()
|
||||
dev = dev.get_child_device(child)
|
||||
|
||||
if isinstance(dev, IotDevice):
|
||||
res = await dev._query_helper(module, command, parameters)
|
||||
elif isinstance(dev, SmartDevice):
|
||||
@ -771,27 +868,30 @@ async def cmd_command(dev: Device, module, child, command, parameters):
|
||||
|
||||
|
||||
@cli.command()
|
||||
@pass_dev
|
||||
@click.option("--index", type=int, required=False)
|
||||
@click.option("--name", type=str, required=False)
|
||||
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
|
||||
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
|
||||
@click.option("--erase", is_flag=True)
|
||||
async def emeter(dev: Device, index: int, name: str, year, month, erase):
|
||||
"""Query emeter for historical consumption.
|
||||
@click.pass_context
|
||||
async def emeter(ctx: click.Context, index, name, year, month, erase):
|
||||
"""Query emeter for historical consumption."""
|
||||
logging.warning("Deprecated, use 'kasa energy'")
|
||||
return await ctx.invoke(
|
||||
energy, child_index=index, child=name, year=year, month=month, erase=erase
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
if index is not None or name is not None:
|
||||
if not dev.is_strip:
|
||||
error("Index and name are only for power strips!")
|
||||
return
|
||||
|
||||
if index is not None:
|
||||
dev = dev.get_plug_by_index(index)
|
||||
elif name:
|
||||
dev = dev.get_plug_by_name(name)
|
||||
|
||||
echo("[bold]== Emeter ==[/bold]")
|
||||
if not dev.has_emeter:
|
||||
error("Device has no emeter")
|
||||
@ -817,7 +917,7 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase):
|
||||
usage_data = await dev.get_emeter_daily(year=month.year, month=month.month)
|
||||
else:
|
||||
# Call with no argument outputs summary data and returns
|
||||
if index is not None or name is not None:
|
||||
if isinstance(dev, IotStripPlug):
|
||||
emeter_status = await dev.get_emeter_realtime()
|
||||
else:
|
||||
emeter_status = dev.emeter_realtime
|
||||
@ -840,10 +940,10 @@ async def emeter(dev: Device, index: int, name: str, year, month, erase):
|
||||
|
||||
|
||||
@cli.command()
|
||||
@pass_dev
|
||||
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
|
||||
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
|
||||
@click.option("--erase", is_flag=True)
|
||||
@pass_dev_or_child
|
||||
async def usage(dev: Device, year, month, erase):
|
||||
"""Query usage for historical consumption.
|
||||
|
||||
@ -881,7 +981,7 @@ async def usage(dev: Device, year, month, erase):
|
||||
@cli.command()
|
||||
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
|
||||
@click.option("--transition", type=int, required=False)
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
async def brightness(dev: Device, brightness: int, transition: int):
|
||||
"""Get or set brightness."""
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
|
||||
@ -901,7 +1001,7 @@ async def brightness(dev: Device, brightness: int, transition: int):
|
||||
"temperature", type=click.IntRange(2500, 9000), default=None, required=False
|
||||
)
|
||||
@click.option("--transition", type=int, required=False)
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
async def temperature(dev: Device, temperature: int, transition: int):
|
||||
"""Get or set color temperature."""
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
|
||||
@ -927,7 +1027,7 @@ async def temperature(dev: Device, temperature: int, transition: int):
|
||||
@cli.command()
|
||||
@click.argument("effect", type=click.STRING, default=None, required=False)
|
||||
@click.pass_context
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
async def effect(dev: Device, ctx, effect):
|
||||
"""Set an effect."""
|
||||
if not (light_effect := dev.modules.get(Module.LightEffect)):
|
||||
@ -955,7 +1055,7 @@ async def effect(dev: Device, ctx, effect):
|
||||
@click.argument("v", type=click.IntRange(0, 100), default=None, required=False)
|
||||
@click.option("--transition", type=int, required=False)
|
||||
@click.pass_context
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
async def hsv(dev: Device, ctx, h, s, v, transition):
|
||||
"""Get or set color in HSV."""
|
||||
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
|
||||
@ -974,7 +1074,7 @@ async def hsv(dev: Device, ctx, h, s, v, transition):
|
||||
|
||||
@cli.command()
|
||||
@click.argument("state", type=bool, required=False)
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
async def led(dev: Device, state):
|
||||
"""Get or set (Plug's) led state."""
|
||||
if not (led := dev.modules.get(Module.Led)):
|
||||
@ -1026,64 +1126,28 @@ async def time_sync(dev: Device):
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--index", type=int, required=False)
|
||||
@click.option("--name", type=str, required=False)
|
||||
@click.option("--transition", type=int, required=False)
|
||||
@pass_dev
|
||||
async def on(dev: Device, index: int, name: str, transition: int):
|
||||
@pass_dev_or_child
|
||||
async def on(dev: Device, transition: int):
|
||||
"""Turn the device on."""
|
||||
if index is not None or name is not None:
|
||||
if not dev.children:
|
||||
error("Index and name are only for devices with children.")
|
||||
return
|
||||
|
||||
if index is not None:
|
||||
dev = dev.get_plug_by_index(index)
|
||||
elif name:
|
||||
dev = dev.get_plug_by_name(name)
|
||||
|
||||
echo(f"Turning on {dev.alias}")
|
||||
return await dev.turn_on(transition=transition)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--index", type=int, required=False)
|
||||
@click.option("--name", type=str, required=False)
|
||||
@cli.command
|
||||
@click.option("--transition", type=int, required=False)
|
||||
@pass_dev
|
||||
async def off(dev: Device, index: int, name: str, transition: int):
|
||||
@pass_dev_or_child
|
||||
async def off(dev: Device, transition: int):
|
||||
"""Turn the device off."""
|
||||
if index is not None or name is not None:
|
||||
if not dev.children:
|
||||
error("Index and name are only for devices with children.")
|
||||
return
|
||||
|
||||
if index is not None:
|
||||
dev = dev.get_plug_by_index(index)
|
||||
elif name:
|
||||
dev = dev.get_plug_by_name(name)
|
||||
|
||||
echo(f"Turning off {dev.alias}")
|
||||
return await dev.turn_off(transition=transition)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--index", type=int, required=False)
|
||||
@click.option("--name", type=str, required=False)
|
||||
@click.option("--transition", type=int, required=False)
|
||||
@pass_dev
|
||||
async def toggle(dev: Device, index: int, name: str, transition: int):
|
||||
@pass_dev_or_child
|
||||
async def toggle(dev: Device, transition: int):
|
||||
"""Toggle the device on/off."""
|
||||
if index is not None or name is not None:
|
||||
if not dev.children:
|
||||
error("Index and name are only for devices with children.")
|
||||
return
|
||||
|
||||
if index is not None:
|
||||
dev = dev.get_plug_by_index(index)
|
||||
elif name:
|
||||
dev = dev.get_plug_by_name(name)
|
||||
|
||||
if dev.is_on:
|
||||
echo(f"Turning off {dev.alias}")
|
||||
return await dev.turn_off(transition=transition)
|
||||
@ -1108,9 +1172,9 @@ async def schedule(dev):
|
||||
|
||||
|
||||
@schedule.command(name="list")
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
@click.argument("type", default="schedule")
|
||||
def _schedule_list(dev, type):
|
||||
async def _schedule_list(dev, type):
|
||||
"""Return the list of schedule actions for the given type."""
|
||||
sched = dev.modules[type]
|
||||
for rule in sched.rules:
|
||||
@ -1122,7 +1186,7 @@ def _schedule_list(dev, type):
|
||||
|
||||
|
||||
@schedule.command(name="delete")
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
@click.option("--id", type=str, required=True)
|
||||
async def delete_rule(dev, id):
|
||||
"""Delete rule from device."""
|
||||
@ -1136,25 +1200,26 @@ async def delete_rule(dev, id):
|
||||
|
||||
|
||||
@cli.group(invoke_without_command=True)
|
||||
@pass_dev_or_child
|
||||
@click.pass_context
|
||||
async def presets(ctx):
|
||||
async def presets(ctx, dev):
|
||||
"""List and modify bulb setting presets."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
return await ctx.invoke(presets_list)
|
||||
|
||||
|
||||
@presets.command(name="list")
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
def presets_list(dev: Device):
|
||||
"""List presets."""
|
||||
if not dev.is_bulb or not isinstance(dev, IotBulb):
|
||||
error("Presets only supported on iot bulbs")
|
||||
if not (light_preset := dev.modules.get(Module.LightPreset)):
|
||||
error("Presets not supported on device")
|
||||
return
|
||||
|
||||
for preset in dev.presets:
|
||||
for preset in light_preset.preset_states_list:
|
||||
echo(preset)
|
||||
|
||||
return dev.presets
|
||||
return light_preset.preset_states_list
|
||||
|
||||
|
||||
@presets.command(name="modify")
|
||||
@ -1163,7 +1228,7 @@ def presets_list(dev: Device):
|
||||
@click.option("--hue", type=int)
|
||||
@click.option("--saturation", type=int)
|
||||
@click.option("--temperature", type=int)
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature):
|
||||
"""Modify a preset."""
|
||||
for preset in dev.presets:
|
||||
@ -1188,7 +1253,7 @@ async def presets_modify(dev: Device, index, brightness, hue, saturation, temper
|
||||
|
||||
|
||||
@cli.command()
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False))
|
||||
@click.option("--last", is_flag=True)
|
||||
@click.option("--preset", type=int)
|
||||
@ -1240,7 +1305,7 @@ async def update_credentials(dev, username, password):
|
||||
|
||||
|
||||
@cli.command()
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
async def shell(dev: Device):
|
||||
"""Open interactive shell."""
|
||||
echo("Opening shell for %s" % dev)
|
||||
@ -1263,10 +1328,14 @@ async def shell(dev: Device):
|
||||
@cli.command(name="feature")
|
||||
@click.argument("name", required=False)
|
||||
@click.argument("value", required=False)
|
||||
@click.option("--child", required=False)
|
||||
@pass_dev
|
||||
@pass_dev_or_child
|
||||
@click.pass_context
|
||||
async def feature(ctx: click.Context, dev: Device, child: str, name: str, value):
|
||||
async def feature(
|
||||
ctx: click.Context,
|
||||
dev: Device,
|
||||
name: str,
|
||||
value,
|
||||
):
|
||||
"""Access and modify features.
|
||||
|
||||
If no *name* is given, lists available features and their values.
|
||||
@ -1275,9 +1344,6 @@ async def feature(ctx: click.Context, dev: Device, child: str, name: str, value)
|
||||
"""
|
||||
verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False
|
||||
|
||||
if child is not None:
|
||||
echo(f"Targeting child device {child}")
|
||||
dev = dev.get_child_device(child)
|
||||
if not name:
|
||||
_echo_all_features(dev.features, verbose=verbose, indent="")
|
||||
|
||||
|
@ -347,14 +347,25 @@ class Device(ABC):
|
||||
"""Send a raw query to the device."""
|
||||
return await self.protocol.query(request=request)
|
||||
|
||||
@property
|
||||
def parent(self) -> Device | None:
|
||||
"""Return the parent on child devices."""
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
def children(self) -> Sequence[Device]:
|
||||
"""Returns the child devices."""
|
||||
return list(self._children.values())
|
||||
|
||||
def get_child_device(self, id_: str) -> Device:
|
||||
"""Return child device by its ID."""
|
||||
return self._children[id_]
|
||||
def get_child_device(self, name_or_id: str) -> Device | None:
|
||||
"""Return child device by its device_id or alias."""
|
||||
if name_or_id in self._children:
|
||||
return self._children[name_or_id]
|
||||
name_lower = name_or_id.lower()
|
||||
for child in self.children:
|
||||
if child.alias and child.alias.lower() == name_lower:
|
||||
return child
|
||||
return None
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
|
@ -333,10 +333,12 @@ class IotStripPlug(IotPlug):
|
||||
The plug inherits (most of) the system information from the parent.
|
||||
"""
|
||||
|
||||
_parent: IotStrip
|
||||
|
||||
def __init__(self, host: str, parent: IotStrip, child_id: str) -> None:
|
||||
super().__init__(host)
|
||||
|
||||
self.parent = parent
|
||||
self._parent = parent
|
||||
self.child_id = child_id
|
||||
self._last_update = parent._last_update
|
||||
self._set_sys_info(parent.sys_info)
|
||||
@ -400,14 +402,15 @@ class IotStripPlug(IotPlug):
|
||||
update_children_or_parent = False
|
||||
|
||||
if update_children_or_parent:
|
||||
await self.parent._update(called_from_child=self)
|
||||
await self._parent._update(called_from_child=self)
|
||||
else:
|
||||
await self._update()
|
||||
|
||||
async def _update(self):
|
||||
"""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({})
|
||||
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
|
||||
) -> Any:
|
||||
"""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]
|
||||
)
|
||||
|
||||
@ -490,13 +493,15 @@ class IotStripPlug(IotPlug):
|
||||
@requires_update
|
||||
def model(self) -> str:
|
||||
"""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']}"
|
||||
|
||||
def _get_child_info(self) -> dict:
|
||||
"""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:
|
||||
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
|
||||
|
||||
@property
|
||||
def credentials_hash(self) -> str:
|
||||
def credentials_hash(self) -> str | None:
|
||||
"""The hashed credentials used by the transport."""
|
||||
if self._credentials == Credentials():
|
||||
return None
|
||||
return base64.b64encode(self._local_auth_hash).decode()
|
||||
|
||||
async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]:
|
||||
|
@ -112,6 +112,9 @@ class Module(ABC):
|
||||
"LightTransition"
|
||||
)
|
||||
ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode")
|
||||
SmartLightEffect: Final[ModuleName[smart.SmartLightEffect]] = ModuleName(
|
||||
"LightEffect"
|
||||
)
|
||||
TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName(
|
||||
"TemperatureSensor"
|
||||
)
|
||||
|
@ -59,7 +59,7 @@ class BaseTransport(ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def credentials_hash(self) -> str:
|
||||
def credentials_hash(self) -> str | None:
|
||||
"""The hashed credentials used by the transport."""
|
||||
|
||||
@abstractmethod
|
||||
|
@ -2,8 +2,33 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
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 = {
|
||||
"custom": 0,
|
||||
"id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP",
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""Modules for SMART devices."""
|
||||
|
||||
from ..effects import SmartLightEffect
|
||||
from .alarm import Alarm
|
||||
from .autooff import AutoOff
|
||||
from .batterysensor import BatterySensor
|
||||
@ -54,4 +55,5 @@ __all__ = [
|
||||
"WaterleakSensor",
|
||||
"ContactSensor",
|
||||
"FrostProtection",
|
||||
"SmartLightEffect",
|
||||
]
|
||||
|
@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...feature import Feature
|
||||
from ..smartmodule import SmartModule
|
||||
from ..smartmodule import Module, SmartModule
|
||||
|
||||
BRIGHTNESS_MIN = 0
|
||||
BRIGHTNESS_MAX = 100
|
||||
@ -42,6 +42,12 @@ class Brightness(SmartModule):
|
||||
@property
|
||||
def brightness(self):
|
||||
"""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"]
|
||||
|
||||
async def set_brightness(self, brightness: int, *, transition: int | None = None):
|
||||
@ -59,6 +65,13 @@ class Brightness(SmartModule):
|
||||
|
||||
if brightness == 0:
|
||||
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})
|
||||
|
||||
async def _check_supported(self):
|
||||
|
@ -3,14 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import contextlib
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from ...interfaces.lighteffect import LightEffect as LightEffectInterface
|
||||
from ..smartmodule import SmartModule
|
||||
from ..effects import SmartLightEffect
|
||||
from ..smartmodule import Module, SmartModule
|
||||
|
||||
|
||||
class LightEffect(SmartModule, LightEffectInterface):
|
||||
class LightEffect(SmartModule, SmartLightEffect):
|
||||
"""Implementation of dynamic light effects."""
|
||||
|
||||
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
|
||||
effect["scene_name"] = self.AVAILABLE_BULB_EFFECTS[effect["id"]]
|
||||
else:
|
||||
# Otherwise it will be b64 encoded
|
||||
effect["scene_name"] = base64.b64decode(effect["scene_name"]).decode()
|
||||
# Otherwise it might be b64 encoded or raw string
|
||||
with contextlib.suppress(binascii.Error):
|
||||
effect["scene_name"] = base64.b64decode(
|
||||
effect["scene_name"]
|
||||
).decode()
|
||||
|
||||
self._effect_state_list = effects
|
||||
self._effect_list = [self.LIGHT_EFFECTS_OFF]
|
||||
@ -77,6 +82,8 @@ class LightEffect(SmartModule, LightEffectInterface):
|
||||
) -> None:
|
||||
"""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.
|
||||
"""
|
||||
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:
|
||||
effect_id = self._scenes_names_to_id[effect]
|
||||
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(
|
||||
self,
|
||||
|
@ -4,15 +4,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...interfaces.lighteffect import LightEffect as LightEffectInterface
|
||||
from ..effects import EFFECT_MAPPING, EFFECT_NAMES
|
||||
from ..smartmodule import SmartModule
|
||||
from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect
|
||||
from ..smartmodule import Module, SmartModule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..smartdevice import SmartDevice
|
||||
|
||||
|
||||
class LightStripEffect(SmartModule, LightEffectInterface):
|
||||
class LightStripEffect(SmartModule, SmartLightEffect):
|
||||
"""Implementation of dynamic light effects."""
|
||||
|
||||
REQUIRED_COMPONENT = "light_strip_lighting_effect"
|
||||
@ -22,6 +21,7 @@ class LightStripEffect(SmartModule, LightEffectInterface):
|
||||
effect_list = [self.LIGHT_EFFECTS_OFF]
|
||||
effect_list.extend(EFFECT_NAMES)
|
||||
self._effect_list = effect_list
|
||||
self._effect_mapping = EFFECT_MAPPING
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -53,6 +53,28 @@ class LightStripEffect(SmartModule, LightEffectInterface):
|
||||
return name
|
||||
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
|
||||
def effect_list(self) -> list[str]:
|
||||
"""Return built-in effects list.
|
||||
@ -81,16 +103,24 @@ class LightStripEffect(SmartModule, LightEffectInterface):
|
||||
:param int brightness: The wanted brightness
|
||||
:param int transition: The wanted transition time
|
||||
"""
|
||||
brightness_module = self._device.modules[Module.Brightness]
|
||||
if effect == self.LIGHT_EFFECTS_OFF:
|
||||
effect_dict = dict(self.data["lighting_effect"])
|
||||
effect_dict["enable"] = 0
|
||||
elif effect not in EFFECT_MAPPING:
|
||||
state = self._device.modules[Module.Light].state
|
||||
await self._device.modules[Module.Light].set_state(state)
|
||||
return
|
||||
|
||||
if effect not in self._effect_mapping:
|
||||
raise ValueError(f"The effect {effect} is not a built in effect.")
|
||||
else:
|
||||
effect_dict = EFFECT_MAPPING[effect]
|
||||
effect_dict = self._effect_mapping[effect]
|
||||
|
||||
# Use explicitly given brightness
|
||||
if brightness is not None:
|
||||
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:
|
||||
effect_dict["transition"] = transition
|
||||
|
||||
|
@ -64,12 +64,13 @@ class SmartChildDevice(SmartDevice):
|
||||
async def _update(self):
|
||||
"""Update child module info.
|
||||
|
||||
The parent updates our internal info so just update modules with
|
||||
their own queries.
|
||||
Internal implementation to allow patching of public update in the cli
|
||||
or test framework.
|
||||
"""
|
||||
# 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
|
||||
|
||||
req: dict[str, Any] = {}
|
||||
for module in self.modules.values():
|
||||
if mod_query := module.query():
|
||||
|
@ -47,6 +47,9 @@ class SmartProtocol(BaseProtocol):
|
||||
self._terminal_uuid: str = base64.b64encode(md5(uuid.uuid4().bytes)).decode()
|
||||
self._request_id_generator = SnowflakeId(1, 1)
|
||||
self._query_lock = asyncio.Lock()
|
||||
self._multi_request_batch_size = (
|
||||
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
|
||||
)
|
||||
|
||||
def get_smart_request(self, method, params=None) -> str:
|
||||
"""Get a request message as a string."""
|
||||
@ -117,9 +120,16 @@ class SmartProtocol(BaseProtocol):
|
||||
|
||||
end = len(multi_requests)
|
||||
# Break the requests down as there can be a size limit
|
||||
step = (
|
||||
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
|
||||
)
|
||||
step = self._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):
|
||||
requests_step = multi_requests[i : i + step]
|
||||
|
||||
@ -141,7 +151,25 @@ class SmartProtocol(BaseProtocol):
|
||||
batch_name,
|
||||
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"]
|
||||
for response in responses:
|
||||
method = response["method"]
|
||||
|
@ -234,8 +234,8 @@ class FakeIotTransport(BaseTransport):
|
||||
return 9999
|
||||
|
||||
@property
|
||||
def credentials_hash(self) -> str:
|
||||
return ""
|
||||
def credentials_hash(self) -> None:
|
||||
return None
|
||||
|
||||
def set_alias(self, x, child_ids=None):
|
||||
if child_ids is None:
|
||||
|
@ -250,18 +250,31 @@ class FakeSmartTransport(BaseTransport):
|
||||
info["get_dynamic_light_effect_rules"]["enable"] = params["enable"]
|
||||
if params["enable"]:
|
||||
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:
|
||||
if "dynamic_light_effect_id" in info["get_device_info"]:
|
||||
del info["get_device_info"]["dynamic_light_effect_id"]
|
||||
if "current_rule_id" in info["get_dynamic_light_effect_rules"]:
|
||||
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):
|
||||
"""Set or remove values as per the device behaviour."""
|
||||
info["get_device_info"]["lighting_effect"]["enable"] = params["enable"]
|
||||
info["get_device_info"]["lighting_effect"]["name"] = params["name"]
|
||||
info["get_device_info"]["lighting_effect"]["id"] = params["id"]
|
||||
# Brightness is not always available
|
||||
if (brightness := params.get("brightness")) is not None:
|
||||
info["get_device_info"]["lighting_effect"]["brightness"] = brightness
|
||||
info["get_lighting_effect"] = copy.deepcopy(params)
|
||||
|
||||
def _set_led_info(self, info, params):
|
||||
@ -365,6 +378,9 @@ class FakeSmartTransport(BaseTransport):
|
||||
elif method == "set_dynamic_light_effect_rule_enable":
|
||||
self._set_dynamic_light_effect(info, params)
|
||||
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":
|
||||
self._set_light_strip_effect(info, params)
|
||||
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):
|
||||
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()
|
||||
else:
|
||||
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 pytest
|
||||
from asyncclick.testing import CliRunner
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from kasa import (
|
||||
AuthenticationError,
|
||||
@ -24,6 +25,7 @@ from kasa.cli import (
|
||||
cmd_command,
|
||||
effect,
|
||||
emeter,
|
||||
energy,
|
||||
hsv,
|
||||
led,
|
||||
raw_command,
|
||||
@ -62,7 +64,6 @@ def runner():
|
||||
[
|
||||
pytest.param(None, None, id="No connect params"),
|
||||
pytest.param("SMART.TAPOPLUG", None, id="Only device_family"),
|
||||
pytest.param(None, "KLAP", id="Only encrypt_type"),
|
||||
],
|
||||
)
|
||||
async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_type):
|
||||
@ -171,13 +172,16 @@ async def test_command_with_child(dev, mocker, runner):
|
||||
class DummyDevice(dev.__class__):
|
||||
def __init__(self):
|
||||
super().__init__("127.0.0.1")
|
||||
# device_type and _info initialised for repr
|
||||
self._device_type = Device.Type.StripSocket
|
||||
self._info = {}
|
||||
|
||||
async def _query_helper(*_, **__):
|
||||
return {"dummy": "response"}
|
||||
|
||||
dummy_child = DummyDevice()
|
||||
|
||||
mocker.patch.object(dev, "_children", {"XYZ": dummy_child})
|
||||
mocker.patch.object(dev, "_children", {"XYZ": [dummy_child]})
|
||||
mocker.patch.object(dev, "get_child_device", return_value=dummy_child)
|
||||
|
||||
res = await runner.invoke(
|
||||
@ -314,9 +318,9 @@ async def test_emeter(dev: Device, mocker, runner):
|
||||
|
||||
if not dev.is_strip:
|
||||
res = await runner.invoke(emeter, ["--index", "0"], obj=dev)
|
||||
assert "Index and name are only for power strips!" in res.output
|
||||
assert f"Device: {dev.host} does not have children" in res.output
|
||||
res = await runner.invoke(emeter, ["--name", "mock"], obj=dev)
|
||||
assert "Index and name are only for power strips!" in res.output
|
||||
assert f"Device: {dev.host} does not have children" in res.output
|
||||
|
||||
if dev.is_strip and len(dev.children) > 0:
|
||||
realtime_emeter = mocker.patch.object(dev.children[0], "get_emeter_realtime")
|
||||
@ -930,3 +934,110 @@ async def test_feature_set_child(mocker, runner):
|
||||
assert f"Targeting child device {child_id}"
|
||||
assert "Changing state from False to True" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
|
||||
async def test_cli_child_commands(
|
||||
dev: Device, runner: CliRunner, mocker: MockerFixture
|
||||
):
|
||||
if not dev.children:
|
||||
res = await runner.invoke(alias, ["--child-index", "0"], obj=dev)
|
||||
assert f"Device: {dev.host} does not have children" in res.output
|
||||
assert res.exit_code == 1
|
||||
|
||||
res = await runner.invoke(alias, ["--index", "0"], obj=dev)
|
||||
assert f"Device: {dev.host} does not have children" in res.output
|
||||
assert res.exit_code == 1
|
||||
|
||||
res = await runner.invoke(alias, ["--child", "Plug 2"], obj=dev)
|
||||
assert f"Device: {dev.host} does not have children" in res.output
|
||||
assert res.exit_code == 1
|
||||
|
||||
res = await runner.invoke(alias, ["--name", "Plug 2"], obj=dev)
|
||||
assert f"Device: {dev.host} does not have children" in res.output
|
||||
assert res.exit_code == 1
|
||||
|
||||
if dev.children:
|
||||
child_alias = dev.children[0].alias
|
||||
assert child_alias
|
||||
child_device_id = dev.children[0].device_id
|
||||
child_count = len(dev.children)
|
||||
child_update_method = dev.children[0].update
|
||||
|
||||
# Test child retrieval
|
||||
res = await runner.invoke(alias, ["--child-index", "0"], obj=dev)
|
||||
assert f"Targeting child device {child_alias}" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
res = await runner.invoke(alias, ["--index", "0"], obj=dev)
|
||||
assert f"Targeting child device {child_alias}" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
res = await runner.invoke(alias, ["--child", child_alias], obj=dev)
|
||||
assert f"Targeting child device {child_alias}" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
res = await runner.invoke(alias, ["--name", child_alias], obj=dev)
|
||||
assert f"Targeting child device {child_alias}" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
res = await runner.invoke(alias, ["--child", child_device_id], obj=dev)
|
||||
assert f"Targeting child device {child_alias}" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
res = await runner.invoke(alias, ["--name", child_device_id], obj=dev)
|
||||
assert f"Targeting child device {child_alias}" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
# Test invalid name and index
|
||||
res = await runner.invoke(alias, ["--child-index", "-1"], obj=dev)
|
||||
assert f"Invalid index -1, device has {child_count} children" in res.output
|
||||
assert res.exit_code == 1
|
||||
|
||||
res = await runner.invoke(alias, ["--child-index", str(child_count)], obj=dev)
|
||||
assert (
|
||||
f"Invalid index {child_count}, device has {child_count} children"
|
||||
in res.output
|
||||
)
|
||||
assert res.exit_code == 1
|
||||
|
||||
res = await runner.invoke(alias, ["--child", "foobar"], obj=dev)
|
||||
assert "No child device found with device_id or name: foobar" in res.output
|
||||
assert res.exit_code == 1
|
||||
|
||||
# Test using both options:
|
||||
|
||||
res = await runner.invoke(
|
||||
alias, ["--child", child_alias, "--child-index", "0"], obj=dev
|
||||
)
|
||||
assert "Use either --child or --child-index, not both." in res.output
|
||||
assert res.exit_code == 2
|
||||
|
||||
# Test child with no parameter interactive prompt
|
||||
|
||||
res = await runner.invoke(alias, ["--child"], obj=dev, input="0\n")
|
||||
assert "Enter the index number of the child device:" in res.output
|
||||
assert f"Alias: {child_alias}" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
# Test values and updates
|
||||
|
||||
res = await runner.invoke(alias, ["foo", "--child", child_device_id], obj=dev)
|
||||
assert "Alias set to: foo" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
# Test help has command options plus child options
|
||||
|
||||
res = await runner.invoke(energy, ["--help"], obj=dev)
|
||||
assert "--year" in res.output
|
||||
assert "--child" in res.output
|
||||
assert "--child-index" in res.output
|
||||
assert res.exit_code == 0
|
||||
|
||||
# Test child update patching calls parent and is undone on exit
|
||||
|
||||
parent_update_spy = mocker.spy(dev, "update")
|
||||
res = await runner.invoke(alias, ["bar", "--child", child_device_id], obj=dev)
|
||||
assert "Alias set to: bar" in res.output
|
||||
assert res.exit_code == 0
|
||||
parent_update_spy.assert_called_once()
|
||||
assert dev.children[0].update == child_update_method
|
||||
|
@ -89,35 +89,39 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture):
|
||||
assert light_effect_module.has_custom_effects is not None
|
||||
|
||||
await light_effect_module.set_effect("Off")
|
||||
assert call.call_count == 1
|
||||
call.assert_called()
|
||||
await dev.update()
|
||||
assert light_effect_module.effect == "Off"
|
||||
assert feat.value == "Off"
|
||||
call.reset_mock()
|
||||
|
||||
second_effect = effect_list[1]
|
||||
await light_effect_module.set_effect(second_effect)
|
||||
assert call.call_count == 2
|
||||
call.assert_called()
|
||||
await dev.update()
|
||||
assert light_effect_module.effect == second_effect
|
||||
assert feat.value == second_effect
|
||||
call.reset_mock()
|
||||
|
||||
last_effect = effect_list[len(effect_list) - 1]
|
||||
await light_effect_module.set_effect(last_effect)
|
||||
assert call.call_count == 3
|
||||
call.assert_called()
|
||||
await dev.update()
|
||||
assert light_effect_module.effect == last_effect
|
||||
assert feat.value == last_effect
|
||||
call.reset_mock()
|
||||
|
||||
# Test feature set
|
||||
await feat.set_value(second_effect)
|
||||
assert call.call_count == 4
|
||||
call.assert_called()
|
||||
await dev.update()
|
||||
assert light_effect_module.effect == second_effect
|
||||
assert feat.value == second_effect
|
||||
call.reset_mock()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await light_effect_module.set_effect("foobar")
|
||||
assert call.call_count == 4
|
||||
call.assert_not_called()
|
||||
|
||||
|
||||
@dimmable
|
||||
|
@ -13,6 +13,7 @@ import pytest
|
||||
|
||||
from ..aestransport import AesTransport
|
||||
from ..credentials import Credentials
|
||||
from ..device import Device
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..exceptions import KasaException
|
||||
from ..iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol
|
||||
@ -512,11 +513,72 @@ def test_transport_init_signature(class_name_obj):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("transport_class", "login_version", "expected_hash"),
|
||||
[
|
||||
pytest.param(
|
||||
AesTransport,
|
||||
1,
|
||||
"eyJwYXNzd29yZCI6IlFtRnkiLCJ1c2VybmFtZSI6Ik1qQXhZVFppTXpBMU0yTmpNVFF5TW1ReVl6TTJOekJpTmpJMk1UWXlNakZrTWpJNU1Ea3lPUT09In0=",
|
||||
id="aes-lv-1",
|
||||
),
|
||||
pytest.param(
|
||||
AesTransport,
|
||||
2,
|
||||
"eyJwYXNzd29yZDIiOiJaVFE1Tm1aa01qQXhNelprTkdKaU56Z3lPR1ZpWWpCaFlqa3lOV0l4WW1RNU56Y3lNRGhsTkE9PSIsInVzZXJuYW1lIjoiTWpBeFlUWmlNekExTTJOak1UUXlNbVF5WXpNMk56QmlOakkyTVRZeU1qRmtNakk1TURreU9RPT0ifQ==",
|
||||
id="aes-lv-2",
|
||||
),
|
||||
pytest.param(KlapTransport, 1, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-1"),
|
||||
pytest.param(KlapTransport, 2, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-2"),
|
||||
pytest.param(
|
||||
KlapTransportV2,
|
||||
1,
|
||||
"tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=",
|
||||
id="klapv2-lv-1",
|
||||
),
|
||||
pytest.param(
|
||||
KlapTransportV2,
|
||||
2,
|
||||
"tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=",
|
||||
id="klapv2-lv-2",
|
||||
),
|
||||
pytest.param(XorTransport, None, None, id="xor"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("credentials", "expected_blank"),
|
||||
[
|
||||
pytest.param(Credentials("Foo", "Bar"), False, id="credentials"),
|
||||
pytest.param(None, True, id="no-credentials"),
|
||||
pytest.param(Credentials(None, "Bar"), True, id="no-username"), # type: ignore[arg-type]
|
||||
],
|
||||
)
|
||||
async def test_transport_credentials_hash(
|
||||
mocker, transport_class, login_version, expected_hash, credentials, expected_blank
|
||||
):
|
||||
"""Test that the actual hashing doesn't break and empty credential returns an empty hash."""
|
||||
host = "127.0.0.1"
|
||||
|
||||
params = Device.ConnectionParameters(
|
||||
device_family=Device.Family.SmartTapoPlug,
|
||||
encryption_type=Device.EncryptionType.Xor,
|
||||
login_version=login_version,
|
||||
)
|
||||
config = DeviceConfig(host, credentials=credentials, connection_type=params)
|
||||
transport = transport_class(config=config)
|
||||
|
||||
credentials_hash = transport.credentials_hash
|
||||
|
||||
expected = None if expected_blank else expected_hash
|
||||
assert credentials_hash == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"transport_class",
|
||||
[AesTransport, KlapTransport, KlapTransportV2, XorTransport, XorTransport],
|
||||
)
|
||||
async def test_transport_credentials_hash(mocker, transport_class):
|
||||
async def test_transport_credentials_hash_from_config(mocker, transport_class):
|
||||
"""Test that credentials_hash provided via config sets correctly."""
|
||||
host = "127.0.0.1"
|
||||
|
||||
credentials = Credentials("Foo", "Bar")
|
||||
|
@ -2,10 +2,9 @@ import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from ..credentials import Credentials
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..exceptions import (
|
||||
SMART_RETRYABLE_ERRORS,
|
||||
DeviceError,
|
||||
KasaException,
|
||||
SmartErrorCode,
|
||||
)
|
||||
@ -93,7 +92,6 @@ async def test_smart_device_errors_in_multiple_request(
|
||||
async def test_smart_device_multiple_request(
|
||||
dummy_protocol, mocker, request_size, batch_size
|
||||
):
|
||||
host = "127.0.0.1"
|
||||
requests = {}
|
||||
mock_response = {
|
||||
"result": {"responses": []},
|
||||
@ -109,16 +107,101 @@ async def test_smart_device_multiple_request(
|
||||
send_mock = mocker.patch.object(
|
||||
dummy_protocol._transport, "send", return_value=mock_response
|
||||
)
|
||||
config = DeviceConfig(
|
||||
host, credentials=Credentials("foo", "bar"), batch_size=batch_size
|
||||
)
|
||||
dummy_protocol._transport._config = config
|
||||
dummy_protocol._multi_request_batch_size = batch_size
|
||||
|
||||
await dummy_protocol.query(requests, retry_count=0)
|
||||
expected_count = int(request_size / batch_size) + (request_size % batch_size > 0)
|
||||
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):
|
||||
"""Test that responseData gets unwrapped correctly."""
|
||||
wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol)
|
||||
|
@ -54,9 +54,9 @@ class XorTransport(BaseTransport):
|
||||
return self.DEFAULT_PORT
|
||||
|
||||
@property
|
||||
def credentials_hash(self) -> str:
|
||||
def credentials_hash(self) -> str | None:
|
||||
"""The hashed credentials used by the transport."""
|
||||
return ""
|
||||
return None
|
||||
|
||||
async def _connect(self, timeout: int) -> None:
|
||||
"""Try to connect or reconnect to the device."""
|
||||
|
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]]
|
||||
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_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-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-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
|
||||
{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_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-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-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-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"},
|
||||
@ -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_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-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-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
|
||||
{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_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-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-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
|
||||
{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]]
|
||||
name = "voluptuous"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
description = "Python data validation library"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "voluptuous-0.15.0-py3-none-any.whl", hash = "sha256:ab8d0c3b74b83d062b72fde6ed120b9801d7acb7e504666b0f278dd214ae7ce5"},
|
||||
{file = "voluptuous-0.15.0.tar.gz", hash = "sha256:90fb449f6088f3985b24c0df79887e3823355639e0a6a220394ceac07258aea0"},
|
||||
{file = "voluptuous-0.15.1.tar.gz", hash = "sha256:4ba7f38f624379ecd02666e87e99cb24b6f5997a28258d3302c761d1a2c35d00"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "python-kasa"
|
||||
version = "0.7.0.1"
|
||||
version = "0.7.0.2"
|
||||
description = "Python API for TP-Link Kasa Smarthome devices"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["python-kasa developers"]
|
||||
|
Loading…
Reference in New Issue
Block a user