Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions kasa/cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
UnsupportedDeviceError,
)
from kasa.discover import ConnectAttempt, DiscoveryResult
from kasa.iot.iotdevice import _extract_sys_info

from .common import echo, error

Expand Down Expand Up @@ -201,8 +202,8 @@ def _echo_discovery_info(discovery_info) -> None:
if discovery_info is None:
return

if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]:
_echo_dictionary(discovery_info["system"]["get_sysinfo"])
if sysinfo := _extract_sys_info(discovery_info):
_echo_dictionary(sysinfo)
return

try:
Expand Down
1 change: 1 addition & 0 deletions kasa/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def get_default_credentials(tuple: tuple[str, str]) -> Credentials:

DEFAULT_CREDENTIALS = {
"KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"),
"KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="),
"TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="),
"TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="),
}
5 changes: 5 additions & 0 deletions kasa/device_factory.py
100755 → 100644
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized this changes the permission bits (removing the execute bit), I added a TODO that we will fix this also for other files at some point.

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .exceptions import KasaException, UnsupportedDeviceError
from .iot import (
IotBulb,
IotCamera,
IotDevice,
IotDimmer,
IotLightStrip,
Expand All @@ -32,6 +33,7 @@
BaseTransport,
KlapTransport,
KlapTransportV2,
LinkieTransportV2,
SslTransport,
XorTransport,
)
Expand Down Expand Up @@ -138,6 +140,7 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]:
DeviceType.Strip: IotStrip,
DeviceType.WallSwitch: IotWallSwitch,
DeviceType.LightStrip: IotLightStrip,
DeviceType.Camera: IotCamera,
}
return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)]

Expand All @@ -159,6 +162,7 @@ def get_device_class_from_family(
"SMART.TAPOROBOVAC": SmartDevice,
"IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": IotBulb,
"IOT.IPCAMERA": IotCamera,
}
lookup_key = f"{device_type}{'.HTTPS' if https else ''}"
if (
Expand Down Expand Up @@ -197,6 +201,7 @@ def get_protocol(
] = {
"IOT.XOR": (IotProtocol, XorTransport),
"IOT.KLAP": (IotProtocol, KlapTransport),
"IOT.XOR.HTTPS.2": (IotProtocol, LinkieTransportV2),
"SMART.AES": (SmartProtocol, AesTransport),
"SMART.AES.2": (SmartProtocol, AesTransport),
"SMART.KLAP.2": (SmartProtocol, KlapTransportV2),
Expand Down
1 change: 1 addition & 0 deletions kasa/deviceconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class DeviceFamily(Enum):

IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH"
IotSmartBulb = "IOT.SMARTBULB"
IotIpCamera = "IOT.IPCAMERA"
SmartKasaPlug = "SMART.KASAPLUG"
SmartKasaSwitch = "SMART.KASASWITCH"
SmartTapoPlug = "SMART.TAPOPLUG"
Expand Down
19 changes: 12 additions & 7 deletions kasa/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@
TimeoutError,
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 dumps as json_dumps
from kasa.json import loads as json_loads
Expand Down Expand Up @@ -681,12 +681,17 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice:

device_class = cast(type[IotDevice], Discover._get_device_class(info))
device = device_class(config.host, config=config)
sys_info = info["system"]["get_sysinfo"]
if device_type := sys_info.get("mic_type", sys_info.get("type")):
config.connection_type = DeviceConnectionParameters.from_values(
device_family=device_type,
encryption_type=DeviceEncryptionType.Xor.value,
)
sys_info = _extract_sys_info(info)
device_type = sys_info.get("mic_type", sys_info.get("type"))
login_version = (
sys_info.get("stream_version") if device_type == "IOT.IPCAMERA" else None
)
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.update_from_discover_info(info)
return device
Expand Down
2 changes: 2 additions & 0 deletions kasa/iot/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Package for supporting legacy kasa devices."""

from .iotbulb import IotBulb
from .iotcamera import IotCamera
from .iotdevice import IotDevice
from .iotdimmer import IotDimmer
from .iotlightstrip import IotLightStrip
Expand All @@ -15,4 +16,5 @@
"IotDimmer",
"IotLightStrip",
"IotWallSwitch",
"IotCamera",
]
42 changes: 42 additions & 0 deletions kasa/iot/iotcamera.py
Original file line number Diff line number Diff line change
@@ -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"])

Check warning on line 32 in kasa/iot/iotcamera.py

View check run for this annotation

Codecov / codecov/patch

kasa/iot/iotcamera.py#L32

Added line #L32 was not covered by tests

@property
def timezone(self) -> tzinfo:
"""Get the camera's timezone."""
return None # type: ignore

Check warning on line 37 in kasa/iot/iotcamera.py

View check run for this annotation

Codecov / codecov/patch

kasa/iot/iotcamera.py#L37

Added line #L37 was not covered by tests

@property # type: ignore
def is_on(self) -> bool:
"""Return whether device is on."""
return True

Check warning on line 42 in kasa/iot/iotcamera.py

View check run for this annotation

Codecov / codecov/patch

kasa/iot/iotcamera.py#L42

Added line #L42 was not covered by tests
22 changes: 18 additions & 4 deletions kasa/iot/iotdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@
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

Check warning on line 79 in kasa/iot/iotdevice.py

View check run for this annotation

Codecov / codecov/patch

kasa/iot/iotdevice.py#L79

Added line #L79 was not covered by tests
return sysinfo_default


class IotDevice(Device):
"""Base class for all supported device types.

Expand Down Expand Up @@ -304,14 +314,14 @@
_LOGGER.debug("Performing the initial update to obtain sysinfo")
response = await self.protocol.query(req)
self._last_update = response
self._set_sys_info(response["system"]["get_sysinfo"])
self._set_sys_info(_extract_sys_info(response))

if not self._modules:
await self._initialize_modules()

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():
await module._post_update_hook()

Expand Down Expand Up @@ -705,10 +715,13 @@
@staticmethod
def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType:
"""Find SmartDevice subclass for device described by passed data."""
if "system" in info.get("system", {}).get("get_sysinfo", {}):
return DeviceType.Camera

Check warning on line 719 in kasa/iot/iotdevice.py

View check run for this annotation

Codecov / codecov/patch

kasa/iot/iotdevice.py#L719

Added line #L719 was not covered by tests

if "system" not in info or "get_sysinfo" not in info["system"]:
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"))
if type_ is None:
raise KasaException("Unable to find the device type field!")
Expand All @@ -728,6 +741,7 @@
return DeviceType.LightStrip

return DeviceType.Bulb

_LOGGER.warning("Unknown device type %s, falling back to plug", type_)
return DeviceType.Plug

Expand All @@ -736,7 +750,7 @@
info: dict[str, Any], discovery_info: dict[str, Any] | None
) -> _DeviceInfo:
"""Get model information for a device."""
sys_info = info["system"]["get_sysinfo"]
sys_info = _extract_sys_info(info)

# Get model and region info
region = None
Expand Down
2 changes: 2 additions & 0 deletions kasa/transports/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .aestransport import AesEncyptionSession, AesTransport
from .basetransport import BaseTransport
from .klaptransport import KlapTransport, KlapTransportV2
from .linkietransport import LinkieTransportV2
from .ssltransport import SslTransport
from .xortransport import XorEncryption, XorTransport

Expand All @@ -13,6 +14,7 @@
"BaseTransport",
"KlapTransport",
"KlapTransportV2",
"LinkieTransportV2",
"XorTransport",
"XorEncryption",
]
143 changes: 143 additions & 0 deletions kasa/transports/linkietransport.py
Original file line number Diff line number Diff line change
@@ -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()

Check warning on line 112 in kasa/transports/linkietransport.py

View check run for this annotation

Codecov / codecov/patch

kasa/transports/linkietransport.py#L112

Added line #L112 was not covered by tests

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
Loading
Loading