mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 11:13:34 +00:00
Add LinkieTransportV2 and basic IOT.IPCAMERA support (#1270)
Add LinkieTransportV2 transport used by kasa cameras and a basic implementation for IOT.IPCAMERA (kasacam) devices. --------- Co-authored-by: Zach Price <pricezt@ornl.gov> Co-authored-by: Steven B <51370195+sdb9696@users.noreply.github.com> Co-authored-by: Teemu Rytilahti <tpr@iki.fi>
This commit is contained in:
parent
6d9b4421fe
commit
cb89342be1
@ -15,6 +15,7 @@ from kasa import (
|
|||||||
UnsupportedDeviceError,
|
UnsupportedDeviceError,
|
||||||
)
|
)
|
||||||
from kasa.discover import ConnectAttempt, DiscoveryResult
|
from kasa.discover import ConnectAttempt, DiscoveryResult
|
||||||
|
from kasa.iot.iotdevice import _extract_sys_info
|
||||||
|
|
||||||
from .common import echo, error
|
from .common import echo, error
|
||||||
|
|
||||||
@ -201,8 +202,8 @@ def _echo_discovery_info(discovery_info) -> None:
|
|||||||
if discovery_info is None:
|
if discovery_info is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]:
|
if sysinfo := _extract_sys_info(discovery_info):
|
||||||
_echo_dictionary(discovery_info["system"]["get_sysinfo"])
|
_echo_dictionary(sysinfo)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -25,6 +25,7 @@ def get_default_credentials(tuple: tuple[str, str]) -> Credentials:
|
|||||||
|
|
||||||
DEFAULT_CREDENTIALS = {
|
DEFAULT_CREDENTIALS = {
|
||||||
"KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"),
|
"KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"),
|
||||||
|
"KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="),
|
||||||
"TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="),
|
"TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="),
|
||||||
"TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="),
|
"TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="),
|
||||||
}
|
}
|
||||||
|
5
kasa/device_factory.py
Executable file → Normal file
5
kasa/device_factory.py
Executable file → Normal file
@ -12,6 +12,7 @@ from .deviceconfig import DeviceConfig
|
|||||||
from .exceptions import KasaException, UnsupportedDeviceError
|
from .exceptions import KasaException, UnsupportedDeviceError
|
||||||
from .iot import (
|
from .iot import (
|
||||||
IotBulb,
|
IotBulb,
|
||||||
|
IotCamera,
|
||||||
IotDevice,
|
IotDevice,
|
||||||
IotDimmer,
|
IotDimmer,
|
||||||
IotLightStrip,
|
IotLightStrip,
|
||||||
@ -32,6 +33,7 @@ from .transports import (
|
|||||||
BaseTransport,
|
BaseTransport,
|
||||||
KlapTransport,
|
KlapTransport,
|
||||||
KlapTransportV2,
|
KlapTransportV2,
|
||||||
|
LinkieTransportV2,
|
||||||
SslTransport,
|
SslTransport,
|
||||||
XorTransport,
|
XorTransport,
|
||||||
)
|
)
|
||||||
@ -138,6 +140,7 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]:
|
|||||||
DeviceType.Strip: IotStrip,
|
DeviceType.Strip: IotStrip,
|
||||||
DeviceType.WallSwitch: IotWallSwitch,
|
DeviceType.WallSwitch: IotWallSwitch,
|
||||||
DeviceType.LightStrip: IotLightStrip,
|
DeviceType.LightStrip: IotLightStrip,
|
||||||
|
DeviceType.Camera: IotCamera,
|
||||||
}
|
}
|
||||||
return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)]
|
return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)]
|
||||||
|
|
||||||
@ -159,6 +162,7 @@ def get_device_class_from_family(
|
|||||||
"SMART.TAPOROBOVAC": SmartDevice,
|
"SMART.TAPOROBOVAC": SmartDevice,
|
||||||
"IOT.SMARTPLUGSWITCH": IotPlug,
|
"IOT.SMARTPLUGSWITCH": IotPlug,
|
||||||
"IOT.SMARTBULB": IotBulb,
|
"IOT.SMARTBULB": IotBulb,
|
||||||
|
"IOT.IPCAMERA": IotCamera,
|
||||||
}
|
}
|
||||||
lookup_key = f"{device_type}{'.HTTPS' if https else ''}"
|
lookup_key = f"{device_type}{'.HTTPS' if https else ''}"
|
||||||
if (
|
if (
|
||||||
@ -197,6 +201,7 @@ def get_protocol(
|
|||||||
] = {
|
] = {
|
||||||
"IOT.XOR": (IotProtocol, XorTransport),
|
"IOT.XOR": (IotProtocol, XorTransport),
|
||||||
"IOT.KLAP": (IotProtocol, KlapTransport),
|
"IOT.KLAP": (IotProtocol, KlapTransport),
|
||||||
|
"IOT.XOR.HTTPS.2": (IotProtocol, LinkieTransportV2),
|
||||||
"SMART.AES": (SmartProtocol, AesTransport),
|
"SMART.AES": (SmartProtocol, AesTransport),
|
||||||
"SMART.AES.2": (SmartProtocol, AesTransport),
|
"SMART.AES.2": (SmartProtocol, AesTransport),
|
||||||
"SMART.KLAP.2": (SmartProtocol, KlapTransportV2),
|
"SMART.KLAP.2": (SmartProtocol, KlapTransportV2),
|
||||||
|
@ -69,6 +69,7 @@ class DeviceFamily(Enum):
|
|||||||
|
|
||||||
IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH"
|
IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH"
|
||||||
IotSmartBulb = "IOT.SMARTBULB"
|
IotSmartBulb = "IOT.SMARTBULB"
|
||||||
|
IotIpCamera = "IOT.IPCAMERA"
|
||||||
SmartKasaPlug = "SMART.KASAPLUG"
|
SmartKasaPlug = "SMART.KASAPLUG"
|
||||||
SmartKasaSwitch = "SMART.KASASWITCH"
|
SmartKasaSwitch = "SMART.KASASWITCH"
|
||||||
SmartTapoPlug = "SMART.TAPOPLUG"
|
SmartTapoPlug = "SMART.TAPOPLUG"
|
||||||
|
@ -123,7 +123,7 @@ from kasa.exceptions import (
|
|||||||
TimeoutError,
|
TimeoutError,
|
||||||
UnsupportedDeviceError,
|
UnsupportedDeviceError,
|
||||||
)
|
)
|
||||||
from kasa.iot.iotdevice import IotDevice
|
from kasa.iot.iotdevice import IotDevice, _extract_sys_info
|
||||||
from kasa.json import DataClassJSONMixin
|
from kasa.json import DataClassJSONMixin
|
||||||
from kasa.json import dumps as json_dumps
|
from kasa.json import dumps as json_dumps
|
||||||
from kasa.json import loads as json_loads
|
from kasa.json import loads as json_loads
|
||||||
@ -681,12 +681,17 @@ class Discover:
|
|||||||
|
|
||||||
device_class = cast(type[IotDevice], Discover._get_device_class(info))
|
device_class = cast(type[IotDevice], Discover._get_device_class(info))
|
||||||
device = device_class(config.host, config=config)
|
device = device_class(config.host, config=config)
|
||||||
sys_info = info["system"]["get_sysinfo"]
|
sys_info = _extract_sys_info(info)
|
||||||
if device_type := sys_info.get("mic_type", sys_info.get("type")):
|
device_type = sys_info.get("mic_type", sys_info.get("type"))
|
||||||
config.connection_type = DeviceConnectionParameters.from_values(
|
login_version = (
|
||||||
device_family=device_type,
|
sys_info.get("stream_version") if device_type == "IOT.IPCAMERA" else None
|
||||||
encryption_type=DeviceEncryptionType.Xor.value,
|
)
|
||||||
)
|
config.connection_type = DeviceConnectionParameters.from_values(
|
||||||
|
device_family=device_type,
|
||||||
|
encryption_type=DeviceEncryptionType.Xor.value,
|
||||||
|
https=device_type == "IOT.IPCAMERA",
|
||||||
|
login_version=login_version,
|
||||||
|
)
|
||||||
device.protocol = get_protocol(config) # type: ignore[assignment]
|
device.protocol = get_protocol(config) # type: ignore[assignment]
|
||||||
device.update_from_discover_info(info)
|
device.update_from_discover_info(info)
|
||||||
return device
|
return device
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""Package for supporting legacy kasa devices."""
|
"""Package for supporting legacy kasa devices."""
|
||||||
|
|
||||||
from .iotbulb import IotBulb
|
from .iotbulb import IotBulb
|
||||||
|
from .iotcamera import IotCamera
|
||||||
from .iotdevice import IotDevice
|
from .iotdevice import IotDevice
|
||||||
from .iotdimmer import IotDimmer
|
from .iotdimmer import IotDimmer
|
||||||
from .iotlightstrip import IotLightStrip
|
from .iotlightstrip import IotLightStrip
|
||||||
@ -15,4 +16,5 @@ __all__ = [
|
|||||||
"IotDimmer",
|
"IotDimmer",
|
||||||
"IotLightStrip",
|
"IotLightStrip",
|
||||||
"IotWallSwitch",
|
"IotWallSwitch",
|
||||||
|
"IotCamera",
|
||||||
]
|
]
|
||||||
|
42
kasa/iot/iotcamera.py
Normal file
42
kasa/iot/iotcamera.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""Module for cameras."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, tzinfo
|
||||||
|
|
||||||
|
from ..device_type import DeviceType
|
||||||
|
from ..deviceconfig import DeviceConfig
|
||||||
|
from ..protocols import BaseProtocol
|
||||||
|
from .iotdevice import IotDevice
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class IotCamera(IotDevice):
|
||||||
|
"""Representation of a TP-Link Camera."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
*,
|
||||||
|
config: DeviceConfig | None = None,
|
||||||
|
protocol: BaseProtocol | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(host=host, config=config, protocol=protocol)
|
||||||
|
self._device_type = DeviceType.Camera
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time(self) -> datetime:
|
||||||
|
"""Get the camera's time."""
|
||||||
|
return datetime.fromtimestamp(self.sys_info["system_time"])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timezone(self) -> tzinfo:
|
||||||
|
"""Get the camera's timezone."""
|
||||||
|
return None # type: ignore
|
||||||
|
|
||||||
|
@property # type: ignore
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return whether device is on."""
|
||||||
|
return True
|
@ -70,6 +70,16 @@ def _parse_features(features: str) -> set[str]:
|
|||||||
return set(features.split(":"))
|
return set(features.split(":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_sys_info(info: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return the system info structure."""
|
||||||
|
sysinfo_default = info.get("system", {}).get("get_sysinfo", {})
|
||||||
|
sysinfo_nest = sysinfo_default.get("system", {})
|
||||||
|
|
||||||
|
if len(sysinfo_nest) > len(sysinfo_default) and isinstance(sysinfo_nest, dict):
|
||||||
|
return sysinfo_nest
|
||||||
|
return sysinfo_default
|
||||||
|
|
||||||
|
|
||||||
class IotDevice(Device):
|
class IotDevice(Device):
|
||||||
"""Base class for all supported device types.
|
"""Base class for all supported device types.
|
||||||
|
|
||||||
@ -304,14 +314,14 @@ class IotDevice(Device):
|
|||||||
_LOGGER.debug("Performing the initial update to obtain sysinfo")
|
_LOGGER.debug("Performing the initial update to obtain sysinfo")
|
||||||
response = await self.protocol.query(req)
|
response = await self.protocol.query(req)
|
||||||
self._last_update = response
|
self._last_update = response
|
||||||
self._set_sys_info(response["system"]["get_sysinfo"])
|
self._set_sys_info(_extract_sys_info(response))
|
||||||
|
|
||||||
if not self._modules:
|
if not self._modules:
|
||||||
await self._initialize_modules()
|
await self._initialize_modules()
|
||||||
|
|
||||||
await self._modular_update(req)
|
await self._modular_update(req)
|
||||||
|
|
||||||
self._set_sys_info(self._last_update["system"]["get_sysinfo"])
|
self._set_sys_info(_extract_sys_info(self._last_update))
|
||||||
for module in self._modules.values():
|
for module in self._modules.values():
|
||||||
await module._post_update_hook()
|
await module._post_update_hook()
|
||||||
|
|
||||||
@ -705,10 +715,13 @@ class IotDevice(Device):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType:
|
def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType:
|
||||||
"""Find SmartDevice subclass for device described by passed data."""
|
"""Find SmartDevice subclass for device described by passed data."""
|
||||||
|
if "system" in info.get("system", {}).get("get_sysinfo", {}):
|
||||||
|
return DeviceType.Camera
|
||||||
|
|
||||||
if "system" not in info or "get_sysinfo" not in info["system"]:
|
if "system" not in info or "get_sysinfo" not in info["system"]:
|
||||||
raise KasaException("No 'system' or 'get_sysinfo' in response")
|
raise KasaException("No 'system' or 'get_sysinfo' in response")
|
||||||
|
|
||||||
sysinfo: dict[str, Any] = info["system"]["get_sysinfo"]
|
sysinfo: dict[str, Any] = _extract_sys_info(info)
|
||||||
type_: str | None = sysinfo.get("type", sysinfo.get("mic_type"))
|
type_: str | None = sysinfo.get("type", sysinfo.get("mic_type"))
|
||||||
if type_ is None:
|
if type_ is None:
|
||||||
raise KasaException("Unable to find the device type field!")
|
raise KasaException("Unable to find the device type field!")
|
||||||
@ -728,6 +741,7 @@ class IotDevice(Device):
|
|||||||
return DeviceType.LightStrip
|
return DeviceType.LightStrip
|
||||||
|
|
||||||
return DeviceType.Bulb
|
return DeviceType.Bulb
|
||||||
|
|
||||||
_LOGGER.warning("Unknown device type %s, falling back to plug", type_)
|
_LOGGER.warning("Unknown device type %s, falling back to plug", type_)
|
||||||
return DeviceType.Plug
|
return DeviceType.Plug
|
||||||
|
|
||||||
@ -736,7 +750,7 @@ class IotDevice(Device):
|
|||||||
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
||||||
) -> _DeviceInfo:
|
) -> _DeviceInfo:
|
||||||
"""Get model information for a device."""
|
"""Get model information for a device."""
|
||||||
sys_info = info["system"]["get_sysinfo"]
|
sys_info = _extract_sys_info(info)
|
||||||
|
|
||||||
# Get model and region info
|
# Get model and region info
|
||||||
region = None
|
region = None
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from .aestransport import AesEncyptionSession, AesTransport
|
from .aestransport import AesEncyptionSession, AesTransport
|
||||||
from .basetransport import BaseTransport
|
from .basetransport import BaseTransport
|
||||||
from .klaptransport import KlapTransport, KlapTransportV2
|
from .klaptransport import KlapTransport, KlapTransportV2
|
||||||
|
from .linkietransport import LinkieTransportV2
|
||||||
from .ssltransport import SslTransport
|
from .ssltransport import SslTransport
|
||||||
from .xortransport import XorEncryption, XorTransport
|
from .xortransport import XorEncryption, XorTransport
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ __all__ = [
|
|||||||
"BaseTransport",
|
"BaseTransport",
|
||||||
"KlapTransport",
|
"KlapTransport",
|
||||||
"KlapTransportV2",
|
"KlapTransportV2",
|
||||||
|
"LinkieTransportV2",
|
||||||
"XorTransport",
|
"XorTransport",
|
||||||
"XorEncryption",
|
"XorEncryption",
|
||||||
]
|
]
|
||||||
|
143
kasa/transports/linkietransport.py
Normal file
143
kasa/transports/linkietransport.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""Implementation of the linkie kasa camera transport."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from yarl import URL
|
||||||
|
|
||||||
|
from kasa.credentials import DEFAULT_CREDENTIALS, get_default_credentials
|
||||||
|
from kasa.deviceconfig import DeviceConfig
|
||||||
|
from kasa.exceptions import KasaException, _RetryableError
|
||||||
|
from kasa.httpclient import HttpClient
|
||||||
|
from kasa.json import loads as json_loads
|
||||||
|
from kasa.transports.xortransport import XorEncryption
|
||||||
|
|
||||||
|
from .basetransport import BaseTransport
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkieTransportV2(BaseTransport):
|
||||||
|
"""Implementation of the Linkie encryption protocol.
|
||||||
|
|
||||||
|
Linkie is used as the endpoint for TP-Link's camera encryption
|
||||||
|
protocol, used by newer firmware versions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_PORT: int = 10443
|
||||||
|
CIPHERS = ":".join(
|
||||||
|
[
|
||||||
|
"AES256-GCM-SHA384",
|
||||||
|
"AES256-SHA256",
|
||||||
|
"AES128-GCM-SHA256",
|
||||||
|
"AES128-SHA256",
|
||||||
|
"AES256-SHA",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *, config: DeviceConfig) -> None:
|
||||||
|
super().__init__(config=config)
|
||||||
|
self._http_client = HttpClient(config)
|
||||||
|
self._ssl_context: ssl.SSLContext | None = None
|
||||||
|
self._app_url = URL(f"https://{self._host}:{self._port}/data/LINKIE2.json")
|
||||||
|
|
||||||
|
self._headers = {
|
||||||
|
"Authorization": f"Basic {self.credentials_hash}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_port(self) -> int:
|
||||||
|
"""Default port for the transport."""
|
||||||
|
return self.DEFAULT_PORT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credentials_hash(self) -> str | None:
|
||||||
|
"""The hashed credentials used by the transport."""
|
||||||
|
creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"])
|
||||||
|
creds_combined = f"{creds.username}:{creds.password}"
|
||||||
|
return base64.b64encode(creds_combined.encode()).decode()
|
||||||
|
|
||||||
|
async def _execute_send(self, request: str) -> dict:
|
||||||
|
"""Execute a query on the device and wait for the response."""
|
||||||
|
_LOGGER.debug("%s >> %s", self._host, request)
|
||||||
|
|
||||||
|
encrypted_cmd = XorEncryption.encrypt(request)[4:]
|
||||||
|
b64_cmd = base64.b64encode(encrypted_cmd).decode()
|
||||||
|
url_safe_cmd = quote(b64_cmd, safe="!~*'()")
|
||||||
|
|
||||||
|
status_code, response = await self._http_client.post(
|
||||||
|
self._app_url,
|
||||||
|
headers=self._headers,
|
||||||
|
data=f"content={url_safe_cmd}".encode(),
|
||||||
|
ssl=await self._get_ssl_context(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
response = cast(bytes, response)
|
||||||
|
|
||||||
|
if status_code != 200:
|
||||||
|
raise KasaException(
|
||||||
|
f"{self._host} responded with an unexpected "
|
||||||
|
+ f"status code {status_code} to passthrough"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expected response
|
||||||
|
try:
|
||||||
|
json_payload: dict = json_loads(
|
||||||
|
XorEncryption.decrypt(base64.b64decode(response))
|
||||||
|
)
|
||||||
|
_LOGGER.debug("%s << %s", self._host, json_payload)
|
||||||
|
return json_payload
|
||||||
|
except Exception: # noqa: S110
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Device returned error as json plaintext
|
||||||
|
to_raise: KasaException | None = None
|
||||||
|
try:
|
||||||
|
error_payload: dict = json_loads(response)
|
||||||
|
to_raise = KasaException(f"Device {self._host} send error: {error_payload}")
|
||||||
|
except Exception as ex:
|
||||||
|
raise KasaException("Unable to read response") from ex
|
||||||
|
raise to_raise
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the http client and reset internal state."""
|
||||||
|
await self._http_client.close()
|
||||||
|
|
||||||
|
async def reset(self) -> None:
|
||||||
|
"""Reset the transport.
|
||||||
|
|
||||||
|
NOOP for this transport.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def send(self, request: str) -> dict:
|
||||||
|
"""Send a message to the device and return a response."""
|
||||||
|
try:
|
||||||
|
return await self._execute_send(request)
|
||||||
|
except Exception as ex:
|
||||||
|
await self.reset()
|
||||||
|
raise _RetryableError(
|
||||||
|
f"Unable to query the device {self._host}:{self._port}: {ex}"
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
async def _get_ssl_context(self) -> ssl.SSLContext:
|
||||||
|
if not self._ssl_context:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._ssl_context = await loop.run_in_executor(
|
||||||
|
None, self._create_ssl_context
|
||||||
|
)
|
||||||
|
return self._ssl_context
|
||||||
|
|
||||||
|
def _create_ssl_context(self) -> ssl.SSLContext:
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
context.set_ciphers(self.CIPHERS)
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
return context
|
86
tests/fixtures/iotcam/EC60(US)_4.0_2.3.22.json
vendored
Normal file
86
tests/fixtures/iotcam/EC60(US)_4.0_2.3.22.json
vendored
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"emeter": {
|
||||||
|
"get_realtime": {
|
||||||
|
"err_code": -10008,
|
||||||
|
"err_msg": "Unsupported API call."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smartlife.iot.LAS": {
|
||||||
|
"get_current_brt": {
|
||||||
|
"err_code": -10008,
|
||||||
|
"err_msg": "Unsupported API call."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smartlife.iot.PIR": {
|
||||||
|
"get_config": {
|
||||||
|
"err_code": -10008,
|
||||||
|
"err_msg": "Unsupported API call."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smartlife.iot.common.emeter": {
|
||||||
|
"get_realtime": {
|
||||||
|
"err_code": -10008,
|
||||||
|
"err_msg": "Unsupported API call."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smartlife.iot.dimmer": {
|
||||||
|
"get_dimmer_parameters": {
|
||||||
|
"err_code": -10008,
|
||||||
|
"err_msg": "Unsupported API call."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smartlife.iot.smartbulb.lightingservice": {
|
||||||
|
"get_light_state": {
|
||||||
|
"err_code": -10008,
|
||||||
|
"err_msg": "Unsupported API call."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"get_sysinfo": {
|
||||||
|
"err_code": 0,
|
||||||
|
"system": {
|
||||||
|
"a_type": 2,
|
||||||
|
"alias": "#MASKED_NAME#",
|
||||||
|
"bind_status": false,
|
||||||
|
"c_opt": [
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"camera_switch": "on",
|
||||||
|
"dev_name": "Kasa Spot, 24/7 Recording",
|
||||||
|
"deviceId": "0000000000000000000000000000000000000000",
|
||||||
|
"f_list": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"hwId": "00000000000000000000000000000000",
|
||||||
|
"hw_ver": "4.0",
|
||||||
|
"is_cal": 1,
|
||||||
|
"last_activity_timestamp": 0,
|
||||||
|
"latitude": 0,
|
||||||
|
"led_status": "on",
|
||||||
|
"longitude": 0,
|
||||||
|
"mac": "74:FE:CE:00:00:00",
|
||||||
|
"mic_mac": "74FECE000000",
|
||||||
|
"model": "EC60(US)",
|
||||||
|
"new_feature": [
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"oemId": "00000000000000000000000000000000",
|
||||||
|
"resolution": "720P",
|
||||||
|
"rssi": -28,
|
||||||
|
"status": "new",
|
||||||
|
"stream_version": 2,
|
||||||
|
"sw_ver": "2.3.22 Build 20230731 rel.69808",
|
||||||
|
"system_time": 1690827820,
|
||||||
|
"type": "IOT.IPCAMERA",
|
||||||
|
"updating": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -16,6 +16,7 @@ import kasa
|
|||||||
from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module
|
from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module
|
||||||
from kasa.iot import (
|
from kasa.iot import (
|
||||||
IotBulb,
|
IotBulb,
|
||||||
|
IotCamera,
|
||||||
IotDevice,
|
IotDevice,
|
||||||
IotDimmer,
|
IotDimmer,
|
||||||
IotLightStrip,
|
IotLightStrip,
|
||||||
@ -118,6 +119,7 @@ async def test_device_class_repr(device_class_name_obj):
|
|||||||
IotStrip: DeviceType.Strip,
|
IotStrip: DeviceType.Strip,
|
||||||
IotWallSwitch: DeviceType.WallSwitch,
|
IotWallSwitch: DeviceType.WallSwitch,
|
||||||
IotLightStrip: DeviceType.LightStrip,
|
IotLightStrip: DeviceType.LightStrip,
|
||||||
|
IotCamera: DeviceType.Camera,
|
||||||
SmartChildDevice: DeviceType.Unknown,
|
SmartChildDevice: DeviceType.Unknown,
|
||||||
SmartDevice: DeviceType.Unknown,
|
SmartDevice: DeviceType.Unknown,
|
||||||
SmartCamDevice: DeviceType.Camera,
|
SmartCamDevice: DeviceType.Camera,
|
||||||
|
144
tests/transports/test_linkietransport.py
Normal file
144
tests/transports/test_linkietransport.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import base64
|
||||||
|
from unittest.mock import ANY
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pytest
|
||||||
|
from yarl import URL
|
||||||
|
|
||||||
|
from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials
|
||||||
|
from kasa.deviceconfig import DeviceConfig
|
||||||
|
from kasa.exceptions import KasaException
|
||||||
|
from kasa.httpclient import HttpClient
|
||||||
|
from kasa.json import dumps as json_dumps
|
||||||
|
from kasa.transports.linkietransport import LinkieTransportV2
|
||||||
|
|
||||||
|
KASACAM_REQUEST_PLAINTEXT = '{"smartlife.cam.ipcamera.dateTime":{"get_status":{}}}'
|
||||||
|
KASACAM_RESPONSE_ENCRYPTED = "0PKG74LnnfKc+dvhw5bCgaycqZOjk7Gdv96syaiKsJLTvtupwKPC7aPGse632KrB48/tiPiX9JzDsNW2lK6fqZCgmKuZoZGh3A=="
|
||||||
|
KASACAM_RESPONSE_ERROR = '{"smartlife.cam.ipcamera.cloud": {"get_inf": {"err_code": -10008, "err_msg": "Unsupported API call."}}}'
|
||||||
|
KASA_DEFAULT_CREDENTIALS_HASH = "YWRtaW46MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="
|
||||||
|
|
||||||
|
|
||||||
|
async def test_working(mocker):
|
||||||
|
"""No errors with an expected request/response."""
|
||||||
|
host = "127.0.0.1"
|
||||||
|
mock_linkie_device = MockLinkieDevice(host)
|
||||||
|
mocker.patch.object(
|
||||||
|
aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post
|
||||||
|
)
|
||||||
|
transport_no_creds = LinkieTransportV2(config=DeviceConfig(host))
|
||||||
|
|
||||||
|
response = await transport_no_creds.send(KASACAM_REQUEST_PLAINTEXT)
|
||||||
|
assert response == {
|
||||||
|
"timezone": "UTC-05:00",
|
||||||
|
"area": "America/New_York",
|
||||||
|
"epoch_sec": 1690832800,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_credentials_hash(mocker):
|
||||||
|
"""Ensure the default credentials are always passed as Basic Auth."""
|
||||||
|
# Test without credentials input
|
||||||
|
|
||||||
|
host = "127.0.0.1"
|
||||||
|
mock_linkie_device = MockLinkieDevice(host)
|
||||||
|
mock_post = mocker.patch.object(
|
||||||
|
aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post
|
||||||
|
)
|
||||||
|
transport_no_creds = LinkieTransportV2(config=DeviceConfig(host))
|
||||||
|
await transport_no_creds.send(KASACAM_REQUEST_PLAINTEXT)
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
URL(f"https://{host}:10443/data/LINKIE2.json"),
|
||||||
|
params=None,
|
||||||
|
data=ANY,
|
||||||
|
json=None,
|
||||||
|
timeout=ANY,
|
||||||
|
cookies=None,
|
||||||
|
headers={
|
||||||
|
"Authorization": "Basic " + _generate_kascam_basic_auth(),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
ssl=ANY,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transport_no_creds.credentials_hash == KASA_DEFAULT_CREDENTIALS_HASH
|
||||||
|
# Test with credentials input
|
||||||
|
|
||||||
|
transport_with_creds = LinkieTransportV2(
|
||||||
|
config=DeviceConfig(host, credentials=Credentials("Admin", "password"))
|
||||||
|
)
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
await transport_with_creds.send(KASACAM_REQUEST_PLAINTEXT)
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
URL(f"https://{host}:10443/data/LINKIE2.json"),
|
||||||
|
params=None,
|
||||||
|
data=ANY,
|
||||||
|
json=None,
|
||||||
|
timeout=ANY,
|
||||||
|
cookies=None,
|
||||||
|
headers={
|
||||||
|
"Authorization": "Basic " + _generate_kascam_basic_auth(),
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
ssl=ANY,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("return_status", "return_data", "expected"),
|
||||||
|
[
|
||||||
|
(500, KASACAM_RESPONSE_ENCRYPTED, "500"),
|
||||||
|
(200, "AAAAAAAAAAAAAAAAAAAAAAAA", "Unable to read response"),
|
||||||
|
(200, KASACAM_RESPONSE_ERROR, "Unsupported API call"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_exceptions(mocker, return_status, return_data, expected):
|
||||||
|
"""Test a variety of possible responses from the device."""
|
||||||
|
host = "127.0.0.1"
|
||||||
|
transport = LinkieTransportV2(config=DeviceConfig(host))
|
||||||
|
mock_linkie_device = MockLinkieDevice(
|
||||||
|
host, status_code=return_status, response=return_data
|
||||||
|
)
|
||||||
|
mocker.patch.object(
|
||||||
|
aiohttp.ClientSession, "post", side_effect=mock_linkie_device.post
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KasaException, match=expected):
|
||||||
|
await transport.send(KASACAM_REQUEST_PLAINTEXT)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_kascam_basic_auth():
|
||||||
|
creds = get_default_credentials(DEFAULT_CREDENTIALS["KASACAMERA"])
|
||||||
|
creds_combined = f"{creds.username}:{creds.password}"
|
||||||
|
return base64.b64encode(creds_combined.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
class MockLinkieDevice:
|
||||||
|
"""Based on MockSslDevice."""
|
||||||
|
|
||||||
|
class _mock_response:
|
||||||
|
def __init__(self, status, request: dict):
|
||||||
|
self.status = status
|
||||||
|
self._json = request
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_t, exc_v, exc_tb):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def read(self):
|
||||||
|
if isinstance(self._json, dict):
|
||||||
|
return json_dumps(self._json).encode()
|
||||||
|
return self._json
|
||||||
|
|
||||||
|
def __init__(self, host, *, status_code=200, response=KASACAM_RESPONSE_ENCRYPTED):
|
||||||
|
self.host = host
|
||||||
|
self.http_client = HttpClient(DeviceConfig(self.host))
|
||||||
|
self.status_code = status_code
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self, url: URL, *, headers=None, params=None, json=None, data=None, **__
|
||||||
|
):
|
||||||
|
return self._mock_response(self.status_code, self.response)
|
Loading…
Reference in New Issue
Block a user