From b0d77b35a2cdeed8dc26d68ce8b098abe8761c3f Mon Sep 17 00:00:00 2001 From: FreeKC Date: Sat, 3 Oct 2026 18:19:40 +0200 Subject: [PATCH] SslAesTransport: handshake again when a camera answers 401 to passthrough (#1771) Some cameras (C220 and C100 on the 1.4.4 line in those reports) drop the session about every ten minutes and answer the next `securePassthrough` with HTTP 401 and `-40421` in the body. With this change a 401 on passthrough marks the transport as needing a handshake and raises `_RetryableError`, so the protocol logs in again and retries the same request. --------- Co-authored-by: freeKC <7538438+freeKC@users.noreply.github.com> --- kasa/transports/sslaestransport.py | 14 ++++++++++++++ tests/transports/test_sslaestransport.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index a517ca48..ba7e8322 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -274,6 +274,20 @@ class SslAesTransport(BaseTransport): _LOGGER.debug(msg) raise _RetryableError(msg) + # Some devices answer 401 when the session has expired and they + # require a new handshake: reauthenticate and retry the request. + if status_code == 401: + _LOGGER.debug( + "Device %s replied with status 401 to passthrough, " + "session expired, handshake required", + self._host, + ) + self._state = TransportState.HANDSHAKE_REQUIRED + raise _RetryableError( + f"{self._host} responded with status 401 to passthrough, " + "session expired" + ) + if status_code != 200: raise KasaException( f"{self._host} responded with an unexpected " diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py index eeaebfea..3142a2f0 100644 --- a/tests/transports/test_sslaestransport.py +++ b/tests/transports/test_sslaestransport.py @@ -797,3 +797,24 @@ class MockSslAesDevice: def put_next_response(self, request: dict | bytes) -> None: self._next_responses.append(request) + + +async def test_passthrough_401_requires_new_handshake(mocker): + """A 401 on passthrough means the session expired: retryable, new handshake.""" + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + request = {"method": "getDeviceInfo", "params": None} + + await transport.perform_handshake() + assert transport._state is TransportState.ESTABLISHED + + mock_ssl_aes_device.status_code = 401 + with pytest.raises(_RetryableError, match="session expired"): + await transport.send(json_dumps(request)) + assert transport._state is TransportState.HANDSHAKE_REQUIRED