New Wi-Fi handling for SMARTCAM devices (#1639)
Some checks failed
CI / Perform Lint Checks (3.13) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
CodeQL Checks / Analyze (python) (push) Has been cancelled

Updated scanning and joining Wi-Fi for SMARTCAM devices that may use a
newer connection process.
This commit is contained in:
ZeliardM
2026-02-21 18:03:52 -05:00
committed by GitHub
parent 494db73fa8
commit 30a8fd45a8
9 changed files with 345 additions and 13 deletions

View File

@@ -256,6 +256,52 @@ class FakeSmartCamTransport(BaseTransport):
method = request_dict["method"]
info = self.info
if method == "connectAp":
if self.verbatim:
return {"error_code": -1}
return {"result": {}, "error_code": 0}
if method == "scanApList":
if method in info:
result = self._get_method_from_info(method, request_dict.get("params"))
if not self.verbatim:
scan = (
result.get("result", {}).get("onboarding", {}).get("scan", {})
)
ap_list = scan.get("ap_list")
if isinstance(ap_list, list) and not any(
ap.get("ssid") == "FOOBAR" for ap in ap_list
):
ap_list.append(
{
"ssid": "FOOBAR",
"auth": 3,
"encryption": 3,
"rssi": -40,
"bssid": "00:00:00:00:00:00",
}
)
return result
if self.verbatim:
return {"error_code": -1}
return {
"result": {
"onboarding": {
"scan": {
"publicKey": "",
"ap_list": [
{
"ssid": "FOOBAR",
"auth": 3,
"encryption": 3,
"rssi": -40,
"bssid": "00:00:00:00:00:00",
}
],
}
}
},
"error_code": 0,
}
if method == "controlChild":
return await self._handle_control_child(
request_dict["params"]["childControl"]

View File

@@ -2,12 +2,16 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
from unittest.mock import AsyncMock, PropertyMock, patch
import pytest
from freezegun.api import FrozenDateTimeFactory
from kasa import Device, DeviceType, Module
from kasa.exceptions import AuthenticationError, DeviceError, KasaException
from kasa.smartcam import SmartCamDevice
from ..conftest import device_smartcam, hub_smartcam
@@ -34,7 +38,7 @@ async def test_state(dev: Device):
@device_smartcam
async def test_alias(dev):
async def test_alias(dev: Device):
test_alias = "TEST1234"
original = dev.alias
@@ -49,7 +53,7 @@ async def test_alias(dev):
@hub_smartcam
async def test_hub(dev):
async def test_hub(dev: Device):
assert dev.children
for child in dev.children:
assert child.modules
@@ -60,6 +64,95 @@ async def test_hub(dev):
assert child.device_id
@device_smartcam
async def test_wifi_scan(dev: SmartCamDevice):
fake_scan_data = {
"scanApList": {
"onboarding": {
"scan": {
"publicKey": base64.b64encode(b"fakekey").decode(),
"ap_list": [
{
"ssid": "TestSSID",
"auth": "WPA2",
"encryption": "AES",
"rssi": -40,
"bssid": "00:11:22:33:44:55",
}
],
}
}
}
}
with patch.object(dev, "_query_helper", AsyncMock(return_value=fake_scan_data)):
networks = await dev.wifi_scan()
assert len(networks) == 1
net = networks[0]
assert net.ssid == "TestSSID"
assert net.auth == "WPA2"
assert net.encryption == "AES"
assert net.rssi == -40
assert net.bssid == "00:11:22:33:44:55"
assert dev._public_key == base64.b64encode(b"fakekey").decode()
@device_smartcam
async def test_wifi_join_success_and_errors(dev: SmartCamDevice):
dev._networks = [
type(
"WifiNetwork",
(),
{
"ssid": "TestSSID",
"auth": "WPA2",
"encryption": "AES",
"rssi": -40,
"bssid": "00:11:22:33:44:55",
},
)()
]
with patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock:
cred_mock.return_value = object()
with patch.object(dev.protocol, "query", AsyncMock(return_value={})):
result = await dev.wifi_join("TestSSID", "password123")
assert isinstance(result, dict)
cred_mock.return_value = None
with pytest.raises(AuthenticationError):
await dev.wifi_join("TestSSID", "password123")
cred_mock.return_value = object()
dev._networks = []
with (
patch.object(dev, "wifi_scan", AsyncMock(return_value=[])),
pytest.raises(DeviceError),
):
await dev.wifi_join("TestSSID", "password123")
dev._networks = [
type(
"WifiNetwork",
(),
{
"ssid": "TestSSID",
"auth": "WPA2",
"encryption": "AES",
"rssi": -40,
"bssid": "00:11:22:33:44:55",
},
)()
]
with (
patch.object(
dev.protocol, "query", AsyncMock(side_effect=DeviceError("fail"))
),
pytest.raises(DeviceError),
):
await dev.wifi_join("TestSSID", "password123")
with patch.object(
dev.protocol, "query", AsyncMock(side_effect=KasaException("fail"))
):
result = await dev.wifi_join("TestSSID", "password123")
assert result == {}
@device_smartcam
async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory):
"""Test a child device gets the time from it's parent module."""
@@ -69,3 +162,36 @@ async def test_device_time(dev: Device, freezer: FrozenDateTimeFactory):
await module.set_time(fallback_time)
await dev.update()
assert dev.time == fallback_time
@device_smartcam
async def test_wifi_join_typeerror_on_non_rsa_key(dev: SmartCamDevice):
dev._networks = [
type(
"WifiNetwork",
(),
{
"ssid": "TestSSID",
"auth": "WPA2",
"encryption": "AES",
"rssi": -40,
"bssid": "00:11:22:33:44:55",
},
)()
]
with patch.object(type(dev), "credentials", new_callable=PropertyMock) as cred_mock:
cred_mock.return_value = object()
with (
patch(
"cryptography.hazmat.primitives.serialization.load_der_public_key",
return_value=object(),
),
patch(
"kasa.smartcam.smartcamdevice.RSAPublicKey",
new=type("FakeRSA", (), {}),
),
pytest.raises(
TypeError, match="Loaded public key is not an RSA public key"
),
):
await dev.wifi_join("TestSSID", "password123")

View File

@@ -49,10 +49,13 @@ from kasa.smart import SmartDevice
from kasa.smartcam import SmartCamDevice
from .conftest import (
device_iot,
device_smart,
device_smartcam,
get_device_for_fixture_protocol,
handle_turn_on,
new_discovery,
parametrize_combine,
turn_on,
)
@@ -359,12 +362,47 @@ async def test_wifi_scan(dev, runner):
assert re.search(r"Found [\d]+ wifi networks!", res.output)
@device_smart
@parametrize_combine([device_smart, device_iot])
async def test_wifi_join(dev, mocker, runner):
update = mocker.patch.object(dev, "update")
res = await runner.invoke(
wifi,
["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"],
["join", "FOOBAR", "--keytype", "3", "--password", "foobar"],
obj=dev,
)
# Make sure that update was not called for wifi
with pytest.raises(AssertionError):
update.assert_called()
assert res.exit_code == 0
assert "Asking the device to connect to FOOBAR" in res.output
@parametrize_combine([device_smart, device_iot])
async def test_wifi_join_missing_keytype(dev, mocker, runner):
"""Test that missing keytype raises KasaException and CLI echoes the message."""
update = mocker.patch.object(dev, "update")
res = await runner.invoke(
wifi,
["join", "FOOBAR", "--password", "foobar"],
obj=dev,
)
# Make sure that update was not called for wifi
with pytest.raises(AssertionError):
update.assert_called()
assert res.exit_code == 0
assert "KeyType is required for this device." in res.output
@device_smartcam
async def test_wifi_join_smartcam(dev, mocker, runner):
update = mocker.patch.object(dev, "update")
res = await runner.invoke(
wifi,
["join", "FOOBAR", "--password", "foobar"],
obj=dev,
)