mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-04-26 16:46:23 +00:00
Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport (#710)
* Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport * Add test * Update docs * Fix ruff deleting deprecated import
This commit is contained in:
parent
c318303255
commit
0d0f56414c
@ -9,7 +9,7 @@ import dpkt
|
|||||||
from dpkt.ethernet import ETH_TYPE_IP, Ethernet
|
from dpkt.ethernet import ETH_TYPE_IP, Ethernet
|
||||||
|
|
||||||
from kasa.cli import echo
|
from kasa.cli import echo
|
||||||
from kasa.protocol import TPLinkSmartHomeProtocol
|
from kasa.xortransport import XorEncryption
|
||||||
|
|
||||||
|
|
||||||
def read_payloads_from_file(file):
|
def read_payloads_from_file(file):
|
||||||
@ -34,7 +34,7 @@ def read_payloads_from_file(file):
|
|||||||
data = transport.data
|
data = transport.data
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decrypted = TPLinkSmartHomeProtocol.decrypt(data[4:])
|
decrypted = XorEncryption.decrypt(data[4:])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
echo(f"[red]Unable to decrypt the data, ignoring: {ex}[/red]")
|
echo(f"[red]Unable to decrypt the data, ignoring: {ex}[/red]")
|
||||||
continue
|
continue
|
||||||
|
@ -86,7 +86,7 @@ Also in 2023 TP-Link started releasing newer Kasa branded devices using the ``SM
|
|||||||
This appears to be driven by hardware version rather than firmware.
|
This appears to be driven by hardware version rather than firmware.
|
||||||
|
|
||||||
|
|
||||||
In order to support these different configurations the library migrated from a single :class:`TPLinkSmartHomeProtocol <kasa.protocol.TPLinkSmartHomeProtocol>`
|
In order to support these different configurations the library migrated from a single protocol class ``TPLinkSmartHomeProtocol``
|
||||||
to support pluggable transports and protocols.
|
to support pluggable transports and protocols.
|
||||||
The classes providing this functionality are:
|
The classes providing this functionality are:
|
||||||
|
|
||||||
@ -95,6 +95,7 @@ The classes providing this functionality are:
|
|||||||
- :class:`SmartProtocol <kasa.smartprotocol.SmartProtocol>`
|
- :class:`SmartProtocol <kasa.smartprotocol.SmartProtocol>`
|
||||||
|
|
||||||
- :class:`BaseTransport <kasa.protocol.BaseTransport>`
|
- :class:`BaseTransport <kasa.protocol.BaseTransport>`
|
||||||
|
- :class:`XorTransport <kasa.xortransport.XorTransport>`
|
||||||
- :class:`AesTransport <kasa.aestransport.AesTransport>`
|
- :class:`AesTransport <kasa.aestransport.AesTransport>`
|
||||||
- :class:`KlapTransport <kasa.klaptransport.KlapTransport>`
|
- :class:`KlapTransport <kasa.klaptransport.KlapTransport>`
|
||||||
- :class:`KlapTransportV2 <kasa.klaptransport.KlapTransportV2>`
|
- :class:`KlapTransportV2 <kasa.klaptransport.KlapTransportV2>`
|
||||||
@ -134,6 +135,11 @@ API documentation for protocols and transports
|
|||||||
:inherited-members:
|
:inherited-members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
|
|
||||||
|
.. autoclass:: kasa.xortransport.XorTransport
|
||||||
|
:members:
|
||||||
|
:inherited-members:
|
||||||
|
:undoc-members:
|
||||||
|
|
||||||
.. autoclass:: kasa.klaptransport.KlapTransport
|
.. autoclass:: kasa.klaptransport.KlapTransport
|
||||||
:members:
|
:members:
|
||||||
:inherited-members:
|
:inherited-members:
|
||||||
@ -148,8 +154,3 @@ API documentation for protocols and transports
|
|||||||
:members:
|
:members:
|
||||||
:inherited-members:
|
:inherited-members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
|
|
||||||
.. autoclass:: kasa.protocol.TPLinkSmartHomeProtocol
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
|
@ -12,6 +12,7 @@ Module-specific errors are raised as `SmartDeviceException` and are expected
|
|||||||
to be handled by the user of the library.
|
to be handled by the user of the library.
|
||||||
"""
|
"""
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
from warnings import warn
|
||||||
|
|
||||||
from kasa.credentials import Credentials
|
from kasa.credentials import Credentials
|
||||||
from kasa.deviceconfig import (
|
from kasa.deviceconfig import (
|
||||||
@ -28,8 +29,11 @@ from kasa.exceptions import (
|
|||||||
TimeoutException,
|
TimeoutException,
|
||||||
UnsupportedDeviceException,
|
UnsupportedDeviceException,
|
||||||
)
|
)
|
||||||
from kasa.iotprotocol import IotProtocol
|
from kasa.iotprotocol import (
|
||||||
from kasa.protocol import BaseProtocol, TPLinkSmartHomeProtocol
|
IotProtocol,
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol, # noqa: F401
|
||||||
|
)
|
||||||
|
from kasa.protocol import BaseProtocol
|
||||||
from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors
|
from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors
|
||||||
from kasa.smartdevice import DeviceType, SmartDevice
|
from kasa.smartdevice import DeviceType, SmartDevice
|
||||||
from kasa.smartdimmer import SmartDimmer
|
from kasa.smartdimmer import SmartDimmer
|
||||||
@ -43,7 +47,6 @@ __version__ = version("python-kasa")
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Discover",
|
"Discover",
|
||||||
"TPLinkSmartHomeProtocol",
|
|
||||||
"BaseProtocol",
|
"BaseProtocol",
|
||||||
"IotProtocol",
|
"IotProtocol",
|
||||||
"SmartProtocol",
|
"SmartProtocol",
|
||||||
@ -68,3 +71,12 @@ __all__ = [
|
|||||||
"EncryptType",
|
"EncryptType",
|
||||||
"DeviceFamilyType",
|
"DeviceFamilyType",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
deprecated_names = ["TPLinkSmartHomeProtocol"]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name in deprecated_names:
|
||||||
|
warn(f"{name} is deprecated", DeprecationWarning, stacklevel=1)
|
||||||
|
return globals()[f"_deprecated_{name}"]
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
@ -11,8 +11,6 @@ from .klaptransport import KlapTransport, KlapTransportV2
|
|||||||
from .protocol import (
|
from .protocol import (
|
||||||
BaseProtocol,
|
BaseProtocol,
|
||||||
BaseTransport,
|
BaseTransport,
|
||||||
TPLinkSmartHomeProtocol,
|
|
||||||
_XorTransport,
|
|
||||||
)
|
)
|
||||||
from .smartbulb import SmartBulb
|
from .smartbulb import SmartBulb
|
||||||
from .smartdevice import SmartDevice
|
from .smartdevice import SmartDevice
|
||||||
@ -22,6 +20,7 @@ from .smartplug import SmartPlug
|
|||||||
from .smartprotocol import SmartProtocol
|
from .smartprotocol import SmartProtocol
|
||||||
from .smartstrip import SmartStrip
|
from .smartstrip import SmartStrip
|
||||||
from .tapo import TapoBulb, TapoPlug
|
from .tapo import TapoBulb, TapoPlug
|
||||||
|
from .xortransport import XorTransport
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -76,7 +75,9 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Smart
|
|||||||
|
|
||||||
device_class: Optional[Type[SmartDevice]]
|
device_class: Optional[Type[SmartDevice]]
|
||||||
|
|
||||||
if isinstance(protocol, TPLinkSmartHomeProtocol):
|
if isinstance(protocol, IotProtocol) and isinstance(
|
||||||
|
protocol._transport, XorTransport
|
||||||
|
):
|
||||||
info = await protocol.query(GET_SYSINFO_QUERY)
|
info = await protocol.query(GET_SYSINFO_QUERY)
|
||||||
_perf_log(True, "get_sysinfo")
|
_perf_log(True, "get_sysinfo")
|
||||||
device_class = get_device_class_from_sys_info(info)
|
device_class = get_device_class_from_sys_info(info)
|
||||||
@ -151,7 +152,7 @@ def get_protocol(
|
|||||||
supported_device_protocols: Dict[
|
supported_device_protocols: Dict[
|
||||||
str, Tuple[Type[BaseProtocol], Type[BaseTransport]]
|
str, Tuple[Type[BaseProtocol], Type[BaseTransport]]
|
||||||
] = {
|
] = {
|
||||||
"IOT.XOR": (TPLinkSmartHomeProtocol, _XorTransport),
|
"IOT.XOR": (IotProtocol, XorTransport),
|
||||||
"IOT.KLAP": (IotProtocol, KlapTransport),
|
"IOT.KLAP": (IotProtocol, KlapTransport),
|
||||||
"SMART.AES": (SmartProtocol, AesTransport),
|
"SMART.AES": (SmartProtocol, AesTransport),
|
||||||
"SMART.KLAP": (SmartProtocol, KlapTransportV2),
|
"SMART.KLAP": (SmartProtocol, KlapTransportV2),
|
||||||
|
@ -25,8 +25,8 @@ from kasa.deviceconfig import ConnectionType, DeviceConfig, EncryptType
|
|||||||
from kasa.exceptions import TimeoutException, UnsupportedDeviceException
|
from kasa.exceptions import TimeoutException, UnsupportedDeviceException
|
||||||
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
|
||||||
from kasa.protocol import TPLinkSmartHomeProtocol
|
|
||||||
from kasa.smartdevice import SmartDevice, SmartDeviceException
|
from kasa.smartdevice import SmartDevice, SmartDeviceException
|
||||||
|
from kasa.xortransport import XorEncryption
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -103,7 +103,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
|
|||||||
"""Send number of discovery datagrams."""
|
"""Send number of discovery datagrams."""
|
||||||
req = json_dumps(Discover.DISCOVERY_QUERY)
|
req = json_dumps(Discover.DISCOVERY_QUERY)
|
||||||
_LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY)
|
_LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY)
|
||||||
encrypted_req = TPLinkSmartHomeProtocol.encrypt(req)
|
encrypted_req = XorEncryption.encrypt(req)
|
||||||
sleep_between_packets = self.discovery_timeout / self.discovery_packets
|
sleep_between_packets = self.discovery_timeout / self.discovery_packets
|
||||||
for i in range(self.discovery_packets):
|
for i in range(self.discovery_packets):
|
||||||
if self.target in self.seen_hosts: # Stop sending for discover_single
|
if self.target in self.seen_hosts: # Stop sending for discover_single
|
||||||
@ -400,7 +400,7 @@ class Discover:
|
|||||||
def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevice:
|
def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevice:
|
||||||
"""Get SmartDevice from legacy 9999 response."""
|
"""Get SmartDevice from legacy 9999 response."""
|
||||||
try:
|
try:
|
||||||
info = json_loads(TPLinkSmartHomeProtocol.decrypt(data))
|
info = json_loads(XorEncryption.decrypt(data))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise SmartDeviceException(
|
raise SmartDeviceException(
|
||||||
f"Unable to read response from device: {config.host}: {ex}"
|
f"Unable to read response from device: {config.host}: {ex}"
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
"""Module for the IOT legacy IOT KASA protocol."""
|
"""Module for the IOT legacy IOT KASA protocol."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Union
|
from typing import Dict, Optional, Union
|
||||||
|
|
||||||
|
from .deviceconfig import DeviceConfig
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
AuthenticationException,
|
AuthenticationException,
|
||||||
ConnectionException,
|
ConnectionException,
|
||||||
@ -12,6 +13,7 @@ from .exceptions import (
|
|||||||
)
|
)
|
||||||
from .json import dumps as json_dumps
|
from .json import dumps as json_dumps
|
||||||
from .protocol import BaseProtocol, BaseTransport
|
from .protocol import BaseProtocol, BaseTransport
|
||||||
|
from .xortransport import XorEncryption, XorTransport
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -86,3 +88,43 @@ class IotProtocol(BaseProtocol):
|
|||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Close the underlying transport."""
|
"""Close the underlying transport."""
|
||||||
await self._transport.close()
|
await self._transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
class _deprecated_TPLinkSmartHomeProtocol(IotProtocol):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
port: Optional[int] = None,
|
||||||
|
timeout: Optional[int] = None,
|
||||||
|
transport: Optional[BaseTransport] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Create a protocol object."""
|
||||||
|
if not host and not transport:
|
||||||
|
raise SmartDeviceException("host or transport must be supplied")
|
||||||
|
if not transport:
|
||||||
|
config = DeviceConfig(
|
||||||
|
host=host, # type: ignore[arg-type]
|
||||||
|
port_override=port,
|
||||||
|
timeout=timeout or XorTransport.DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
transport = XorTransport(config=config)
|
||||||
|
super().__init__(transport=transport)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encrypt(request: str) -> bytes:
|
||||||
|
"""Encrypt a request for a TP-Link Smart Home Device.
|
||||||
|
|
||||||
|
:param request: plaintext request data
|
||||||
|
:return: ciphertext to be send over wire, in bytes
|
||||||
|
"""
|
||||||
|
return XorEncryption.encrypt(request)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decrypt(ciphertext: bytes) -> str:
|
||||||
|
"""Decrypt a response of a TP-Link Smart Home Device.
|
||||||
|
|
||||||
|
:param ciphertext: encrypted response data
|
||||||
|
:return: plaintext response
|
||||||
|
"""
|
||||||
|
return XorEncryption.decrypt(ciphertext)
|
||||||
|
275
kasa/protocol.py
275
kasa/protocol.py
@ -9,27 +9,19 @@ https://github.com/softScheck/tplink-smartplug/
|
|||||||
which are licensed under the Apache License, Version 2.0
|
which are licensed under the Apache License, Version 2.0
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import base64
|
import base64
|
||||||
import contextlib
|
|
||||||
import errno
|
import errno
|
||||||
import logging
|
import logging
|
||||||
import socket
|
|
||||||
import struct
|
import struct
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from pprint import pformat as pf
|
from typing import Dict, Tuple, Union
|
||||||
from typing import Dict, Generator, Optional, Tuple, Union
|
|
||||||
|
|
||||||
# When support for cpython older than 3.11 is dropped
|
# When support for cpython older than 3.11 is dropped
|
||||||
# async_timeout can be replaced with asyncio.timeout
|
# async_timeout can be replaced with asyncio.timeout
|
||||||
from async_timeout import timeout as asyncio_timeout
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
|
||||||
from .credentials import Credentials
|
from .credentials import Credentials
|
||||||
from .deviceconfig import DeviceConfig
|
from .deviceconfig import DeviceConfig
|
||||||
from .exceptions import SmartDeviceException
|
|
||||||
from .json import dumps as json_dumps
|
|
||||||
from .json import loads as json_loads
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED}
|
_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED}
|
||||||
@ -114,262 +106,6 @@ class BaseProtocol(ABC):
|
|||||||
"""Close the protocol. Abstract method to be overriden."""
|
"""Close the protocol. Abstract method to be overriden."""
|
||||||
|
|
||||||
|
|
||||||
class _XorTransport(BaseTransport):
|
|
||||||
"""Implementation of the Xor encryption transport.
|
|
||||||
|
|
||||||
WIP, currently only to ensure consistent __init__ method signatures
|
|
||||||
for protocol classes. Will eventually incorporate the logic from
|
|
||||||
TPLinkSmartHomeProtocol to simplify the API and re-use the IotProtocol
|
|
||||||
class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEFAULT_PORT: int = 9999
|
|
||||||
BLOCK_SIZE = 4
|
|
||||||
|
|
||||||
def __init__(self, *, config: DeviceConfig) -> None:
|
|
||||||
super().__init__(config=config)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def default_port(self):
|
|
||||||
"""Default port for the transport."""
|
|
||||||
return self.DEFAULT_PORT
|
|
||||||
|
|
||||||
@property
|
|
||||||
def credentials_hash(self) -> str:
|
|
||||||
"""The hashed credentials used by the transport."""
|
|
||||||
return ""
|
|
||||||
|
|
||||||
async def send(self, request: str) -> Dict:
|
|
||||||
"""Send a message to the device and return a response."""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
"""Close the transport."""
|
|
||||||
|
|
||||||
async def reset(self) -> None:
|
|
||||||
"""Reset internal state.."""
|
|
||||||
|
|
||||||
|
|
||||||
class TPLinkSmartHomeProtocol(BaseProtocol):
|
|
||||||
"""Implementation of the TP-Link Smart Home protocol."""
|
|
||||||
|
|
||||||
INITIALIZATION_VECTOR = 171
|
|
||||||
DEFAULT_PORT = 9999
|
|
||||||
BLOCK_SIZE = 4
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
transport: BaseTransport,
|
|
||||||
) -> None:
|
|
||||||
"""Create a protocol object."""
|
|
||||||
super().__init__(transport=transport)
|
|
||||||
|
|
||||||
self.reader: Optional[asyncio.StreamReader] = None
|
|
||||||
self.writer: Optional[asyncio.StreamWriter] = None
|
|
||||||
self.query_lock = asyncio.Lock()
|
|
||||||
self.loop: Optional[asyncio.AbstractEventLoop] = None
|
|
||||||
|
|
||||||
self._timeout = self._transport._timeout
|
|
||||||
self._port = self._transport._port
|
|
||||||
|
|
||||||
async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict:
|
|
||||||
"""Request information from a TP-Link SmartHome Device.
|
|
||||||
|
|
||||||
:param str host: host name or ip address of the device
|
|
||||||
:param request: command to send to the device (can be either dict or
|
|
||||||
json string)
|
|
||||||
:param retry_count: how many retries to do in case of failure
|
|
||||||
:return: response dict
|
|
||||||
"""
|
|
||||||
if isinstance(request, dict):
|
|
||||||
request = json_dumps(request)
|
|
||||||
assert isinstance(request, str) # noqa: S101
|
|
||||||
|
|
||||||
async with self.query_lock:
|
|
||||||
return await self._query(request, retry_count, self._timeout) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
async def _connect(self, timeout: int) -> None:
|
|
||||||
"""Try to connect or reconnect to the device."""
|
|
||||||
if self.writer:
|
|
||||||
return
|
|
||||||
self.reader = self.writer = None
|
|
||||||
|
|
||||||
task = asyncio.open_connection(self._host, self._port)
|
|
||||||
async with asyncio_timeout(timeout):
|
|
||||||
self.reader, self.writer = await task
|
|
||||||
sock: socket.socket = self.writer.get_extra_info("socket")
|
|
||||||
# Ensure our packets get sent without delay as we do all
|
|
||||||
# our writes in a single go and we do not want any buffering
|
|
||||||
# which would needlessly delay the request or risk overloading
|
|
||||||
# the buffer on the device
|
|
||||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
||||||
|
|
||||||
async def _execute_query(self, request: str) -> Dict:
|
|
||||||
"""Execute a query on the device and wait for the response."""
|
|
||||||
assert self.writer is not None # noqa: S101
|
|
||||||
assert self.reader is not None # noqa: S101
|
|
||||||
debug_log = _LOGGER.isEnabledFor(logging.DEBUG)
|
|
||||||
if debug_log:
|
|
||||||
_LOGGER.debug("%s >> %s", self._host, request)
|
|
||||||
self.writer.write(TPLinkSmartHomeProtocol.encrypt(request))
|
|
||||||
await self.writer.drain()
|
|
||||||
|
|
||||||
packed_block_size = await self.reader.readexactly(self.BLOCK_SIZE)
|
|
||||||
length = _UNSIGNED_INT_NETWORK_ORDER.unpack(packed_block_size)[0]
|
|
||||||
|
|
||||||
buffer = await self.reader.readexactly(length)
|
|
||||||
response = TPLinkSmartHomeProtocol.decrypt(buffer)
|
|
||||||
json_payload = json_loads(response)
|
|
||||||
if debug_log:
|
|
||||||
_LOGGER.debug("%s << %s", self._host, pf(json_payload))
|
|
||||||
|
|
||||||
return json_payload
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
"""Close the connection."""
|
|
||||||
writer = self.writer
|
|
||||||
self.close_without_wait()
|
|
||||||
if writer:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await writer.wait_closed()
|
|
||||||
|
|
||||||
def close_without_wait(self) -> None:
|
|
||||||
"""Close the connection without waiting for the connection to close."""
|
|
||||||
writer = self.writer
|
|
||||||
self.reader = self.writer = None
|
|
||||||
if writer:
|
|
||||||
writer.close()
|
|
||||||
|
|
||||||
async def reset(self) -> None:
|
|
||||||
"""Reset the transport."""
|
|
||||||
await self.close()
|
|
||||||
|
|
||||||
async def _query(self, request: str, retry_count: int, timeout: int) -> Dict:
|
|
||||||
"""Try to query a device."""
|
|
||||||
#
|
|
||||||
# Most of the time we will already be connected if the device is online
|
|
||||||
# and the connect call will do nothing and return right away
|
|
||||||
#
|
|
||||||
# However, if we get an unrecoverable error (_NO_RETRY_ERRORS and
|
|
||||||
# ConnectionRefusedError) we do not want to keep trying since many
|
|
||||||
# connection open/close operations in the same time frame can block
|
|
||||||
# the event loop.
|
|
||||||
# This is especially import when there are multiple tplink devices being polled.
|
|
||||||
for retry in range(retry_count + 1):
|
|
||||||
try:
|
|
||||||
await self._connect(timeout)
|
|
||||||
except ConnectionRefusedError as ex:
|
|
||||||
await self.reset()
|
|
||||||
raise SmartDeviceException(
|
|
||||||
f"Unable to connect to the device: {self._host}:{self._port}: {ex}"
|
|
||||||
) from ex
|
|
||||||
except OSError as ex:
|
|
||||||
await self.reset()
|
|
||||||
if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count:
|
|
||||||
raise SmartDeviceException(
|
|
||||||
f"Unable to connect to the device:"
|
|
||||||
f" {self._host}:{self._port}: {ex}"
|
|
||||||
) from ex
|
|
||||||
continue
|
|
||||||
except Exception as ex:
|
|
||||||
await self.reset()
|
|
||||||
if retry >= retry_count:
|
|
||||||
_LOGGER.debug("Giving up on %s after %s retries", self._host, retry)
|
|
||||||
raise SmartDeviceException(
|
|
||||||
f"Unable to connect to the device:"
|
|
||||||
f" {self._host}:{self._port}: {ex}"
|
|
||||||
) from ex
|
|
||||||
continue
|
|
||||||
except BaseException as ex:
|
|
||||||
# Likely something cancelled the task so we need to close the connection
|
|
||||||
# as we are not in an indeterminate state
|
|
||||||
self.close_without_wait()
|
|
||||||
_LOGGER.debug(
|
|
||||||
"%s: BaseException during connect, closing connection: %s",
|
|
||||||
self._host,
|
|
||||||
ex,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
assert self.reader is not None # noqa: S101
|
|
||||||
assert self.writer is not None # noqa: S101
|
|
||||||
async with asyncio_timeout(timeout):
|
|
||||||
return await self._execute_query(request)
|
|
||||||
except Exception as ex:
|
|
||||||
await self.reset()
|
|
||||||
if retry >= retry_count:
|
|
||||||
_LOGGER.debug("Giving up on %s after %s retries", self._host, retry)
|
|
||||||
raise SmartDeviceException(
|
|
||||||
f"Unable to query the device {self._host}:{self._port}: {ex}"
|
|
||||||
) from ex
|
|
||||||
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Unable to query the device %s, retrying: %s", self._host, ex
|
|
||||||
)
|
|
||||||
except BaseException as ex:
|
|
||||||
# Likely something cancelled the task so we need to close the connection
|
|
||||||
# as we are not in an indeterminate state
|
|
||||||
self.close_without_wait()
|
|
||||||
_LOGGER.debug(
|
|
||||||
"%s: BaseException during query, closing connection: %s",
|
|
||||||
self._host,
|
|
||||||
ex,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
# make mypy happy, this should never be reached..
|
|
||||||
await self.reset()
|
|
||||||
raise SmartDeviceException("Query reached somehow to unreachable")
|
|
||||||
|
|
||||||
def __del__(self) -> None:
|
|
||||||
if self.writer and self.loop and self.loop.is_running():
|
|
||||||
# Since __del__ will be called when python does
|
|
||||||
# garbage collection is can happen in the event loop thread
|
|
||||||
# or in another thread so we need to make sure the call to
|
|
||||||
# close is called safely with call_soon_threadsafe
|
|
||||||
self.loop.call_soon_threadsafe(self.writer.close)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]:
|
|
||||||
key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR
|
|
||||||
for unencryptedbyte in unencrypted:
|
|
||||||
key = key ^ unencryptedbyte
|
|
||||||
yield key
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def encrypt(request: str) -> bytes:
|
|
||||||
"""Encrypt a request for a TP-Link Smart Home Device.
|
|
||||||
|
|
||||||
:param request: plaintext request data
|
|
||||||
:return: ciphertext to be send over wire, in bytes
|
|
||||||
"""
|
|
||||||
plainbytes = request.encode()
|
|
||||||
return _UNSIGNED_INT_NETWORK_ORDER.pack(len(plainbytes)) + bytes(
|
|
||||||
TPLinkSmartHomeProtocol._xor_payload(plainbytes)
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _xor_encrypted_payload(ciphertext: bytes) -> Generator[int, None, None]:
|
|
||||||
key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR
|
|
||||||
for cipherbyte in ciphertext:
|
|
||||||
plainbyte = key ^ cipherbyte
|
|
||||||
key = cipherbyte
|
|
||||||
yield plainbyte
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def decrypt(ciphertext: bytes) -> str:
|
|
||||||
"""Decrypt a response of a TP-Link Smart Home Device.
|
|
||||||
|
|
||||||
:param ciphertext: encrypted response data
|
|
||||||
:return: plaintext response
|
|
||||||
"""
|
|
||||||
return bytes(
|
|
||||||
TPLinkSmartHomeProtocol._xor_encrypted_payload(ciphertext)
|
|
||||||
).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_credentials(tuple: Tuple[str, str]) -> Credentials:
|
def get_default_credentials(tuple: Tuple[str, str]) -> Credentials:
|
||||||
"""Return decoded default credentials."""
|
"""Return decoded default credentials."""
|
||||||
un = base64.b64decode(tuple[0].encode()).decode()
|
un = base64.b64decode(tuple[0].encode()).decode()
|
||||||
@ -381,12 +117,3 @@ DEFAULT_CREDENTIALS = {
|
|||||||
"KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"),
|
"KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"),
|
||||||
"TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="),
|
"TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to load the kasa_crypt module and if it is available
|
|
||||||
try:
|
|
||||||
from kasa_crypt import decrypt, encrypt
|
|
||||||
|
|
||||||
TPLinkSmartHomeProtocol.decrypt = decrypt # type: ignore[method-assign]
|
|
||||||
TPLinkSmartHomeProtocol.encrypt = encrypt # type: ignore[method-assign]
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
@ -24,8 +24,10 @@ from .device_type import DeviceType
|
|||||||
from .deviceconfig import DeviceConfig
|
from .deviceconfig import DeviceConfig
|
||||||
from .emeterstatus import EmeterStatus
|
from .emeterstatus import EmeterStatus
|
||||||
from .exceptions import SmartDeviceException
|
from .exceptions import SmartDeviceException
|
||||||
|
from .iotprotocol import IotProtocol
|
||||||
from .modules import Emeter, Module
|
from .modules import Emeter, Module
|
||||||
from .protocol import BaseProtocol, TPLinkSmartHomeProtocol, _XorTransport
|
from .protocol import BaseProtocol
|
||||||
|
from .xortransport import XorTransport
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -204,8 +206,8 @@ class SmartDevice:
|
|||||||
"""
|
"""
|
||||||
if config and protocol:
|
if config and protocol:
|
||||||
protocol._transport._config = config
|
protocol._transport._config = config
|
||||||
self.protocol: BaseProtocol = protocol or TPLinkSmartHomeProtocol(
|
self.protocol: BaseProtocol = protocol or IotProtocol(
|
||||||
transport=_XorTransport(config=config or DeviceConfig(host=host)),
|
transport=XorTransport(config=config or DeviceConfig(host=host)),
|
||||||
)
|
)
|
||||||
_LOGGER.debug("Initializing %s of type %s", self.host, type(self))
|
_LOGGER.debug("Initializing %s of type %s", self.host, type(self))
|
||||||
self._device_type = DeviceType.Unknown
|
self._device_type = DeviceType.Unknown
|
||||||
|
@ -20,9 +20,9 @@ from kasa import (
|
|||||||
SmartLightStrip,
|
SmartLightStrip,
|
||||||
SmartPlug,
|
SmartPlug,
|
||||||
SmartStrip,
|
SmartStrip,
|
||||||
TPLinkSmartHomeProtocol,
|
|
||||||
)
|
)
|
||||||
from kasa.tapo import TapoBulb, TapoDevice, TapoPlug
|
from kasa.tapo import TapoBulb, TapoDevice, TapoPlug
|
||||||
|
from kasa.xortransport import XorEncryption
|
||||||
|
|
||||||
from .newfakes import FakeSmartProtocol, FakeTransportProtocol
|
from .newfakes import FakeSmartProtocol, FakeTransportProtocol
|
||||||
|
|
||||||
@ -478,7 +478,7 @@ def discovery_mock(all_fixture_data, mocker):
|
|||||||
device_type = sys_info.get("mic_type") or sys_info.get("type")
|
device_type = sys_info.get("mic_type") or sys_info.get("type")
|
||||||
encrypt_type = "XOR"
|
encrypt_type = "XOR"
|
||||||
login_version = None
|
login_version = None
|
||||||
datagram = TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:]
|
datagram = XorEncryption.encrypt(json_dumps(discovery_data))[4:]
|
||||||
dm = _DiscoveryMock(
|
dm = _DiscoveryMock(
|
||||||
"127.0.0.123",
|
"127.0.0.123",
|
||||||
9999,
|
9999,
|
||||||
@ -517,7 +517,6 @@ def discovery_mock(all_fixture_data, mocker):
|
|||||||
|
|
||||||
mocker.patch("kasa.IotProtocol.query", side_effect=_query)
|
mocker.patch("kasa.IotProtocol.query", side_effect=_query)
|
||||||
mocker.patch("kasa.SmartProtocol.query", side_effect=_query)
|
mocker.patch("kasa.SmartProtocol.query", side_effect=_query)
|
||||||
mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=_query)
|
|
||||||
|
|
||||||
yield dm
|
yield dm
|
||||||
|
|
||||||
|
@ -19,8 +19,10 @@ from voluptuous import (
|
|||||||
from ..credentials import Credentials
|
from ..credentials import Credentials
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
from ..exceptions import SmartDeviceException
|
from ..exceptions import SmartDeviceException
|
||||||
from ..protocol import BaseTransport, TPLinkSmartHomeProtocol, _XorTransport
|
from ..iotprotocol import IotProtocol
|
||||||
|
from ..protocol import BaseTransport
|
||||||
from ..smartprotocol import SmartProtocol
|
from ..smartprotocol import SmartProtocol
|
||||||
|
from ..xortransport import XorTransport
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -381,10 +383,10 @@ class FakeSmartTransport(BaseTransport):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FakeTransportProtocol(TPLinkSmartHomeProtocol):
|
class FakeTransportProtocol(IotProtocol):
|
||||||
def __init__(self, info):
|
def __init__(self, info):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
transport=_XorTransport(
|
transport=XorTransport(
|
||||||
config=DeviceConfig("127.0.0.123"),
|
config=DeviceConfig("127.0.0.123"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -11,7 +11,6 @@ from kasa import (
|
|||||||
EmeterStatus,
|
EmeterStatus,
|
||||||
SmartDevice,
|
SmartDevice,
|
||||||
SmartDeviceException,
|
SmartDeviceException,
|
||||||
TPLinkSmartHomeProtocol,
|
|
||||||
UnsupportedDeviceException,
|
UnsupportedDeviceException,
|
||||||
)
|
)
|
||||||
from kasa.cli import (
|
from kasa.cli import (
|
||||||
|
@ -54,7 +54,6 @@ async def test_connect(
|
|||||||
|
|
||||||
mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data)
|
mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data)
|
||||||
mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data)
|
mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data)
|
||||||
mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data)
|
|
||||||
|
|
||||||
config = DeviceConfig(
|
config = DeviceConfig(
|
||||||
host=host, credentials=Credentials("foor", "bar"), connection_type=ctype
|
host=host, credentials=Credentials("foor", "bar"), connection_type=ctype
|
||||||
@ -87,7 +86,6 @@ async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port):
|
|||||||
default_port = 80 if "discovery_result" in all_fixture_data else 9999
|
default_port = 80 if "discovery_result" in all_fixture_data else 9999
|
||||||
|
|
||||||
ctype, _ = _get_connection_type_device_class(all_fixture_data)
|
ctype, _ = _get_connection_type_device_class(all_fixture_data)
|
||||||
mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data)
|
|
||||||
mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data)
|
mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data)
|
||||||
mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data)
|
mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data)
|
||||||
dev = await connect(config=config)
|
dev = await connect(config=config)
|
||||||
@ -102,7 +100,6 @@ async def test_connect_logs_connect_time(
|
|||||||
ctype, _ = _get_connection_type_device_class(all_fixture_data)
|
ctype, _ = _get_connection_type_device_class(all_fixture_data)
|
||||||
mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data)
|
mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data)
|
||||||
mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data)
|
mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data)
|
||||||
mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data)
|
|
||||||
|
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
config = DeviceConfig(
|
config = DeviceConfig(
|
||||||
@ -118,7 +115,6 @@ async def test_connect_logs_connect_time(
|
|||||||
async def test_connect_query_fails(all_fixture_data: dict, mocker):
|
async def test_connect_query_fails(all_fixture_data: dict, mocker):
|
||||||
"""Make sure that connect fails when query fails."""
|
"""Make sure that connect fails when query fails."""
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException)
|
|
||||||
mocker.patch("kasa.IotProtocol.query", side_effect=SmartDeviceException)
|
mocker.patch("kasa.IotProtocol.query", side_effect=SmartDeviceException)
|
||||||
mocker.patch("kasa.SmartProtocol.query", side_effect=SmartDeviceException)
|
mocker.patch("kasa.SmartProtocol.query", side_effect=SmartDeviceException)
|
||||||
|
|
||||||
@ -138,7 +134,6 @@ async def test_connect_http_client(all_fixture_data, mocker):
|
|||||||
|
|
||||||
mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data)
|
mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data)
|
||||||
mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data)
|
mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data)
|
||||||
mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data)
|
|
||||||
|
|
||||||
http_client = aiohttp.ClientSession()
|
http_client = aiohttp.ClientSession()
|
||||||
|
|
||||||
|
@ -15,7 +15,6 @@ from kasa import (
|
|||||||
Discover,
|
Discover,
|
||||||
SmartDevice,
|
SmartDevice,
|
||||||
SmartDeviceException,
|
SmartDeviceException,
|
||||||
TPLinkSmartHomeProtocol,
|
|
||||||
protocol,
|
protocol,
|
||||||
)
|
)
|
||||||
from kasa.deviceconfig import (
|
from kasa.deviceconfig import (
|
||||||
@ -26,6 +25,7 @@ from kasa.deviceconfig import (
|
|||||||
)
|
)
|
||||||
from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps
|
from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps
|
||||||
from kasa.exceptions import AuthenticationException, UnsupportedDeviceException
|
from kasa.exceptions import AuthenticationException, UnsupportedDeviceException
|
||||||
|
from kasa.xortransport import XorEncryption
|
||||||
|
|
||||||
from .conftest import bulb, bulb_iot, dimmer, lightstrip, new_discovery, plug, strip
|
from .conftest import bulb, bulb_iot, dimmer, lightstrip, new_discovery, plug, strip
|
||||||
|
|
||||||
@ -189,7 +189,7 @@ async def test_discover_invalid_info(msg, data, mocker):
|
|||||||
|
|
||||||
def mock_discover(self):
|
def mock_discover(self):
|
||||||
self.datagram_received(
|
self.datagram_received(
|
||||||
protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(data))[4:], (host, 9999)
|
XorEncryption.encrypt(json_dumps(data))[4:], (host, 9999)
|
||||||
)
|
)
|
||||||
|
|
||||||
mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover)
|
mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover)
|
||||||
@ -212,7 +212,7 @@ async def test_discover_datagram_received(mocker, discovery_data):
|
|||||||
"""Verify that datagram received fills discovered_devices."""
|
"""Verify that datagram received fills discovered_devices."""
|
||||||
proto = _DiscoverProtocol()
|
proto = _DiscoverProtocol()
|
||||||
|
|
||||||
mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt")
|
mocker.patch.object(XorEncryption, "decrypt")
|
||||||
|
|
||||||
addr = "127.0.0.1"
|
addr = "127.0.0.1"
|
||||||
port = 20002 if "result" in discovery_data else 9999
|
port = 20002 if "result" in discovery_data else 9999
|
||||||
@ -238,8 +238,8 @@ async def test_discover_invalid_responses(msg, data, mocker):
|
|||||||
"""Verify that we don't crash whole discovery if some devices in the network are sending unexpected data."""
|
"""Verify that we don't crash whole discovery if some devices in the network are sending unexpected data."""
|
||||||
proto = _DiscoverProtocol()
|
proto = _DiscoverProtocol()
|
||||||
mocker.patch("kasa.discover.json_loads", return_value=data)
|
mocker.patch("kasa.discover.json_loads", return_value=data)
|
||||||
mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "encrypt")
|
mocker.patch.object(XorEncryption, "encrypt")
|
||||||
mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "decrypt")
|
mocker.patch.object(XorEncryption, "decrypt")
|
||||||
|
|
||||||
proto.datagram_received(data, ("127.0.0.1", 9999))
|
proto.datagram_received(data, ("127.0.0.1", 9999))
|
||||||
assert len(proto.discovered_devices) == 0
|
assert len(proto.discovered_devices) == 0
|
||||||
@ -375,9 +375,7 @@ class FakeDatagramTransport(asyncio.DatagramTransport):
|
|||||||
self.do_not_reply_count = do_not_reply_count
|
self.do_not_reply_count = do_not_reply_count
|
||||||
self.send_count = 0
|
self.send_count = 0
|
||||||
if port == 9999:
|
if port == 9999:
|
||||||
self.datagram = TPLinkSmartHomeProtocol.encrypt(
|
self.datagram = XorEncryption.encrypt(json_dumps(LEGACY_DISCOVER_DATA))[4:]
|
||||||
json_dumps(LEGACY_DISCOVER_DATA)
|
|
||||||
)[4:]
|
|
||||||
elif port == 20002:
|
elif port == 20002:
|
||||||
discovery_data = UNSUPPORTED if unsupported else AUTHENTICATION_DATA_KLAP
|
discovery_data = UNSUPPORTED if unsupported else AUTHENTICATION_DATA_KLAP
|
||||||
self.datagram = (
|
self.datagram = (
|
||||||
|
@ -15,13 +15,11 @@ from ..aestransport import AesTransport
|
|||||||
from ..credentials import Credentials
|
from ..credentials import Credentials
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
from ..exceptions import SmartDeviceException
|
from ..exceptions import SmartDeviceException
|
||||||
from ..iotprotocol import IotProtocol
|
from ..iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol
|
||||||
from ..klaptransport import KlapTransport, KlapTransportV2
|
from ..klaptransport import KlapTransport, KlapTransportV2
|
||||||
from ..protocol import (
|
from ..protocol import (
|
||||||
BaseProtocol,
|
BaseProtocol,
|
||||||
BaseTransport,
|
BaseTransport,
|
||||||
TPLinkSmartHomeProtocol,
|
|
||||||
_XorTransport,
|
|
||||||
)
|
)
|
||||||
from ..xortransport import XorEncryption, XorTransport
|
from ..xortransport import XorEncryption, XorTransport
|
||||||
|
|
||||||
@ -29,10 +27,10 @@ from ..xortransport import XorEncryption, XorTransport
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class",
|
"protocol_class, transport_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport),
|
(_deprecated_TPLinkSmartHomeProtocol, XorTransport),
|
||||||
(IotProtocol, XorTransport),
|
(IotProtocol, XorTransport),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
||||||
async def test_protocol_retries(mocker, retry_count, protocol_class, transport_class):
|
async def test_protocol_retries(mocker, retry_count, protocol_class, transport_class):
|
||||||
@ -59,10 +57,10 @@ async def test_protocol_retries(mocker, retry_count, protocol_class, transport_c
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class",
|
"protocol_class, transport_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport),
|
(_deprecated_TPLinkSmartHomeProtocol, XorTransport),
|
||||||
(IotProtocol, XorTransport),
|
(IotProtocol, XorTransport),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
async def test_protocol_no_retry_on_unreachable(
|
async def test_protocol_no_retry_on_unreachable(
|
||||||
mocker, protocol_class, transport_class
|
mocker, protocol_class, transport_class
|
||||||
@ -83,10 +81,10 @@ async def test_protocol_no_retry_on_unreachable(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class",
|
"protocol_class, transport_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport),
|
(_deprecated_TPLinkSmartHomeProtocol, XorTransport),
|
||||||
(IotProtocol, XorTransport),
|
(IotProtocol, XorTransport),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
async def test_protocol_no_retry_connection_refused(
|
async def test_protocol_no_retry_connection_refused(
|
||||||
mocker, protocol_class, transport_class
|
mocker, protocol_class, transport_class
|
||||||
@ -107,10 +105,10 @@ async def test_protocol_no_retry_connection_refused(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class",
|
"protocol_class, transport_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport),
|
(_deprecated_TPLinkSmartHomeProtocol, XorTransport),
|
||||||
(IotProtocol, XorTransport),
|
(IotProtocol, XorTransport),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
async def test_protocol_retry_recoverable_error(
|
async def test_protocol_retry_recoverable_error(
|
||||||
mocker, protocol_class, transport_class
|
mocker, protocol_class, transport_class
|
||||||
@ -131,10 +129,14 @@ async def test_protocol_retry_recoverable_error(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class, encryption_class",
|
"protocol_class, transport_class, encryption_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol),
|
(
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
XorTransport,
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
),
|
||||||
(IotProtocol, XorTransport, XorEncryption),
|
(IotProtocol, XorTransport, XorEncryption),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
@pytest.mark.parametrize("retry_count", [1, 3, 5])
|
||||||
async def test_protocol_reconnect(
|
async def test_protocol_reconnect(
|
||||||
@ -177,10 +179,14 @@ async def test_protocol_reconnect(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class, encryption_class",
|
"protocol_class, transport_class, encryption_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol),
|
(
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
XorTransport,
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
),
|
||||||
(IotProtocol, XorTransport, XorEncryption),
|
(IotProtocol, XorTransport, XorEncryption),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
async def test_protocol_handles_cancellation_during_write(
|
async def test_protocol_handles_cancellation_during_write(
|
||||||
mocker, protocol_class, transport_class, encryption_class
|
mocker, protocol_class, transport_class, encryption_class
|
||||||
@ -227,10 +233,14 @@ async def test_protocol_handles_cancellation_during_write(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class, encryption_class",
|
"protocol_class, transport_class, encryption_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol),
|
(
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
XorTransport,
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
),
|
||||||
(IotProtocol, XorTransport, XorEncryption),
|
(IotProtocol, XorTransport, XorEncryption),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
async def test_protocol_handles_cancellation_during_connection(
|
async def test_protocol_handles_cancellation_during_connection(
|
||||||
mocker, protocol_class, transport_class, encryption_class
|
mocker, protocol_class, transport_class, encryption_class
|
||||||
@ -275,10 +285,14 @@ async def test_protocol_handles_cancellation_during_connection(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class, encryption_class",
|
"protocol_class, transport_class, encryption_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol),
|
(
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
XorTransport,
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
),
|
||||||
(IotProtocol, XorTransport, XorEncryption),
|
(IotProtocol, XorTransport, XorEncryption),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG])
|
@pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG])
|
||||||
async def test_protocol_logging(
|
async def test_protocol_logging(
|
||||||
@ -318,10 +332,14 @@ async def test_protocol_logging(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class, encryption_class",
|
"protocol_class, transport_class, encryption_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport, TPLinkSmartHomeProtocol),
|
(
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
XorTransport,
|
||||||
|
_deprecated_TPLinkSmartHomeProtocol,
|
||||||
|
),
|
||||||
(IotProtocol, XorTransport, XorEncryption),
|
(IotProtocol, XorTransport, XorEncryption),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize("custom_port", [123, None])
|
@pytest.mark.parametrize("custom_port", [123, None])
|
||||||
async def test_protocol_custom_port(
|
async def test_protocol_custom_port(
|
||||||
@ -358,11 +376,11 @@ async def test_protocol_custom_port(
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"encrypt_class",
|
"encrypt_class",
|
||||||
[TPLinkSmartHomeProtocol, XorEncryption],
|
[_deprecated_TPLinkSmartHomeProtocol, XorEncryption],
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"decrypt_class",
|
"decrypt_class",
|
||||||
[TPLinkSmartHomeProtocol, XorEncryption],
|
[_deprecated_TPLinkSmartHomeProtocol, XorEncryption],
|
||||||
)
|
)
|
||||||
def test_encrypt(encrypt_class, decrypt_class):
|
def test_encrypt(encrypt_class, decrypt_class):
|
||||||
d = json.dumps({"foo": 1, "bar": 2})
|
d = json.dumps({"foo": 1, "bar": 2})
|
||||||
@ -374,7 +392,7 @@ def test_encrypt(encrypt_class, decrypt_class):
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"encrypt_class",
|
"encrypt_class",
|
||||||
[TPLinkSmartHomeProtocol, XorEncryption],
|
[_deprecated_TPLinkSmartHomeProtocol, XorEncryption],
|
||||||
)
|
)
|
||||||
def test_encrypt_unicode(encrypt_class):
|
def test_encrypt_unicode(encrypt_class):
|
||||||
d = "{'snowman': '\u2603'}"
|
d = "{'snowman': '\u2603'}"
|
||||||
@ -411,7 +429,7 @@ def test_encrypt_unicode(encrypt_class):
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"decrypt_class",
|
"decrypt_class",
|
||||||
[TPLinkSmartHomeProtocol, XorEncryption],
|
[_deprecated_TPLinkSmartHomeProtocol, XorEncryption],
|
||||||
)
|
)
|
||||||
def test_decrypt_unicode(decrypt_class):
|
def test_decrypt_unicode(decrypt_class):
|
||||||
e = bytes(
|
e = bytes(
|
||||||
@ -451,7 +469,11 @@ def _get_subclasses(of_class):
|
|||||||
importlib.import_module("." + modname, package="kasa")
|
importlib.import_module("." + modname, package="kasa")
|
||||||
module = sys.modules["kasa." + modname]
|
module = sys.modules["kasa." + modname]
|
||||||
for name, obj in inspect.getmembers(module):
|
for name, obj in inspect.getmembers(module):
|
||||||
if inspect.isclass(obj) and issubclass(obj, of_class):
|
if (
|
||||||
|
inspect.isclass(obj)
|
||||||
|
and issubclass(obj, of_class)
|
||||||
|
and name != "_deprecated_TPLinkSmartHomeProtocol"
|
||||||
|
):
|
||||||
subclasses.add((name, obj))
|
subclasses.add((name, obj))
|
||||||
return subclasses
|
return subclasses
|
||||||
|
|
||||||
@ -491,7 +513,7 @@ def test_transport_init_signature(class_name_obj):
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"transport_class",
|
"transport_class",
|
||||||
[AesTransport, KlapTransport, KlapTransportV2, _XorTransport, XorTransport],
|
[AesTransport, KlapTransport, KlapTransportV2, XorTransport, XorTransport],
|
||||||
)
|
)
|
||||||
async def test_transport_credentials_hash(mocker, transport_class):
|
async def test_transport_credentials_hash(mocker, transport_class):
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
@ -519,10 +541,10 @@ async def test_transport_credentials_hash(mocker, transport_class):
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class",
|
"protocol_class, transport_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport),
|
(_deprecated_TPLinkSmartHomeProtocol, XorTransport),
|
||||||
(IotProtocol, XorTransport),
|
(IotProtocol, XorTransport),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
async def test_protocol_will_retry_on_connect(
|
async def test_protocol_will_retry_on_connect(
|
||||||
mocker, protocol_class, transport_class, error, retry_expectation
|
mocker, protocol_class, transport_class, error, retry_expectation
|
||||||
@ -551,10 +573,10 @@ async def test_protocol_will_retry_on_connect(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"protocol_class, transport_class",
|
"protocol_class, transport_class",
|
||||||
[
|
[
|
||||||
(TPLinkSmartHomeProtocol, _XorTransport),
|
(_deprecated_TPLinkSmartHomeProtocol, XorTransport),
|
||||||
(IotProtocol, XorTransport),
|
(IotProtocol, XorTransport),
|
||||||
],
|
],
|
||||||
ids=("TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"),
|
||||||
)
|
)
|
||||||
async def test_protocol_will_retry_on_write(
|
async def test_protocol_will_retry_on_write(
|
||||||
mocker, protocol_class, transport_class, error, retry_expectation
|
mocker, protocol_class, transport_class, error, retry_expectation
|
||||||
@ -580,3 +602,16 @@ async def test_protocol_will_retry_on_write(
|
|||||||
expected_call_count = retry_count + 1 if retry_expectation else 1
|
expected_call_count = retry_count + 1 if retry_expectation else 1
|
||||||
assert conn.call_count == expected_call_count
|
assert conn.call_count == expected_call_count
|
||||||
assert write_mock.call_count == expected_call_count
|
assert write_mock.call_count == expected_call_count
|
||||||
|
|
||||||
|
|
||||||
|
def test_deprecated_protocol():
|
||||||
|
with pytest.deprecated_call():
|
||||||
|
from kasa import TPLinkSmartHomeProtocol
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
SmartDeviceException, match="host or transport must be supplied"
|
||||||
|
):
|
||||||
|
proto = TPLinkSmartHomeProtocol()
|
||||||
|
host = "127.0.0.1"
|
||||||
|
proto = TPLinkSmartHomeProtocol(host=host)
|
||||||
|
assert proto.config.host == host
|
||||||
|
@ -1,4 +1,14 @@
|
|||||||
"""Module for the XorTransport."""
|
"""Implementation of the legacy TP-Link Smart Home Protocol.
|
||||||
|
|
||||||
|
Encryption/Decryption methods based on the works of
|
||||||
|
Lubomir Stroetmann and Tobias Esser
|
||||||
|
|
||||||
|
https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/
|
||||||
|
https://github.com/softScheck/tplink-smartplug/
|
||||||
|
|
||||||
|
which are licensed under the Apache License, Version 2.0
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import errno
|
import errno
|
||||||
@ -23,13 +33,7 @@ _UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I")
|
|||||||
|
|
||||||
|
|
||||||
class XorTransport(BaseTransport):
|
class XorTransport(BaseTransport):
|
||||||
"""Implementation of the Xor encryption transport.
|
"""XorTransport class."""
|
||||||
|
|
||||||
WIP, currently only to ensure consistent __init__ method signatures
|
|
||||||
for protocol classes. Will eventually incorporate the logic from
|
|
||||||
TPLinkSmartHomeProtocol to simplify the API and re-use the IotProtocol
|
|
||||||
class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DEFAULT_PORT: int = 9999
|
DEFAULT_PORT: int = 9999
|
||||||
BLOCK_SIZE = 4
|
BLOCK_SIZE = 4
|
||||||
|
Loading…
x
Reference in New Issue
Block a user