From e29864bfdc9aa520850c92517e5e0c94533b776d Mon Sep 17 00:00:00 2001 From: hugaraujo <161214313+hugaraujo@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:33:29 +0100 Subject: [PATCH] Keep multi requests for smartcam devices after a timeout (#1802) Smartcam devices do not support sending individual requests, but respond always with `PROTOCOL_FORMAT_ERROR (-40210)`. This commit forces them to always use the multi-request format for requests even after receiving an error where we would otherwise fallback to individual requests. --- kasa/protocols/smartcamprotocol.py | 2 ++ kasa/protocols/smartprotocol.py | 5 ++++ tests/protocols/test_smartprotocol.py | 33 +++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/kasa/protocols/smartcamprotocol.py b/kasa/protocols/smartcamprotocol.py index 9bf40f7d..247bd754 100644 --- a/kasa/protocols/smartcamprotocol.py +++ b/kasa/protocols/smartcamprotocol.py @@ -49,6 +49,8 @@ class SingleRequest: class SmartCamProtocol(SmartProtocol): """Class for SmartCam Protocol.""" + REQUIRES_MULTIREQUESTS = True + def _get_list_request( self, method: str, params: dict | None, start_index: int ) -> dict: diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py index 364e795c..e10a8538 100644 --- a/kasa/protocols/smartprotocol.py +++ b/kasa/protocols/smartprotocol.py @@ -103,6 +103,8 @@ class SmartProtocol(BaseProtocol): BACKOFF_SECONDS_AFTER_TIMEOUT = 1 DEFAULT_MULTI_REQUEST_BATCH_SIZE = 5 + # True if the device rejects single requests, so batching is never disabled. + REQUIRES_MULTIREQUESTS = False def __init__( self, @@ -248,6 +250,8 @@ class SmartProtocol(BaseProtocol): try: response_step = await self._transport.send(smart_request) except TimeoutError as ex: + if self.REQUIRES_MULTIREQUESTS: + raise # P300 does not respond to some batched requests (e.g. both child # list requests together) so disable batching. Batch size is never # 1 here as that case sends single requests above. @@ -278,6 +282,7 @@ class SmartProtocol(BaseProtocol): SmartErrorCode.INTERNAL_UNKNOWN_ERROR, } and self._multi_request_batch_size != 1 + and not self.REQUIRES_MULTIREQUESTS ): self._multi_request_batch_size = 1 raise _RetryableError( diff --git a/tests/protocols/test_smartprotocol.py b/tests/protocols/test_smartprotocol.py index 608be537..42012481 100644 --- a/tests/protocols/test_smartprotocol.py +++ b/tests/protocols/test_smartprotocol.py @@ -333,6 +333,39 @@ async def test_smart_device_multiple_request_child_lists_timeout( assert send_mock.call_count == 3 +async def test_smartcam_multiple_request_timeout_keeps_batching( + dummy_protocol: SmartProtocol, mocker: MockerFixture +) -> None: + """Test that a timeout does not disable batching for smartcam devices.""" + protocol = SmartCamProtocol(transport=dummy_protocol._transport) + requests = {f"get_method_{i}": {"foo": "bar"} for i in range(3)} + batch_response = { + "result": { + "responses": [ + {"method": method, "result": {"great": "success"}, "error_code": 0} + for method in requests + ] + }, + "error_code": 0, + } + + send_mock = mocker.patch.object( + protocol._transport, + "send", + side_effect=[TimeoutError("Simulated timeout"), batch_response], + ) + mocker.patch("asyncio.sleep") + batch_size = protocol._multi_request_batch_size + assert batch_size > 1 + resp = await protocol.query(requests, retry_count=1) + assert resp == {method: {"great": "success"} for method in requests} + assert protocol._multi_request_batch_size == batch_size + # The timed out batch + the retried batch, no single requests + assert send_mock.call_count == 2 + for call in send_mock.call_args_list: + assert json.loads(call.args[0])["method"] == "multipleRequest" + + async def test_childdevicewrapper_unwrapping( dummy_protocol: SmartProtocol, mocker: MockerFixture ) -> None: