| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -103,6 +103,7 @@ def _device_to_serializable(val: SmartDevice): | |||
| 103 | 103 | "--port", | |
| 104 | 104 | envvar="KASA_PORT", | |
| 105 | 105 | required=False, | |
| 106 | + type=int, | ||
| 106 | 107 | help="The port of the device to connect to.", | |
| 107 | 108 | ) | |
| 108 | 109 | @click.option( | |
@@ -138,7 +139,17 @@ def _device_to_serializable(val: SmartDevice): | |||
| 138 | 139 | ) | |
| 139 | 140 | @click.version_option(package_name="python-kasa") | |
| 140 | 141 | @click.pass_context | |
| 141 | - async def cli(ctx, host, port, alias, target, debug, type, json, discovery_timeout): | ||
| 142 | + async def cli( | ||
| 143 | + ctx, | ||
| 144 | + host, | ||
| 145 | + port, | ||
| 146 | + alias, | ||
| 147 | + target, | ||
| 148 | + debug, | ||
| 149 | + type, | ||
| 150 | + json, | ||
| 151 | + discovery_timeout, | ||
| 152 | + ): | ||
| 142 | 153 | """A tool for controlling TP-Link smart home devices.""" # noqa | |
| 143 | 154 | # no need to perform any checks if we are just displaying the help | |
| 144 | 155 | if sys.argv[-1] == "--help": | |
@@ -238,13 +249,29 @@ async def join(dev: SmartDevice, ssid, password, keytype): | |||
| 238 | 249 | ||
| 239 | 250 | @cli.command() | |
| 240 | 251 | @click.option("--timeout", default=3, required=False) | |
| 252 | + @click.option( | ||
| 253 | + "--show-unsupported", | ||
| 254 | + envvar="KASA_SHOW_UNSUPPORTED", | ||
| 255 | + required=False, | ||
| 256 | + default=False, | ||
| 257 | + is_flag=True, | ||
| 258 | + help="Print out discovered unsupported devices", | ||
| 259 | + ) | ||
| 241 | 260 | @click.pass_context | |
| 242 | - async def discover(ctx, timeout): | ||
| 261 | + async def discover(ctx, timeout, show_unsupported): | ||
| 243 | 262 | """Discover devices in the network.""" | |
| 244 | 263 | target = ctx.parent.params["target"] | |
| 245 | - echo(f"Discovering devices on {target} for {timeout} seconds") | ||
| 246 | 264 | sem = asyncio.Semaphore() | |
| 247 | 265 | discovered = dict() | |
| 266 | + unsupported = [] | ||
| 267 | + | ||
| 268 | + async def print_unsupported(data: Dict): | ||
| 269 | + unsupported.append(data) | ||
| 270 | + if show_unsupported: | ||
| 271 | + echo(f"Found unsupported device (tapo/unknown encryption): {data}") | ||
| 272 | + echo() | ||
| 273 | + | ||
| 274 | + echo(f"Discovering devices on {target} for {timeout} seconds") | ||
| 248 | 275 | ||
| 249 | 276 | async def print_discovered(dev: SmartDevice): | |
| 250 | 277 | await dev.update() | |
@@ -255,9 +282,23 @@ async def print_discovered(dev: SmartDevice): | |||
| 255 | 282 | echo() | |
| 256 | 283 | ||
| 257 | 284 | await Discover.discover( | |
| 258 | - target=target, timeout=timeout, on_discovered=print_discovered | ||
| 285 | + target=target, | ||
| 286 | + timeout=timeout, | ||
| 287 | + on_discovered=print_discovered, | ||
| 288 | + on_unsupported=print_unsupported, | ||
| 259 | 289 | ) | |
| 260 | 290 | ||
| 291 | + echo(f"Found {len(discovered)} devices") | ||
| 292 | + if unsupported: | ||
| 293 | + echo( | ||
| 294 | + f"Found {len(unsupported)} unsupported devices" | ||
| 295 | + + ( | ||
| 296 | + "" | ||
| 297 | + if show_unsupported | ||
| 298 | + else ", to show them use: kasa discover --show-unsupported" | ||
| 299 | + ) | ||
| 300 | + ) | ||
| 301 | + | ||
| 261 | 302 | return discovered | |
| 262 | 303 | ||
| 263 | 304 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,9 +1,15 @@ | |||
| 1 | 1 | """Discovery module for TP-Link Smart Home devices.""" | |
| 2 | 2 | import asyncio | |
| 3 | + import binascii | ||
| 3 | 4 | import logging | |
| 4 | 5 | import socket | |
| 5 | 6 | from typing import Awaitable, Callable, Dict, Optional, Type, cast | |
| 6 | 7 | ||
| 8 | + # When support for cpython older than 3.11 is dropped | ||
| 9 | + # async_timeout can be replaced with asyncio.timeout | ||
| 10 | + from async_timeout import timeout as asyncio_timeout | ||
| 11 | + | ||
| 12 | + from kasa.exceptions import UnsupportedDeviceException | ||
| 7 | 13 | from kasa.json import dumps as json_dumps | |
| 8 | 14 | from kasa.json import loads as json_loads | |
| 9 | 15 | from kasa.protocol import TPLinkSmartHomeProtocol | |
@@ -36,13 +42,22 @@ def __init__( | |||
| 36 | 42 | target: str = "255.255.255.255", | |
| 37 | 43 | discovery_packets: int = 3, | |
| 38 | 44 | interface: Optional[str] = None, | |
| 45 | + on_unsupported: Optional[Callable[[Dict], Awaitable[None]]] = None, | ||
| 46 | + port: Optional[int] = None, | ||
| 47 | + discovered_event: Optional[asyncio.Event] = None, | ||
| 39 | 48 | ): | |
| 40 | 49 | self.transport = None | |
| 41 | 50 | self.discovery_packets = discovery_packets | |
| 42 | 51 | self.interface = interface | |
| 43 | 52 | self.on_discovered = on_discovered | |
| 44 | - self.target = (target, Discover.DISCOVERY_PORT) | ||
| 53 | + self.discovery_port = port or Discover.DISCOVERY_PORT | ||
| 54 | + self.target = (target, self.discovery_port) | ||
| 55 | + self.target_2 = (target, Discover.DISCOVERY_PORT_2) | ||
| 45 | 56 | self.discovered_devices = {} | |
| 57 | + self.unsupported_devices: Dict = {} | ||
| 58 | + self.invalid_device_exceptions: Dict = {} | ||
| 59 | + self.on_unsupported = on_unsupported | ||
| 60 | + self.discovered_event = discovered_event | ||
| 46 | 61 | ||
| 47 | 62 | def connection_made(self, transport) -> None: | |
| 48 | 63 | """Set socket options for broadcasting.""" | |
@@ -69,30 +84,58 @@ def do_discover(self) -> None: | |||
| 69 | 84 | encrypted_req = TPLinkSmartHomeProtocol.encrypt(req) | |
| 70 | 85 | for i in range(self.discovery_packets): | |
| 71 | 86 | self.transport.sendto(encrypted_req[4:], self.target) # type: ignore | |
| 87 | + self.transport.sendto(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore | ||
| 72 | 88 | ||
| 73 | 89 | def datagram_received(self, data, addr) -> None: | |
| 74 | 90 | """Handle discovery responses.""" | |
| 75 | 91 | ip, port = addr | |
| 76 | - if ip in self.discovered_devices: | ||
| 92 | + if ( | ||
| 93 | + ip in self.discovered_devices | ||
| 94 | + or ip in self.unsupported_devices | ||
| 95 | + or ip in self.invalid_device_exceptions | ||
| 96 | + ): | ||
| 77 | 97 | return | |
| 78 | 98 | ||
| 79 | - info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) | ||
| 80 | - _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) | ||
| 99 | + if port == self.discovery_port: | ||
| 100 | + info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) | ||
| 101 | + _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) | ||
| 102 | + | ||
| 103 | + elif port == Discover.DISCOVERY_PORT_2: | ||
| 104 | + info = json_loads(data[16:]) | ||
| 105 | + self.unsupported_devices[ip] = info | ||
| 106 | + if self.on_unsupported is not None: | ||
| 107 | + asyncio.ensure_future(self.on_unsupported(info)) | ||
| 108 | + _LOGGER.debug("[DISCOVERY] Unsupported device found at %s << %s", ip, info) | ||
| 109 | + if self.discovered_event is not None and "255" not in self.target[0].split( | ||
| 110 | + "." | ||
| 111 | + ): | ||
| 112 | + self.discovered_event.set() | ||
| 113 | + return | ||
| 81 | 114 | ||
| 82 | 115 | try: | |
| 83 | 116 | device_class = Discover._get_device_class(info) | |
| 84 | 117 | except SmartDeviceException as ex: | |
| 85 | - _LOGGER.debug("Unable to find device type from %s: %s", info, ex) | ||
| 118 | + _LOGGER.debug( | ||
| 119 | + "[DISCOVERY] Unable to find device type from %s: %s", info, ex | ||
| 120 | + ) | ||
| 121 | + self.invalid_device_exceptions[ip] = ex | ||
| 122 | + if self.discovered_event is not None and "255" not in self.target[0].split( | ||
| 123 | + "." | ||
| 124 | + ): | ||
| 125 | + self.discovered_event.set() | ||
| 86 | 126 | return | |
| 87 | 127 | ||
| 88 | - device = device_class(ip) | ||
| 128 | + device = device_class(ip, port=port) | ||
| 89 | 129 | device.update_from_discover_info(info) | |
| 90 | 130 | ||
| 91 | 131 | self.discovered_devices[ip] = device | |
| 92 | 132 | ||
| 93 | 133 | if self.on_discovered is not None: | |
| 94 | 134 | asyncio.ensure_future(self.on_discovered(device)) | |
| 95 | 135 | ||
| 136 | + if self.discovered_event is not None and "255" not in self.target[0].split("."): | ||
| 137 | + self.discovered_event.set() | ||
| 138 | + | ||
| 96 | 139 | def error_received(self, ex): | |
| 97 | 140 | """Handle asyncio.Protocol errors.""" | |
| 98 | 141 | _LOGGER.error("Got error: %s", ex) | |
@@ -142,6 +185,9 @@ class Discover: | |||
| 142 | 185 | "system": {"get_sysinfo": None}, | |
| 143 | 186 | } | |
| 144 | 187 | ||
| 188 | + DISCOVERY_PORT_2 = 20002 | ||
| 189 | + DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") | ||
| 190 | + | ||
| 145 | 191 | @staticmethod | |
| 146 | 192 | async def discover( | |
| 147 | 193 | *, | |
@@ -150,6 +196,7 @@ async def discover( | |||
| 150 | 196 | timeout=5, | |
| 151 | 197 | discovery_packets=3, | |
| 152 | 198 | interface=None, | |
| 199 | + on_unsupported=None, | ||
| 153 | 200 | ) -> DeviceDict: | |
| 154 | 201 | """Discover supported devices. | |
| 155 | 202 | ||
@@ -177,6 +224,7 @@ async def discover( | |||
| 177 | 224 | on_discovered=on_discovered, | |
| 178 | 225 | discovery_packets=discovery_packets, | |
| 179 | 226 | interface=interface, | |
| 227 | + on_unsupported=on_unsupported, | ||
| 180 | 228 | ), | |
| 181 | 229 | local_addr=("0.0.0.0", 0), | |
| 182 | 230 | ) | |
@@ -193,22 +241,47 @@ async def discover( | |||
| 193 | 241 | return protocol.discovered_devices | |
| 194 | 242 | ||
| 195 | 243 | @staticmethod | |
| 196 | - async def discover_single(host: str, *, port: Optional[int] = None) -> SmartDevice: | ||
| 244 | + async def discover_single( | ||
| 245 | + host: str, *, port: Optional[int] = None, timeout=5 | ||
| 246 | + ) -> SmartDevice: | ||
| 197 | 247 | """Discover a single device by the given IP address. | |
| 198 | 248 | ||
| 199 | 249 | :param host: Hostname of device to query | |
| 200 | 250 | :rtype: SmartDevice | |
| 201 | 251 | :return: Object for querying/controlling found device. | |
| 202 | 252 | """ | |
| 203 | - protocol = TPLinkSmartHomeProtocol(host, port=port) | ||
| 253 | + loop = asyncio.get_event_loop() | ||
| 254 | + event = asyncio.Event() | ||
| 255 | + transport, protocol = await loop.create_datagram_endpoint( | ||
| 256 | + lambda: _DiscoverProtocol(target=host, port=port, discovered_event=event), | ||
| 257 | + local_addr=("0.0.0.0", 0), | ||
| 258 | + ) | ||
| 259 | + protocol = cast(_DiscoverProtocol, protocol) | ||
| 204 | 260 | ||
| 205 | - info = await protocol.query(Discover.DISCOVERY_QUERY) | ||
| 261 | + try: | ||
| 262 | + _LOGGER.debug("Waiting a total of %s seconds for responses...", timeout) | ||
| 206 | 263 | ||
| 207 | - device_class = Discover._get_device_class(info) | ||
| 208 | - dev = device_class(host, port=port) | ||
| 209 | - await dev.update() | ||
| 264 | + async with asyncio_timeout(timeout): | ||
| 265 | + await event.wait() | ||
| 266 | + except asyncio.TimeoutError: | ||
| 267 | + raise SmartDeviceException( | ||
| 268 | + f"Timed out getting discovery response for {host}" | ||
| 269 | + ) | ||
| 270 | + finally: | ||
| 271 | + transport.close() | ||
| 210 | 272 | ||
| 211 | - return dev | ||
| 273 | + if host in protocol.discovered_devices: | ||
| 274 | + dev = protocol.discovered_devices[host] | ||
| 275 | + await dev.update() | ||
| 276 | + return dev | ||
| 277 | + elif host in protocol.unsupported_devices: | ||
| 278 | + raise UnsupportedDeviceException( | ||
| 279 | + f"Unsupported device {host}: {protocol.unsupported_devices[host]}" | ||
| 280 | + ) | ||
| 281 | + elif host in protocol.invalid_device_exceptions: | ||
| 282 | + raise protocol.invalid_device_exceptions[host] | ||
| 283 | + else: | ||
| 284 | + raise SmartDeviceException(f"Unable to get discovery response for {host}") | ||
| 212 | 285 | ||
| 213 | 286 | @staticmethod | |
| 214 | 287 | def _get_device_class(info: dict) -> Type[SmartDevice]: | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -3,3 +3,7 @@ | |||
| 3 | 3 | ||
| 4 | 4 | class SmartDeviceException(Exception): | |
| 5 | 5 | """Base exception for device errors.""" | |
| 6 | + | ||
| 7 | + | ||
| 8 | + class UnsupportedDeviceException(SmartDeviceException): | ||
| 9 | + """Exception for trying to connect to unsupported devices.""" | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments