2024-07-23 18:13:52 +00:00
|
|
|
"""Module for cli discovery commands."""
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import asyncio
|
2024-10-16 14:28:27 +00:00
|
|
|
from pprint import pformat as pf
|
2024-07-23 18:13:52 +00:00
|
|
|
|
|
|
|
import asyncclick as click
|
|
|
|
|
|
|
|
from kasa import (
|
|
|
|
AuthenticationError,
|
|
|
|
Credentials,
|
|
|
|
Device,
|
|
|
|
Discover,
|
|
|
|
UnsupportedDeviceError,
|
|
|
|
)
|
2024-12-10 22:42:14 +00:00
|
|
|
from kasa.discover import (
|
|
|
|
NEW_DISCOVERY_REDACTORS,
|
|
|
|
ConnectAttempt,
|
|
|
|
DiscoveredRaw,
|
|
|
|
DiscoveryResult,
|
|
|
|
)
|
2024-12-06 23:06:58 +00:00
|
|
|
from kasa.iot.iotdevice import _extract_sys_info
|
2024-12-10 22:42:14 +00:00
|
|
|
from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS
|
|
|
|
from kasa.protocols.protocol import redact_data
|
2024-07-23 18:13:52 +00:00
|
|
|
|
2024-12-10 22:42:14 +00:00
|
|
|
from ..json import dumps as json_dumps
|
2024-10-22 13:33:46 +00:00
|
|
|
from .common import echo, error
|
2024-07-23 18:13:52 +00:00
|
|
|
|
|
|
|
|
2024-10-22 11:15:08 +00:00
|
|
|
@click.group(invoke_without_command=True)
|
2024-07-23 18:13:52 +00:00
|
|
|
@click.pass_context
|
|
|
|
async def discover(ctx):
|
|
|
|
"""Discover devices in the network."""
|
2024-10-22 11:15:08 +00:00
|
|
|
if ctx.invoked_subcommand is None:
|
|
|
|
return await ctx.invoke(detail)
|
2024-07-23 18:13:52 +00:00
|
|
|
|
|
|
|
|
2024-10-22 11:15:08 +00:00
|
|
|
@discover.command()
|
|
|
|
@click.pass_context
|
|
|
|
async def detail(ctx):
|
|
|
|
"""Discover devices in the network using udp broadcasts."""
|
2024-07-23 18:13:52 +00:00
|
|
|
unsupported = []
|
|
|
|
auth_failed = []
|
2024-10-22 11:15:08 +00:00
|
|
|
sem = asyncio.Semaphore()
|
2024-07-23 18:13:52 +00:00
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
async def print_unsupported(unsupported_exception: UnsupportedDeviceError) -> None:
|
2024-07-23 18:13:52 +00:00
|
|
|
unsupported.append(unsupported_exception)
|
|
|
|
async with sem:
|
|
|
|
if unsupported_exception.discovery_result:
|
|
|
|
echo("== Unsupported device ==")
|
|
|
|
_echo_discovery_info(unsupported_exception.discovery_result)
|
|
|
|
echo()
|
|
|
|
else:
|
|
|
|
echo("== Unsupported device ==")
|
|
|
|
echo(f"\t{unsupported_exception}")
|
|
|
|
echo()
|
|
|
|
|
|
|
|
from .device import state
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
async def print_discovered(dev: Device) -> None:
|
2024-07-23 18:13:52 +00:00
|
|
|
async with sem:
|
|
|
|
try:
|
|
|
|
await dev.update()
|
|
|
|
except AuthenticationError:
|
|
|
|
auth_failed.append(dev._discovery_info)
|
|
|
|
echo("== Authentication failed for device ==")
|
|
|
|
_echo_discovery_info(dev._discovery_info)
|
|
|
|
echo()
|
|
|
|
else:
|
|
|
|
ctx.parent.obj = dev
|
|
|
|
await ctx.parent.invoke(state)
|
|
|
|
echo()
|
|
|
|
|
2024-12-10 22:42:14 +00:00
|
|
|
discovered = await _discover(
|
|
|
|
ctx, print_discovered=print_discovered, print_unsupported=print_unsupported
|
|
|
|
)
|
2024-10-22 11:15:08 +00:00
|
|
|
if ctx.parent.parent.params["host"]:
|
|
|
|
return discovered
|
|
|
|
|
|
|
|
echo(f"Found {len(discovered)} devices")
|
|
|
|
if unsupported:
|
|
|
|
echo(f"Found {len(unsupported)} unsupported devices")
|
|
|
|
if auth_failed:
|
|
|
|
echo(f"Found {len(auth_failed)} devices that failed to authenticate")
|
|
|
|
|
|
|
|
return discovered
|
|
|
|
|
|
|
|
|
2024-12-10 22:42:14 +00:00
|
|
|
@discover.command()
|
|
|
|
@click.option(
|
|
|
|
"--redact/--no-redact",
|
|
|
|
default=False,
|
|
|
|
is_flag=True,
|
|
|
|
type=bool,
|
|
|
|
help="Set flag to redact sensitive data from raw output.",
|
|
|
|
)
|
|
|
|
@click.pass_context
|
|
|
|
async def raw(ctx, redact: bool):
|
|
|
|
"""Return raw discovery data returned from devices."""
|
|
|
|
|
|
|
|
def print_raw(discovered: DiscoveredRaw):
|
|
|
|
if redact:
|
|
|
|
redactors = (
|
|
|
|
NEW_DISCOVERY_REDACTORS
|
|
|
|
if discovered["meta"]["port"] == Discover.DISCOVERY_PORT_2
|
|
|
|
else IOT_REDACTORS
|
|
|
|
)
|
|
|
|
discovered["discovery_response"] = redact_data(
|
|
|
|
discovered["discovery_response"], redactors
|
|
|
|
)
|
|
|
|
echo(json_dumps(discovered, indent=True))
|
|
|
|
|
|
|
|
return await _discover(ctx, print_raw=print_raw, do_echo=False)
|
|
|
|
|
|
|
|
|
2024-10-22 11:15:08 +00:00
|
|
|
@discover.command()
|
|
|
|
@click.pass_context
|
|
|
|
async def list(ctx):
|
|
|
|
"""List devices in the network in a table using udp broadcasts."""
|
|
|
|
sem = asyncio.Semaphore()
|
|
|
|
|
|
|
|
async def print_discovered(dev: Device):
|
|
|
|
cparams = dev.config.connection_type
|
|
|
|
infostr = (
|
2024-12-17 20:09:17 +00:00
|
|
|
f"{dev.host:<15} {dev.model:<9} {cparams.device_family.value:<20} "
|
|
|
|
f"{cparams.encryption_type.value:<7} {cparams.https:<5} "
|
|
|
|
f"{cparams.login_version or '-':<3}"
|
2024-10-22 11:15:08 +00:00
|
|
|
)
|
|
|
|
async with sem:
|
|
|
|
try:
|
|
|
|
await dev.update()
|
|
|
|
except AuthenticationError:
|
|
|
|
echo(f"{infostr} - Authentication failed")
|
2024-12-17 20:09:17 +00:00
|
|
|
except TimeoutError:
|
|
|
|
echo(f"{infostr} - Timed out")
|
|
|
|
except Exception as ex:
|
|
|
|
echo(f"{infostr} - Error: {ex}")
|
2024-10-22 11:15:08 +00:00
|
|
|
else:
|
|
|
|
echo(f"{infostr} {dev.alias}")
|
|
|
|
|
|
|
|
async def print_unsupported(unsupported_exception: UnsupportedDeviceError):
|
2024-10-22 17:09:35 +00:00
|
|
|
if host := unsupported_exception.host:
|
|
|
|
echo(f"{host:<15} UNSUPPORTED DEVICE")
|
2024-10-22 11:15:08 +00:00
|
|
|
|
2024-12-17 20:09:17 +00:00
|
|
|
echo(
|
|
|
|
f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} "
|
|
|
|
f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}"
|
|
|
|
)
|
2024-12-10 22:42:14 +00:00
|
|
|
return await _discover(
|
|
|
|
ctx,
|
|
|
|
print_discovered=print_discovered,
|
|
|
|
print_unsupported=print_unsupported,
|
|
|
|
do_echo=False,
|
|
|
|
)
|
2024-10-22 11:15:08 +00:00
|
|
|
|
|
|
|
|
2024-12-10 22:42:14 +00:00
|
|
|
async def _discover(
|
|
|
|
ctx, *, print_discovered=None, print_unsupported=None, print_raw=None, do_echo=True
|
|
|
|
):
|
2024-10-22 11:15:08 +00:00
|
|
|
params = ctx.parent.parent.params
|
|
|
|
target = params["target"]
|
|
|
|
username = params["username"]
|
|
|
|
password = params["password"]
|
|
|
|
discovery_timeout = params["discovery_timeout"]
|
|
|
|
timeout = params["timeout"]
|
|
|
|
host = params["host"]
|
|
|
|
port = params["port"]
|
|
|
|
|
|
|
|
credentials = Credentials(username, password) if username and password else None
|
|
|
|
|
2024-10-16 14:28:27 +00:00
|
|
|
if host:
|
|
|
|
echo(f"Discovering device {host} for {discovery_timeout} seconds")
|
|
|
|
return await Discover.discover_single(
|
|
|
|
host,
|
|
|
|
port=port,
|
|
|
|
credentials=credentials,
|
|
|
|
timeout=timeout,
|
|
|
|
discovery_timeout=discovery_timeout,
|
|
|
|
on_unsupported=print_unsupported,
|
2024-12-10 22:42:14 +00:00
|
|
|
on_discovered_raw=print_raw,
|
2024-10-16 14:28:27 +00:00
|
|
|
)
|
2024-10-22 11:15:08 +00:00
|
|
|
if do_echo:
|
|
|
|
echo(f"Discovering devices on {target} for {discovery_timeout} seconds")
|
2024-07-23 18:13:52 +00:00
|
|
|
discovered_devices = await Discover.discover(
|
|
|
|
target=target,
|
|
|
|
discovery_timeout=discovery_timeout,
|
|
|
|
on_discovered=print_discovered,
|
|
|
|
on_unsupported=print_unsupported,
|
|
|
|
port=port,
|
|
|
|
timeout=timeout,
|
|
|
|
credentials=credentials,
|
2024-12-10 22:42:14 +00:00
|
|
|
on_discovered_raw=print_raw,
|
2024-07-23 18:13:52 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
for device in discovered_devices.values():
|
|
|
|
await device.protocol.close()
|
|
|
|
|
2024-10-22 11:15:08 +00:00
|
|
|
return discovered_devices
|
2024-07-23 18:13:52 +00:00
|
|
|
|
|
|
|
|
2024-10-22 13:33:46 +00:00
|
|
|
@discover.command()
|
|
|
|
@click.pass_context
|
|
|
|
async def config(ctx):
|
|
|
|
"""Bypass udp discovery and try to show connection config for a device.
|
|
|
|
|
|
|
|
Bypasses udp discovery and shows the parameters required to connect
|
|
|
|
directly to the device.
|
|
|
|
"""
|
|
|
|
params = ctx.parent.parent.params
|
|
|
|
username = params["username"]
|
|
|
|
password = params["password"]
|
|
|
|
timeout = params["timeout"]
|
|
|
|
host = params["host"]
|
|
|
|
port = params["port"]
|
|
|
|
|
|
|
|
if not host:
|
|
|
|
error("--host option must be supplied to discover config")
|
|
|
|
|
|
|
|
credentials = Credentials(username, password) if username and password else None
|
|
|
|
|
2024-11-01 18:17:18 +00:00
|
|
|
host_port = host + (f":{port}" if port else "")
|
|
|
|
|
|
|
|
def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None:
|
|
|
|
prot, tran, dev = connect_attempt
|
|
|
|
key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}"
|
|
|
|
result = "succeeded" if success else "failed"
|
|
|
|
msg = f"Attempt to connect to {host_port} with {key_str} {result}"
|
|
|
|
echo(msg)
|
|
|
|
|
2024-10-22 13:33:46 +00:00
|
|
|
dev = await Discover.try_connect_all(
|
2024-11-01 18:17:18 +00:00
|
|
|
host, credentials=credentials, timeout=timeout, port=port, on_attempt=on_attempt
|
2024-10-22 13:33:46 +00:00
|
|
|
)
|
|
|
|
if dev:
|
|
|
|
cparams = dev.config.connection_type
|
|
|
|
echo("Managed to connect, cli options to connect are:")
|
|
|
|
echo(
|
|
|
|
f"--device-family {cparams.device_family.value} "
|
|
|
|
f"--encrypt-type {cparams.encryption_type.value} "
|
|
|
|
f"{'--https' if cparams.https else '--no-https'}"
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
error(f"Unable to connect to {host}")
|
|
|
|
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def _echo_dictionary(discovery_info: dict) -> None:
|
2024-07-23 18:13:52 +00:00
|
|
|
echo("\t[bold]== Discovery information ==[/bold]")
|
|
|
|
for key, value in discovery_info.items():
|
|
|
|
key_name = " ".join(x.capitalize() or "_" for x in key.split("_"))
|
|
|
|
key_name_and_spaces = "{:<15}".format(key_name + ":")
|
|
|
|
echo(f"\t{key_name_and_spaces}{value}")
|
|
|
|
|
|
|
|
|
2024-11-10 18:55:13 +00:00
|
|
|
def _echo_discovery_info(discovery_info) -> None:
|
2024-07-23 18:13:52 +00:00
|
|
|
# We don't have discovery info when all connection params are passed manually
|
|
|
|
if discovery_info is None:
|
|
|
|
return
|
|
|
|
|
2024-12-06 23:06:58 +00:00
|
|
|
if sysinfo := _extract_sys_info(discovery_info):
|
|
|
|
_echo_dictionary(sysinfo)
|
2024-07-23 18:13:52 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
2024-11-12 21:00:04 +00:00
|
|
|
dr = DiscoveryResult.from_dict(discovery_info)
|
2024-11-20 15:19:12 +00:00
|
|
|
except Exception:
|
2024-07-23 18:13:52 +00:00
|
|
|
_echo_dictionary(discovery_info)
|
|
|
|
return
|
|
|
|
|
2024-10-16 14:28:27 +00:00
|
|
|
def _conditional_echo(label, value):
|
|
|
|
if value:
|
|
|
|
ws = " " * (19 - len(label))
|
|
|
|
echo(f"\t{label}:{ws}{value}")
|
|
|
|
|
2024-07-23 18:13:52 +00:00
|
|
|
echo("\t[bold]== Discovery Result ==[/bold]")
|
2024-10-16 14:28:27 +00:00
|
|
|
_conditional_echo("Device Type", dr.device_type)
|
|
|
|
_conditional_echo("Device Model", dr.device_model)
|
|
|
|
_conditional_echo("Device Name", dr.device_name)
|
|
|
|
_conditional_echo("IP", dr.ip)
|
|
|
|
_conditional_echo("MAC", dr.mac)
|
|
|
|
_conditional_echo("Device Id (hash)", dr.device_id)
|
|
|
|
_conditional_echo("Owner (hash)", dr.owner)
|
|
|
|
_conditional_echo("FW Ver", dr.firmware_version)
|
|
|
|
_conditional_echo("HW Ver", dr.hw_ver)
|
|
|
|
_conditional_echo("HW Ver", dr.hardware_version)
|
|
|
|
_conditional_echo("Supports IOT Cloud", dr.is_support_iot_cloud)
|
|
|
|
_conditional_echo("OBD Src", dr.owner)
|
|
|
|
_conditional_echo("Factory Default", dr.factory_default)
|
|
|
|
_conditional_echo("Encrypt Type", dr.encrypt_type)
|
2024-11-29 15:23:16 +00:00
|
|
|
if mgt_encrypt_schm := dr.mgt_encrypt_schm:
|
|
|
|
_conditional_echo("Encrypt Type", mgt_encrypt_schm.encrypt_type)
|
|
|
|
_conditional_echo("Supports HTTPS", mgt_encrypt_schm.is_support_https)
|
|
|
|
_conditional_echo("HTTP Port", mgt_encrypt_schm.http_port)
|
|
|
|
_conditional_echo("Login version", mgt_encrypt_schm.lv)
|
2024-10-16 14:28:27 +00:00
|
|
|
_conditional_echo("Encrypt info", pf(dr.encrypt_info) if dr.encrypt_info else None)
|
|
|
|
_conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None)
|
2024-07-23 18:13:52 +00:00
|
|
|
|
|
|
|
|
2024-11-18 13:03:13 +00:00
|
|
|
async def find_dev_from_alias(
|
|
|
|
alias: str,
|
|
|
|
credentials: Credentials | None,
|
|
|
|
target: str = "255.255.255.255",
|
|
|
|
timeout: int = 5,
|
|
|
|
attempts: int = 3,
|
|
|
|
) -> Device | None:
|
2024-07-23 18:13:52 +00:00
|
|
|
"""Discover a device identified by its alias."""
|
2024-11-18 13:03:13 +00:00
|
|
|
found_event = asyncio.Event()
|
|
|
|
found_device = []
|
|
|
|
seen_hosts = set()
|
|
|
|
|
|
|
|
async def on_discovered(dev: Device):
|
|
|
|
if dev.host in seen_hosts:
|
|
|
|
return
|
|
|
|
seen_hosts.add(dev.host)
|
|
|
|
try:
|
|
|
|
await dev.update()
|
|
|
|
except Exception as ex:
|
|
|
|
echo(f"Error querying device {dev.host}: {ex}")
|
|
|
|
return
|
|
|
|
finally:
|
|
|
|
await dev.protocol.close()
|
|
|
|
if not dev.alias:
|
|
|
|
echo(f"Skipping device {dev.host} with no alias")
|
|
|
|
return
|
|
|
|
if dev.alias.lower() == alias.lower():
|
|
|
|
found_device.append(dev)
|
|
|
|
found_event.set()
|
|
|
|
|
|
|
|
async def do_discover():
|
|
|
|
for _ in range(1, attempts):
|
|
|
|
await Discover.discover(
|
|
|
|
target=target,
|
|
|
|
timeout=timeout,
|
|
|
|
credentials=credentials,
|
|
|
|
on_discovered=on_discovered,
|
|
|
|
)
|
|
|
|
if found_event.is_set():
|
|
|
|
break
|
|
|
|
found_event.set()
|
|
|
|
|
|
|
|
asyncio.create_task(do_discover())
|
|
|
|
await found_event.wait()
|
|
|
|
return found_device[0] if found_device else None
|