From 2687c71c4b09ab3e9a90ca76568c4618889a1a9f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:51:06 +0100 Subject: [PATCH 1/9] Make parent attribute on device consistent across iot and smart (#1023) Both device types now have an internal `_parent` and a public property getter --- kasa/device.py | 5 +++++ kasa/iot/iotstrip.py | 14 +++++++++----- kasa/tests/test_childdevice.py | 22 +++++++++++++++++++++- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 9bf0903e..ac23fdb2 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -328,6 +328,11 @@ 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.""" diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index e64ace05..3a1406aa 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -307,10 +307,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) @@ -380,7 +382,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] ) @@ -441,13 +443,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 diff --git a/kasa/tests/test_childdevice.py b/kasa/tests/test_childdevice.py index 26568c24..251af878 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -3,12 +3,19 @@ import sys import pytest +from kasa import Device from kasa.device_type import DeviceType from kasa.smart.smartchilddevice import SmartChildDevice from kasa.smart.smartdevice import NON_HUB_PARENT_ONLY_MODULES from kasa.smartprotocol import _ChildProtocolWrapper -from .conftest import parametrize, parametrize_subtract, strip_smart +from .conftest import ( + parametrize, + parametrize_combine, + parametrize_subtract, + strip_iot, + strip_smart, +) has_children_smart = parametrize( "has children", component_filter="control_child", protocol_filter={"SMART"} @@ -18,6 +25,8 @@ hub_smart = parametrize( ) non_hub_parent_smart = parametrize_subtract(has_children_smart, hub_smart) +has_children = parametrize_combine([has_children_smart, strip_iot]) + @strip_smart def test_childdevice_init(dev, dummy_protocol, mocker): @@ -100,3 +109,14 @@ async def test_parent_only_modules(dev, dummy_protocol, mocker): for child in dev.children: for module in NON_HUB_PARENT_ONLY_MODULES: assert module not in [type(module) for module in child.modules.values()] + + +@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 From b31a2ede7ff38b0612fd4883a573a45310341ce4 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 1 Jul 2024 13:59:24 +0200 Subject: [PATCH 2/9] Fix changing brightness when effect is active (#1019) This PR changes the behavior of `brightness` module if an effect is active. Currently, changing the brightness disables the effect when the brightness is changed, this fixes that. This will also improve the `set_effect` interface to use the current brightness when an effect is activated. * light_strip_effect: passing `bAdjusted` with the changed properties changes the brightness. * light_effect: the brightness is stored only in the rule, so we modify it when adjusting the brightness. This is also done during the initial effect activation. --------- Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> --- kasa/module.py | 3 + kasa/smart/effects.py | 25 +++++ kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/brightness.py | 15 ++- kasa/smart/modules/lighteffect.py | 76 +++++++++++-- kasa/smart/modules/lightstripeffect.py | 46 ++++++-- kasa/tests/fakeprotocol_smart.py | 18 +++- kasa/tests/smart/modules/test_light_effect.py | 42 ++++++++ .../smart/modules/test_light_strip_effect.py | 101 ++++++++++++++++++ kasa/tests/test_common_modules.py | 14 ++- 10 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 kasa/tests/smart/modules/test_light_strip_effect.py diff --git a/kasa/module.py b/kasa/module.py index 3a090782..69c4e9e2 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -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" ) diff --git a/kasa/smart/effects.py b/kasa/smart/effects.py index 28e27d3f..e0ed615c 100644 --- a/kasa/smart/effects.py +++ b/kasa/smart/effects.py @@ -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", diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index ada52f91..fd987751 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -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", ] diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index fbd90808..f5e6d6d6 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -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): diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 170cfbb3..07f6aece 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -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, diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index c2f35188..a80c20f3 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -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 diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 94c75104..600cd75d 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -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} diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index ed691e66..20435dde 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -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}) diff --git a/kasa/tests/smart/modules/test_light_strip_effect.py b/kasa/tests/smart/modules/test_light_strip_effect.py new file mode 100644 index 00000000..92ef2202 --- /dev/null +++ b/kasa/tests/smart/modules/test_light_strip_effect.py @@ -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}) diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index c0d90578..beed8e8b 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -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 From 8d1a4a4229ed3fa9646573a788b23a6143ef42e0 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 1 Jul 2024 13:57:13 +0100 Subject: [PATCH 3/9] Disable multi requests on json decode error during multi-request (#1025) Issue affecting some P100 devices --- kasa/smartprotocol.py | 32 +++++++++-- kasa/tests/test_smartprotocol.py | 97 +++++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 22fd49dc..f7551e33 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -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,21 @@ 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 on batched request so + # disable batching + if ( + ex.error_code is SmartErrorCode.JSON_DECODE_FAIL_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"] diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 5ead00d6..d362fd00 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -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) From 03f72b8be08f2b4cc008563efbc508a70d983bf9 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 1 Jul 2024 14:33:28 +0100 Subject: [PATCH 4/9] Disable multi-request on unknown errors (#1027) Another P100 fix --- kasa/smartprotocol.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index f7551e33..e6741bc4 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -154,10 +154,14 @@ class SmartProtocol(BaseProtocol): try: self._handle_response_error_code(response_step, batch_name) except DeviceError as ex: - # P100 sometimes raises JSON_DECODE_FAIL_ERROR on batched request so - # disable batching + # P100 sometimes raises JSON_DECODE_FAIL_ERROR or INTERNAL_UNKNOWN_ERROR + # on batched request so disable batching if ( - ex.error_code is SmartErrorCode.JSON_DECODE_FAIL_ERROR + 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 From 38a8c964b25e3e6969e1d65e6a87411c5b769bba Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 1 Jul 2024 15:02:21 +0100 Subject: [PATCH 5/9] Prepare 0.7.0.2 (#1028) ## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01) [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) --- CHANGELOG.md | 48 +++++++++++++++++++++++++++++++++--------------- poetry.lock | 17 +++++++++++++---- pyproject.toml | 2 +- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6746f9..a80adb55 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/poetry.lock b/poetry.lock index aec24c09..b6511e14 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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]] diff --git a/pyproject.toml b/pyproject.toml index f7bb8acd..45350aef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] From 1bf6d80b2a389ea30dc0a59667f649bc6b605395 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jul 2024 12:30:43 +0100 Subject: [PATCH 6/9] Update changelog generator config (#1030) Move the static command line options into the config file for consistency and remove `--no-issues` in favour of `issues-wo-labels=false` to fix the problem where `release-summary` issues are being excluded from the changelog. --- .github_changelog_generator | 10 ++++++++-- RELEASING.md | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github_changelog_generator b/.github_changelog_generator index 9a0c0af9..f72e740c 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -1,5 +1,11 @@ -breaking_labels=breaking change -add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} +output=CHANGELOG.md +base=HISTORY.md +user=python-kasa +project=python-kasa +since-tag=0.3.5 release_branch=master usernames-as-github-logins=true +breaking_labels=breaking change +add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} exclude-labels=duplicate,question,invalid,wontfix,release-prep +issues-wo-labels=false diff --git a/RELEASING.md b/RELEASING.md index 476e9de5..e42e1c87 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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: From e5b959e4a9dc612c419424ed7e4c1d5d25a6eca3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 2 Jul 2024 14:36:57 +0200 Subject: [PATCH 7/9] Add L920(EU) v1.1.3 fixture (#1031) --- SUPPORTED.md | 1 + .../fixtures/smart/L920-5(EU)_1.0_1.1.3.json | 436 ++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json diff --git a/SUPPORTED.md b/SUPPORTED.md index a644254a..08ae8ada 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -209,6 +209,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (EU) / Firmware: 1.1.0 - **L920-5** - Hardware: 1.0 (EU) / Firmware: 1.0.7 + - Hardware: 1.0 (EU) / Firmware: 1.1.3 - Hardware: 1.0 (US) / Firmware: 1.1.0 - Hardware: 1.0 (US) / Firmware: 1.1.3 - **L930-5** diff --git a/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json new file mode 100644 index 00000000..0e7679e2 --- /dev/null +++ b/kasa/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json @@ -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 + } + } +} From b8a87f1c571aee48aa2f168aefdca9509ac53915 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:43:37 +0100 Subject: [PATCH 8/9] Fix credential hash to return None on empty credentials (#1029) If discovery is triggered without credentials and discovers devices requiring authentication, blank credentials are used to initialise the protocols and no connection is actually made. In this instance we should not return the credentials_hash for blank credentials as it will be invalid. --- kasa/aestransport.py | 4 ++- kasa/klaptransport.py | 4 ++- kasa/protocol.py | 2 +- kasa/tests/fakeprotocol_iot.py | 4 +-- kasa/tests/test_protocol.py | 64 +++++++++++++++++++++++++++++++++- kasa/xortransport.py | 4 +-- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index cc373b19..c9cb83bd 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -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]: diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 3a1eb336..dd90ffd2 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -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]: diff --git a/kasa/protocol.py b/kasa/protocol.py index c7d505b8..7d717c5e 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -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 diff --git a/kasa/tests/fakeprotocol_iot.py b/kasa/tests/fakeprotocol_iot.py index 52320598..9c5f655c 100644 --- a/kasa/tests/fakeprotocol_iot.py +++ b/kasa/tests/fakeprotocol_iot.py @@ -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: diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index e0ddbbb4..1aeeedb2 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -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") diff --git a/kasa/xortransport.py b/kasa/xortransport.py index e9686453..52fba3d3 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -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.""" From 9cffbe9e485c004f2b6b4f685d2524f26fc29d0d Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:11:19 +0100 Subject: [PATCH 9/9] Support child devices in all applicable cli commands (#1020) Adds a new decorator that adds child options to a command and gets the child device if the options are set. - Single definition of options and error handling - Adds options automatically to command - Backwards compatible with `--index` and `--name` - `--child` allows for id and alias for ease of use - Omitting a value for `--child` gives an interactive prompt Implements private `_update` to allow the CLI to patch a child `update` method to call the parent device `update`. Example help output: ``` $ kasa brightness --help Usage: kasa brightness [OPTIONS] [BRIGHTNESS] Get or set brightness. Options: --transition INTEGER --child, --name TEXT 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, --index INTEGER Child index controlling sub-devices --help Show this message and exit. ``` Fixes #769 --- kasa/cli.py | 316 ++++++++++++++++++++------------- kasa/device.py | 12 +- kasa/iot/iotstrip.py | 10 +- kasa/smart/smartchilddevice.py | 8 + kasa/smart/smartdevice.py | 2 +- kasa/tests/test_cli.py | 119 ++++++++++++- 6 files changed, 333 insertions(+), 134 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 4d0a1db5..10c42297 100755 --- a/kasa/cli.py +++ b/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="") diff --git a/kasa/device.py b/kasa/device.py index ac23fdb2..69b7370b 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -338,9 +338,15 @@ class Device(ABC): """Returns the child devices.""" return list(self._children.values()) - def get_child_device(self, id_: str) -> Device: - """Return child device by its ID.""" - return self._children[id_] + def get_child_device(self, name_or_id: str) -> Device | None: + """Return child device by its device_id or alias.""" + if name_or_id in self._children: + return self._children[name_or_id] + name_lower = name_or_id.lower() + for child in self.children: + if child.alias and child.alias.lower() == name_lower: + return child + return None @property @abstractmethod diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 3a1406aa..61017228 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -145,7 +145,7 @@ class IotStrip(IotDevice): if update_children: for plug in self.children: - await plug.update() + await plug._update() if not self.features: await self._initialize_features() @@ -362,6 +362,14 @@ class IotStripPlug(IotPlug): Needed for properties that are decorated with `requires_update`. """ + await self._update(update_children) + + async def _update(self, update_children: bool = True): + """Query the device to update the data. + + Internal implementation to allow patching of public update in the cli + or test framework. + """ await self._modular_update({}) for module in self._modules.values(): module._post_update_hook() diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index c6596b96..98145f6c 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -40,6 +40,14 @@ class SmartChildDevice(SmartDevice): The parent updates our internal info so just update modules with their own queries. """ + await self._update(update_children) + + async def _update(self, update_children: bool = True): + """Update child module info. + + Internal implementation to allow patching of public update in the cli + or test framework. + """ req: dict[str, Any] = {} for module in self.modules.values(): if mod_query := module.query(): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index a5b64e52..408ba027 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -171,7 +171,7 @@ class SmartDevice(Device): # devices will always update children to prevent errors on module access. if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): - await child.update() + await child._update() if child_info := self._try_get_response(resp, "get_child_device_list", {}): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 4f815702..06a7d37a 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -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