mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +00:00
a01247d48f
Python 3.11 ships with latest Debian Bookworm. pypy is not that widely used with this library based on statistics. It could be added back when pypy supports python 3.11.
236 lines
8.3 KiB
Python
236 lines
8.3 KiB
Python
"""Configuration for connecting directly to a device without discovery.
|
|
|
|
If you are connecting to a newer KASA or TAPO device you can get the device
|
|
via discovery or connect directly with :class:`DeviceConfig`.
|
|
|
|
Discovery returns a list of discovered devices:
|
|
|
|
>>> from kasa import Discover, Device
|
|
>>> device = await Discover.discover_single(
|
|
>>> "127.0.0.3",
|
|
>>> username="user@example.com",
|
|
>>> password="great_password",
|
|
>>> )
|
|
>>> print(device.alias) # Alias is None because update() has not been called
|
|
None
|
|
|
|
>>> config_dict = device.config.to_dict()
|
|
>>> # DeviceConfig.to_dict() can be used to store for later
|
|
>>> print(config_dict)
|
|
{'host': '127.0.0.3', 'timeout': 5, 'credentials': Credentials(), 'connection_type'\
|
|
: {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'https': False, \
|
|
'login_version': 2}, 'uses_http': True}
|
|
|
|
>>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict))
|
|
>>> print(later_device.alias) # Alias is available as connect() calls update()
|
|
Living Room Bulb
|
|
|
|
"""
|
|
|
|
# Module cannot use from __future__ import annotations until migrated to mashumaru
|
|
# as dataclass.fields() will not resolve the type.
|
|
import logging
|
|
from dataclasses import asdict, dataclass, field, fields, is_dataclass
|
|
from enum import Enum
|
|
from typing import TYPE_CHECKING, Any, Optional, TypedDict
|
|
|
|
from .credentials import Credentials
|
|
from .exceptions import KasaException
|
|
|
|
if TYPE_CHECKING:
|
|
from aiohttp import ClientSession
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class KeyPairDict(TypedDict):
|
|
"""Class to represent a public/private key pair."""
|
|
|
|
private: str
|
|
public: str
|
|
|
|
|
|
class DeviceEncryptionType(Enum):
|
|
"""Encrypt type enum."""
|
|
|
|
Klap = "KLAP"
|
|
Aes = "AES"
|
|
Xor = "XOR"
|
|
|
|
|
|
class DeviceFamily(Enum):
|
|
"""Encrypt type enum."""
|
|
|
|
IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH"
|
|
IotSmartBulb = "IOT.SMARTBULB"
|
|
SmartKasaPlug = "SMART.KASAPLUG"
|
|
SmartKasaSwitch = "SMART.KASASWITCH"
|
|
SmartTapoPlug = "SMART.TAPOPLUG"
|
|
SmartTapoBulb = "SMART.TAPOBULB"
|
|
SmartTapoSwitch = "SMART.TAPOSWITCH"
|
|
SmartTapoHub = "SMART.TAPOHUB"
|
|
SmartKasaHub = "SMART.KASAHUB"
|
|
SmartIpCamera = "SMART.IPCAMERA"
|
|
|
|
|
|
def _dataclass_from_dict(klass: Any, in_val: dict) -> Any:
|
|
if is_dataclass(klass):
|
|
fieldtypes = {f.name: f.type for f in fields(klass)}
|
|
val = {}
|
|
for dict_key in in_val:
|
|
if dict_key in fieldtypes:
|
|
if hasattr(fieldtypes[dict_key], "from_dict"):
|
|
val[dict_key] = fieldtypes[dict_key].from_dict(in_val[dict_key]) # type: ignore[union-attr]
|
|
else:
|
|
val[dict_key] = _dataclass_from_dict(
|
|
fieldtypes[dict_key], in_val[dict_key]
|
|
)
|
|
else:
|
|
raise KasaException(
|
|
f"Cannot create dataclass from dict, unknown key: {dict_key}"
|
|
)
|
|
return klass(**val) # type: ignore[operator]
|
|
else:
|
|
return in_val
|
|
|
|
|
|
def _dataclass_to_dict(in_val: Any) -> dict:
|
|
fieldtypes = {f.name: f.type for f in fields(in_val) if f.compare}
|
|
out_val = {}
|
|
for field_name in fieldtypes:
|
|
val = getattr(in_val, field_name)
|
|
if val is None:
|
|
continue
|
|
elif hasattr(val, "to_dict"):
|
|
out_val[field_name] = val.to_dict()
|
|
elif is_dataclass(fieldtypes[field_name]):
|
|
out_val[field_name] = asdict(val)
|
|
else:
|
|
out_val[field_name] = val
|
|
return out_val
|
|
|
|
|
|
@dataclass
|
|
class DeviceConnectionParameters:
|
|
"""Class to hold the the parameters determining connection type."""
|
|
|
|
device_family: DeviceFamily
|
|
encryption_type: DeviceEncryptionType
|
|
login_version: int | None = None
|
|
https: bool = False
|
|
|
|
@staticmethod
|
|
def from_values(
|
|
device_family: str,
|
|
encryption_type: str,
|
|
login_version: int | None = None,
|
|
https: bool | None = 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(
|
|
f"Invalid connection parameters for {device_family}."
|
|
+ f"{encryption_type}.{login_version}"
|
|
) from ex
|
|
|
|
@staticmethod
|
|
def from_dict(connection_type_dict: dict[str, Any]) -> "DeviceConnectionParameters":
|
|
"""Return connection parameters from dict."""
|
|
if (
|
|
isinstance(connection_type_dict, dict)
|
|
and (device_family := connection_type_dict.get("device_family"))
|
|
and (encryption_type := connection_type_dict.get("encryption_type"))
|
|
):
|
|
if login_version := connection_type_dict.get("login_version"):
|
|
login_version = int(login_version) # type: ignore[assignment]
|
|
return DeviceConnectionParameters.from_values(
|
|
device_family,
|
|
encryption_type,
|
|
login_version, # type: ignore[arg-type]
|
|
connection_type_dict.get("https", False),
|
|
)
|
|
|
|
raise KasaException(f"Invalid connection type data for {connection_type_dict}")
|
|
|
|
def to_dict(self) -> dict[str, str | int | bool]:
|
|
"""Convert connection params to dict."""
|
|
result: dict[str, str | int] = {
|
|
"device_family": self.device_family.value,
|
|
"encryption_type": self.encryption_type.value,
|
|
"https": self.https,
|
|
}
|
|
if self.login_version:
|
|
result["login_version"] = self.login_version
|
|
return result
|
|
|
|
|
|
@dataclass
|
|
class DeviceConfig:
|
|
"""Class to represent paramaters that determine how to connect to devices."""
|
|
|
|
DEFAULT_TIMEOUT = 5
|
|
#: IP address or hostname
|
|
host: str
|
|
#: Timeout for querying the device
|
|
timeout: int | None = DEFAULT_TIMEOUT
|
|
#: Override the default 9999 port to support port forwarding
|
|
port_override: int | None = None
|
|
#: Credentials for devices requiring authentication
|
|
credentials: Credentials | None = None
|
|
#: Credentials hash for devices requiring authentication.
|
|
#: If credentials are also supplied they take precendence over credentials_hash.
|
|
#: Credentials hash can be retrieved from :attr:`Device.credentials_hash`
|
|
credentials_hash: str | None = None
|
|
#: The protocol specific type of connection. Defaults to the legacy type.
|
|
batch_size: int | None = None
|
|
#: The batch size for protoools supporting multiple request batches.
|
|
connection_type: DeviceConnectionParameters = field(
|
|
default_factory=lambda: DeviceConnectionParameters(
|
|
DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor
|
|
)
|
|
)
|
|
#: True if the device uses http. Consumers should retrieve rather than set this
|
|
#: in order to determine whether they should pass a custom http client if desired.
|
|
uses_http: bool = False
|
|
|
|
# compare=False will be excluded from the serialization and object comparison.
|
|
#: Set a custom http_client for the device to use.
|
|
http_client: Optional["ClientSession"] = field(default=None, compare=False)
|
|
|
|
aes_keys: KeyPairDict | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.connection_type is None:
|
|
self.connection_type = DeviceConnectionParameters(
|
|
DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor
|
|
)
|
|
|
|
def to_dict(
|
|
self,
|
|
*,
|
|
credentials_hash: str | None = None,
|
|
exclude_credentials: bool = False,
|
|
) -> dict[str, dict[str, str]]:
|
|
"""Convert device config to dict."""
|
|
if credentials_hash is not None or exclude_credentials:
|
|
self.credentials = None
|
|
if credentials_hash:
|
|
self.credentials_hash = credentials_hash
|
|
return _dataclass_to_dict(self)
|
|
|
|
@staticmethod
|
|
def from_dict(config_dict: dict[str, dict[str, str]]) -> "DeviceConfig":
|
|
"""Return device config from dict."""
|
|
if isinstance(config_dict, dict):
|
|
return _dataclass_from_dict(DeviceConfig, config_dict)
|
|
raise KasaException(f"Invalid device config data: {config_dict}")
|