8000 Handle smartcam device blocked response (#1393) · python-kasa/python-kasa@93ca3ad · GitHub
[go: up one dir, main page]

Skip to content

Commit 93ca3ad

Browse files
authored
Handle smartcam device blocked response (#1393)
Devices that have failed authentication multiple times due to bad credentials go into a blocked state for 30 mins. Handle that as a different error type instead of treating it as a normal `AuthenticationError`.
1 parent 296af31 commit 93ca3ad

File tree

2 files changed

+57
-1
lines changed

2 files changed

+57
-1
lines changed

kasa/transports/sslaestransport.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,19 @@ def _get_response_error(self, resp_dict: Any) -> SmartErrorCode:
160160
error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR
161161
return error_code
162162

163+
def _get_response_inner_error(self, resp_dict: Any) -> SmartErrorCode | None:
164+
error_code_raw = resp_dict.get("data", {}).get("code")
165+
if error_code_raw is None:
166+
return None
167+
try:
168+
error_code = SmartErrorCode.from_int(error_code_raw)
169+
except ValueError:
170+
_LOGGER.warning(
171+
"Device %s received unknown error code: %s", self._host, error_code_raw
172+
)
173+
error_code = SmartErrorCode.INTERNAL_UNKNOWN_ERROR
174+
return error_code
175+
163176
def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None:
164177
error_code = self._get_response_error(resp_dict)
165178
if error_code is SmartErrorCode.SUCCESS:
@@ -383,13 +396,29 @@ async def perform_handshake1(self) -> tuple[str, str, str]:
383396
error_code = default_error_code
384397
resp_dict = default_resp_dict
385398

399+
# If the default login worked it's ok not to provide credentials but if
400+
# it didn't raise auth error here.
386401
if not self._username:
387402
raise AuthenticationError(
388403
f"Credentials must be supplied to connect to {self._host}"
389404
)
405+
406+
# Device responds with INVALID_NONCE and a "nonce" to indicate ready
407+
# for secure login. Otherwise error.
390408
if error_code is not SmartErrorCode.INVALID_NONCE or (
391-
resp_dict and "nonce" not in resp_dict["result"].get("data", {})
409+
resp_dict and "nonce" not in resp_dict.get("result", {}).get("data", {})
392410
):
411+
if (
412+
resp_dict
413+
and self._get_response_inner_error(resp_dict)
414+
is SmartErrorCode.DEVICE_BLOCKED
415+
):
416+
sec_left = resp_dict.get("data", {}).get("sec_left")
417+
msg = "Device blocked" + (
418+
f" for {sec_left} seconds" if sec_left else ""
419+
)
420+
raise DeviceError(msg, error_code=SmartErrorCode.DEVICE_BLOCKED)
421+
393422
raise AuthenticationError(f"Error trying handshake1: {resp_dict}")
394423

395424
if TYPE_CHECKING:

tests/transports/test_sslaestransport.py

Lines changed: 27 additions & 0 deletions
< 57A7 /tr>
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from kasa.deviceconfig import DeviceConfig
1616
from kasa.exceptions import (
1717
AuthenticationError,
18+
DeviceError,
1819
KasaException,
1920
SmartErrorCode,
2021
)
@@ -200,6 +201,22 @@ async def test_unencrypted_response(mocker, caplog):
200201
)
201202

202203

204+
async def test_device_blocked_response(mocker):
205+
host = "127.0.0.1"
206+
mock_ssl_aes_device = MockSslAesDevice(host, device_blocked=True)
207+
mocker.patch.object(
208+
aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post
209+
)
210+
211+
transport = SslAesTransport(
212+
config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD))
213+
)
214+
msg = "Device blocked for 1685 seconds"
215+
216+
with pytest.raises(DeviceError, match=msg):
217+
await transport.perform_handshake()
218+
219+
203220
async def test_port_override():
204221
"""Test that port override sets the app_url."""
205222
host = "127.0.0.1"
@@ -235,6 +252,11 @@ class MockSslAesDevice:
235252
},
236253
}
237254

255+
DEVICE_BLOCKED_RESP = {
256+
"data": {"code": SmartErrorCode.DEVICE_BLOCKED.value, "sec_left": 1685},
257+
"error_code": SmartErrorCode.SESSION_EXPIRED.value,
258+
}
259+
238260
class _mock_response:
239261
def __init__(self, status, request: dict):
240262
self.status = status
@@ -263,6 +285,7 @@ def __init__(
263285
send_error_code=0,
264286
secure_passthrough_error_code=0,
265287
digest_password_fail=False,
288+
device_blocked=False,
266289
):
267290
self.host = host
268291
self.http_client = HttpClient(DeviceConfig(self.host))
@@ -277,6 +300,7 @@ def __init__(
277300
self.do_not_encrypt_response = do_not_encrypt_response
278301
self.want_default_username = want_default_username
279302
self.digest_password_fail = digest_password_fail
303+
self.device_blocked = device_blocked
280304

281305
async def post(self, url: URL, params=None, json=None, data=None, *_, **__):
282306
if data:
@@ -303,6 +327,9 @@ async def _return_handshake1_response(self, url: URL, request: dict[str, Any]):
303327
request_nonce = request["params"].get("cnonce")
304328
request_username = request["params"].get("username")
305329

330+
if self.device_blocked:
331+
return self._mock_response(self.status_code, self.DEVICE_BLOCKED_RESP)
332+
306333
if (self.want_default_username and request_username != MOCK_ADMIN_USER) or (
307334
not self.want_default_username and request_username != MOCK_USER
308335
):

0 commit comments

Comments
 (0)
0