From 2f24797033885a08856ae1a7b85340ce30d20b0c Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 08:14:01 +0100 Subject: [PATCH 1/9] Enable CI on the patch branch (#1042) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/codeql-analysis.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80511bd3..c957f890 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["master"] + branches: ["master", "patch"] pull_request: - branches: ["master"] + branches: ["master", "patch"] workflow_dispatch: # to allow manual re-runs env: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b8d5f396..29d53358 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,9 +2,9 @@ name: "CodeQL checks" on: push: - branches: [ master ] + branches: [ "master", "patch" ] pull_request: - branches: [ master ] + branches: [ master, "patch" ] schedule: - cron: '44 17 * * 3' From fe116eaefbe209169ed9df618fd57a5b04665ba6 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 08:29:53 +0100 Subject: [PATCH 2/9] Handle module errors more robustly and add query params to light preset and transition (#1043) Ensures that all modules try to access their data in `_post_update_hook` in a safe manner and disable themselves if there's an error. Also adds parameters to get_preset_rules and get_on_off_gradually_info to fix issues with recent firmware updates. Cherry pick of [#1036](https://github.com/python-kasa/python-kasa/pull/1036) to patch --- devtools/helpers/smartrequests.py | 11 ++- kasa/smart/modules/autooff.py | 6 -- kasa/smart/modules/batterysensor.py | 4 ++ kasa/smart/modules/cloud.py | 10 ++- kasa/smart/modules/devicemodule.py | 7 ++ kasa/smart/modules/firmware.py | 12 +++- kasa/smart/modules/frostprotection.py | 4 ++ kasa/smart/modules/humiditysensor.py | 4 ++ kasa/smart/modules/lightpreset.py | 2 +- kasa/smart/modules/lighttransition.py | 2 +- kasa/smart/modules/reportmode.py | 4 ++ kasa/smart/modules/temperaturesensor.py | 4 ++ kasa/smart/smartdevice.py | 30 ++++++-- kasa/smart/smartmodule.py | 22 +++++- kasa/smartprotocol.py | 4 ++ kasa/tests/test_smartdevice.py | 94 ++++++++++++++++++++++--- kasa/tests/test_smartprotocol.py | 16 +++++ 17 files changed, 206 insertions(+), 30 deletions(-) diff --git a/devtools/helpers/smartrequests.py b/devtools/helpers/smartrequests.py index 881488b5..4db1f7a1 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -284,6 +284,15 @@ class SmartRequest: """Get preset rules.""" return SmartRequest("get_preset_rules", params or SmartRequest.GetRulesParams()) + @staticmethod + def get_on_off_gradually_info( + params: SmartRequestParams | None = None, + ) -> SmartRequest: + """Get preset rules.""" + return SmartRequest( + "get_on_off_gradually_info", params or SmartRequest.SmartRequestParams() + ) + @staticmethod def get_auto_light_info() -> SmartRequest: """Get auto light info.""" @@ -382,7 +391,7 @@ COMPONENT_REQUESTS = { "auto_light": [SmartRequest.get_auto_light_info()], "light_effect": [SmartRequest.get_dynamic_light_effect_rules()], "bulb_quick_control": [], - "on_off_gradually": [SmartRequest.get_raw_request("get_on_off_gradually_info")], + "on_off_gradually": [SmartRequest.get_on_off_gradually_info()], "light_strip": [], "light_strip_lighting_effect": [ SmartRequest.get_raw_request("get_lighting_effect") diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py index 0004aec4..5e4b100f 100644 --- a/kasa/smart/modules/autooff.py +++ b/kasa/smart/modules/autooff.py @@ -19,12 +19,6 @@ class AutoOff(SmartModule): def _initialize_features(self): """Initialize features after the initial update.""" - if not isinstance(self.data, dict): - _LOGGER.warning( - "No data available for module, skipping %s: %s", self, self.data - ) - return - self._add_feature( Feature( self._device, diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py index 415e47d1..7ff7df2d 100644 --- a/kasa/smart/modules/batterysensor.py +++ b/kasa/smart/modules/batterysensor.py @@ -43,6 +43,10 @@ class BatterySensor(SmartModule): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def battery(self): """Return battery level.""" diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index 1b64f090..8346af57 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from ...exceptions import SmartErrorCode from ...feature import Feature from ..smartmodule import SmartModule @@ -18,6 +17,13 @@ class Cloud(SmartModule): QUERY_GETTER_NAME = "get_connect_cloud_state" REQUIRED_COMPONENT = "cloud_connect" + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because the logic here is to treat that as not connected. + """ + def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -37,6 +43,6 @@ class Cloud(SmartModule): @property def is_connected(self): """Return True if device is connected to the cloud.""" - if isinstance(self.data, SmartErrorCode): + if self._has_data_error(): return False return self.data["status"] == 0 diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 6a846d54..3203e82f 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -10,6 +10,13 @@ class DeviceModule(SmartModule): REQUIRED_COMPONENT = "device" + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because this module is critical. + """ + def query(self) -> dict: """Query to execute during the update cycle.""" query = { diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 3dcaddd6..10a6b824 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Any, Callable, Optional from async_timeout import timeout as asyncio_timeout from pydantic.v1 import BaseModel, Field, validator -from ...exceptions import SmartErrorCode from ...feature import Feature from ..smartmodule import SmartModule @@ -123,6 +122,13 @@ class Firmware(SmartModule): req["get_auto_update_info"] = None return req + def _post_update_hook(self): + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because some of the module still functions. + """ + @property def current_firmware(self) -> str: """Return the current firmware version.""" @@ -136,11 +142,11 @@ class Firmware(SmartModule): @property def firmware_update_info(self): """Return latest firmware information.""" - fw = self.data.get("get_latest_fw") or self.data - if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): + if not self._device.is_cloud_connected or self._has_data_error(): # Error in response, probably disconnected from the cloud. return UpdateInfo(type=0, need_to_upgrade=False) + fw = self.data.get("get_latest_fw") or self.data return UpdateInfo.parse_obj(fw) @property diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index f1811012..440e1ed1 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -14,6 +14,10 @@ class FrostProtection(SmartModule): REQUIRED_COMPONENT = "frost_protection" QUERY_GETTER_NAME = "get_frost_protection" + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def enabled(self) -> bool: """Return True if frost protection is on.""" diff --git a/kasa/smart/modules/humiditysensor.py b/kasa/smart/modules/humiditysensor.py index f0dcc18a..b137736f 100644 --- a/kasa/smart/modules/humiditysensor.py +++ b/kasa/smart/modules/humiditysensor.py @@ -45,6 +45,10 @@ class HumiditySensor(SmartModule): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def humidity(self): """Return current humidity in percentage.""" diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 8e5cae20..7635a5f8 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -140,7 +140,7 @@ class LightPreset(SmartModule, LightPresetInterface): """Query to execute during the update cycle.""" if self._state_in_sysinfo: # Child lights can have states in the child info return {} - return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {"start_index": 0}} async def _check_supported(self): """Additional check to see if the module is supported by the device. diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index 29a4bb05..ca0eca86 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -230,7 +230,7 @@ class LightTransition(SmartModule): if self._state_in_sysinfo: return {} else: - return {self.QUERY_GETTER_NAME: None} + return {self.QUERY_GETTER_NAME: {}} async def _check_supported(self): """Additional check to see if the module is supported by the device.""" diff --git a/kasa/smart/modules/reportmode.py b/kasa/smart/modules/reportmode.py index 79c8ae62..8d210a5b 100644 --- a/kasa/smart/modules/reportmode.py +++ b/kasa/smart/modules/reportmode.py @@ -32,6 +32,10 @@ class ReportMode(SmartModule): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def report_interval(self): """Reporting interval of a sensor device.""" diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py index d9850150..a61859cd 100644 --- a/kasa/smart/modules/temperaturesensor.py +++ b/kasa/smart/modules/temperaturesensor.py @@ -58,6 +58,10 @@ class TemperatureSensor(SmartModule): ) ) + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + @property def temperature(self): """Return current humidity in percentage.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index a5b64e52..fcbc8a15 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -177,11 +177,20 @@ class SmartDevice(Device): self._children[info["device_id"]]._update_internal_state(info) # Call handle update for modules that want to update internal data - for module in self._modules.values(): - module._post_update_hook() + errors = [] + for module_name, module in self._modules.items(): + if not self._handle_module_post_update_hook(module): + errors.append(module_name) + for error in errors: + self._modules.pop(error) + for child in self._children.values(): - for child_module in child._modules.values(): - child_module._post_update_hook() + errors = [] + for child_module_name, child_module in child._modules.items(): + if not self._handle_module_post_update_hook(child_module): + errors.append(child_module_name) + for error in errors: + child._modules.pop(error) # We can first initialize the features after the first update. # We make here an assumption that every device has at least a single feature. @@ -190,6 +199,19 @@ class SmartDevice(Device): _LOGGER.debug("Got an update: %s", self._last_update) + def _handle_module_post_update_hook(self, module: SmartModule) -> bool: + try: + module._post_update_hook() + return True + except Exception as ex: + _LOGGER.error( + "Error processing %s for device %s, module will be unavailable: %s", + module.name, + self.host, + ex, + ) + return False + async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index e78f4393..fb946a8b 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from ..exceptions import KasaException +from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..module import Module if TYPE_CHECKING: @@ -41,6 +41,14 @@ class SmartModule(Module): """Name of the module.""" return getattr(self, "NAME", self.__class__.__name__) + def _post_update_hook(self): # noqa: B027 + """Perform actions after a device update. + + Any modules overriding this should ensure that self.data is + accessed unless the module should remain active despite errors. + """ + assert self.data # noqa: S101 + def query(self) -> dict: """Query to execute during the update cycle. @@ -87,6 +95,11 @@ class SmartModule(Module): filtered_data = {k: v for k, v in dev._last_update.items() if k in q_keys} + for data_item in filtered_data: + if isinstance(filtered_data[data_item], SmartErrorCode): + raise DeviceError( + f"{data_item} for {self.name}", error_code=filtered_data[data_item] + ) if len(filtered_data) == 1: return next(iter(filtered_data.values())) @@ -110,3 +123,10 @@ class SmartModule(Module): color_temp_range but only supports one value. """ return True + + def _has_data_error(self) -> bool: + try: + assert self.data # noqa: S101 + return False + except DeviceError: + return True diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index e6741bc4..3085714c 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -416,6 +416,10 @@ class _ChildProtocolWrapper(SmartProtocol): return smart_method, smart_params async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside control_child envelope.""" + return await self._query(request, retry_count) + + async def _query(self, request: str | dict, retry_count: int = 3) -> dict: """Wrap request inside control_child envelope.""" method, params = self._get_method_and_params_for_request(request) request_data = { diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 48475a90..44fabc71 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from unittest.mock import patch import pytest @@ -132,6 +132,78 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): spies[device].assert_not_called() +@device_smart +async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): + """Test that modules that error are disabled / removed.""" + # We need to have some modules initialized by now + assert dev._modules + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + not_disabling_modules = {Module.Firmware, Module.Cloud} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + + module_queries = { + modname: q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + child_module_queries = { + modname: q + for child in dev.children + for modname, module in child._modules.items() + if (q := module.query()) and modname not in critical_modules + } + all_queries_names = { + key for mod_query in module_queries.values() for key in mod_query + } + all_child_queries_names = { + key for mod_query in child_module_queries.values() for key in mod_query + } + + async def _query(request, *args, **kwargs): + responses = await dev.protocol._query(request, *args, **kwargs) + for k in responses: + if k in all_queries_names: + responses[k] = SmartErrorCode.PARAMS_ERROR + return responses + + async def _child_query(self, request, *args, **kwargs): + responses = await child_protocols[self._device_id]._query( + request, *args, **kwargs + ) + for k in responses: + if k in all_child_queries_names: + responses[k] = SmartErrorCode.PARAMS_ERROR + return responses + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + + from kasa.smartprotocol import _ChildProtocolWrapper + + child_protocols = { + cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol + for child in dev.children + } + # children not created yet so cannot patch.object + mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) + + await new_dev.update() + for modname in module_queries: + no_disable = modname in not_disabling_modules + mod_present = modname in new_dev._modules + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + + for modname in child_module_queries: + no_disable = modname in not_disabling_modules + mod_present = any(modname in child._modules for child in new_dev.children) + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + + async def test_get_modules(): """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( @@ -181,6 +253,9 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt assert dev.is_cloud_connected == is_connected last_update = dev._last_update + for child in dev.children: + mocker.patch.object(child.protocol, "query", return_value=child._last_update) + last_update["get_connect_cloud_state"] = {"status": 0} with patch.object(dev.protocol, "query", return_value=last_update): await dev.update() @@ -207,21 +282,18 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt "get_connect_cloud_state": last_update["get_connect_cloud_state"], "get_device_info": last_update["get_device_info"], } - # Child component list is not stored on the device - if "get_child_device_list" in last_update: - child_component_list = await dev.protocol.query( - "get_child_device_component_list" - ) - last_update["get_child_device_component_list"] = child_component_list[ - "get_child_device_component_list" - ] + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) first_call = True - def side_effect_func(*_, **__): + async def side_effect_func(*args, **kwargs): nonlocal first_call - resp = initial_response if first_call else last_update + resp = ( + initial_response + if first_call + else await new_dev.protocol._query(*args, **kwargs) + ) first_call = False return resp diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index d362fd00..71125ca8 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,6 +1,7 @@ import logging import pytest +import pytest_mock from ..exceptions import ( SMART_RETRYABLE_ERRORS, @@ -19,6 +20,21 @@ DUMMY_MULTIPLE_QUERY = { ERRORS = [e for e in SmartErrorCode if e != 0] +async def test_smart_queries(dummy_protocol, mocker: pytest_mock.MockerFixture): + mock_response = {"result": {"great": "success"}, "error_code": 0} + + mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) + # test sending a method name as a string + resp = await dummy_protocol.query("foobar") + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + # test sending a method name as a dict + resp = await dummy_protocol.query(DUMMY_QUERY) + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) async def test_smart_device_errors(dummy_protocol, mocker, error_code): mock_response = {"result": {"great": "success"}, "error_code": error_code.value} From 407cedf781f28edb1a7992212a74904c1f2ccf08 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 4 Jul 2024 09:43:45 +0100 Subject: [PATCH 3/9] Prepare 0.7.0.3 (#1045) ## [0.7.0.3](https://github.com/python-kasa/python-kasa/tree/0.7.0.3) (2024-07-04) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. Partially fixes light preset module errors with L920 and L930. **Fixed bugs:** Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) --- CHANGELOG.md | 129 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80adb55..1b2466df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.7.0.3](https://github.com/python-kasa/python-kasa/tree/0.7.0.3) (2024-07-04) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) + +Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. +Partially fixes light preset module errors with L920 and L930. + +**Fixed bugs:** + +Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) + ## [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) @@ -71,6 +82,7 @@ For more information on the changes please checkout our [documentation on the AP **Implemented enhancements:** +- Radiator support \(KE100\) [\#422](https://github.com/python-kasa/python-kasa/issues/422) - Cleanup cli output [\#1000](https://github.com/python-kasa/python-kasa/pull/1000) (@rytilahti) - Update mode, time, rssi and report\_interval feature names/units [\#995](https://github.com/python-kasa/python-kasa/pull/995) (@sdb9696) - Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) @@ -133,6 +145,15 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- TAPO P100 \(hw 1.0.0, sw 1.1.3\) EU plug with 0.6.2.1 Kasa results JSON\_DECODE\_FAIL\_ERROR [\#819](https://github.com/python-kasa/python-kasa/issues/819) +- Cannot add Tapo Plug P110 to Home Assistant 2024.2.3 - Error in debug mode [\#797](https://github.com/python-kasa/python-kasa/issues/797) +- KS240 gets discovered but will not authenticate [\#749](https://github.com/python-kasa/python-kasa/issues/749) +- Individual commands do not work on discovered devices [\#71](https://github.com/python-kasa/python-kasa/issues/71) +- SMART.TAPOHUB does not work with 0.7.0 dev2 [\#958](https://github.com/python-kasa/python-kasa/issues/958) +- Fix --help on subcommands [\#885](https://github.com/python-kasa/python-kasa/issues/885) +- "Unclosed client session" Trying to set brightness on Tapo Bulb [\#828](https://github.com/python-kasa/python-kasa/issues/828) +- Error when trying to discover new Tapo P110 plug [\#818](https://github.com/python-kasa/python-kasa/issues/818) +- Individual errors cause failing the whole query [\#616](https://github.com/python-kasa/python-kasa/issues/616) - 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) - Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) @@ -188,6 +209,8 @@ For more information on the changes please checkout our [documentation on the AP **Documentation updates:** +- Document device features [\#755](https://github.com/python-kasa/python-kasa/issues/755) +- Clean up the README [\#979](https://github.com/python-kasa/python-kasa/issues/979) - 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 docs with more howto examples [\#968](https://github.com/python-kasa/python-kasa/pull/968) (@sdb9696) @@ -278,6 +301,10 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) +Release highlights: +* Support for tapo power strips (P300) +* Performance improvements and bug fixes + **Implemented enhancements:** - Implement alias set for tapodevice [\#721](https://github.com/python-kasa/python-kasa/pull/721) (@rytilahti) @@ -314,6 +341,11 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.1...0.6.1) +Release highlights: +* Support for tapo wall switches +* Support for unprovisioned devices +* Performance and stability improvements + **Implemented enhancements:** - Add support for tapo wall switches \(S500D\) [\#704](https://github.com/python-kasa/python-kasa/pull/704) (@bdraco) @@ -365,6 +397,8 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0...0.6.0.1) +A patch release to improve the protocol handling. + **Fixed bugs:** - Fix httpclient exceptions on read and improve error info [\#655](https://github.com/python-kasa/python-kasa/pull/655) (@sdb9696) @@ -382,6 +416,19 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) +This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! + +This release adds support to a large range of previously unsupported devices, including: + +* Newer kasa-branded devices, including Matter-enabled devices like KP125M +* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol +* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) +* UK variant of HS110, which was the first device using the new protocol + +If your device that is not currently listed as supported is working, please consider contributing a test fixture file. + +Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! + **Breaking changes:** - Add DeviceConfig to allow specifying configuration parameters [\#569](https://github.com/python-kasa/python-kasa/pull/569) (@sdb9696) @@ -389,6 +436,9 @@ For more information on the changes please checkout our [documentation on the AP **Implemented enhancements:** +- Support for KS225\(US\) Light Dimmer and KS205\(US\) Light Switch [\#589](https://github.com/python-kasa/python-kasa/issues/589) +- Set timeout using command line parameters [\#310](https://github.com/python-kasa/python-kasa/issues/310) +- Implement the new protocol \(HTTP over 80/tcp, 20002/udp for discovery\) [\#115](https://github.com/python-kasa/python-kasa/issues/115) - Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) - Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) - Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) @@ -415,6 +465,7 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- dump\_devinfo crashes when credentials are not given [\#591](https://github.com/python-kasa/python-kasa/issues/591) - Fix connection indeterminate state on cancellation [\#636](https://github.com/python-kasa/python-kasa/pull/636) (@bdraco) - Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) - Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) @@ -423,6 +474,7 @@ For more information on the changes please checkout our [documentation on the AP **Documentation updates:** +- Update the documentation for 0.6 release [\#600](https://github.com/python-kasa/python-kasa/issues/600) - Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) - Update readme with clearer instructions, tapo support [\#571](https://github.com/python-kasa/python-kasa/pull/571) (@rytilahti) - Add some more external links to README [\#541](https://github.com/python-kasa/python-kasa/pull/541) (@rytilahti) @@ -469,6 +521,15 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.3...0.5.4) +The highlights of this maintenance release: + +* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. +* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. +* Optimizations for downstream device accesses, thanks to @bdraco. +* Support for both pydantic v1 and v2. + +As always, see the full changelog for details. + **Implemented enhancements:** - Add a connect\_single method to Discover to avoid the need for UDP [\#528](https://github.com/python-kasa/python-kasa/pull/528) (@bdraco) @@ -507,6 +568,8 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.2...0.5.3) +This release adds support for defining the device port and introduces dependency on async-timeout which improves timeout handling. + **Implemented enhancements:** - Make device port configurable [\#471](https://github.com/python-kasa/python-kasa/pull/471) (@karpach) @@ -524,6 +587,10 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) +Besides some small improvements, this release: +* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. +* Drops Python 3.7 support as it is no longer maintained. + **Breaking changes:** - Drop python 3.7 support [\#455](https://github.com/python-kasa/python-kasa/pull/455) (@rytilahti) @@ -537,6 +604,9 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- Request for KP405 Support - Dimmable Plug [\#469](https://github.com/python-kasa/python-kasa/issues/469) +- Issue printing device in on\_discovered: pydantic.error\_wrappers.ValidationError: 3 validation errors for SmartBulbPreset [\#439](https://github.com/python-kasa/python-kasa/issues/439) +- Possible firmware issue with KL125 \(1.0.7 Build 211009 Rel.172044\) [\#345](https://github.com/python-kasa/python-kasa/issues/345) - Exclude querying certain modules for KL125\(US\) which cause crashes [\#451](https://github.com/python-kasa/python-kasa/pull/451) (@brianthedavis) - Return result objects for cli discover and implicit 'state' [\#446](https://github.com/python-kasa/python-kasa/pull/446) (@rytilahti) - Allow effect presets seen on light strips [\#440](https://github.com/python-kasa/python-kasa/pull/440) (@rytilahti) @@ -553,6 +623,13 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.0...0.5.1) +This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: +* Improved console tool (JSON output, colorized output if rich is installed) +* Pretty, colorized console output, if `rich` is installed +* Support for configuring bulb presets +* Usage data is now reported in the expected format +* Dependency pinning is relaxed to give downstreams more control + **Breaking changes:** - Implement changing the bulb turn-on behavior [\#381](https://github.com/python-kasa/python-kasa/pull/381) (@rytilahti) @@ -569,11 +646,16 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- cli.py usage year and month options do not output data as expected [\#373](https://github.com/python-kasa/python-kasa/issues/373) +- cli.py usage --year command passes year argument incorrectly [\#371](https://github.com/python-kasa/python-kasa/issues/371) +- KP303 reporting as device off [\#319](https://github.com/python-kasa/python-kasa/issues/319) +- HS210 not updating the state correctly [\#193](https://github.com/python-kasa/python-kasa/issues/193) - Fix year emeter for cli by using kwarg for year parameter [\#372](https://github.com/python-kasa/python-kasa/pull/372) (@rytilahti) - Return usage.get\_{monthstat,daystat} in expected format [\#394](https://github.com/python-kasa/python-kasa/pull/394) (@jules43) **Documentation updates:** +- Update misleading docs about supported devices \(was: add support for EP25 plug\) [\#367](https://github.com/python-kasa/python-kasa/issues/367) - Minor fixes to smartbulb docs [\#431](https://github.com/python-kasa/python-kasa/pull/431) (@rytilahti) - Add a note that transition is not supported by all devices [\#398](https://github.com/python-kasa/python-kasa/pull/398) (@rytilahti) - fix more outdated CLI examples, remove EP40 from bulb list [\#383](https://github.com/python-kasa/python-kasa/pull/383) (@HankB) @@ -583,6 +665,10 @@ For more information on the changes please checkout our [documentation on the AP - Update README to add missing models and fix a link [\#351](https://github.com/python-kasa/python-kasa/pull/351) (@rytilahti) - Add KP125 test fixture and support note. [\#350](https://github.com/python-kasa/python-kasa/pull/350) (@jalseth) +**Closed issues:** + +- Add support for setting default behaviors for a soft or hard power on of the bulb [\#365](https://github.com/python-kasa/python-kasa/issues/365) + **Merged pull requests:** - Some release preparation janitoring [\#432](https://github.com/python-kasa/python-kasa/pull/432) (@rytilahti) @@ -605,18 +691,43 @@ For more information on the changes please checkout our [documentation on the AP [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.3...0.5.0) +This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. + +There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): +* Basic system info +* Emeter +* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device +* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) +* Countdown (new) +* Antitheft (new) +* Schedule (new) +* Motion - for configuring motion settings on some dimmers (new) +* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) +* Cloud - information about cloud connectivity (new) + +For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. +Pull requests improving the functionality of modules as well as adding better interfaces to device classes are welcome! + **Breaking changes:** - Drop deprecated, type-specific options in favor of --type [\#336](https://github.com/python-kasa/python-kasa/pull/336) (@rytilahti) - Convert the codebase to be more modular [\#299](https://github.com/python-kasa/python-kasa/pull/299) (@rytilahti) +**Implemented enhancements:** + +- Improve HS220 support [\#44](https://github.com/python-kasa/python-kasa/issues/44) + **Fixed bugs:** +- Skip running discovery on --help on subcommands [\#122](https://github.com/python-kasa/python-kasa/issues/122) - Avoid retrying open\_connection on unrecoverable errors [\#340](https://github.com/python-kasa/python-kasa/pull/340) (@bdraco) - Avoid discovery on --help [\#335](https://github.com/python-kasa/python-kasa/pull/335) (@rytilahti) **Documentation updates:** +- Trying to poll device every 5 seconds but getting asyncio errors [\#316](https://github.com/python-kasa/python-kasa/issues/316) +- Docs: Smart Strip - Emeter feature Note [\#257](https://github.com/python-kasa/python-kasa/issues/257) +- Documentation addition: Smartplug access to internet ntp server pool. [\#129](https://github.com/python-kasa/python-kasa/issues/129) - Export modules & make sphinx happy [\#334](https://github.com/python-kasa/python-kasa/pull/334) (@rytilahti) - Various documentation updates [\#333](https://github.com/python-kasa/python-kasa/pull/333) (@rytilahti) @@ -630,6 +741,7 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- Divide by zero when HS300 powerstrip is discovered [\#292](https://github.com/python-kasa/python-kasa/issues/292) - Ensure bulb state is restored when turning back on [\#330](https://github.com/python-kasa/python-kasa/pull/330) (@bdraco) **Merged pull requests:** @@ -650,6 +762,8 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- TypeError: \_\_init\_\_\(\) got an unexpected keyword argument 'package\_name' [\#311](https://github.com/python-kasa/python-kasa/issues/311) +- RuntimeError: Event loop is closed on WSL [\#294](https://github.com/python-kasa/python-kasa/issues/294) - Don't crash on devices not reporting features [\#317](https://github.com/python-kasa/python-kasa/pull/317) (@rytilahti) **Merged pull requests:** @@ -685,6 +799,8 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- Discovery on WSL results in OSError: \[Errno 22\] Invalid argument [\#246](https://github.com/python-kasa/python-kasa/issues/246) +- New firmware for HS103 blocking local access? [\#42](https://github.com/python-kasa/python-kasa/issues/42) - Pin mistune to \<2.0.0 to fix doc builds [\#270](https://github.com/python-kasa/python-kasa/pull/270) (@rytilahti) - Catch exceptions raised on unknown devices during discovery [\#240](https://github.com/python-kasa/python-kasa/pull/240) (@rytilahti) @@ -704,6 +820,8 @@ For more information on the changes please checkout our [documentation on the AP **Implemented enhancements:** +- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) +- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) - Fix lock being unexpectedly reset on close [\#218](https://github.com/python-kasa/python-kasa/pull/218) (@bdraco) - Avoid calling pformat unless debug logging is enabled [\#217](https://github.com/python-kasa/python-kasa/pull/217) (@bdraco) - Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) (@bdraco) @@ -720,6 +838,10 @@ For more information on the changes please checkout our [documentation on the AP **Fixed bugs:** +- KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) +- `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) +- `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) +- HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) - dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) (@rytilahti) - Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) - Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) @@ -728,6 +850,9 @@ For more information on the changes please checkout our [documentation on the AP **Documentation updates:** +- Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) +- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) +- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) - Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) **Merged pull requests:** @@ -773,6 +898,10 @@ For more information on the changes please checkout our [documentation on the AP - Add commands to control the wifi settings [\#45](https://github.com/python-kasa/python-kasa/pull/45) (@rytilahti) +**Fixed bugs:** + +- HSV cli command not working [\#43](https://github.com/python-kasa/python-kasa/issues/43) + **Merged pull requests:** - Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) (@rytilahti) diff --git a/pyproject.toml b/pyproject.toml index 45350aef..fb8df913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.2" +version = "0.7.0.3" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From 5dac0922274b6b474072cebc30f96ac50bcd2652 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jul 2024 16:54:15 +0100 Subject: [PATCH 4/9] Defer module updates for less volatile modules (pick 1052) (#1056) Pick commit 7fd5c213e6a73e4aca709098211bed347ea42b07 from 1052 Addresses stability issues on older hw device versions - Handles module timeout errors better by querying modules individually on errors and disabling problematic modules like Firmware that go out to the internet to get updates. - Addresses an issue with the Led module on P100 hardware version 1.0 which appears to have a memory leak and will cause the device to crash after approximately 500 calls. - Delays updates of modules that do not have regular changes like LightPreset and LightEffect and enables them to be updated on the next update cycle only if required values have changed. --- kasa/aestransport.py | 14 ++- kasa/exceptions.py | 2 + kasa/httpclient.py | 23 +++- kasa/smart/modules/cloud.py | 1 + kasa/smart/modules/firmware.py | 12 +-- kasa/smart/modules/led.py | 5 +- kasa/smart/modules/lighteffect.py | 5 +- kasa/smart/modules/lightpreset.py | 4 +- kasa/smart/modules/lightstripeffect.py | 4 +- kasa/smart/modules/lighttransition.py | 6 +- kasa/smart/smartchilddevice.py | 2 + kasa/smart/smartdevice.py | 141 ++++++++++++++++++++----- kasa/smart/smartmodule.py | 29 ++++- kasa/smartprotocol.py | 44 ++++++-- kasa/tests/test_smartdevice.py | 126 +++++++++++++++++++++- kasa/tests/test_smartprotocol.py | 2 +- 16 files changed, 364 insertions(+), 56 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index cc373b19..abe282c0 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -144,7 +144,9 @@ class AesTransport(BaseTransport): try: error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: - _LOGGER.warning("Received unknown error code: %s", error_code_raw) + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR if error_code is SmartErrorCode.SUCCESS: return @@ -214,10 +216,18 @@ class AesTransport(BaseTransport): """Login to the device.""" try: await self.try_login(self._login_params) + _LOGGER.debug( + "%s: logged in with provided credentials", + self._host, + ) except AuthenticationError as aex: try: if aex.error_code is not SmartErrorCode.LOGIN_ERROR: raise aex + _LOGGER.debug( + "%s: trying login with default TAPO credentials", + self._host, + ) if self._default_credentials is None: self._default_credentials = get_default_credentials( DEFAULT_CREDENTIALS["TAPO"] @@ -225,7 +235,7 @@ class AesTransport(BaseTransport): await self.perform_handshake() await self.try_login(self._get_login_params(self._default_credentials)) _LOGGER.debug( - "%s: logged in with default credentials", + "%s: logged in with default TAPO credentials", self._host, ) except (AuthenticationError, _ConnectionError, TimeoutError): diff --git a/kasa/exceptions.py b/kasa/exceptions.py index f5c26ff0..3f7f301b 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -128,6 +128,8 @@ class SmartErrorCode(IntEnum): # Library internal for unknown error codes INTERNAL_UNKNOWN_ERROR = -100_000 + # Library internal for query errors + INTERNAL_QUERY_ERROR = -100_001 SMART_RETRYABLE_ERRORS = [ diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 02e69782..1c8c46e2 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -75,13 +75,21 @@ class HttpClient: now = time.time() gap = now - self._last_request_time if gap < self._wait_between_requests: - await asyncio.sleep(self._wait_between_requests - gap) + sleep = self._wait_between_requests - gap + _LOGGER.debug( + "Device %s waiting %s seconds to send request", + self._config.host, + sleep, + ) + await asyncio.sleep(sleep) _LOGGER.debug("Posting to %s", url) response_data = None self._last_url = url self.client.cookie_jar.clear() return_json = bool(json) + client_timeout = aiohttp.ClientTimeout(total=self._config.timeout) + # If json is not a dict send as data. # This allows the json parameter to be used to pass other # types of data such as async_generator and still have json @@ -95,9 +103,10 @@ class HttpClient: params=params, data=data, json=json, - timeout=self._config.timeout, + timeout=client_timeout, cookies=cookies_dict, headers=headers, + ssl=False, ) async with resp: if resp.status == 200: @@ -106,9 +115,15 @@ class HttpClient: response_data = json_loads(response_data.decode()) except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: - if isinstance(ex, aiohttp.ClientOSError): + if not self._wait_between_requests: + _LOGGER.debug( + "Device %s received an os error, " + "enabling sequential request delay: %s", + self._config.host, + ex, + ) self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR - self._last_request_time = time.time() + self._last_request_time = time.time() raise _ConnectionError( f"Device connection error: {self._config.host}: {ex}", ex ) from ex diff --git a/kasa/smart/modules/cloud.py b/kasa/smart/modules/cloud.py index 8346af57..e7513a56 100644 --- a/kasa/smart/modules/cloud.py +++ b/kasa/smart/modules/cloud.py @@ -16,6 +16,7 @@ class Cloud(SmartModule): QUERY_GETTER_NAME = "get_connect_cloud_state" REQUIRED_COMPONENT = "cloud_connect" + MINIMUM_UPDATE_INTERVAL_SECS = 60 def _post_update_hook(self): """Perform actions after a device update. diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 10a6b824..dc0483e7 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -14,7 +14,7 @@ from async_timeout import timeout as asyncio_timeout from pydantic.v1 import BaseModel, Field, validator from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -66,6 +66,7 @@ class Firmware(SmartModule): """Implementation of firmware module.""" REQUIRED_COMPONENT = "firmware" + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) @@ -122,13 +123,6 @@ class Firmware(SmartModule): req["get_auto_update_info"] = None return req - def _post_update_hook(self): - """Perform actions after a device update. - - Overrides the default behaviour to disable a module if the query returns - an error because some of the module still functions. - """ - @property def current_firmware(self) -> str: """Return the current firmware version.""" @@ -162,6 +156,7 @@ class Firmware(SmartModule): state = resp["get_fw_download_state"] return DownloadState(**state) + @allow_update_after async def update( self, progress_cb: Callable[[DownloadState], Coroutine] | None = None ): @@ -219,6 +214,7 @@ class Firmware(SmartModule): and self.data["get_auto_update_info"]["enable"] ) + @allow_update_after async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" data = {**self.data["get_auto_update_info"], "enable": enabled} diff --git a/kasa/smart/modules/led.py b/kasa/smart/modules/led.py index 2d0a354c..bbfe3579 100644 --- a/kasa/smart/modules/led.py +++ b/kasa/smart/modules/led.py @@ -3,7 +3,7 @@ from __future__ import annotations from ...interfaces.led import Led as LedInterface -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after class Led(SmartModule, LedInterface): @@ -11,6 +11,8 @@ class Led(SmartModule, LedInterface): REQUIRED_COMPONENT = "led" QUERY_GETTER_NAME = "get_led_info" + # Led queries can cause device to crash on P100 + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 def query(self) -> dict: """Query to execute during the update cycle.""" @@ -29,6 +31,7 @@ class Led(SmartModule, LedInterface): """Return current led status.""" return self.data["led_rule"] != "never" + @allow_update_after async def set_led(self, enable: bool): """Set led. diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 07f6aece..5f589d6d 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -9,7 +9,7 @@ import copy from typing import Any from ..effects import SmartLightEffect -from ..smartmodule import Module, SmartModule +from ..smartmodule import Module, SmartModule, allow_update_after class LightEffect(SmartModule, SmartLightEffect): @@ -17,6 +17,7 @@ class LightEffect(SmartModule, SmartLightEffect): REQUIRED_COMPONENT = "light_effect" QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 AVAILABLE_BULB_EFFECTS = { "L1": "Party", "L2": "Relax", @@ -130,6 +131,7 @@ class LightEffect(SmartModule, SmartLightEffect): return brightness + @allow_update_after async def set_brightness( self, brightness: int, @@ -156,6 +158,7 @@ class LightEffect(SmartModule, SmartLightEffect): return await self.call("edit_dynamic_light_effect_rule", new_effect) + @allow_update_after async def set_custom_effect( self, effect_dict: dict, diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 7635a5f8..b9692438 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING from ...interfaces import LightPreset as LightPresetInterface from ...interfaces import LightState -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -19,6 +19,7 @@ class LightPreset(SmartModule, LightPresetInterface): REQUIRED_COMPONENT = "preset" QUERY_GETTER_NAME = "get_preset_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 SYS_INFO_STATE_KEY = "preset_state" @@ -113,6 +114,7 @@ class LightPreset(SmartModule, LightPresetInterface): raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") await self._device.modules[SmartModule.Light].set_state(preset) + @allow_update_after async def save_preset( self, preset_name: str, diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py index a80c20f3..f7562068 100644 --- a/kasa/smart/modules/lightstripeffect.py +++ b/kasa/smart/modules/lightstripeffect.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect -from ..smartmodule import Module, SmartModule +from ..smartmodule import Module, SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -84,6 +84,7 @@ class LightStripEffect(SmartModule, SmartLightEffect): """ return self._effect_list + @allow_update_after async def set_effect( self, effect: str, @@ -126,6 +127,7 @@ class LightStripEffect(SmartModule, SmartLightEffect): await self.set_custom_effect(effect_dict) + @allow_update_after async def set_custom_effect( self, effect_dict: dict, diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index ca0eca86..3a5897d1 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, TypedDict from ...exceptions import KasaException from ...feature import Feature -from ..smartmodule import SmartModule +from ..smartmodule import SmartModule, allow_update_after if TYPE_CHECKING: from ..smartdevice import SmartDevice @@ -23,6 +23,7 @@ class LightTransition(SmartModule): REQUIRED_COMPONENT = "on_off_gradually" QUERY_GETTER_NAME = "get_on_off_gradually_info" + MINIMUM_UPDATE_INTERVAL_SECS = 60 MAXIMUM_DURATION = 60 # Key in sysinfo that indicates state can be retrieved from there. @@ -136,6 +137,7 @@ class LightTransition(SmartModule): "max_duration": off_max, } + @allow_update_after async def set_enabled(self, enable: bool): """Enable gradual on/off.""" if not self._supports_on_and_off: @@ -168,6 +170,7 @@ class LightTransition(SmartModule): # v3 added max_duration, we default to 60 when it's not available return self._on_state["max_duration"] + @allow_update_after async def set_turn_on_transition(self, seconds: int): """Set turn on transition in seconds. @@ -203,6 +206,7 @@ class LightTransition(SmartModule): # v3 added max_duration, we default to 60 when it's not available return self._off_state["max_duration"] + @allow_update_after async def set_turn_off_transition(self, seconds: int): """Set turn on transition in seconds. diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index c6596b96..3dfbd146 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time from typing import Any from ..device_type import DeviceType @@ -46,6 +47,7 @@ class SmartChildDevice(SmartDevice): req.update(mod_query) if req: self._last_update = await self.protocol.query(req) + self._last_update_time = time.time() @classmethod async def create(cls, parent: SmartDevice, child_info, child_components): diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index fcbc8a15..731789a0 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import logging +import time from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from typing import Any, cast @@ -18,6 +19,7 @@ from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( + ChildDevice, Cloud, DeviceModule, Firmware, @@ -35,6 +37,9 @@ _LOGGER = logging.getLogger(__name__) # same issue, homekit perhaps? NON_HUB_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] +# Modules that are called as part of the init procedure on first update +FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud} + # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. @@ -60,6 +65,7 @@ class SmartDevice(Device): self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} self._last_update = {} + self._last_update_time: float | None = None async def _initialize_children(self): """Initialize children for power strips.""" @@ -152,19 +158,15 @@ class SmartDevice(Device): if self.credentials is None and self.credentials_hash is None: raise AuthenticationError("Tapo plug requires authentication.") - if self._components_raw is None: + first_update = self._last_update_time is None + now = time.time() + self._last_update_time = now + + if first_update: await self._negotiate() await self._initialize_modules() - req: dict[str, Any] = {} - - # TODO: this could be optimized by constructing the query only once - for module in self._modules.values(): - req.update(module.query()) - - self._last_update = resp = await self.protocol.query(req) - - self._info = self._try_get_response(resp, "get_device_info") + resp = await self._modular_update(first_update, now) # Call child update which will only update module calls, info is updated # from get_child_device_list. update_children only affects hub devices, other @@ -172,18 +174,12 @@ class SmartDevice(Device): if update_children or self.device_type != DeviceType.Hub: for child in self._children.values(): await child.update() - if child_info := self._try_get_response(resp, "get_child_device_list", {}): + if child_info := self._try_get_response( + self._last_update, "get_child_device_list", {} + ): for info in child_info["child_device_list"]: self._children[info["device_id"]]._update_internal_state(info) - # Call handle update for modules that want to update internal data - errors = [] - for module_name, module in self._modules.items(): - if not self._handle_module_post_update_hook(module): - errors.append(module_name) - for error in errors: - self._modules.pop(error) - for child in self._children.values(): errors = [] for child_module_name, child_module in child._modules.items(): @@ -197,14 +193,18 @@ class SmartDevice(Device): if not self._features: await self._initialize_features() - _LOGGER.debug("Got an update: %s", self._last_update) + _LOGGER.debug( + "Update completed %s: %s", + self.host, + self._last_update if first_update else resp, + ) def _handle_module_post_update_hook(self, module: SmartModule) -> bool: try: module._post_update_hook() return True except Exception as ex: - _LOGGER.error( + _LOGGER.warning( "Error processing %s for device %s, module will be unavailable: %s", module.name, self.host, @@ -212,6 +212,100 @@ class SmartDevice(Device): ) return False + async def _modular_update( + self, first_update: bool, update_time: float + ) -> dict[str, Any]: + """Update the device with via the module queries.""" + req: dict[str, Any] = {} + # Keep a track of actual module queries so we can track the time for + # modules that do not need to be updated frequently + module_queries: list[SmartModule] = [] + mq = { + module: query + for module in self._modules.values() + if (query := module.query()) + } + for module, query in mq.items(): + if first_update and module.__class__ in FIRST_UPDATE_MODULES: + module._last_update_time = update_time + continue + if ( + not module.MINIMUM_UPDATE_INTERVAL_SECS + or not module._last_update_time + or (update_time - module._last_update_time) + >= module.MINIMUM_UPDATE_INTERVAL_SECS + ): + module_queries.append(module) + req.update(query) + + _LOGGER.debug( + "Querying %s for modules: %s", + self.host, + ", ".join(mod.name for mod in module_queries), + ) + + try: + resp = await self.protocol.query(req) + except Exception as ex: + resp = await self._handle_modular_update_error( + ex, first_update, ", ".join(mod.name for mod in module_queries), req + ) + + info_resp = self._last_update if first_update else resp + self._last_update.update(**resp) + self._info = self._try_get_response(info_resp, "get_device_info") + + # Call handle update for modules that want to update internal data + errors = [] + for module_name, module in self._modules.items(): + if not self._handle_module_post_update_hook(module): + errors.append(module_name) + for error in errors: + self._modules.pop(error) + + # Set the last update time for modules that had queries made. + for module in module_queries: + module._last_update_time = update_time + + return resp + + async def _handle_modular_update_error( + self, + ex: Exception, + first_update: bool, + module_names: str, + requests: dict[str, Any], + ) -> dict[str, Any]: + """Handle an error on calling module update. + + Will try to call all modules individually + and any errors such as timeouts will be set as a SmartErrorCode. + """ + msg_part = "on first update" if first_update else "after first update" + + _LOGGER.error( + "Error querying %s for modules '%s' %s: %s", + self.host, + module_names, + msg_part, + ex, + ) + responses = {} + for meth, params in requests.items(): + try: + resp = await self.protocol.query({meth: params}) + responses[meth] = resp[meth] + except Exception as iex: + _LOGGER.error( + "Error querying %s individually for module query '%s' %s: %s", + self.host, + meth, + msg_part, + iex, + ) + responses[meth] = SmartErrorCode.INTERNAL_QUERY_ERROR + return responses + async def _initialize_modules(self): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule @@ -229,8 +323,6 @@ class SmartDevice(Device): skip_parent_only_modules = True for mod in SmartModule.REGISTERED_MODULES.values(): - _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) - if ( skip_parent_only_modules and mod in NON_HUB_PARENT_ONLY_MODULES ) or mod.__name__ in child_modules_to_skip: @@ -240,7 +332,8 @@ class SmartDevice(Device): or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None ): _LOGGER.debug( - "Found required %s, adding %s to modules.", + "Device %s, found required %s, adding %s to modules.", + self.host, mod.REQUIRED_COMPONENT, mod.__name__, ) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index fb946a8b..f5f2c212 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -3,7 +3,10 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from collections.abc import Awaitable, Callable, Coroutine +from typing import TYPE_CHECKING, Any + +from typing_extensions import Concatenate, ParamSpec, TypeVar from ..exceptions import DeviceError, KasaException, SmartErrorCode from ..module import Module @@ -13,6 +16,27 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +_T = TypeVar("_T", bound="SmartModule") +_P = ParamSpec("_P") + + +def allow_update_after( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Define a wrapper to set _last_update_time to None. + + This will ensure that a module is updated in the next update cycle after + a value has been changed. + """ + + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + try: + await func(self, *args, **kwargs) + finally: + self._last_update_time = None + + return _async_wrap + class SmartModule(Module): """Base class for SMART modules.""" @@ -27,9 +51,12 @@ class SmartModule(Module): REGISTERED_MODULES: dict[str, type[SmartModule]] = {} + MINIMUM_UPDATE_INTERVAL_SECS = 0 + def __init__(self, device: SmartDevice, module: str): self._device: SmartDevice super().__init__(device, module) + self._last_update_time: float | None = None def __init_subclass__(cls, **kwargs): name = getattr(cls, "NAME", cls.__name__) diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 3085714c..0c95325a 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -73,18 +73,32 @@ class SmartProtocol(BaseProtocol): return await self._execute_query( request, retry_count=retry, iterate_list_pages=True ) - except _ConnectionError as sdex: + except _ConnectionError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a connection error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise sdex + raise ex continue - except AuthenticationError as auex: + except AuthenticationError as ex: await self._transport.reset() _LOGGER.debug( - "Unable to authenticate with %s, not retrying", self._host + "Unable to authenticate with %s, not retrying: %s", self._host, ex ) - raise auex + raise ex except _RetryableError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a retryable error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) @@ -92,6 +106,13 @@ class SmartProtocol(BaseProtocol): await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) continue except TimeoutError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a timeout error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) @@ -130,20 +151,21 @@ class SmartProtocol(BaseProtocol): self._handle_response_error_code(resp, method, raise_on_error=False) multi_result[method] = resp["result"] return multi_result - for i in range(0, end, step): + + for batch_num, i in enumerate(range(0, end, step)): requests_step = multi_requests[i : i + step] smart_params = {"requests": requests_step} smart_request = self.get_smart_request(smart_method, smart_params) + batch_name = f"multi-request-batch-{batch_num+1}-of-{int(end/step)+1}" if debug_enabled: _LOGGER.debug( - "%s multi-request-batch-%s >> %s", + "%s %s >> %s", self._host, - i + 1, + batch_name, pf(smart_request), ) response_step = await self._transport.send(smart_request) - batch_name = f"multi-request-batch-{i+1}" if debug_enabled: _LOGGER.debug( "%s %s << %s", @@ -271,7 +293,9 @@ class SmartProtocol(BaseProtocol): try: error_code = SmartErrorCode.from_int(error_code_raw) except ValueError: - _LOGGER.warning("Received unknown error code: %s", error_code_raw) + _LOGGER.warning( + "Device %s received unknown error code: %s", self._host, error_code_raw + ) error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR if error_code is SmartErrorCode.SUCCESS: diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 44fabc71..99e2ddb9 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -3,10 +3,12 @@ from __future__ import annotations import logging +import time from typing import Any, cast from unittest.mock import patch import pytest +from freezegun.api import FrozenDateTimeFactory from pytest_mock import MockerFixture from kasa import Device, KasaException, Module @@ -54,6 +56,8 @@ async def test_initial_update(dev: SmartDevice, mocker: MockerFixture): dev._modules = {} dev._features = {} dev._children = {} + dev._last_update = {} + dev._last_update_time = None negotiate = mocker.spy(dev, "_negotiate") initialize_modules = mocker.spy(dev, "_initialize_modules") @@ -109,6 +113,9 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): """Test that the regular update uses queries from all supported modules.""" # We need to have some modules initialized by now assert dev._modules + # Reset last update so all modules will query + for mod in dev._modules.values(): + mod._last_update_time = None device_queries: dict[SmartDevice, dict[str, Any]] = {} for mod in dev._modules.values(): @@ -139,7 +146,7 @@ async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): assert dev._modules critical_modules = {Module.DeviceModule, Module.ChildDevice} - not_disabling_modules = {Module.Firmware, Module.Cloud} + not_disabling_modules = {Module.Cloud} new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) @@ -204,6 +211,123 @@ async def test_update_module_errors(dev: SmartDevice, mocker: MockerFixture): ), f"{modname} present {mod_present} when no_disable {no_disable}" +@device_smart +async def test_update_module_update_delays( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +): + """Test that modules that disabled / removed on query failures.""" + # We need to have some modules initialized by now + assert dev._modules + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + await new_dev.update() + first_update_time = time.time() + assert new_dev._last_update_time == first_update_time + for module in new_dev.modules.values(): + if module.query(): + assert module._last_update_time == first_update_time + + seconds = 0 + tick = 30 + while seconds <= 180: + seconds += tick + freezer.tick(tick) + + now = time.time() + await new_dev.update() + for module in new_dev.modules.values(): + mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS + if module.query(): + expected_update_time = ( + now if mod_delay == 0 else now - (seconds % mod_delay) + ) + + assert ( + module._last_update_time == expected_update_time + ), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" + + +@pytest.mark.parametrize( + ("first_update"), + [ + pytest.param(True, id="First update true"), + pytest.param(False, id="First update false"), + ], +) +@device_smart +async def test_update_module_query_errors( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, + first_update, +): + """Test that modules that disabled / removed on query failures.""" + # We need to have some modules initialized by now + assert dev._modules + + first_update_queries = {"get_device_info", "get_connect_cloud_state"} + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + not_disabling_modules = {Module.Cloud} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + if not first_update: + await new_dev.update() + freezer.tick( + max(module.MINIMUM_UPDATE_INTERVAL_SECS for module in dev._modules.values()) + ) + + module_queries = { + modname: q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + + async def _query(request, *args, **kwargs): + if ( + "component_nego" in request + or "get_child_device_component_list" in request + or "control_child" in request + ): + return await dev.protocol._query(request, *args, **kwargs) + if len(request) == 1 and "get_device_info" in request: + return await dev.protocol._query(request, *args, **kwargs) + + raise TimeoutError("Dummy timeout") + + from kasa.smartprotocol import _ChildProtocolWrapper + + child_protocols = { + cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol + for child in dev.children + } + + async def _child_query(self, request, *args, **kwargs): + return await child_protocols[self._device_id]._query(request, *args, **kwargs) + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + # children not created yet so cannot patch.object + mocker.patch("kasa.smartprotocol._ChildProtocolWrapper.query", new=_child_query) + + await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" + assert msg in caplog.text + for modname in module_queries: + no_disable = modname in not_disabling_modules + mod_present = modname in new_dev._modules + assert ( + mod_present is no_disable + ), f"{modname} present {mod_present} when no_disable {no_disable}" + for mod_query in module_queries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + + async def test_get_modules(): """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 71125ca8..204d0c7f 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -66,7 +66,7 @@ async def test_smart_device_unknown_errors( assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR send_mock.assert_called_once() - assert f"Received unknown error code: {error_code}" in caplog.text + assert f"received unknown error code: {error_code}" in caplog.text @pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) From 377fa06d392d08ace2ad3dbf7411c8088f3d4510 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:05:40 +0100 Subject: [PATCH 5/9] Use first known thermostat state as main state (pick #1054) (#1057) Pick commit a0440635260abe2d8b6719ff2260510d2fed9b3f from #1054 Instead of trying to use the first state when multiple are reported, iterate over the known states and pick the first matching. This will fix an issue where the device reports extra states (like `low_battery`) while having a known mode active. Related to home-assistant/core#121335 --- kasa/smart/modules/temperaturecontrol.py | 43 ++++++++++--------- .../smart/modules/test_temperaturecontrol.py | 10 ++++- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/kasa/smart/modules/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py index dcd0da72..00afe5b5 100644 --- a/kasa/smart/modules/temperaturecontrol.py +++ b/kasa/smart/modules/temperaturecontrol.py @@ -4,15 +4,10 @@ from __future__ import annotations import logging from enum import Enum -from typing import TYPE_CHECKING from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - _LOGGER = logging.getLogger(__name__) @@ -31,11 +26,11 @@ class TemperatureControl(SmartModule): REQUIRED_COMPONENT = "temp_control" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features after the initial update.""" self._add_feature( Feature( - device, + self._device, id="target_temperature", name="Target temperature", container=self, @@ -50,7 +45,7 @@ class TemperatureControl(SmartModule): # TODO: this might belong into its own module, temperature_correction? self._add_feature( Feature( - device, + self._device, id="temperature_offset", name="Temperature offset", container=self, @@ -65,7 +60,7 @@ class TemperatureControl(SmartModule): self._add_feature( Feature( - device, + self._device, id="state", name="State", container=self, @@ -78,7 +73,7 @@ class TemperatureControl(SmartModule): self._add_feature( Feature( - device, + self._device, id="thermostat_mode", name="Thermostat mode", container=self, @@ -109,23 +104,24 @@ class TemperatureControl(SmartModule): if self._device.sys_info.get("frost_protection_on", False): return ThermostatState.Off - states = self._device.sys_info["trv_states"] + states = self.states # If the states is empty, the device is idling if not states: return ThermostatState.Idle + # Discard known extra states, and report on unknown extra states + states.discard("low_battery") if len(states) > 1: - _LOGGER.warning( - "Got multiple states (%s), using the first one: %s", states, states[0] - ) + _LOGGER.warning("Got multiple states: %s", states) - state = states[0] - try: - return ThermostatState(state) - except: # noqa: E722 - _LOGGER.warning("Got unknown state: %s", state) - return ThermostatState.Unknown + # Return the first known state + for state in ThermostatState: + if state.value in states: + return state + + _LOGGER.warning("Got unknown state: %s", states) + return ThermostatState.Unknown @property def allowed_temperature_range(self) -> tuple[int, int]: @@ -147,6 +143,11 @@ class TemperatureControl(SmartModule): """Return target temperature.""" return self._device.sys_info["target_temp"] + @property + def states(self) -> set: + """Return thermostat states.""" + return set(self._device.sys_info["trv_states"]) + async def set_target_temperature(self, target: float): """Set target temperature.""" if ( diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 16e01ed2..90f91216 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -94,7 +94,7 @@ async def test_temperature_offset(dev): ), pytest.param( ThermostatState.Heating, - [ThermostatState.Heating], + ["heating"], False, id="heating is heating", ), @@ -135,3 +135,11 @@ async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog): temp_module.data["trv_states"] = states assert temp_module.mode is mode assert msg in caplog.text + + +@thermostats_smart +async def test_thermostat_heating_with_low_battery(dev): + """Test that mode is reported correctly with extra states.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + temp_module.data["trv_states"] = ["low_battery", "heating"] + assert temp_module.mode is ThermostatState.Heating From 448efd7e4ce362f3602745cd24d34ab20f6c02b3 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:30:14 +0100 Subject: [PATCH 6/9] Prepare 0.7.0.4 (#1059) ## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-011) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) Critical bugfixes for issues with P100s and thermostats. **Fixed bugs:** - Use first known thermostat state as main state (pick #1054) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) - Defer module updates for less volatile modules (pick 1052) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) --- CHANGELOG.md | 13 ++++++++++++- pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2466df..77e5c395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-011) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) + +Critical bugfixes for issues with P100s and thermostats. + +**Fixed bugs:** + +- Use first known thermostat state as main state (pick #1054) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) +- Defer module updates for less volatile modules (pick 1052) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) + ## [0.7.0.3](https://github.com/python-kasa/python-kasa/tree/0.7.0.3) (2024-07-04) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) @@ -9,7 +20,7 @@ Partially fixes light preset module errors with L920 and L930. **Fixed bugs:** -Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) +- Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) ## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01) diff --git a/pyproject.toml b/pyproject.toml index fb8df913..8b9f73eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.3" +version = "0.7.0.4" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"] From a97d2c92bbba415dbd948cfb1ec7869036073848 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 08:28:11 +0100 Subject: [PATCH 7/9] Only refresh smart LightEffect module daily (#1064) Fixes an issue with L530 bulbs on HW version 1.0 whereby the light effect query causes the device to crash with JSON_ENCODE_FAIL_ERROR after approximately 60 calls. --- kasa/smart/modules/lighteffect.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 5f589d6d..699c679b 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -17,7 +17,7 @@ class LightEffect(SmartModule, SmartLightEffect): REQUIRED_COMPONENT = "light_effect" QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" - MINIMUM_UPDATE_INTERVAL_SECS = 60 + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 AVAILABLE_BULB_EFFECTS = { "L1": "Party", "L2": "Relax", @@ -74,6 +74,7 @@ class LightEffect(SmartModule, SmartLightEffect): """Return effect name.""" return self._effect + @allow_update_after async def set_effect( self, effect: str, From c4a9a19d5b98c05ac4d9019d41641433c959c8a2 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:57:09 +0100 Subject: [PATCH 8/9] Redact sensitive info from debug logs (#1069) Redacts sensitive data when debug logging device responses such as mac, location and usernames --- kasa/discover.py | 40 ++++++++++++++++--- kasa/iotprotocol.py | 39 ++++++++++++++++++- kasa/klaptransport.py | 9 +---- kasa/protocol.py | 41 +++++++++++++++++++ kasa/smart/smartdevice.py | 8 ++-- kasa/smartprotocol.py | 32 +++++++++++++-- kasa/tests/discovery_fixtures.py | 22 ++++++----- kasa/tests/test_discovery.py | 36 +++++++++++++++++ kasa/tests/test_protocol.py | 67 ++++++++++++++++++++++++++++++++ kasa/tests/test_smartprotocol.py | 35 +++++++++++++++++ kasa/xortransport.py | 10 ++--- 11 files changed, 300 insertions(+), 39 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index b9e34ee2..c69933a9 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -87,7 +87,8 @@ import ipaddress import logging import socket from collections.abc import Awaitable -from typing import Callable, Dict, Optional, Type, cast +from pprint import pformat as pf +from typing import Any, Callable, Dict, Optional, Type, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -112,8 +113,10 @@ from kasa.exceptions import ( UnsupportedDeviceError, ) from kasa.iot.iotdevice import IotDevice +from kasa.iotprotocol import REDACTORS as IOT_REDACTORS from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads +from kasa.protocol import mask_mac, redact_data from kasa.xortransport import XorEncryption _LOGGER = logging.getLogger(__name__) @@ -123,6 +126,12 @@ OnDiscoveredCallable = Callable[[Device], Awaitable[None]] OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Awaitable[None]] DeviceDict = Dict[str, Device] +NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": lambda x: "REDACTED_" + x[9::], + "mac": mask_mac, +} + class _DiscoverProtocol(asyncio.DatagramProtocol): """Implementation of the discovery protocol handler. @@ -293,6 +302,8 @@ class Discover: DISCOVERY_PORT_2 = 20002 DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") + _redact_data = True + @staticmethod async def discover( *, @@ -484,7 +495,9 @@ class Discover: f"Unable to read response from device: {config.host}: {ex}" ) from ex - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + if _LOGGER.isEnabledFor(logging.DEBUG): + data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device_class = cast(Type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) @@ -504,6 +517,7 @@ class Discover: config: DeviceConfig, ) -> Device: """Get SmartDevice from the new 20002 response.""" + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) try: info = json_loads(data[16:]) except Exception as ex: @@ -514,9 +528,17 @@ class Discover: try: discovery_result = DiscoveryResult(**info["result"]) except ValidationError as ex: - _LOGGER.debug( - "Unable to parse discovery from device %s: %s", config.host, info - ) + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug( + "Unable to parse discovery from device %s: %s", + config.host, + pf(data), + ) raise UnsupportedDeviceError( f"Unable to parse discovery from device: {config.host}: {ex}" ) from ex @@ -551,7 +573,13 @@ class Discover: discovery_result=discovery_result.get_dict(), ) - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) device = device_class(config.host, protocol=protocol) di = discovery_result.get_dict() diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 1795566e..91edb032 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -4,6 +4,8 @@ from __future__ import annotations import asyncio import logging +from pprint import pformat as pf +from typing import Any, Callable from .deviceconfig import DeviceConfig from .exceptions import ( @@ -14,11 +16,26 @@ from .exceptions import ( _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport +from .protocol import BaseProtocol, BaseTransport, mask_mac, redact_data from .xortransport import XorEncryption, XorTransport _LOGGER = logging.getLogger(__name__) +REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "latitude": lambda x: 0, + "longitude": lambda x: 0, + "latitude_i": lambda x: 0, + "longitude_i": lambda x: 0, + "deviceId": lambda x: "REDACTED_" + x[9::], + "id": lambda x: "REDACTED_" + x[9::], + "alias": lambda x: "#MASKED_NAME#" if x else "", + "mac": mask_mac, + "mic_mac": mask_mac, + "ssid": lambda x: "#MASKED_SSID#" if x else "", + "oemId": lambda x: "REDACTED_" + x[9::], + "username": lambda _: "user@example.com", # cnCloud +} + class IotProtocol(BaseProtocol): """Class for the legacy TPLink IOT KASA Protocol.""" @@ -34,6 +51,7 @@ class IotProtocol(BaseProtocol): super().__init__(transport=transport) self._query_lock = asyncio.Lock() + self._redact_data = True async def query(self, request: str | dict, retry_count: int = 3) -> dict: """Query the device retrying for retry_count on failure.""" @@ -85,7 +103,24 @@ class IotProtocol(BaseProtocol): raise KasaException("Query reached somehow to unreachable") async def _execute_query(self, request: str, retry_count: int) -> dict: - return await self._transport.send(request) + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + + if debug_enabled: + _LOGGER.debug( + "%s >> %s", + self._host, + request, + ) + resp = await self._transport.send(request) + + if debug_enabled: + data = redact_data(resp, REDACTORS) if self._redact_data else resp + _LOGGER.debug( + "%s << %s", + self._host, + pf(data), + ) + return resp async def close(self) -> None: """Close the underlying transport.""" diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 3a1eb336..a3a20000 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -50,7 +50,6 @@ import logging import secrets import struct import time -from pprint import pformat as pf from typing import Any, cast from cryptography.hazmat.primitives import padding @@ -349,7 +348,7 @@ class KlapTransport(BaseTransport): + f"request with seq {seq}" ) else: - _LOGGER.debug("Query posted " + msg) + _LOGGER.debug("Device %s query posted %s", self._host, msg) # Check for mypy if self._encryption_session is not None: @@ -357,11 +356,7 @@ class KlapTransport(BaseTransport): json_payload = json_loads(decrypted_response) - _LOGGER.debug( - "%s << %s", - self._host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(json_payload), - ) + _LOGGER.debug("Device %s query response received", self._host) return json_payload diff --git a/kasa/protocol.py b/kasa/protocol.py index c7d505b8..ad0432dd 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -18,6 +18,7 @@ import hashlib import logging import struct from abc import ABC, abstractmethod +from typing import Any, Callable, TypeVar, cast # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -28,6 +29,46 @@ _LOGGER = logging.getLogger(__name__) _NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} _UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") +_T = TypeVar("_T") + + +def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> _T: + """Redact sensitive data for logging.""" + if not isinstance(data, (dict, list)): + return data + + if isinstance(data, list): + return cast(_T, [redact_data(val, redactors) for val in data]) + + redacted = {**data} + + for key, value in redacted.items(): + if value is None: + continue + if isinstance(value, str) and not value: + continue + if key in redactors: + if redactor := redactors[key]: + try: + redacted[key] = redactor(value) + except: # noqa: E722 + redacted[key] = "**REDACTEX**" + else: + redacted[key] = "**REDACTED**" + elif isinstance(value, dict): + redacted[key] = redact_data(value, redactors) + elif isinstance(value, list): + redacted[key] = [redact_data(item, redactors) for item in value] + + return cast(_T, redacted) + + +def mask_mac(mac: str) -> str: + """Return mac address with last two octects blanked.""" + delim = ":" if ":" in mac else "-" + rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000")) + return f"{mac[:8]}{delim}{rest}" + def md5(payload: bytes) -> bytes: """Return the MD5 hash of the payload.""" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 731789a0..b183f8db 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -193,11 +193,9 @@ class SmartDevice(Device): if not self._features: await self._initialize_features() - _LOGGER.debug( - "Update completed %s: %s", - self.host, - self._last_update if first_update else resp, - ) + if _LOGGER.isEnabledFor(logging.DEBUG): + updated = self._last_update if first_update else resp + _LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys())) def _handle_module_post_update_hook(self, module: SmartModule) -> bool: try: diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index 0c95325a..24203007 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -12,7 +12,7 @@ import logging import time import uuid from pprint import pformat as pf -from typing import Any +from typing import Any, Callable from .exceptions import ( SMART_AUTHENTICATION_ERRORS, @@ -26,10 +26,31 @@ from .exceptions import ( _RetryableError, ) from .json import dumps as json_dumps -from .protocol import BaseProtocol, BaseTransport, md5 +from .protocol import BaseProtocol, BaseTransport, mask_mac, md5, redact_data _LOGGER = logging.getLogger(__name__) +REDACTORS: dict[str, Callable[[Any], Any] | None] = { + "latitude": lambda x: 0, + "longitude": lambda x: 0, + "la": lambda x: 0, # lat on ks240 + "lo": lambda x: 0, # lon on ks240 + "device_id": lambda x: "REDACTED_" + x[9::], + "parent_device_id": lambda x: "REDACTED_" + x[9::], # Hub attached children + "original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children + "nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "", + "mac": mask_mac, + "ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "", + "bssid": lambda _: "000000000000", + "oem_id": lambda x: "REDACTED_" + x[9::], + "setup_code": None, # matter + "setup_payload": None, # matter + "mfi_setup_code": None, # mfi_ for homekit + "mfi_setup_id": None, + "mfi_token_token": None, + "mfi_token_uuid": None, +} + class SmartProtocol(BaseProtocol): """Class for the new TPLink SMART protocol.""" @@ -50,6 +71,7 @@ class SmartProtocol(BaseProtocol): self._multi_request_batch_size = ( self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE ) + self._redact_data = True def get_smart_request(self, method, params=None) -> str: """Get a request message as a string.""" @@ -167,11 +189,15 @@ class SmartProtocol(BaseProtocol): ) response_step = await self._transport.send(smart_request) if debug_enabled: + if self._redact_data: + data = redact_data(response_step, REDACTORS) + else: + data = response_step _LOGGER.debug( "%s %s << %s", self._host, batch_name, - pf(response_step), + pf(data), ) try: self._handle_response_error_code(response_step, batch_name) diff --git a/kasa/tests/discovery_fixtures.py b/kasa/tests/discovery_fixtures.py index 1ba24bf1..1451a5ca 100644 --- a/kasa/tests/discovery_fixtures.py +++ b/kasa/tests/discovery_fixtures.py @@ -90,21 +90,26 @@ def create_discovery_mock(ip: str, fixture_data: dict): query_data: dict device_type: str encrypt_type: str - _datagram: bytes login_version: int | None = None port_override: int | None = None + @property + def _datagram(self) -> bytes: + if self.default_port == 9999: + return XorEncryption.encrypt(json_dumps(self.discovery_data))[4:] + else: + return ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(self.discovery_data).encode() + ) + if "discovery_result" in fixture_data: - discovery_data = {"result": fixture_data["discovery_result"]} + discovery_data = {"result": fixture_data["discovery_result"].copy()} device_type = fixture_data["discovery_result"]["device_type"] encrypt_type = fixture_data["discovery_result"]["mgt_encrypt_schm"][ "encrypt_type" ] login_version = fixture_data["discovery_result"]["mgt_encrypt_schm"].get("lv") - datagram = ( - b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" - + json_dumps(discovery_data).encode() - ) dm = _DiscoveryMock( ip, 80, @@ -113,16 +118,14 @@ def create_discovery_mock(ip: str, fixture_data: dict): fixture_data, device_type, encrypt_type, - datagram, login_version, ) else: sys_info = fixture_data["system"]["get_sysinfo"] - discovery_data = {"system": {"get_sysinfo": sys_info}} + discovery_data = {"system": {"get_sysinfo": sys_info.copy()}} device_type = sys_info.get("mic_type") or sys_info.get("type") encrypt_type = "XOR" login_version = None - datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:] dm = _DiscoveryMock( ip, 9999, @@ -131,7 +134,6 @@ def create_discovery_mock(ip: str, fixture_data: dict): fixture_data, device_type, encrypt_type, - datagram, login_version, ) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index b657b12e..19eef1f7 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -2,6 +2,7 @@ # ruff: noqa: S106 import asyncio +import logging import re import socket from unittest.mock import MagicMock @@ -565,3 +566,38 @@ async def test_do_discover_external_cancel(mocker): with pytest.raises(asyncio.TimeoutError): async with asyncio_timeout(0): await dp.wait_for_discovery_to_complete() + + +async def test_discovery_redaction(discovery_mock, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + mac = "12:34:56:78:9A:BC" + + if discovery_mock.default_port == 9999: + sysinfo = discovery_mock.discovery_data["system"]["get_sysinfo"] + if "mac" in sysinfo: + sysinfo["mac"] = mac + elif "mic_mac" in sysinfo: + sysinfo["mic_mac"] = mac + else: + discovery_mock.discovery_data["result"]["mac"] = mac + + # Info no message logging + caplog.set_level(logging.INFO) + await Discover.discover() + + assert mac not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + Discover._redact_data = False + await Discover.discover() + assert mac in caplog.text + + # Debug redaction + caplog.clear() + Discover._redact_data = True + await Discover.discover() + assert mac not in caplog.text + assert "12:34:56:00:00:00" in caplog.text diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index e0ddbbb4..57390b74 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -8,9 +8,12 @@ import os import pkgutil import struct import sys +from typing import cast import pytest +from kasa.iot import IotDevice + from ..aestransport import AesTransport from ..credentials import Credentials from ..deviceconfig import DeviceConfig @@ -20,8 +23,12 @@ from ..klaptransport import KlapTransport, KlapTransportV2 from ..protocol import ( BaseProtocol, BaseTransport, + mask_mac, + redact_data, ) from ..xortransport import XorEncryption, XorTransport +from .conftest import device_iot +from .fakeprotocol_iot import FakeIotTransport @pytest.mark.parametrize( @@ -614,3 +621,63 @@ def test_deprecated_protocol(): host = "127.0.0.1" proto = TPLinkSmartHomeProtocol(host=host) assert proto.config.host == host + + +@device_iot +async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + device_id = "123456789ABCDEF" + cast(FakeIotTransport, dev.protocol._transport).proto["system"]["get_sysinfo"][ + "deviceId" + ] = device_id + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG, logger="kasa") + # The fake iot protocol also logs so disable it + test_logger = logging.getLogger("kasa.tests.fakeprotocol_iot") + test_logger.setLevel(logging.INFO) + + # Debug no redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = True + await dev.update() + assert device_id not in caplog.text + assert "REDACTED_" + device_id[9::] in caplog.text + + +async def test_redact_data(): + """Test redact data function.""" + data = { + "device_id": "123456789ABCDEF", + "owner": "0987654", + "mac": "12:34:56:78:90:AB", + "ip": "192.168.1", + "no_val": None, + } + excpected_data = { + "device_id": "REDACTED_ABCDEF", + "owner": "**REDACTED**", + "mac": "12:34:56:00:00:00", + "ip": "**REDACTEX**", + "no_val": None, + } + REDACTORS = { + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": None, + "mac": mask_mac, + "ip": lambda x: "127.0.0." + x.split(".")[3], + } + + redacted_data = redact_data(data, REDACTORS) + + assert redacted_data == excpected_data diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py index 204d0c7f..058bfc3b 100644 --- a/kasa/tests/test_smartprotocol.py +++ b/kasa/tests/test_smartprotocol.py @@ -1,8 +1,11 @@ import logging +from typing import cast import pytest import pytest_mock +from kasa.smart import SmartDevice + from ..exceptions import ( SMART_RETRYABLE_ERRORS, DeviceError, @@ -10,6 +13,7 @@ from ..exceptions import ( SmartErrorCode, ) from ..smartprotocol import SmartProtocol, _ChildProtocolWrapper +from .conftest import device_smart from .fakeprotocol_smart import FakeSmartTransport DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -409,3 +413,34 @@ async def test_incomplete_list(mocker, caplog): "Device 127.0.0.123 returned empty results list for method get_preset_rules" in caplog.text ) + + +@device_smart +async def test_smart_queries_redaction( + dev: SmartDevice, caplog: pytest.LogCaptureFixture +): + """Test query sensitive info redaction.""" + device_id = "123456789ABCDEF" + cast(FakeSmartTransport, dev.protocol._transport).info["get_device_info"][ + "device_id" + ] = device_id + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + dev.protocol._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + dev.protocol._redact_data = True + await dev.update() + assert device_id not in caplog.text + assert "REDACTED_" + device_id[9::] in caplog.text diff --git a/kasa/xortransport.py b/kasa/xortransport.py index e9686453..75572bb0 100644 --- a/kasa/xortransport.py +++ b/kasa/xortransport.py @@ -19,7 +19,6 @@ import logging import socket import struct from collections.abc import Generator -from pprint import pformat as pf # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout @@ -78,9 +77,8 @@ class XorTransport(BaseTransport): """Execute a query on the device and wait for the response.""" assert self.writer is not None # noqa: S101 assert self.reader is not None # noqa: S101 - debug_log = _LOGGER.isEnabledFor(logging.DEBUG) - if debug_log: - _LOGGER.debug("%s >> %s", self._host, request) + _LOGGER.debug("Device %s sending query %s", self._host, request) + self.writer.write(XorEncryption.encrypt(request)) await self.writer.drain() @@ -90,8 +88,8 @@ class XorTransport(BaseTransport): buffer = await self.reader.readexactly(length) response = XorEncryption.decrypt(buffer) json_payload = json_loads(response) - if debug_log: - _LOGGER.debug("%s << %s", self._host, pf(json_payload)) + + _LOGGER.debug("Device %s query response received", self._host) return json_payload From 82cff1346d7846dec6c78628e0bf254e72113ec3 Mon Sep 17 00:00:00 2001 From: sdb9696 Date: Thu, 18 Jul 2024 08:40:35 +0100 Subject: [PATCH 9/9] Prepare 0.7.0.5 --- CHANGELOG.md | 16 +++++++++++++++- pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e5c395..73ab6cd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog -## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-011) +## [0.7.0.5](https://github.com/python-kasa/python-kasa/tree/0.7.0.5) (2024-07-18) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.4...0.7.0.5) + +A critical bugfix for an issue with some L530 Series devices and a redactor for sensitive info from debug logs. + +**Fixed bugs:** + +- Only refresh smart LightEffect module daily [\#1064](https://github.com/python-kasa/python-kasa/pull/1064) + +**Project maintenance:** + +- Redact sensitive info from debug logs [\#1069](https://github.com/python-kasa/python-kasa/pull/1069) + +## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-11) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) diff --git a/pyproject.toml b/pyproject.toml index 8b9f73eb..141edf07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.7.0.4" +version = "0.7.0.5" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"]