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

This commit is contained in:
sdb9696 2024-07-02 14:40:08 +01:00
commit 5735f37c58
31 changed files with 1360 additions and 206 deletions

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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**

View File

@ -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]:

View File

@ -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="")

View File

@ -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

View File

@ -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

View File

@ -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]:

View File

@ -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"
)

View File

@ -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

View File

@ -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",

View File

@ -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",
]

View File

@ -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):

View File

@ -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,

View File

@ -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

View File

@ -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():

View File

@ -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"]

View File

@ -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:

View File

@ -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}

View 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
}
}
}

View File

@ -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})

View 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})

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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
View File

@ -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]]

View File

@ -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"]