Skip to content
Closed
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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ jobs:
# exclude pypy on windows, as the poetry install seems to be very flaky:
# PermissionError(13, 'The process cannot access the file because it is being used by another process'))
# at C:\hostedtoolcache\windows\PyPy\3.7.10\x86\site-packages\requests\models.py:761 in generate
# exclude:
# - python-version: pypy-3.8
# os: windows-latest
# and with pypy3.8 trying to use setuptools to build frozenlist which is a dependency of aiohttp
# ChefBuildError: Backend 'setuptools.build_meta' is not available.
- os: windows-latest
python-version: pypy-3.8

steps:
- uses: "actions/checkout@v2"
Expand Down
5 changes: 4 additions & 1 deletion kasa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
SmartDeviceException,
UnsupportedDeviceException,
)
from kasa.protocol import TPLinkSmartHomeProtocol
from kasa.klapprotocol import TPLinkKlap
from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol
from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors
from kasa.smartdevice import DeviceType, SmartDevice
from kasa.smartdimmer import SmartDimmer
Expand All @@ -35,6 +36,8 @@
__all__ = [
"Discover",
"TPLinkSmartHomeProtocol",
"TPLinkProtocol",
"TPLinkKlap",
"SmartBulb",
"SmartBulbPreset",
"TurnOnBehaviors",
Expand Down
24 changes: 17 additions & 7 deletions kasa/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
SmartStrip,
)

from .exceptions import AuthenticationException

try:
from rich import print as _do_echo
except ImportError:
Expand Down Expand Up @@ -302,8 +304,9 @@ async def discover(ctx, timeout, show_unsupported):
sem = asyncio.Semaphore()
discovered = dict()
unsupported = []
auth_failed = []

async def print_unsupported(data: Dict):
async def print_unsupported(data: str):
unsupported.append(data)
if show_unsupported:
echo(f"Found unsupported device (tapo/unknown encryption): {data}")
Expand All @@ -312,12 +315,15 @@ async def print_unsupported(data: Dict):
echo(f"Discovering devices on {target} for {timeout} seconds")

async def print_discovered(dev: SmartDevice):
await dev.update()
async with sem:
discovered[dev.host] = dev.internal_state
ctx.obj = dev
await ctx.invoke(state)
echo()
try:
await dev.update()
async with sem:
discovered[dev.host] = dev.internal_state
ctx.obj = dev
await ctx.invoke(state)
echo()
except AuthenticationException as aex:
auth_failed.append(str(aex))

await Discover.discover(
target=target,
Expand All @@ -337,6 +343,10 @@ async def print_discovered(dev: SmartDevice):
else ", to show them use: kasa discover --show-unsupported"
)
)
if auth_failed:
echo(f"Found {len(auth_failed)} devices that failed to authenticate")
for fail in auth_failed:
echo(fail)

return discovered

Expand Down
108 changes: 82 additions & 26 deletions kasa/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import binascii
import logging
import socket
from typing import Awaitable, Callable, Dict, Optional, Type, cast
from typing import Awaitable, Callable, Dict, Optional, Set, Type, cast

# When support for cpython older than 3.11 is dropped
# async_timeout can be replaced with asyncio.timeout
Expand All @@ -13,6 +13,7 @@
from kasa.exceptions import UnsupportedDeviceException
from kasa.json import dumps as json_dumps
from kasa.json import loads as json_loads
from kasa.klapprotocol import TPLinkKlap
from kasa.protocol import TPLinkSmartHomeProtocol
from kasa.smartbulb import SmartBulb
from kasa.smartdevice import SmartDevice, SmartDeviceException
Expand Down Expand Up @@ -43,7 +44,7 @@ def __init__(
target: str = "255.255.255.255",
discovery_packets: int = 3,
interface: Optional[str] = None,
on_unsupported: Optional[Callable[[Dict], Awaitable[None]]] = None,
on_unsupported: Optional[Callable[[str], Awaitable[None]]] = None,
port: Optional[int] = None,
discovered_event: Optional[asyncio.Event] = None,
credentials: Optional[Credentials] = None,
Expand All @@ -61,6 +62,7 @@ def __init__(
self.on_unsupported = on_unsupported
self.discovered_event = discovered_event
self.credentials = credentials
self.ip_set: Set[str] = set()

def connection_made(self, transport) -> None:
"""Set socket options for broadcasting."""
Expand Down Expand Up @@ -92,41 +94,36 @@ def do_discover(self) -> None:
def datagram_received(self, data, addr) -> None:
"""Handle discovery responses."""
ip, port = addr
if (
ip in self.discovered_devices
or ip in self.unsupported_devices
or ip in self.invalid_device_exceptions
):
# Prevent multiple entries due multiple broadcasts
if ip in self.ip_set:
return
self.ip_set.add(ip)

if port == self.discovery_port:
info = json_loads(TPLinkSmartHomeProtocol.decrypt(data))
_LOGGER.debug("[DISCOVERY] %s << %s", ip, info)

elif port == Discover.DISCOVERY_PORT_2:
info = json_loads(data[16:])
self.unsupported_devices[ip] = info
device = None
try:
if port == self.discovery_port:
device = Discover._get_device_instance_legacy(data, ip, port)
elif port == Discover.DISCOVERY_PORT_2:
device = Discover._get_device_instance(
data, ip, port, self.credentials or Credentials()
)
else:
return
except UnsupportedDeviceException as udex:
_LOGGER.debug("Unsupported device found at %s << %s", ip, udex)
self.unsupported_devices[ip] = str(udex)
if self.on_unsupported is not None:
asyncio.ensure_future(self.on_unsupported(info))
_LOGGER.debug("[DISCOVERY] Unsupported device found at %s << %s", ip, info)
asyncio.ensure_future(self.on_unsupported(str(udex)))
if self.discovered_event is not None:
self.discovered_event.set()
return

try:
device_class = Discover._get_device_class(info)
except SmartDeviceException as ex:
_LOGGER.debug(
"[DISCOVERY] Unable to find device type from %s: %s", info, ex
)
_LOGGER.debug(f"[DISCOVERY] Unable to find device type for {ip}: {ex}")
self.invalid_device_exceptions[ip] = ex
if self.discovered_event is not None:
self.discovered_event.set()
return

device = device_class(ip, port=port, credentials=self.credentials)
device.update_from_discover_info(info)

self.discovered_devices[ip] = device

if self.on_discovered is not None:
Expand Down Expand Up @@ -315,5 +312,64 @@ def _get_device_class(info: dict) -> Type[SmartDevice]:
return SmartLightStrip

return SmartBulb
raise UnsupportedDeviceException("Unknown device type: %s" % type_)

@staticmethod
def _get_device_instance_legacy(data: bytes, ip: str, port: int) -> SmartDevice:
"""Get SmartDevice for device described by passed data from legacy 9999 response."""
try:
info = json_loads(TPLinkSmartHomeProtocol.decrypt(data))
except Exception as ex:
raise SmartDeviceException(
f"Unable to read response from device: {ip}: {ex}"
)

_LOGGER.debug("[DISCOVERY] %s << %s", ip, info)

device_class = Discover._get_device_class(info)
device = device_class(ip, port=port)
device.update_from_discover_info(info)
return device

raise SmartDeviceException("Unknown device type: %s" % type_)
@staticmethod
def _get_device_instance(
data: bytes, ip: str, port: int, credentials: Credentials
) -> SmartDevice:
"""Get SmartDevice for device described by passed data from new 20002 response."""
try:
info = json_loads(data[16:])
except Exception as ex:
raise SmartDeviceException(
f"Unable to read response from device: {ip}: {ex}"
)

if (
"result" in info
and "mgt_encrypt_schm" in info["result"]
and "encrypt_type" in info["result"]["mgt_encrypt_schm"]
and info["result"]["mgt_encrypt_schm"]["encrypt_type"] == "KLAP"
and "lv" not in info["result"]["mgt_encrypt_schm"]
and "device_type" in info["result"]
):
type_ = info["result"]["device_type"]
device_class = None
if type_.upper() == "IOT.SMARTPLUGSWITCH":
device_class = SmartPlug

if device_class:
_LOGGER.debug("[DISCOVERY] %s << %s", ip, info)
device = device_class(ip, port=port, credentials=credentials)
# Set the MAC so HA can add the device and try authentication later
device._requires_update_overrides = {
"mac": info["result"].get("mac"),
"alias": ip,
"model": info["result"].get("device_model"),
}
device.protocol = TPLinkKlap(ip, credentials, info)
return device
else:
raise UnsupportedDeviceException(
f"Unsupported device {ip} of type {type_}: {info}"
)
else:
raise UnsupportedDeviceException(f"Unsupported device {ip}: {info}")
Loading