python-kasa/kasa/cli.py
Teemu R ef98c2aed9
Implement changing the bulb turn-on behavior (#381)
* Implement changing the bulb turn-on behavior

* Improve docstrings a bit

* Improve docs and expose TurnOnBehavior(s)

* fix typing
2022-10-27 17:40:54 +02:00

631 lines
19 KiB
Python
Executable File

"""python-kasa cli tool."""
import asyncio
import logging
import sys
from pprint import pformat as pf
from typing import cast
import asyncclick as click
from kasa import (
Discover,
SmartBulb,
SmartDevice,
SmartDimmer,
SmartLightStrip,
SmartPlug,
SmartStrip,
)
TYPE_TO_CLASS = {
"plug": SmartPlug,
"bulb": SmartBulb,
"dimmer": SmartDimmer,
"strip": SmartStrip,
"lightstrip": SmartLightStrip,
}
click.anyio_backend = "asyncio"
pass_dev = click.make_pass_decorator(SmartDevice)
@click.group(invoke_without_command=True)
@click.option(
"--host",
envvar="KASA_HOST",
required=False,
help="The host name or IP address 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="255.255.255.255",
required=False,
show_default=True,
help="The broadcast address to be used for discovery.",
)
@click.option("-d", "--debug", envvar="KASA_DEBUG", default=False, is_flag=True)
@click.option(
"--type",
envvar="KASA_TYPE",
default=None,
type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False),
)
@click.version_option(package_name="python-kasa")
@click.pass_context
async def cli(ctx, host, alias, target, debug, type):
"""A tool for controlling TP-Link smart home devices.""" # noqa
# no need to perform any checks if we are just displaying the help
if sys.argv[-1] == "--help":
# Context object is required to avoid crashing on sub-groups
ctx.obj = SmartDevice(None)
return
if debug:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
if ctx.invoked_subcommand == "discover":
return
if alias is not None and host is None:
click.echo(f"Alias is given, using discovery to find host {alias}")
host = await find_host_from_alias(alias=alias, target=target)
if host:
click.echo(f"Found hostname is {host}")
else:
click.echo(f"No device with name {alias} found")
return
if host is None:
click.echo("No host name given, trying discovery..")
await ctx.invoke(discover)
return
if type is not None:
dev = TYPE_TO_CLASS[type](host)
else:
click.echo("No --type defined, discovering..")
dev = await Discover.discover_single(host)
await dev.update()
ctx.obj = dev
if ctx.invoked_subcommand is None:
await ctx.invoke(state)
@cli.group()
@pass_dev
def wifi(dev):
"""Commands to control wifi settings."""
@wifi.command()
@pass_dev
async def scan(dev):
"""Scan for available wifi networks."""
click.echo("Scanning for wifi networks, wait a second..")
devs = await dev.wifi_scan()
click.echo(f"Found {len(devs)} wifi networks!")
for dev in devs:
click.echo(f"\t {dev}")
return devs
@wifi.command()
@click.argument("ssid")
@click.option("--password", prompt=True, hide_input=True)
@click.option("--keytype", default=3)
@pass_dev
async def join(dev: SmartDevice, ssid, password, keytype):
"""Join the given wifi network."""
click.echo(f"Asking the device to connect to {ssid}..")
res = await dev.wifi_join(ssid, password, keytype=keytype)
click.echo(
f"Response: {res} - if the device is not able to join the network, it will revert back to its previous state."
)
return res
@cli.command()
@click.option("--timeout", default=3, required=False)
@click.pass_context
async def discover(ctx, timeout):
"""Discover devices in the network."""
target = ctx.parent.params["target"]
click.echo(f"Discovering devices on {target} for {timeout} seconds")
sem = asyncio.Semaphore()
async def print_discovered(dev: SmartDevice):
await dev.update()
async with sem:
ctx.obj = dev
await ctx.invoke(state)
click.echo()
await Discover.discover(
target=target, timeout=timeout, on_discovered=print_discovered
)
async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3):
"""Discover a device identified by its alias."""
for attempt in range(1, attempts):
found_devs = await Discover.discover(target=target, timeout=timeout)
for ip, dev in found_devs.items():
if dev.alias.lower() == alias.lower():
host = dev.host
return host
return None
@cli.command()
@pass_dev
async def sysinfo(dev):
"""Print out full system information."""
click.echo(click.style("== System info ==", bold=True))
click.echo(pf(dev.sys_info))
return dev.sys_info
@cli.command()
@pass_dev
async def state(dev: SmartDevice):
"""Print out device state and versions."""
click.echo(click.style(f"== {dev.alias} - {dev.model} ==", bold=True))
click.echo(f"\tHost: {dev.host}")
click.echo(
click.style(
"\tDevice state: {}\n".format("ON" if dev.is_on else "OFF"),
fg="green" if dev.is_on else "red",
)
)
if dev.is_strip:
click.echo(click.style("\t== Plugs ==", bold=True))
for plug in dev.children: # type: ignore
is_on = plug.is_on
alias = plug.alias
click.echo(
click.style(
"\t* Socket '{}' state: {} on_since: {}".format(
alias, ("ON" if is_on else "OFF"), plug.on_since
),
fg="green" if is_on else "red",
)
)
click.echo()
click.echo(click.style("\t== Generic information ==", bold=True))
click.echo(f"\tTime: {dev.time} (tz: {dev.timezone}")
click.echo(f"\tHardware: {dev.hw_info['hw_ver']}")
click.echo(f"\tSoftware: {dev.hw_info['sw_ver']}")
click.echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})")
click.echo(f"\tLocation: {dev.location}")
click.echo(click.style("\n\t== Device specific information ==", bold=True))
for info_name, info_data in dev.state_information.items():
if isinstance(info_data, list):
click.echo(f"\t{info_name}:")
for item in info_data:
click.echo(f"\t\t{item}")
else:
click.echo(f"\t{info_name}: {info_data}")
if dev.has_emeter:
click.echo(click.style("\n\t== Current State ==", bold=True))
emeter_status = dev.emeter_realtime
click.echo(f"\t{emeter_status}")
click.echo(click.style("\n\t== Modules ==", bold=True))
for module in dev.modules.values():
if module.is_supported:
click.echo(click.style(f"\t+ {module}", fg="green"))
else:
click.echo(click.style(f"\t- {module}", fg="red"))
@cli.command()
@pass_dev
@click.argument("new_alias", required=False, default=None)
@click.option("--index", type=int)
async def alias(dev, new_alias, index):
"""Get or set the device (or plug) alias."""
if index is not None:
if not dev.is_strip:
click.echo("Index can only used for power strips!")
return
dev = cast(SmartStrip, dev)
dev = dev.get_plug_by_index(index)
if new_alias is not None:
click.echo(f"Setting alias to {new_alias}")
res = await dev.set_alias(new_alias)
return res
click.echo(f"Alias: {dev.alias}")
if dev.is_strip:
for plug in dev.children:
click.echo(f" * {plug.alias}")
@cli.command()
@pass_dev
@click.argument("module")
@click.argument("command")
@click.argument("parameters", default=None, required=False)
async def raw_command(dev: SmartDevice, module, command, parameters):
"""Run a raw command on the device."""
import ast
if parameters is not None:
parameters = ast.literal_eval(parameters)
res = await dev._query_helper(module, command, parameters)
click.echo(res)
return res
@cli.command()
@pass_dev
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@click.option("--erase", is_flag=True)
async def emeter(dev: SmartDevice, year, month, erase):
"""Query emeter for historical consumption.
Daily and monthly data provided in CSV format.
"""
click.echo(click.style("== Emeter ==", bold=True))
if not dev.has_emeter:
click.echo("Device has no emeter")
return
if erase:
click.echo("Erasing emeter statistics..")
click.echo(await dev.erase_emeter_stats())
return
if year:
click.echo(f"== For year {year.year} ==")
click.echo("Month, usage (kWh)")
usage_data = await dev.get_emeter_monthly(year=year.year)
elif month:
click.echo(f"== For month {month.month} of {month.year} ==")
click.echo("Day, usage (kWh)")
usage_data = await dev.get_emeter_daily(year=month.year, month=month.month)
else:
# Call with no argument outputs summary data and returns
emeter_status = dev.emeter_realtime
click.echo("Current: %s A" % emeter_status["current"])
click.echo("Voltage: %s V" % emeter_status["voltage"])
click.echo("Power: %s W" % emeter_status["power"])
click.echo("Total consumption: %s kWh" % emeter_status["total"])
click.echo("Today: %s kWh" % dev.emeter_today)
click.echo("This month: %s kWh" % dev.emeter_this_month)
return
# output any detailed usage data
for index, usage in usage_data.items():
click.echo(f"{index}, {usage}")
@cli.command()
@pass_dev
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@click.option("--erase", is_flag=True)
async def usage(dev: SmartDevice, year, month, erase):
"""Query usage for historical consumption.
Daily and monthly data provided in CSV format.
"""
click.echo(click.style("== Usage ==", bold=True))
usage = dev.modules["usage"]
if erase:
click.echo("Erasing usage statistics..")
click.echo(await usage.erase_stats())
return
if year:
click.echo(f"== For year {year.year} ==")
click.echo("Month, usage (minutes)")
usage_data = await usage.get_monthstat(year.year)
elif month:
click.echo(f"== For month {month.month} of {month.year} ==")
click.echo("Day, usage (minutes)")
usage_data = await usage.get_daystat(year=month.year, month=month.month)
else:
# Call with no argument outputs summary data and returns
click.echo("Today: %s minutes" % usage.usage_today)
click.echo("This month: %s minutes" % usage.usage_this_month)
return
# output any detailed usage data
for index, usage in usage_data.items():
click.echo(f"{index}, {usage}")
@cli.command()
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
@click.option("--transition", type=int, required=False)
@pass_dev
async def brightness(dev: SmartBulb, brightness: int, transition: int):
"""Get or set brightness."""
if not dev.is_dimmable:
click.echo("This device does not support brightness.")
return
if brightness is None:
click.echo(f"Brightness: {dev.brightness}")
else:
click.echo(f"Setting brightness to {brightness}")
return await dev.set_brightness(brightness, transition=transition)
@cli.command()
@click.argument(
"temperature", type=click.IntRange(2500, 9000), default=None, required=False
)
@click.option("--transition", type=int, required=False)
@pass_dev
async def temperature(dev: SmartBulb, temperature: int, transition: int):
"""Get or set color temperature."""
if not dev.is_variable_color_temp:
click.echo("Device does not support color temperature")
return
if temperature is None:
click.echo(f"Color temperature: {dev.color_temp}")
valid_temperature_range = dev.valid_temperature_range
if valid_temperature_range != (0, 0):
click.echo("(min: {}, max: {})".format(*valid_temperature_range))
else:
click.echo(
"Temperature range unknown, please open a github issue"
f" or a pull request for model '{dev.model}'"
)
else:
click.echo(f"Setting color temperature to {temperature}")
return await dev.set_color_temp(temperature, transition=transition)
@cli.command()
@click.argument("effect", type=click.STRING, default=None, required=False)
@click.pass_context
@pass_dev
async def effect(dev, ctx, effect):
"""Set an effect."""
if not dev.has_effects:
click.echo("Device does not support effects")
return
if effect is None:
raise click.BadArgumentUsage(
f"Setting an effect requires a named built-in effect: {dev.effect_list}",
ctx,
)
if effect not in dev.effect_list:
raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx)
click.echo(f"Setting Effect: {effect}")
return await dev.set_effect(effect)
@cli.command()
@click.argument("h", type=click.IntRange(0, 360), default=None, required=False)
@click.argument("s", type=click.IntRange(0, 100), default=None, required=False)
@click.argument("v", type=click.IntRange(0, 100), default=None, required=False)
@click.option("--transition", type=int, required=False)
@click.pass_context
@pass_dev
async def hsv(dev, ctx, h, s, v, transition):
"""Get or set color in HSV."""
if not dev.is_color:
click.echo("Device does not support colors")
return
if h is None or s is None or v is None:
click.echo(f"Current HSV: {dev.hsv}")
elif s is None or v is None:
raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx)
else:
click.echo(f"Setting HSV: {h} {s} {v}")
return await dev.set_hsv(h, s, v, transition=transition)
@cli.command()
@click.argument("state", type=bool, required=False)
@pass_dev
async def led(dev, state):
"""Get or set (Plug's) led state."""
if state is not None:
click.echo(f"Turning led to {state}")
return await dev.set_led(state)
else:
click.echo(f"LED state: {dev.led}")
@cli.command()
@pass_dev
async def time(dev):
"""Get the device time."""
res = dev.time
click.echo(f"Current time: {res}")
return res
@cli.command()
@click.option("--index", type=int, required=False)
@click.option("--name", type=str, required=False)
@click.option("--transition", type=int, required=False)
@pass_dev
async def on(dev: SmartDevice, index: int, name: str, transition: int):
"""Turn the device on."""
if index is not None or name is not None:
if not dev.is_strip:
click.echo("Index and name are only for power strips!")
return
dev = cast(SmartStrip, dev)
if index is not None:
dev = dev.get_plug_by_index(index)
elif name:
dev = dev.get_plug_by_name(name)
click.echo(f"Turning on {dev.alias}")
return await dev.turn_on(transition=transition)
@cli.command()
@click.option("--index", type=int, required=False)
@click.option("--name", type=str, required=False)
@click.option("--transition", type=int, required=False)
@pass_dev
async def off(dev: SmartDevice, index: int, name: str, transition: int):
"""Turn the device off."""
if index is not None or name is not None:
if not dev.is_strip:
click.echo("Index and name are only for power strips!")
return
dev = cast(SmartStrip, dev)
if index is not None:
dev = dev.get_plug_by_index(index)
elif name:
dev = dev.get_plug_by_name(name)
click.echo(f"Turning off {dev.alias}")
return await dev.turn_off(transition=transition)
@cli.command()
@click.option("--delay", default=1)
@pass_dev
async def reboot(plug, delay):
"""Reboot the device."""
click.echo("Rebooting the device..")
return await plug.reboot(delay)
@cli.group()
@pass_dev
async def schedule(dev):
"""Scheduling commands."""
@schedule.command(name="list")
@pass_dev
@click.argument("type", default="schedule")
def _schedule_list(dev, type):
"""Return the list of schedule actions for the given type."""
sched = dev.modules[type]
for rule in sched.rules:
print(rule)
else:
click.echo(f"No rules of type {type}")
@cli.group(invoke_without_command=True)
@click.pass_context
async def presets(ctx):
"""List and modify bulb setting presets."""
if ctx.invoked_subcommand is None:
return await ctx.invoke(presets_list)
@presets.command(name="list")
@pass_dev
def presets_list(dev: SmartBulb):
"""List presets."""
if not dev.is_bulb:
click.echo("Presets only supported on bulbs")
return
for preset in dev.presets:
print(preset)
@presets.command(name="modify")
@click.argument("index", type=int)
@click.option("--brightness", type=int)
@click.option("--hue", type=int)
@click.option("--saturation", type=int)
@click.option("--temperature", type=int)
@pass_dev
async def presets_modify(
dev: SmartBulb, index, brightness, hue, saturation, temperature
):
"""Modify a preset."""
for preset in dev.presets:
if preset.index == index:
break
else:
click.echo(f"No preset found for index {index}")
return
if brightness is not None:
preset.brightness = brightness
if hue is not None:
preset.hue = hue
if saturation is not None:
preset.saturation = saturation
if temperature is not None:
preset.color_temp = temperature
click.echo(f"Going to save preset: {preset}")
await dev.save_preset(preset)
@cli.command()
@pass_dev
@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False))
@click.option("--last", is_flag=True)
@click.option("--preset", type=int)
async def turn_on_behavior(dev: SmartBulb, type, last, preset):
"""Modify bulb turn-on behavior."""
settings = await dev.get_turn_on_behavior()
click.echo(f"Current turn on behavior: {settings}")
# Return if we are not setting the value
if not type and not last and not preset:
return
# If we are setting the value, the type has to be specified
if (last or preset) and type is None:
click.echo("To set the behavior, you need to define --type")
return
behavior = getattr(settings, type)
if last:
click.echo(f"Going to set {type} to last")
behavior.preset = None
elif preset is not None:
click.echo(f"Going to set {type} to preset {preset}")
behavior.preset = preset
await dev.set_turn_on_behavior(settings)
if __name__ == "__main__":
cli()