mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-02-27 05:09:58 +00:00
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
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:
@@ -6,6 +6,7 @@ import asyncclick as click
|
||||
|
||||
from kasa import (
|
||||
Device,
|
||||
KasaException,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
@@ -15,8 +16,7 @@ from .common import (
|
||||
|
||||
|
||||
@click.group()
|
||||
@pass_dev
|
||||
def wifi(dev) -> None:
|
||||
def wifi() -> None:
|
||||
"""Commands to control wifi settings."""
|
||||
|
||||
|
||||
@@ -35,13 +35,23 @@ async def scan(dev):
|
||||
|
||||
@wifi.command()
|
||||
@click.argument("ssid")
|
||||
@click.option("--keytype", prompt=True)
|
||||
@click.option(
|
||||
"--keytype",
|
||||
default="",
|
||||
help="KeyType (Not needed for SmartCamDevice).",
|
||||
)
|
||||
@click.option("--password", prompt=True, hide_input=True)
|
||||
@pass_dev
|
||||
async def join(dev: Device, ssid: str, password: str, keytype: str):
|
||||
"""Join the given wifi network."""
|
||||
echo(f"Asking the device to connect to {ssid}..")
|
||||
res = await dev.wifi_join(ssid, password, keytype=keytype)
|
||||
try:
|
||||
res = await dev.wifi_join(ssid, password, keytype=keytype)
|
||||
except KasaException as e:
|
||||
if type(e) is KasaException:
|
||||
echo(str(e))
|
||||
return
|
||||
raise
|
||||
echo(
|
||||
f"Response: {res} - if the device is not able to join the network, "
|
||||
f"it will revert back to its previous state."
|
||||
|
||||
@@ -138,15 +138,18 @@ class WifiNetwork:
|
||||
"""Wifi network container."""
|
||||
|
||||
ssid: str
|
||||
key_type: int
|
||||
# This is available on both netif and on softaponboarding
|
||||
key_type: int | None = None
|
||||
# These are available only on softaponboarding
|
||||
cipher_type: int | None = None
|
||||
bssid: str | None = None
|
||||
channel: int | None = None
|
||||
# These are available on softaponboarding, SMART, and SMARTCAM devices
|
||||
bssid: str | None = None
|
||||
rssi: int | None = None
|
||||
|
||||
# For SMART devices
|
||||
# These are available on both SMART and SMARTCAM devices
|
||||
signal_level: int | None = None
|
||||
auth: int | None = None
|
||||
encryption: int | None = None
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -688,6 +688,9 @@ class IotDevice(Device):
|
||||
async def _join(target: str, payload: dict) -> dict:
|
||||
return await self._query_helper(target, "set_stainfo", payload)
|
||||
|
||||
if not keytype:
|
||||
raise KasaException("KeyType is required for this device.")
|
||||
|
||||
payload = {"ssid": ssid, "password": password, "key_type": int(keytype)}
|
||||
try:
|
||||
return await _join("netif", payload)
|
||||
|
||||
@@ -92,6 +92,7 @@ REDACTORS: dict[str, Callable[[Any], Any] | None] = {
|
||||
# Queries that are known not to work properly when sent as a
|
||||
# multiRequest. They will not return the `method` key.
|
||||
FORCE_SINGLE_REQUEST = {
|
||||
"connectAp",
|
||||
"getConnectStatus",
|
||||
"scanApList",
|
||||
}
|
||||
|
||||
@@ -769,6 +769,9 @@ class SmartDevice(Device):
|
||||
if not self.credentials:
|
||||
raise AuthenticationError("Device requires authentication.")
|
||||
|
||||
if not keytype:
|
||||
raise KasaException("KeyType is required for this device.")
|
||||
|
||||
payload = {
|
||||
"account": {
|
||||
"username": base64.b64encode(
|
||||
|
||||
@@ -2,12 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from ..device import DeviceInfo
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
|
||||
from ..device import DeviceInfo, WifiNetwork
|
||||
from ..device_type import DeviceType
|
||||
from ..deviceconfig import DeviceConfig
|
||||
from ..exceptions import AuthenticationError, DeviceError, KasaException
|
||||
from ..module import Module
|
||||
from ..protocols import SmartProtocol
|
||||
from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper
|
||||
from ..smart import SmartChildDevice, SmartDevice
|
||||
from ..smart.smartdevice import ComponentsRaw
|
||||
@@ -23,6 +31,24 @@ class SmartCamDevice(SmartDevice):
|
||||
# Modules that are called as part of the init procedure on first update
|
||||
FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice}
|
||||
|
||||
STATIC_PUBLIC_KEY_B64 = (
|
||||
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC4D6i0oD/Ga5qb//RfSe8MrPVI"
|
||||
"rMIGecCxkcGWGj9kxxk74qQNq8XUuXoy2PczQ30BpiRHrlkbtBEPeWLpq85tfubT"
|
||||
"UjhBz1NPNvWrC88uaYVGvzNpgzZOqDC35961uPTuvdUa8vztcUQjEZy16WbmetRj"
|
||||
"URFIiWJgFCmemyYVbQIDAQAB"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
config: DeviceConfig | None = None,
|
||||
protocol: SmartProtocol | None = None,
|
||||
) -> None:
|
||||
super().__init__(host, config=config, protocol=protocol)
|
||||
self._public_key: str | None = None
|
||||
self._networks: list[WifiNetwork] = []
|
||||
|
||||
@staticmethod
|
||||
def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType:
|
||||
"""Find type to be displayed as a supported device category."""
|
||||
@@ -288,3 +314,79 @@ class SmartCamDevice(SmartDevice):
|
||||
def rssi(self) -> int | None:
|
||||
"""Return the device id."""
|
||||
return self.modules[SmartCamModule.SmartCamDeviceModule].rssi
|
||||
|
||||
async def wifi_scan(self) -> list[WifiNetwork]:
|
||||
"""Scan for available wifi networks."""
|
||||
|
||||
def _net_for_scan_info(res: dict) -> WifiNetwork:
|
||||
return WifiNetwork(
|
||||
ssid=res["ssid"],
|
||||
auth=res["auth"],
|
||||
encryption=res["encryption"],
|
||||
rssi=res["rssi"],
|
||||
bssid=res["bssid"],
|
||||
)
|
||||
|
||||
_LOGGER.debug("Querying networks")
|
||||
|
||||
resp = await self._query_helper("scanApList", {"onboarding": {"scan": {}}})
|
||||
scan_data: dict = resp["scanApList"]["onboarding"]["scan"]
|
||||
self._public_key = scan_data.get("publicKey", "")
|
||||
self._networks = [_net_for_scan_info(net) for net in scan_data["ap_list"]]
|
||||
return self._networks
|
||||
|
||||
async def wifi_join(
|
||||
self, ssid: str, password: str, keytype: str = "wpa2_psk"
|
||||
) -> dict:
|
||||
"""Join the given wifi network.
|
||||
|
||||
This method returns nothing as the device tries to activate the new
|
||||
settings immediately instead of responding to the request.
|
||||
|
||||
If joining the network fails, the device will return to the previous state
|
||||
after some delay.
|
||||
"""
|
||||
if not self.credentials:
|
||||
raise AuthenticationError("Device requires authentication.")
|
||||
|
||||
if not self._networks:
|
||||
await self.wifi_scan()
|
||||
net = next(
|
||||
(n for n in self._networks if getattr(n, "ssid", None) == ssid), None
|
||||
)
|
||||
if net is None:
|
||||
raise DeviceError(f"Network with SSID '{ssid}' not found.")
|
||||
|
||||
public_key_b64 = self._public_key or self.STATIC_PUBLIC_KEY_B64
|
||||
key_bytes = base64.b64decode(public_key_b64)
|
||||
public_key = serialization.load_der_public_key(key_bytes)
|
||||
if not isinstance(public_key, RSAPublicKey):
|
||||
raise TypeError("Loaded public key is not an RSA public key")
|
||||
encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15())
|
||||
encrypted_password = base64.b64encode(encrypted).decode()
|
||||
|
||||
payload = {
|
||||
"onboarding": {
|
||||
"connect": {
|
||||
"auth": net.auth,
|
||||
"bssid": net.bssid,
|
||||
"encryption": net.encryption,
|
||||
"password": encrypted_password,
|
||||
"rssi": net.rssi,
|
||||
"ssid": net.ssid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# The device does not respond to the request but changes the settings
|
||||
# immediately which causes us to timeout.
|
||||
# Thus, We limit retries and suppress the raised exception as useless.
|
||||
try:
|
||||
return await self.protocol.query({"connectAp": payload}, retry_count=0)
|
||||
except DeviceError:
|
||||
raise # Re-raise on device-reported errors
|
||||
except KasaException:
|
||||
_LOGGER.debug(
|
||||
"Received a kasa exception for wifi join, but this is expected"
|
||||
)
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user