python-kasa/kasa/cli/main.py
Teemu R. 9966c6094a
Add ssltransport for robovacs (#943)
This PR implements a clear-text, token-based transport protocol seen on
RV30 Plus (#937).

- Client sends `{"username": "email@example.com", "password":
md5(password)}` and gets back a token in the response
- Rest of the communications are done with POST at `/app?token=<token>`

---------

Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com>
2024-12-01 18:06:48 +01:00

435 lines
11 KiB
Python
Executable File

"""Main module for cli tool."""
from __future__ import annotations
import ast
import asyncio
import json
import logging
import sys
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
import asyncclick as click
if TYPE_CHECKING:
from kasa import Device
from kasa.deviceconfig import DeviceEncryptionType
from .common import (
SKIP_UPDATE_COMMANDS,
CatchAllExceptions,
echo,
error,
json_formatter_cb,
pass_dev_or_child,
)
from .lazygroup import LazyGroup
TYPES = [
"plug",
"switch",
"bulb",
"dimmer",
"strip",
"lightstrip",
"smart",
"camera",
]
ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType]
DEFAULT_TARGET = "255.255.255.255"
def _legacy_type_to_class(_type: str) -> Any:
from kasa.iot import (
IotBulb,
IotDimmer,
IotLightStrip,
IotPlug,
IotStrip,
IotWallSwitch,
)
TYPE_TO_CLASS = {
"plug": IotPlug,
"switch": IotWallSwitch,
"bulb": IotBulb,
"dimmer": IotDimmer,
"strip": IotStrip,
"lightstrip": IotLightStrip,
}
return TYPE_TO_CLASS[_type]
@click.group(
invoke_without_command=True,
cls=CatchAllExceptions(LazyGroup),
lazy_subcommands={
"discover": None,
"device": None,
"feature": None,
"light": None,
"wifi": None,
"time": None,
"schedule": None,
"usage": None,
"energy": "usage",
# device commands runnnable at top level
"state": "device",
"on": "device",
"off": "device",
"toggle": "device",
"led": "device",
"alias": "device",
"reboot": "device",
"update_credentials": "device",
"sysinfo": "device",
# light commands runnnable at top level
"presets": "light",
"brightness": "light",
"hsv": "light",
"temperature": "light",
"effect": "light",
},
result_callback=json_formatter_cb,
)
@click.option(
"--host",
envvar="KASA_HOST",
required=False,
help="The host name or IP address of the device to connect to.",
)
@click.option(
"--port",
envvar="KASA_PORT",
required=False,
type=int,
help="The port of the device to connect to.",
)
@click.option(
"--alias",
envvar="KASA_NAME",
required=False,
help="The device name, or alias, of the device to connect to.",
)
@click.option(
"--target",
envvar="KASA_TARGET",
default=DEFAULT_TARGET,
required=False,
show_default=True,
help="The broadcast address to be used for discovery.",
)
@click.option(
"-v",
"--verbose",
envvar="KASA_VERBOSE",
required=False,
default=False,
is_flag=True,
help="Be more verbose on output",
)
@click.option(
"-d",
"--debug",
envvar="KASA_DEBUG",
default=False,
is_flag=True,
help="Print debug output",
)
@click.option(
"--type",
envvar="KASA_TYPE",
default=None,
type=click.Choice(TYPES, case_sensitive=False),
help="The device type in order to bypass discovery. Use `smart` for newer devices",
)
@click.option(
"--json/--no-json",
envvar="KASA_JSON",
default=False,
is_flag=True,
help="Output raw device response as JSON.",
)
@click.option(
"-e",
"--encrypt-type",
envvar="KASA_ENCRYPT_TYPE",
default=None,
type=click.Choice(ENCRYPT_TYPES, case_sensitive=False),
)
@click.option(
"-df",
"--device-family",
envvar="KASA_DEVICE_FAMILY",
default="SMART.TAPOPLUG",
help="Device family type, e.g. `SMART.KASASWITCH`. Deprecated use `--type smart`",
)
@click.option(
"-lv",
"--login-version",
envvar="KASA_LOGIN_VERSION",
default=2,
type=int,
help="The login version for device authentication. Defaults to 2",
)
@click.option(
"--https/--no-https",
envvar="KASA_HTTPS",
default=False,
is_flag=True,
type=bool,
help="Set flag if the device encryption uses https.",
)
@click.option(
"--timeout",
envvar="KASA_TIMEOUT",
default=5,
required=False,
show_default=True,
help="Timeout for device communications.",
)
@click.option(
"--discovery-timeout",
envvar="KASA_DISCOVERY_TIMEOUT",
default=10,
required=False,
show_default=True,
help="Timeout for discovery.",
)
@click.option(
"--username",
default=None,
required=False,
envvar="KASA_USERNAME",
help="Username/email address to authenticate to device.",
)
@click.option(
"--password",
default=None,
required=False,
envvar="KASA_PASSWORD",
help="Password to use to authenticate to device.",
)
@click.option(
"--credentials-hash",
default=None,
required=False,
envvar="KASA_CREDENTIALS_HASH",
help="Hashed credentials used to authenticate to the device.",
)
@click.version_option(package_name="python-kasa")
@click.pass_context
async def cli(
ctx,
host,
port,
alias,
target,
verbose,
debug,
type,
encrypt_type,
https,
device_family,
login_version,
json,
timeout,
discovery_timeout,
username,
password,
credentials_hash,
):
"""A tool for controlling TP-Link smart home devices.""" # noqa
# no need to perform any checks if we are just displaying the help
if "--help" in sys.argv:
# Context object is required to avoid crashing on sub-groups
ctx.obj = object()
return
if target != DEFAULT_TARGET and host:
error("--target is not a valid option for single host discovery")
logging_config: dict[str, Any] = {
"level": logging.DEBUG if debug > 0 else logging.INFO
}
try:
from rich.logging import RichHandler
rich_config = {
"show_time": False,
}
logging_config["handlers"] = [RichHandler(**rich_config)]
logging_config["format"] = "%(message)s"
except ImportError:
pass
# The configuration should be converted to use dictConfig,
# but this keeps mypy happy for now
logging.basicConfig(**logging_config) # type: ignore
if ctx.invoked_subcommand == "discover":
return
if alias is not None and host is not None:
raise click.BadOptionUsage("alias", "Use either --alias or --host, not both.")
if bool(password) != bool(username):
raise click.BadOptionUsage(
"username", "Using authentication requires both --username and --password"
)
if username:
from kasa.credentials import Credentials
credentials = Credentials(username=username, password=password)
else:
credentials = None
if host is None and alias is None:
if ctx.invoked_subcommand and ctx.invoked_subcommand != "discover":
error("Only discover is available without --host or --alias")
echo("No host name given, trying discovery..")
from .discover import discover
return await ctx.invoke(discover)
device_updated = False
if type is not None and type not in {"smart", "camera"}:
from kasa.deviceconfig import DeviceConfig
config = DeviceConfig(host=host, port_override=port, timeout=timeout)
dev = _legacy_type_to_class(type)(host, config=config)
elif type in {"smart", "camera"} or (device_family and encrypt_type):
if type == "camera":
encrypt_type = "AES"
https = True
login_version = 2
device_family = "SMART.IPCAMERA"
from kasa.device import Device
from kasa.deviceconfig import (
DeviceConfig,
DeviceConnectionParameters,
DeviceEncryptionType,
DeviceFamily,
)
if not encrypt_type:
encrypt_type = "KLAP"
ctype = DeviceConnectionParameters(
DeviceFamily(device_family),
DeviceEncryptionType(encrypt_type),
login_version,
https,
)
config = DeviceConfig(
host=host,
port_override=port,
credentials=credentials,
credentials_hash=credentials_hash,
timeout=timeout,
connection_type=ctype,
)
dev = await Device.connect(config=config)
device_updated = True
elif alias:
echo(f"Alias is given, using discovery to find host {alias}")
from .discover import find_dev_from_alias
dev = await find_dev_from_alias(
alias=alias, target=target, credentials=credentials
)
if not dev:
echo(f"No device with name {alias} found")
return
echo(f"Found hostname by alias: {dev.host}")
device_updated = True
else:
from .discover import discover
dev = await ctx.invoke(discover)
if not dev:
error(f"Unable to create device for {host}")
# Skip update on specific commands, or if device factory,
# that performs an update was used for the device.
if ctx.invoked_subcommand not in SKIP_UPDATE_COMMANDS and not device_updated:
await dev.update()
@asynccontextmanager
async def async_wrapped_device(device: Device):
try:
yield device
finally:
await device.disconnect()
ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev))
if ctx.invoked_subcommand is None:
from .device import state
return await ctx.invoke(state)
@cli.command()
@pass_dev_or_child
async def shell(dev: Device) -> None:
"""Open interactive shell."""
echo(f"Opening shell for {dev}")
from ptpython.repl import embed
logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing
logging.getLogger("asyncio").setLevel(logging.WARNING)
loop = asyncio.get_event_loop()
try:
await embed( # type: ignore[func-returns-value]
globals=globals(),
locals=locals(),
return_asyncio_coroutine=True,
patch_stdout=True,
)
except EOFError:
loop.stop()
@cli.command()
@click.pass_context
@click.argument("module")
@click.argument("command")
@click.argument("parameters", default=None, required=False)
async def raw_command(ctx, module, command, parameters):
"""Run a raw command on the device."""
logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command)
return await ctx.forward(cmd_command)
@cli.command(name="command")
@click.option("--module", required=False, help="Module for IOT protocol.")
@click.argument("command")
@click.argument("parameters", default=None, required=False)
@pass_dev_or_child
async def cmd_command(dev: Device, module, command, parameters):
"""Run a raw command on the device."""
if parameters is not None:
parameters = ast.literal_eval(parameters)
from kasa import KasaException
from kasa.iot import IotDevice
from kasa.smart import SmartDevice
if isinstance(dev, IotDevice):
res = await dev._query_helper(module, command, parameters)
elif isinstance(dev, SmartDevice):
res = await dev._query_helper(command, parameters)
else:
raise KasaException("Unexpected device type %s.", dev)
echo(json.dumps(res))
return res