Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
39 changes: 37 additions & 2 deletions kasa/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"strip",
"lightstrip",
"smart",
"camera",
]

ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType]
Expand Down Expand Up @@ -172,6 +173,14 @@ def _legacy_type_to_class(_type):
type=int,
help="The login version for device authentication. Defaults to 2",
)
@click.option(
"--https/--no-https",
envvar="KASA_HTTPS",
default=False,
is_flag=True,
type=bool,
help="Set flag if the device encryption uses https.",
)
@click.option(
"--timeout",
envvar="KASA_TIMEOUT",
Expand Down Expand Up @@ -209,6 +218,14 @@ def _legacy_type_to_class(_type):
envvar="KASA_CREDENTIALS_HASH",
help="Hashed credentials used to authenticate to the device.",
)
@click.option(
"--experimental",
default=False,
is_flag=True,
type=bool,
envvar="KASA_EXPERIMENTAL",
help="Enable experimental mode for devices not yet fully supported.",
)
@click.version_option(package_name="python-kasa")
@click.pass_context
async def cli(
Expand All @@ -221,6 +238,7 @@ async def cli(
debug,
type,
encrypt_type,
https,
device_family,
login_version,
json,
Expand All @@ -229,6 +247,7 @@ async def cli(
username,
password,
credentials_hash,
experimental,
):
"""A tool for controlling TP-Link smart home devices.""" # noqa
# no need to perform any checks if we are just displaying the help
Expand All @@ -237,6 +256,11 @@ async def cli(
ctx.obj = object()
return

if experimental:
from kasa.experimental.enabled import Enabled

Enabled.set(True)

logging_config: dict[str, Any] = {
"level": logging.DEBUG if debug > 0 else logging.INFO
}
Expand Down Expand Up @@ -295,12 +319,21 @@ async def cli(
return await ctx.invoke(discover)

device_updated = False
if type is not None and type != "smart":
if type is not None and type not in {"smart", "camera"}:
from kasa.deviceconfig import DeviceConfig

config = DeviceConfig(host=host, port_override=port, timeout=timeout)
dev = _legacy_type_to_class(type)(host, config=config)
elif type == "smart" or (device_family and encrypt_type):
elif type in {"smart", "camera"} or (device_family and encrypt_type):
if type == "camera":
if not experimental:
error(
"Camera is an experimental type, please enable with --experimental"
)
encrypt_type = "AES"
https = True
device_family = "SMART.IPCAMERA"

from kasa.device import Device
from kasa.deviceconfig import (
DeviceConfig,
Expand All @@ -311,10 +344,12 @@ async def cli(

if not encrypt_type:
encrypt_type = "KLAP"

ctype = DeviceConnectionParameters(
DeviceFamily(device_family),
DeviceEncryptionType(encrypt_type),
login_version,
https,
)
config = DeviceConfig(
host=host,
Expand Down
25 changes: 17 additions & 8 deletions kasa/device_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
from .device_type import DeviceType
from .deviceconfig import DeviceConfig
from .exceptions import KasaException, UnsupportedDeviceError
from .experimental.smartcamera import SmartCamera
from .experimental.smartcameraprotocol import SmartCameraProtocol
from .experimental.sslaestransport import SslAesTransport
from .iot import (
IotBulb,
IotDevice,
Expand Down Expand Up @@ -171,6 +174,7 @@ def get_device_class_from_family(device_type: str) -> type[Device] | None:
"SMART.TAPOHUB": SmartDevice,
"SMART.KASAHUB": SmartDevice,
"SMART.KASASWITCH": SmartDevice,
"SMART.IPCAMERA": SmartCamera,
"IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": IotBulb,
}
Expand All @@ -188,8 +192,12 @@ def get_protocol(
) -> BaseProtocol | None:
"""Return the protocol from the connection name."""
protocol_name = config.connection_type.device_family.value.split(".")[0]
ctype = config.connection_type
protocol_transport_key = (
protocol_name + "." + config.connection_type.encryption_type.value
protocol_name
+ "."
+ ctype.encryption_type.value
+ (".HTTPS" if ctype.https else "")
)
supported_device_protocols: dict[
str, tuple[type[BaseProtocol], type[BaseTransport]]
Expand All @@ -199,10 +207,11 @@ def get_protocol(
"SMART.AES": (SmartProtocol, AesTransport),
"SMART.KLAP": (SmartProtocol, KlapTransportV2),
}
if protocol_transport_key not in supported_device_protocols:
return None

protocol_class, transport_class = supported_device_protocols.get(
protocol_transport_key
) # type: ignore
return protocol_class(transport=transport_class(config=config))
if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)):
from .experimental.enabled import Enabled

if Enabled.value and protocol_transport_key == "SMART.AES.HTTPS":
prot_tran_cls = (SmartCameraProtocol, SslAesTransport)
else:
return None
return prot_tran_cls[0](transport=prot_tran_cls[1](config=config))
1 change: 1 addition & 0 deletions kasa/device_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class DeviceType(Enum):
Plug = "plug"
Bulb = "bulb"
Strip = "strip"
Camera = "camera"
WallSwitch = "wallswitch"
StripSocket = "stripsocket"
Dimmer = "dimmer"
Expand Down
6 changes: 6 additions & 0 deletions kasa/deviceconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class DeviceFamily(Enum):
SmartTapoSwitch = "SMART.TAPOSWITCH"
SmartTapoHub = "SMART.TAPOHUB"
SmartKasaHub = "SMART.KASAHUB"
SmartIpCamera = "SMART.IPCAMERA"


def _dataclass_from_dict(klass, in_val):
Expand Down Expand Up @@ -118,19 +119,24 @@ class DeviceConnectionParameters:
device_family: DeviceFamily
encryption_type: DeviceEncryptionType
login_version: Optional[int] = None
https: bool = False

@staticmethod
def from_values(
device_family: str,
encryption_type: str,
login_version: Optional[int] = None,
https: Optional[bool] = None,
) -> "DeviceConnectionParameters":
"""Return connection parameters from string values."""
try:
if https is None:
https = False
return DeviceConnectionParameters(
DeviceFamily(device_family),
DeviceEncryptionType(encryption_type),
login_version,
https,
)
except (ValueError, TypeError) as ex:
raise KasaException(
Expand Down
1 change: 1 addition & 0 deletions kasa/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@ def _get_device_instance(
type_,
encrypt_type,
discovery_result.mgt_encrypt_schm.lv,
discovery_result.mgt_encrypt_schm.is_support_https,
)
except KasaException as ex:
raise UnsupportedDeviceError(
Expand Down
1 change: 1 addition & 0 deletions kasa/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Package for experimental."""
12 changes: 12 additions & 0 deletions kasa/experimental/enabled.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Package for experimental enabled."""


class Enabled:
"""Class for enabling experimental functionality."""

value = False

@classmethod
def set(cls, value):
"""Set the enabled value."""
cls.value = value
84 changes: 84 additions & 0 deletions kasa/experimental/smartcamera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Module for smartcamera."""

from __future__ import annotations

from ..device_type import DeviceType
from ..smart import SmartDevice
from .sslaestransport import SmartErrorCode


class SmartCamera(SmartDevice):
"""Class for smart cameras."""

async def update(self, update_children: bool = False):
"""Update the device."""
initial_query = {
"getDeviceInfo": {"device_info": {"name": ["basic_info", "info"]}},
"getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}},
}
resp = await self.protocol.query(initial_query)
self._last_update.update(resp)
info = self._try_get_response(resp, "getDeviceInfo")
self._info = self._map_info(info["device_info"])
self._last_update = resp

def _map_info(self, device_info: dict) -> dict:
basic_info = device_info["basic_info"]
return {
"model": basic_info["device_model"],
"type": basic_info["device_type"],
"alias": basic_info["device_alias"],
"fw_ver": basic_info["sw_version"],
"hw_ver": basic_info["hw_version"],
"mac": basic_info["mac"],
"hwId": basic_info["hw_id"],
"oem_id": basic_info["oem_id"],
}

@property
def is_on(self) -> bool:
"""Return true if the device is on."""
if isinstance(self._last_update["getLensMaskConfig"], SmartErrorCode):
return True
return (
self._last_update["getLensMaskConfig"]["lens_mask"]["lens_mask_info"][
"enabled"
]
== "on"
)

async def set_state(self, on: bool):
"""Set the device state."""
if isinstance(self._last_update["getLensMaskConfig"], SmartErrorCode):
return
query = {
"setLensMaskConfig": {
"lens_mask": {"lens_mask_info": {"enabled": "on" if on else "off"}}
},
}
return await self.protocol.query(query)

@property
def device_type(self) -> DeviceType:
"""Return the device type."""
return DeviceType.Camera

@property
def alias(self) -> str | None:
"""Returns the device alias or nickname."""
if self._info:
return self._info.get("alias")
return None

@property
def hw_info(self) -> dict:
"""Return hardware info for the device."""
return {
"sw_ver": self._info.get("hw_ver"),
"hw_ver": self._info.get("fw_ver"),
"mac": self._info.get("mac"),
"type": self._info.get("type"),
"hwId": self._info.get("hwId"),
"dev_name": self.alias,
"oemId": self._info.get("oem_id"),
}
Loading