From cf24a9452614a7b5210ab4ba28d1d409cbc9403d Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 27 Jun 2024 16:58:45 +0200 Subject: [PATCH 1/8] Handle unknown error codes gracefully (#1016) Makes unknown error codes to be reported through KasaException which may be recoverable in some cases (i.e., a single command failing in the multi request). Related to https://github.com/home-assistant/core/issues/118446 --- kasa/aestransport.py | 8 +++++++- kasa/exceptions.py | 3 +++ kasa/smartprotocol.py | 10 +++++++++- kasa/tests/test_aestransport.py | 27 +++++++++++++++++++++++++++ kasa/tests/test_smartprotocol.py | 19 +++++++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index f406996f2..72df4e17a 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -140,7 +140,13 @@ def hash_credentials(login_v2: bool, credentials: Credentials) -> tuple[str, str return un, pw def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: - error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + error_code_raw = resp_dict.get("error_code") + try: + error_code = SmartErrorCode(error_code_raw) # type: ignore[arg-type] + except ValueError: + _LOGGER.warning("Received unknown error code: %s", error_code_raw) + error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR + if error_code == SmartErrorCode.SUCCESS: return msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 567f01b49..2d913c2af 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -119,6 +119,9 @@ def __str__(self): DST_ERROR = -2301 DST_SAVE_ERROR = -2302 + # Library internal for unknown error codes + INTERNAL_UNKNOWN_ERROR = -100_000 + SMART_RETRYABLE_ERRORS = [ SmartErrorCode.TRANSPORT_NOT_AVAILABLE_ERROR, diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 545f8147a..a430e3afa 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -239,12 +239,20 @@ async def _handle_response_lists( response_result[response_list_name].extend(next_batch[response_list_name]) def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): - error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] + error_code_raw = resp_dict.get("error_code") + try: + error_code = SmartErrorCode(error_code_raw) # type: ignore[arg-type] + except ValueError: + _LOGGER.warning("Received unknown error code: %s", error_code_raw) + error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR + if error_code == SmartErrorCode.SUCCESS: return + if not raise_on_error: resp_dict["result"] = error_code return + msg = ( f"Error querying device: {self._host}: " + f"{error_code.name}({error_code.value})" diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 232546d5a..940b16b0f 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -276,6 +276,33 @@ async def test_passthrough_errors(mocker, error_code): await transport.send(json_dumps(request)) +@pytest.mark.parametrize("error_code", [-13333, 13333]) +async def test_unknown_errors(mocker, error_code): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, 200, error_code, 0) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + config = DeviceConfig(host, credentials=Credentials("foo", "bar")) + transport = AesTransport(config=config) + transport._handshake_done = True + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + with pytest.raises(KasaException): + res = await transport.send(json_dumps(request)) + assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR + + async def test_port_override(): """Test that port override sets the app_url.""" host = "127.0.0.1" diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 5a0eb0fa7..5ead00d61 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -35,6 +35,25 @@ async def test_smart_device_errors(dummy_protocol, mocker, error_code): assert send_mock.call_count == expected_calls +@pytest.mark.parametrize("error_code", [-13333, 13333]) +async def test_smart_device_unknown_errors( + dummy_protocol, mocker, error_code, caplog: pytest.LogCaptureFixture +): + """Test handling of unknown error codes.""" + mock_response = {"result": {"great": "success"}, "error_code": error_code} + + send_mock = mocker.patch.object( + dummy_protocol._transport, "send", return_value=mock_response + ) + + with pytest.raises(KasaException): + res = await dummy_protocol.query(DUMMY_QUERY) + assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR + + send_mock.assert_called_once() + assert f"Received unknown error code: {error_code}" in caplog.text + + @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) async def test_smart_device_errors_in_multiple_request( dummy_protocol, mocker, error_code From 2a628499877216c798e394febd02ec1bfcc11df4 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 27 Jun 2024 18:52:54 +0100 Subject: [PATCH 2/8] Update light transition module to work with child devices (#1017) Fixes module to work with child devices, i.e. ks240 Interrogates the data to see whether maximums are available. Fixes a bug whereby setting a duration while the feature is not enabled does not actually enable it. --- kasa/feature.py | 4 +- kasa/smart/modules/lighttransition.py | 152 ++++++++++++------ kasa/tests/device_fixtures.py | 10 +- kasa/tests/discovery_fixtures.py | 4 +- kasa/tests/fakeprotocol_smart.py | 55 ++++++- kasa/tests/fixtureinfo.py | 24 ++- .../smart/modules/test_lighttransition.py | 80 +++++++++ kasa/tests/test_device_factory.py | 52 ++++-- 8 files changed, 304 insertions(+), 77 deletions(-) create mode 100644 kasa/tests/smart/modules/test_lighttransition.py diff --git a/kasa/feature.py b/kasa/feature.py index e247e6616..0ce13d45f 100644 --- a/kasa/feature.py +++ b/kasa/feature.py @@ -107,6 +107,8 @@ class Type(Enum): Number = Type.Number Choice = Type.Choice + DEFAULT_MAX = 2**16 # Arbitrary max + class Category(Enum): """Category hint to allow feature grouping.""" @@ -155,7 +157,7 @@ class Category(Enum): #: Minimum value minimum_value: int = 0 #: Maximum value - maximum_value: int = 2**16 # Arbitrary max + maximum_value: int = DEFAULT_MAX #: Attribute containing the name of the range getter property. #: If set, this property will be used to set *minimum_value* and *maximum_value*. range_getter: str | None = None diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index fa73cd681..29a4bb055 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict from ...exceptions import KasaException from ...feature import Feature @@ -12,6 +12,12 @@ from ..smartdevice import SmartDevice +class _State(TypedDict): + duration: int + enable: bool + max_duration: int + + class LightTransition(SmartModule): """Implementation of gradual on/off.""" @@ -19,14 +25,30 @@ class LightTransition(SmartModule): QUERY_GETTER_NAME = "get_on_off_gradually_info" MAXIMUM_DURATION = 60 + # Key in sysinfo that indicates state can be retrieved from there. + # Usually only for child lights, i.e, ks240. + SYS_INFO_STATE_KEYS = ( + "gradually_on_mode", + "gradually_off_mode", + "fade_on_time", + "fade_off_time", + ) + + _on_state: _State + _off_state: _State + _enabled: bool + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) - self._create_features() + self._state_in_sysinfo = all( + key in device.sys_info for key in self.SYS_INFO_STATE_KEYS + ) + self._supports_on_and_off: bool = self.supported_version > 1 - def _create_features(self): - """Create features based on the available version.""" + def _initialize_features(self): + """Initialize features.""" icon = "mdi:transition" - if self.supported_version == 1: + if not self._supports_on_and_off: self._add_feature( Feature( device=self._device, @@ -34,16 +56,12 @@ def _create_features(self): id="smooth_transitions", name="Smooth transitions", icon=icon, - attribute_getter="enabled_v1", - attribute_setter="set_enabled_v1", + attribute_getter="enabled", + attribute_setter="set_enabled", type=Feature.Type.Switch, ) ) - elif self.supported_version >= 2: - # v2 adds separate on & off states - # v3 adds max_duration - # TODO: note, hardcoding the maximums for now as the features get - # initialized before the first update. + else: self._add_feature( Feature( self._device, @@ -54,9 +72,9 @@ def _create_features(self): attribute_setter="set_turn_on_transition", icon=icon, type=Feature.Type.Number, - maximum_value=self.MAXIMUM_DURATION, + maximum_value=self._turn_on_transition_max, ) - ) # self._turn_on_transition_max + ) self._add_feature( Feature( self._device, @@ -67,38 +85,74 @@ def _create_features(self): attribute_setter="set_turn_off_transition", icon=icon, type=Feature.Type.Number, - maximum_value=self.MAXIMUM_DURATION, + maximum_value=self._turn_off_transition_max, ) - ) # self._turn_off_transition_max - - @property - def _turn_on(self): - """Internal getter for turn on settings.""" - if "on_state" not in self.data: - raise KasaException( - f"Unsupported for {self.REQUIRED_COMPONENT} v{self.supported_version}" ) - return self.data["on_state"] - - @property - def _turn_off(self): - """Internal getter for turn off settings.""" - if "off_state" not in self.data: + def _post_update_hook(self) -> None: + """Update the states.""" + # Assumes any device with state in sysinfo supports on and off and + # has maximum values for both. + # v2 adds separate on & off states + # v3 adds max_duration except for ks240 which is v2 but supports it + if not self._supports_on_and_off: + self._enabled = self.data["enable"] + return + + if self._state_in_sysinfo: + on_max = self._device.sys_info.get( + "max_fade_on_time", self.MAXIMUM_DURATION + ) + off_max = self._device.sys_info.get( + "max_fade_off_time", self.MAXIMUM_DURATION + ) + on_enabled = bool(self._device.sys_info["gradually_on_mode"]) + off_enabled = bool(self._device.sys_info["gradually_off_mode"]) + on_duration = self._device.sys_info["fade_on_time"] + off_duration = self._device.sys_info["fade_off_time"] + elif (on_state := self.data.get("on_state")) and ( + off_state := self.data.get("off_state") + ): + on_max = on_state.get("max_duration", self.MAXIMUM_DURATION) + off_max = off_state.get("max_duration", self.MAXIMUM_DURATION) + on_enabled = on_state["enable"] + off_enabled = off_state["enable"] + on_duration = on_state["duration"] + off_duration = off_state["duration"] + else: raise KasaException( f"Unsupported for {self.REQUIRED_COMPONENT} v{self.supported_version}" ) - return self.data["off_state"] - - async def set_enabled_v1(self, enable: bool): + self._enabled = on_enabled or off_enabled + self._on_state = { + "duration": on_duration, + "enable": on_enabled, + "max_duration": on_max, + } + self._off_state = { + "duration": off_duration, + "enable": off_enabled, + "max_duration": off_max, + } + + async def set_enabled(self, enable: bool): """Enable gradual on/off.""" - return await self.call("set_on_off_gradually_info", {"enable": enable}) + if not self._supports_on_and_off: + return await self.call("set_on_off_gradually_info", {"enable": enable}) + else: + on = await self.call( + "set_on_off_gradually_info", {"on_state": {"enable": enable}} + ) + off = await self.call( + "set_on_off_gradually_info", {"off_state": {"enable": enable}} + ) + return {**on, **off} @property - def enabled_v1(self) -> bool: + def enabled(self) -> bool: """Return True if gradual on/off is enabled.""" - return bool(self.data["enable"]) + return self._enabled @property def turn_on_transition(self) -> int: @@ -106,15 +160,13 @@ def turn_on_transition(self) -> int: Available only from v2. """ - if "fade_on_time" in self._device.sys_info: - return self._device.sys_info["fade_on_time"] - return self._turn_on["duration"] + return self._on_state["duration"] if self._on_state["enable"] else 0 @property def _turn_on_transition_max(self) -> int: """Maximum turn on duration.""" # v3 added max_duration, we default to 60 when it's not available - return self._turn_on.get("max_duration", 60) + return self._on_state["max_duration"] async def set_turn_on_transition(self, seconds: int): """Set turn on transition in seconds. @@ -129,12 +181,12 @@ async def set_turn_on_transition(self, seconds: int): if seconds <= 0: return await self.call( "set_on_off_gradually_info", - {"on_state": {**self._turn_on, "enable": False}}, + {"on_state": {"enable": False}}, ) return await self.call( "set_on_off_gradually_info", - {"on_state": {**self._turn_on, "duration": seconds}}, + {"on_state": {"enable": True, "duration": seconds}}, ) @property @@ -143,15 +195,13 @@ def turn_off_transition(self) -> int: Available only from v2. """ - if "fade_off_time" in self._device.sys_info: - return self._device.sys_info["fade_off_time"] - return self._turn_off["duration"] + return self._off_state["duration"] if self._off_state["enable"] else 0 @property def _turn_off_transition_max(self) -> int: """Maximum turn on duration.""" # v3 added max_duration, we default to 60 when it's not available - return self._turn_off.get("max_duration", 60) + return self._off_state["max_duration"] async def set_turn_off_transition(self, seconds: int): """Set turn on transition in seconds. @@ -166,26 +216,24 @@ async def set_turn_off_transition(self, seconds: int): if seconds <= 0: return await self.call( "set_on_off_gradually_info", - {"off_state": {**self._turn_off, "enable": False}}, + {"off_state": {"enable": False}}, ) return await self.call( "set_on_off_gradually_info", - {"off_state": {**self._turn_on, "duration": seconds}}, + {"off_state": {"enable": True, "duration": seconds}}, ) def query(self) -> dict: """Query to execute during the update cycle.""" # Some devices have the required info in the device info. - if "gradually_on_mode" in self._device.sys_info: + if self._state_in_sysinfo: return {} else: return {self.QUERY_GETTER_NAME: None} async def _check_supported(self): """Additional check to see if the module is supported by the device.""" - # TODO Temporarily disabled on child light devices until module fixed - # to support updates - if self._device._parent is not None: - return False + # For devices that report child components on the parent that are not + # actually supported by the parent. return "brightness" in self._device.sys_info diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 718789f6a..0d6fbd488 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -15,7 +15,13 @@ from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_smart import FakeSmartProtocol -from .fixtureinfo import FIXTURE_DATA, FixtureInfo, filter_fixtures, idgenerator +from .fixtureinfo import ( + FIXTURE_DATA, + ComponentFilter, + FixtureInfo, + filter_fixtures, + idgenerator, +) # Tapo bulbs BULBS_SMART_VARIABLE_TEMP = {"L530E", "L930-5"} @@ -175,7 +181,7 @@ def parametrize( *, model_filter=None, protocol_filter=None, - component_filter=None, + component_filter: str | ComponentFilter | None = None, data_root_filter=None, device_type_filter=None, ids=None, diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 229c6c44a..1ba24bf1a 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -12,6 +12,8 @@ from .fakeprotocol_smart import FakeSmartProtocol, FakeSmartTransport from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator +DISCOVERY_MOCK_IP = "127.0.0.123" + def _make_unsupported(device_family, encrypt_type): return { @@ -73,7 +75,7 @@ def parametrize_discovery( async def discovery_mock(request, mocker): """Mock discovery and patch protocol queries to use Fake protocols.""" fixture_info: FixtureInfo = request.param - yield patch_discovery({"127.0.0.123": fixture_info}, mocker) + yield patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker) def create_discovery_mock(ip: str, fixture_data: dict): diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index d601128e0..94c751041 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -78,7 +78,6 @@ def credentials_hash(self): }, }, ), - "get_on_off_gradually_info": ("on_off_gradually", {"enable": True}), "get_latest_fw": ( "firmware", { @@ -164,6 +163,8 @@ def _handle_control_child(self, params: dict): return {"error_code": 0} elif child_method == "set_preset_rules": return self._set_child_preset_rules(info, child_params) + elif child_method == "set_on_off_gradually_info": + return self._set_on_off_gradually_info(info, child_params) elif child_method in child_device_calls: result = copy.deepcopy(child_device_calls[child_method]) return {"result": result, "error_code": 0} @@ -200,6 +201,49 @@ def _handle_control_child(self, params: dict): "Method %s not implemented for children" % child_method ) + def _get_on_off_gradually_info(self, info, params): + if self.components["on_off_gradually"] == 1: + info["get_on_off_gradually_info"] = {"enable": True} + else: + info["get_on_off_gradually_info"] = { + "off_state": {"duration": 5, "enable": False, "max_duration": 60}, + "on_state": {"duration": 5, "enable": False, "max_duration": 60}, + } + return copy.deepcopy(info["get_on_off_gradually_info"]) + + def _set_on_off_gradually_info(self, info, params): + # Child devices can have the required properties directly in info + + if self.components["on_off_gradually"] == 1: + info["get_on_off_gradually_info"] = {"enable": params["enable"]} + elif on_state := params.get("on_state"): + if "fade_on_time" in info and "gradually_on_mode" in info: + info["gradually_on_mode"] = 1 if on_state["enable"] else 0 + if "duration" in on_state: + info["fade_on_time"] = on_state["duration"] + else: + info["get_on_off_gradually_info"]["on_state"]["enable"] = on_state[ + "enable" + ] + if "duration" in on_state: + info["get_on_off_gradually_info"]["on_state"]["duration"] = ( + on_state["duration"] + ) + elif off_state := params.get("off_state"): + if "fade_off_time" in info and "gradually_off_mode" in info: + info["gradually_off_mode"] = 1 if off_state["enable"] else 0 + if "duration" in off_state: + info["fade_off_time"] = off_state["duration"] + else: + info["get_on_off_gradually_info"]["off_state"]["enable"] = off_state[ + "enable" + ] + if "duration" in off_state: + info["get_on_off_gradually_info"]["off_state"]["duration"] = ( + off_state["duration"] + ) + return {"error_code": 0} + def _set_dynamic_light_effect(self, info, params): """Set or remove values as per the device behaviour.""" info["get_device_info"]["dynamic_light_effect_enable"] = params["enable"] @@ -294,6 +338,13 @@ def _send_request(self, request_dict: dict): info[method] = copy.deepcopy(missing_result[1]) result = copy.deepcopy(info[method]) retval = {"result": result, "error_code": 0} + elif ( + method == "get_on_off_gradually_info" + and "on_off_gradually" in self.components + ): + # Need to call a method here to determine which version schema to return + result = self._get_on_off_gradually_info(info, params) + return {"result": result, "error_code": 0} else: # PARAMS error returned for KS240 when get_device_usage called # on parent device. Could be any error code though. @@ -324,6 +375,8 @@ def _send_request(self, request_dict: dict): return self._set_preset_rules(info, params) elif method == "edit_preset_rules": return self._edit_preset_rules(info, params) + elif method == "set_on_off_gradually_info": + return self._set_on_off_gradually_info(info, params) elif method[:4] == "set_": target_method = f"get_{method[4:]}" info[target_method].update(params) diff --git a/kasa/tests/fixtureinfo.py b/kasa/tests/fixtureinfo.py index 153d6cc38..9abf0f065 100644 --- a/kasa/tests/fixtureinfo.py +++ b/kasa/tests/fixtureinfo.py @@ -17,6 +17,12 @@ class FixtureInfo(NamedTuple): data: dict +class ComponentFilter(NamedTuple): + component_name: str + minimum_version: int = 0 + maximum_version: int | None = None + + FixtureInfo.__hash__ = lambda self: hash((self.name, self.protocol)) # type: ignore[attr-defined, method-assign] FixtureInfo.__eq__ = lambda x, y: hash(x) == hash(y) # type: ignore[method-assign] @@ -88,7 +94,7 @@ def filter_fixtures( data_root_filter: str | None = None, protocol_filter: set[str] | None = None, model_filter: set[str] | None = None, - component_filter: str | None = None, + component_filter: str | ComponentFilter | None = None, device_type_filter: list[DeviceType] | None = None, ): """Filter the fixtures based on supplied parameters. @@ -106,14 +112,26 @@ def _model_match(fixture_data: FixtureInfo, model_filter): file_model = file_model_region.split("(")[0] return file_model in model_filter - def _component_match(fixture_data: FixtureInfo, component_filter): + def _component_match( + fixture_data: FixtureInfo, component_filter: str | ComponentFilter + ): if (component_nego := fixture_data.data.get("component_nego")) is None: return False components = { component["id"]: component["ver_code"] for component in component_nego["component_list"] } - return component_filter in components + if isinstance(component_filter, str): + return component_filter in components + else: + return ( + (ver_code := components.get(component_filter.component_name)) + and ver_code >= component_filter.minimum_version + and ( + component_filter.maximum_version is None + or ver_code <= component_filter.maximum_version + ) + ) def _device_type_match(fixture_data: FixtureInfo, device_type): if (component_nego := fixture_data.data.get("component_nego")) is None: diff --git a/kasa/tests/smart/modules/test_lighttransition.py b/kasa/tests/smart/modules/test_lighttransition.py new file mode 100644 index 000000000..beee68b37 --- /dev/null +++ b/kasa/tests/smart/modules/test_lighttransition.py @@ -0,0 +1,80 @@ +from pytest_mock import MockerFixture + +from kasa import Feature, Module +from kasa.smart import SmartDevice +from kasa.tests.device_fixtures import get_parent_and_child_modules, parametrize +from kasa.tests.fixtureinfo import ComponentFilter + +light_transition_v1 = parametrize( + "has light transition", + component_filter=ComponentFilter( + component_name="on_off_gradually", maximum_version=1 + ), + protocol_filter={"SMART"}, +) +light_transition_gt_v1 = parametrize( + "has light transition", + component_filter=ComponentFilter( + component_name="on_off_gradually", minimum_version=2 + ), + protocol_filter={"SMART"}, +) + + +@light_transition_v1 +async def test_module_v1(dev: SmartDevice, mocker: MockerFixture): + """Test light transition module.""" + assert isinstance(dev, SmartDevice) + light_transition = next(get_parent_and_child_modules(dev, Module.LightTransition)) + assert light_transition + assert "smooth_transitions" in light_transition._module_features + assert "smooth_transition_on" not in light_transition._module_features + assert "smooth_transition_off" not in light_transition._module_features + + await light_transition.set_enabled(True) + await dev.update() + assert light_transition.enabled is True + + await light_transition.set_enabled(False) + await dev.update() + assert light_transition.enabled is False + + +@light_transition_gt_v1 +async def test_module_gt_v1(dev: SmartDevice, mocker: MockerFixture): + """Test light transition module.""" + assert isinstance(dev, SmartDevice) + light_transition = next(get_parent_and_child_modules(dev, Module.LightTransition)) + assert light_transition + assert "smooth_transitions" not in light_transition._module_features + assert "smooth_transition_on" in light_transition._module_features + assert "smooth_transition_off" in light_transition._module_features + + await light_transition.set_enabled(True) + await dev.update() + assert light_transition.enabled is True + + await light_transition.set_enabled(False) + await dev.update() + assert light_transition.enabled is False + + await light_transition.set_turn_on_transition(5) + await dev.update() + assert light_transition.turn_on_transition == 5 + # enabled is true if either on or off is enabled + assert light_transition.enabled is True + + await light_transition.set_turn_off_transition(10) + await dev.update() + assert light_transition.turn_off_transition == 10 + assert light_transition.enabled is True + + max_on = light_transition._module_features["smooth_transition_on"].maximum_value + assert max_on < Feature.DEFAULT_MAX + max_off = light_transition._module_features["smooth_transition_off"].maximum_value + assert max_off < Feature.DEFAULT_MAX + + await light_transition.set_turn_on_transition(0) + await light_transition.set_turn_off_transition(0) + await dev.update() + assert light_transition.enabled is False diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index d5fd27e19..7940f1e5d 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -1,16 +1,25 @@ -# type: ignore +"""Module for testing device factory. + +As this module tests the factory with discovery data and expects update to be +called on devices it uses the discovery_mock handles all the patching of the +query methods without actually replacing the device protocol class with one of +the testing fake protocols. +""" + import logging +from typing import cast import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( Credentials, - Device, Discover, KasaException, ) from kasa.device_factory import ( + Device, + SmartDevice, _get_device_type_from_sys_info, connect, get_device_class_from_family, @@ -23,7 +32,8 @@ DeviceFamily, ) from kasa.discover import DiscoveryResult -from kasa.smart.smartdevice import SmartDevice + +from .conftest import DISCOVERY_MOCK_IP def _get_connection_type_device_class(discovery_info): @@ -44,18 +54,22 @@ def _get_connection_type_device_class(discovery_info): async def test_connect( - discovery_data, + discovery_mock, mocker, ): """Test that if the protocol is passed in it gets set correctly.""" - host = "127.0.0.1" - ctype, device_class = _get_connection_type_device_class(discovery_data) + host = DISCOVERY_MOCK_IP + ctype, device_class = _get_connection_type_device_class( + discovery_mock.discovery_data + ) config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) protocol_class = get_protocol(config).__class__ close_mock = mocker.patch.object(protocol_class, "close") + # mocker.patch.object(SmartDevice, "update") + # mocker.patch.object(Device, "update") dev = await connect( config=config, ) @@ -69,10 +83,11 @@ async def test_connect( @pytest.mark.parametrize("custom_port", [123, None]) -async def test_connect_custom_port(discovery_data: dict, mocker, custom_port): +async def test_connect_custom_port(discovery_mock, mocker, custom_port): """Make sure that connect returns an initialized SmartDevice instance.""" - host = "127.0.0.1" + host = DISCOVERY_MOCK_IP + discovery_data = discovery_mock.discovery_data ctype, _ = _get_connection_type_device_class(discovery_data) config = DeviceConfig( host=host, @@ -90,13 +105,14 @@ async def test_connect_custom_port(discovery_data: dict, mocker, custom_port): async def test_connect_logs_connect_time( - discovery_data: dict, + discovery_mock, caplog: pytest.LogCaptureFixture, ): """Test that the connect time is logged when debug logging is enabled.""" + discovery_data = discovery_mock.discovery_data ctype, _ = _get_connection_type_device_class(discovery_data) - host = "127.0.0.1" + host = DISCOVERY_MOCK_IP config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) @@ -107,9 +123,10 @@ async def test_connect_logs_connect_time( assert "seconds to update" in caplog.text -async def test_connect_query_fails(discovery_data, mocker): +async def test_connect_query_fails(discovery_mock, mocker): """Make sure that connect fails when query fails.""" - host = "127.0.0.1" + host = DISCOVERY_MOCK_IP + discovery_data = discovery_mock.discovery_data mocker.patch("kasa.IotProtocol.query", side_effect=KasaException) mocker.patch("kasa.SmartProtocol.query", side_effect=KasaException) @@ -125,10 +142,10 @@ async def test_connect_query_fails(discovery_data, mocker): assert close_mock.call_count == 1 -async def test_connect_http_client(discovery_data, mocker): +async def test_connect_http_client(discovery_mock, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" - host = "127.0.0.1" - + host = DISCOVERY_MOCK_IP + discovery_data = discovery_mock.discovery_data ctype, _ = _get_connection_type_device_class(discovery_data) http_client = aiohttp.ClientSession() @@ -157,9 +174,10 @@ async def test_connect_http_client(discovery_data, mocker): async def test_device_types(dev: Device): await dev.update() if isinstance(dev, SmartDevice): - device_type = dev._discovery_info["result"]["device_type"] + assert dev._discovery_info + device_type = cast(str, dev._discovery_info["result"]["device_type"]) res = SmartDevice._get_device_type_from_components( - dev._components.keys(), device_type + list(dev._components.keys()), device_type ) else: res = _get_device_type_from_sys_info(dev._last_update) From 368590cd36145b0b7786bb248ae7cbfcce30180c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 30 Jun 2024 04:49:59 -0500 Subject: [PATCH 3/8] Cache SmartErrorCode creation (#1022) Uses the python 3.9 cache feature to improve performance of error code creation --- kasa/aestransport.py | 5 ++--- kasa/exceptions.py | 7 +++++++ kasa/smartprotocol.py | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 72df4e17a..cc373b190 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -142,12 +142,11 @@ def hash_credentials(login_v2: bool, credentials: Credentials) -> tuple[str, str def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: error_code_raw = resp_dict.get("error_code") try: - error_code = SmartErrorCode(error_code_raw) # type: ignore[arg-type] + error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: _LOGGER.warning("Received unknown error code: %s", error_code_raw) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR - - if error_code == SmartErrorCode.SUCCESS: + if error_code is SmartErrorCode.SUCCESS: return msg = f"{msg}: {self._host}: {error_code.name}({error_code.value})" if error_code in SMART_RETRYABLE_ERRORS: diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 2d913c2af..f5c26ff04 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -4,6 +4,7 @@ from asyncio import TimeoutError as _asyncioTimeoutError from enum import IntEnum +from functools import cache from typing import Any @@ -63,6 +64,12 @@ class SmartErrorCode(IntEnum): def __str__(self): return f"{self.name}({self.value})" + @staticmethod + @cache + def from_int(value: int) -> SmartErrorCode: + """Convert an integer to a SmartErrorCode.""" + return SmartErrorCode(value) + SUCCESS = 0 # Transport Errors diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index a430e3afa..22fd49dc0 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -241,12 +241,12 @@ async def _handle_response_lists( def _handle_response_error_code(self, resp_dict: dict, method, raise_on_error=True): error_code_raw = resp_dict.get("error_code") try: - error_code = SmartErrorCode(error_code_raw) # type: ignore[arg-type] + error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: _LOGGER.warning("Received unknown error code: %s", error_code_raw) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR - if error_code == SmartErrorCode.SUCCESS: + if error_code is SmartErrorCode.SUCCESS: return if not raise_on_error: 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 4/8] 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 9bf0903ee..ac23fdb24 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -328,6 +328,11 @@ async def _raw_query(self, request: str | dict) -> Any: """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 e64ace051..3a1406aa6 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 @@ async def _query_helper( 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 @@ def on_since(self) -> datetime | None: @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 26568c24a..251af8788 100644 --- a/kasa/tests/test_childdevice.py +++ b/kasa/tests/test_childdevice.py @@ -3,12 +3,19 @@ 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 @@ ) 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 5/8] 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 3a090782c..69c4e9e21 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 28e27d3f7..e0ed615c4 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 ada52f91f..fd9877513 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 @@ "WaterleakSensor", "ContactSensor", "FrostProtection", + "SmartLightEffect", ] diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index fbd908083..f5e6d6d64 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 @@ def query(self) -> dict: @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 @@ async def set_brightness(self, brightness: int, *, transition: int | None = None 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 170cfbb39..07f6aece9 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 @@ def _post_update_hook(self) -> None: # 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 @@ async def set_effect( ) -> 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 @@ async def set_effect( 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 c2f351881..a80c20f3c 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -4,15 +4,14 @@ 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 @@ def __init__(self, device: SmartDevice, module: str): 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 @@ def effect(self) -> str: 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 @@ async def set_effect( :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 94c751041..600cd75d3 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -250,18 +250,31 @@ def _set_dynamic_light_effect(self, info, params): 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 @@ def _send_request(self, request_dict: dict): 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 ed691e664..20435dde5 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 000000000..92ef2202c --- /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 c0d905789..beed8e8ba 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 6/8] 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 22fd49dc0..f7551e33b 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -47,6 +47,9 @@ def __init__( 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 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic 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 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic 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 5ead00d61..d362fd00a 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -2,10 +2,9 @@ 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 7/8] 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 f7551e33b..e6741bc47 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -154,10 +154,14 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic 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 8/8] 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 ac6746f91..a80adb555 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 aec24c096..b6511e147 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 f7bb8acda..45350aefd 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"]