async++, small powerstrip improvements (#46)

* async++, small powerstrip improvements

* use asyncclick instead of click, allows defining the commands with async def to avoid manual eventloop/asyncio.run handling
* improve powerstrip support:
  * new powerstrip api: turn_{on,off}_by_{name,index} methods
  * cli: fix on/off for powerstrip using the new apis
* add missing update()s for cli's hsv, led, temperature (fixes #43)
* prettyprint the received payloads when debug mode in use
* cli: debug mode can be activated now with '-d'

* update requirements_test.txt

* remove outdated click-datetime, replace click with asyncclick

* debug is a flag

* make smartstripplug to inherit the sysinfo from its parent, allows for simple access of general plug properties

* proper bound checking for index accesses, allow controlling the plug at index 0

* remove the mess of turn_{on,off}_by_{name,index}, get_plug_by_{name,index} are enough.

* adapt cli to use that
* allow changing the alias per index

* use f-strings consistently everywhere in the cli

* add tests for get_plug_by_{index,name}
This commit is contained in:
Teemu R 2020-04-21 20:46:13 +02:00 committed by GitHub
parent 852ae494af
commit 3fe578cf26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 203 additions and 120 deletions

View File

@ -4,12 +4,13 @@ import json
import logging import logging
import re import re
from pprint import pformat as pf from pprint import pformat as pf
from typing import cast
import click import asyncclick as click
from kasa import Discover, SmartBulb, SmartDevice, SmartPlug, SmartStrip
from kasa import Discover, SmartBulb, SmartDevice, SmartStrip click.anyio_backend = "asyncio"
from kasa import SmartPlug # noqa: E402; noqa: E402
pass_dev = click.make_pass_decorator(SmartDevice) pass_dev = click.make_pass_decorator(SmartDevice)
@ -33,13 +34,13 @@ pass_dev = click.make_pass_decorator(SmartDevice)
required=False, required=False,
help="The broadcast address to be used for discovery.", help="The broadcast address to be used for discovery.",
) )
@click.option("--debug/--normal", default=False) @click.option("-d", "--debug", default=False, is_flag=True)
@click.option("--bulb", default=False, is_flag=True) @click.option("--bulb", default=False, is_flag=True)
@click.option("--plug", default=False, is_flag=True) @click.option("--plug", default=False, is_flag=True)
@click.option("--strip", default=False, is_flag=True) @click.option("--strip", default=False, is_flag=True)
@click.version_option() @click.version_option()
@click.pass_context @click.pass_context
def cli(ctx, host, alias, target, debug, bulb, plug, strip): async def cli(ctx, host, alias, target, debug, bulb, plug, strip):
"""A cli tool for controlling TP-Link smart home plugs.""" # noqa """A cli tool for controlling TP-Link smart home plugs.""" # noqa
if debug: if debug:
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
@ -51,7 +52,7 @@ def cli(ctx, host, alias, target, debug, bulb, plug, strip):
if alias is not None and host is None: if alias is not None and host is None:
click.echo(f"Alias is given, using discovery to find host {alias}") click.echo(f"Alias is given, using discovery to find host {alias}")
host = find_host_from_alias(alias=alias, target=target) host = await find_host_from_alias(alias=alias, target=target)
if host: if host:
click.echo(f"Found hostname is {host}") click.echo(f"Found hostname is {host}")
else: else:
@ -60,12 +61,12 @@ def cli(ctx, host, alias, target, debug, bulb, plug, strip):
if host is None: if host is None:
click.echo("No host name given, trying discovery..") click.echo("No host name given, trying discovery..")
ctx.invoke(discover) await ctx.invoke(discover)
return return
else: else:
if not bulb and not plug and not strip: if not bulb and not plug and not strip:
click.echo("No --strip nor --bulb nor --plug given, discovering..") click.echo("No --strip nor --bulb nor --plug given, discovering..")
dev = asyncio.run(Discover.discover_single(host)) dev = await Discover.discover_single(host)
elif bulb: elif bulb:
dev = SmartBulb(host) dev = SmartBulb(host)
elif plug: elif plug:
@ -78,7 +79,7 @@ def cli(ctx, host, alias, target, debug, bulb, plug, strip):
ctx.obj = dev ctx.obj = dev
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
ctx.invoke(state) await ctx.invoke(state)
@cli.group() @cli.group()
@ -89,10 +90,10 @@ def wifi(dev):
@wifi.command() @wifi.command()
@pass_dev @pass_dev
def scan(dev): async def scan(dev):
"""Scan for available wifi networks.""" """Scan for available wifi networks."""
click.echo("Scanning for wifi networks, wait a second..") click.echo("Scanning for wifi networks, wait a second..")
devs = asyncio.run(dev.wifi_scan()) devs = await dev.wifi_scan()
click.echo(f"Found {len(devs)} wifi networks!") click.echo(f"Found {len(devs)} wifi networks!")
for dev in devs: for dev in devs:
click.echo(f"\t {dev}") click.echo(f"\t {dev}")
@ -103,10 +104,10 @@ def scan(dev):
@click.option("--password", prompt=True, hide_input=True) @click.option("--password", prompt=True, hide_input=True)
@click.option("--keytype", default=3) @click.option("--keytype", default=3)
@pass_dev @pass_dev
def join(dev: SmartDevice, ssid, password, keytype): async def join(dev: SmartDevice, ssid, password, keytype):
"""Join the given wifi network.""" """Join the given wifi network."""
click.echo("Asking the device to connect to {ssid}.." % (ssid)) click.echo("Asking the device to connect to {ssid}.." % (ssid))
res = asyncio.run(dev.wifi_join(ssid, password, keytype=keytype)) res = await dev.wifi_join(ssid, password, keytype=keytype)
click.echo( click.echo(
f"Response: {res} - if the device is not able to join the network, it will revert back to its previous state." f"Response: {res} - if the device is not able to join the network, it will revert back to its previous state."
) )
@ -115,7 +116,7 @@ def join(dev: SmartDevice, ssid, password, keytype):
@cli.command() @cli.command()
@click.option("--scrub/--no-scrub", default=True) @click.option("--scrub/--no-scrub", default=True)
@click.pass_context @click.pass_context
def dump_discover(ctx, scrub): async def dump_discover(ctx, scrub):
"""Dump discovery information. """Dump discovery information.
Useful for dumping into a file to be added to the test suite. Useful for dumping into a file to be added to the test suite.
@ -132,7 +133,7 @@ def dump_discover(ctx, scrub):
"latitude", "latitude",
"longitude", "longitude",
] ]
devs = asyncio.run(Discover.discover(target=target, return_raw=True)) devs = await Discover.discover(target=target, return_raw=True)
if scrub: if scrub:
click.echo("Scrubbing personal data before writing") click.echo("Scrubbing personal data before writing")
for dev in devs.values(): for dev in devs.values():
@ -160,35 +161,35 @@ def dump_discover(ctx, scrub):
@click.option("--discover-only", default=False) @click.option("--discover-only", default=False)
@click.option("--dump-raw", is_flag=True) @click.option("--dump-raw", is_flag=True)
@click.pass_context @click.pass_context
def discover(ctx, timeout, discover_only, dump_raw): async def discover(ctx, timeout, discover_only, dump_raw):
"""Discover devices in the network.""" """Discover devices in the network."""
target = ctx.parent.params["target"] target = ctx.parent.params["target"]
click.echo(f"Discovering devices for {timeout} seconds") click.echo(f"Discovering devices for {timeout} seconds")
found_devs = asyncio.run( found_devs = await Discover.discover(
Discover.discover(target=target, timeout=timeout, return_raw=dump_raw) target=target, timeout=timeout, return_raw=dump_raw
) )
if not discover_only: if not discover_only:
for ip, dev in found_devs.items(): for ip, dev in found_devs.items():
asyncio.run(dev.update()) await dev.update()
if dump_raw: if dump_raw:
click.echo(dev) click.echo(dev)
continue continue
ctx.obj = dev ctx.obj = dev
ctx.invoke(state) await ctx.invoke(state)
print() click.echo()
return found_devs return found_devs
def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3):
"""Discover a device identified by its alias.""" """Discover a device identified by its alias."""
host = None
click.echo( click.echo(
f"Trying to discover {alias} using {attempts} attempts of {timeout} seconds" f"Trying to discover {alias} using {attempts} attempts of {timeout} seconds"
) )
for attempt in range(1, attempts): for attempt in range(1, attempts):
click.echo(f"Attempt {attempt} of {attempts}") click.echo(f"Attempt {attempt} of {attempts}")
found_devs = Discover.discover(target=target, timeout=timeout).items() found_devs = await Discover.discover(target=target, timeout=timeout)
found_devs = found_devs.items()
for ip, dev in found_devs: for ip, dev in found_devs:
if dev.alias.lower() == alias.lower(): if dev.alias.lower() == alias.lower():
host = dev.host host = dev.host
@ -198,9 +199,9 @@ def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3)
@cli.command() @cli.command()
@pass_dev @pass_dev
def sysinfo(dev): async def sysinfo(dev):
"""Print out full system information.""" """Print out full system information."""
asyncio.run(dev.update()) await dev.update()
click.echo(click.style("== System info ==", bold=True)) click.echo(click.style("== System info ==", bold=True))
click.echo(pf(dev.sys_info)) click.echo(pf(dev.sys_info))
@ -208,9 +209,9 @@ def sysinfo(dev):
@cli.command() @cli.command()
@pass_dev @pass_dev
@click.pass_context @click.pass_context
def state(ctx, dev: SmartDevice): async def state(ctx, dev: SmartDevice):
"""Print out device state and versions.""" """Print out device state and versions."""
asyncio.run(dev.update()) await dev.update()
click.echo(click.style(f"== {dev.alias} - {dev.model} ==", bold=True)) click.echo(click.style(f"== {dev.alias} - {dev.model} ==", bold=True))
click.echo( click.echo(
@ -221,12 +222,13 @@ def state(ctx, dev: SmartDevice):
) )
if dev.is_strip: if dev.is_strip:
for plug in dev.plugs: # type: ignore for plug in dev.plugs: # type: ignore
asyncio.run(plug.update())
is_on = plug.is_on is_on = plug.is_on
alias = plug.alias alias = plug.alias
click.echo( click.echo(
click.style( click.style(
" * {} state: {}".format(alias, ("ON" if is_on else "OFF")), " * Socket '{}' state: {} on_since: {}".format(
alias, ("ON" if is_on else "OFF"), plug.on_since
),
fg="green" if is_on else "red", fg="green" if is_on else "red",
) )
) )
@ -235,25 +237,37 @@ def state(ctx, dev: SmartDevice):
for k, v in dev.state_information.items(): for k, v in dev.state_information.items():
click.echo(f"{k}: {v}") click.echo(f"{k}: {v}")
click.echo(click.style("== Generic information ==", bold=True)) click.echo(click.style("== Generic information ==", bold=True))
click.echo(f"Time: {asyncio.run(dev.get_time())}") click.echo(f"Time: {await dev.get_time()}")
click.echo(f"Hardware: {dev.hw_info['hw_ver']}") click.echo(f"Hardware: {dev.hw_info['hw_ver']}")
click.echo(f"Software: {dev.hw_info['sw_ver']}") click.echo(f"Software: {dev.hw_info['sw_ver']}")
click.echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") click.echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
click.echo(f"Location: {dev.location}") click.echo(f"Location: {dev.location}")
ctx.invoke(emeter) await ctx.invoke(emeter)
@cli.command() @cli.command()
@pass_dev @pass_dev
@click.argument("new_alias", required=False, default=None) @click.argument("new_alias", required=False, default=None)
def alias(dev, new_alias): @click.option("--index", type=int)
"""Get or set the device alias.""" async def alias(dev, new_alias, index):
"""Get or set the device (or plug) alias."""
await dev.update()
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: if new_alias is not None:
click.echo(f"Setting alias to {new_alias}") click.echo(f"Setting alias to {new_alias}")
asyncio.run(dev.set_alias(new_alias)) click.echo(await dev.set_alias(new_alias))
click.echo(f"Alias: {dev.alias}") click.echo(f"Alias: {dev.alias}")
if dev.is_strip:
for plug in dev.plugs:
click.echo(f" * {plug.alias}")
@cli.command() @cli.command()
@ -261,14 +275,14 @@ def alias(dev, new_alias):
@click.argument("module") @click.argument("module")
@click.argument("command") @click.argument("command")
@click.argument("parameters", default=None, required=False) @click.argument("parameters", default=None, required=False)
def raw_command(dev: SmartDevice, module, command, parameters): async def raw_command(dev: SmartDevice, module, command, parameters):
"""Run a raw command on the device.""" """Run a raw command on the device."""
import ast import ast
if parameters is not None: if parameters is not None:
parameters = ast.literal_eval(parameters) parameters = ast.literal_eval(parameters)
res = asyncio.run(dev._query_helper(module, command, parameters)) res = await dev._query_helper(module, command, parameters)
asyncio.run(dev.update()) await dev.update() # TODO: is this needed?
click.echo(res) click.echo(res)
@ -277,34 +291,34 @@ def raw_command(dev: SmartDevice, module, command, parameters):
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) @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("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@click.option("--erase", is_flag=True) @click.option("--erase", is_flag=True)
def emeter(dev, year, month, erase): async def emeter(dev, year, month, erase):
"""Query emeter for historical consumption.""" """Query emeter for historical consumption."""
click.echo(click.style("== Emeter ==", bold=True)) click.echo(click.style("== Emeter ==", bold=True))
asyncio.run(dev.update()) await dev.update()
if not dev.has_emeter: if not dev.has_emeter:
click.echo("Device has no emeter") click.echo("Device has no emeter")
return return
if erase: if erase:
click.echo("Erasing emeter statistics..") click.echo("Erasing emeter statistics..")
asyncio.run(dev.erase_emeter_stats()) click.echo(await dev.erase_emeter_stats())
return return
if year: if year:
click.echo(f"== For year {year.year} ==") click.echo(f"== For year {year.year} ==")
emeter_status = asyncio.run(dev.get_emeter_monthly(year.year)) emeter_status = await dev.get_emeter_monthly(year.year)
elif month: elif month:
click.echo(f"== For month {month.month} of {month.year} ==") click.echo(f"== For month {month.month} of {month.year} ==")
emeter_status = asyncio.run( emeter_status = await dev.get_emeter_daily(year=month.year, month=month.month)
dev.get_emeter_daily(year=month.year, month=month.month)
)
else: else:
emeter_status = asyncio.run(dev.get_emeter_realtime()) emeter_status = await dev.get_emeter_realtime()
click.echo("== Current State ==") click.echo("== Current State ==")
if isinstance(emeter_status, list): if isinstance(emeter_status, list):
for plug in emeter_status: for plug in emeter_status:
click.echo("Plug %d: %s" % (emeter_status.index(plug) + 1, plug)) index = emeter_status.index(plug) + 1
click.echo(f"Plug {index}: {plug}")
else: else:
click.echo(str(emeter_status)) click.echo(str(emeter_status))
@ -312,9 +326,9 @@ def emeter(dev, year, month, erase):
@cli.command() @cli.command()
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
@pass_dev @pass_dev
def brightness(dev, brightness): async def brightness(dev, brightness):
"""Get or set brightness.""" """Get or set brightness."""
asyncio.run(dev.update()) await dev.update()
if not dev.is_dimmable: if not dev.is_dimmable:
click.echo("This device does not support brightness.") click.echo("This device does not support brightness.")
return return
@ -322,7 +336,7 @@ def brightness(dev, brightness):
click.echo(f"Brightness: {dev.brightness}") click.echo(f"Brightness: {dev.brightness}")
else: else:
click.echo(f"Setting brightness to {brightness}") click.echo(f"Setting brightness to {brightness}")
asyncio.run(dev.set_brightness(brightness)) click.echo(await dev.set_brightness(brightness))
@cli.command() @cli.command()
@ -330,8 +344,9 @@ def brightness(dev, brightness):
"temperature", type=click.IntRange(2500, 9000), default=None, required=False "temperature", type=click.IntRange(2500, 9000), default=None, required=False
) )
@pass_dev @pass_dev
def temperature(dev: SmartBulb, temperature): async def temperature(dev: SmartBulb, temperature):
"""Get or set color temperature.""" """Get or set color temperature."""
await dev.update()
if temperature is None: if temperature is None:
click.echo(f"Color temperature: {dev.color_temp}") click.echo(f"Color temperature: {dev.color_temp}")
valid_temperature_range = dev.valid_temperature_range valid_temperature_range = dev.valid_temperature_range
@ -353,67 +368,87 @@ def temperature(dev: SmartBulb, temperature):
@click.argument("v", type=click.IntRange(0, 100), default=None, required=False) @click.argument("v", type=click.IntRange(0, 100), default=None, required=False)
@click.pass_context @click.pass_context
@pass_dev @pass_dev
def hsv(dev, ctx, h, s, v): async def hsv(dev, ctx, h, s, v):
"""Get or set color in HSV. (Bulb only).""" """Get or set color in HSV. (Bulb only)."""
await dev.update()
if h is None or s is None or v is None: if h is None or s is None or v is None:
click.echo(f"Current HSV: {dev.hsv}") click.echo(f"Current HSV: {dev.hsv}")
elif s is None or v is None: elif s is None or v is None:
raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx)
else: else:
click.echo(f"Setting HSV: {h} {s} {v}") click.echo(f"Setting HSV: {h} {s} {v}")
asyncio.run(dev.set_hsv(h, s, v)) click.echo(await dev.set_hsv(h, s, v))
@cli.command() @cli.command()
@click.argument("state", type=bool, required=False) @click.argument("state", type=bool, required=False)
@pass_dev @pass_dev
def led(dev, state): async def led(dev, state):
"""Get or set (Plug's) led state.""" """Get or set (Plug's) led state."""
await dev.update()
if state is not None: if state is not None:
click.echo(f"Turning led to {state}") click.echo(f"Turning led to {state}")
asyncio.run(dev.set_led(state)) click.echo(await dev.set_led(state))
else: else:
click.echo(f"LED state: {dev.led}") click.echo(f"LED state: {dev.led}")
@cli.command() @cli.command()
@pass_dev @pass_dev
def time(dev): async def time(dev):
"""Get the device time.""" """Get the device time."""
click.echo(asyncio.run(dev.get_time())) click.echo(await dev.get_time())
@cli.command() @cli.command()
@click.argument("index", type=int, required=False) @click.option("--index", type=int, required=False)
@click.option("--name", type=str, required=False)
@pass_dev @pass_dev
def on(plug, index): async def on(dev: SmartDevice, index, name):
"""Turn the device on.""" """Turn the device on."""
click.echo("Turning on..") await dev.update()
if index is None: if index is not None or name is not None:
asyncio.run(plug.turn_on()) if not dev.is_strip:
else: click.echo("Index and name are only for power strips!")
asyncio.run(plug.turn_on(index=(index - 1))) 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}")
await dev.turn_on()
@cli.command() @cli.command()
@click.argument("index", type=int, required=False) @click.option("--index", type=int, required=False)
@click.option("--name", type=str, required=False)
@pass_dev @pass_dev
def off(plug, index): async def off(dev, index, name):
"""Turn the device off.""" """Turn the device off."""
click.echo("Turning off..") await dev.update()
if index is None: if index is not None or name is not None:
asyncio.run(plug.turn_off()) if not dev.is_strip:
else: click.echo("Index and name are only for power strips!")
asyncio.run(plug.turn_off(index=(index - 1))) 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}")
await dev.turn_off()
@cli.command() @cli.command()
@click.option("--delay", default=1) @click.option("--delay", default=1)
@pass_dev @pass_dev
def reboot(plug, delay): async def reboot(plug, delay):
"""Reboot the device.""" """Reboot the device."""
click.echo("Rebooting the device..") click.echo("Rebooting the device..")
asyncio.run(plug.reboot(delay)) click.echo(await plug.reboot(delay))
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -13,6 +13,7 @@ import asyncio
import json import json
import logging import logging
import struct import struct
from pprint import pformat as pf
from typing import Any, Dict, Union from typing import Any, Dict, Union
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -67,9 +68,10 @@ class TPLinkSmartHomeProtocol:
await writer.wait_closed() await writer.wait_closed()
response = TPLinkSmartHomeProtocol.decrypt(buffer[4:]) response = TPLinkSmartHomeProtocol.decrypt(buffer[4:])
_LOGGER.debug("< (%i) %s", len(response), response) json_payload = json.loads(response)
_LOGGER.debug("< (%i) %s", len(response), pf(json_payload))
return json.loads(response) return json_payload
@staticmethod @staticmethod
def encrypt(request: str) -> bytes: def encrypt(request: str) -> bytes:

View File

@ -122,7 +122,7 @@ class SmartDevice:
self.protocol = TPLinkSmartHomeProtocol() self.protocol = TPLinkSmartHomeProtocol()
self.emeter_type = "emeter" self.emeter_type = "emeter"
_LOGGER.debug("Initializing %s", self.host) _LOGGER.debug("Initializing %s of type %s", self.host, type(self))
self._device_type = DeviceType.Unknown self._device_type = DeviceType.Unknown
self._sys_info: Optional[Dict] = None self._sys_info: Optional[Dict] = None

View File

@ -93,6 +93,22 @@ class SmartStrip(SmartDevice):
await self._query_helper("system", "set_relay_state", {"state": 0}) await self._query_helper("system", "set_relay_state", {"state": 0})
await self.update() await self.update()
def get_plug_by_name(self, name: str) -> "SmartStripPlug":
"""Return child plug for given name."""
for p in self.plugs:
if p.alias == name:
return p
raise SmartDeviceException(f"Device has no child with {name}")
def get_plug_by_index(self, index: int) -> "SmartStripPlug":
"""Return child plug for given index."""
if index + 1 > len(self.plugs) or index < 0:
raise SmartDeviceException(
f"Invalid index {index}, device has {len(self.plugs)} plugs"
)
return self.plugs[index]
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def on_since(self) -> datetime.datetime: def on_since(self) -> datetime.datetime:
@ -107,8 +123,6 @@ class SmartStrip(SmartDevice):
:return: True if led is on, False otherwise :return: True if led is on, False otherwise
:rtype: bool :rtype: bool
""" """
# TODO this is a copypaste from smartplug,
# check if led value is per socket or per device..
sys_info = self.sys_info sys_info = self.sys_info
return bool(1 - sys_info["led_off"]) return bool(1 - sys_info["led_off"])
@ -118,8 +132,6 @@ class SmartStrip(SmartDevice):
:param bool state: True to set led on, False to set led off :param bool state: True to set led on, False to set led off
:raises SmartDeviceException: on error :raises SmartDeviceException: on error
""" """
# TODO this is a copypaste from smartplug,
# check if led value is per socket or per device..
await self._query_helper("system", "set_led_off", {"off": int(not state)}) await self._query_helper("system", "set_led_off", {"off": int(not state)})
await self.update() await self.update()
@ -221,6 +233,8 @@ class SmartStripPlug(SmartPlug):
This allows you to use the sockets as they were SmartPlug objects. This allows you to use the sockets as they were SmartPlug objects.
Instead of calling an update on any of these, you should call an update Instead of calling an update on any of these, you should call an update
on the parent device before accessing the properties. on the parent device before accessing the properties.
The plug inherits (most of) the system information from the parent.
""" """
def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None:
@ -228,7 +242,7 @@ class SmartStripPlug(SmartPlug):
self.parent = parent self.parent = parent
self.child_id = child_id self.child_id = child_id
self._sys_info = self._get_child_info() self._sys_info = {**self.parent.sys_info, **self._get_child_info()}
async def update(self): async def update(self):
"""Override the update to no-op and inform the user.""" """Override the update to no-op and inform the user."""
@ -268,6 +282,12 @@ class SmartStripPlug(SmartPlug):
""" """
return False return False
@property # type: ignore
@requires_update
def has_emeter(self) -> bool:
"""Children have no emeter to my knowledge."""
return False
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def device_id(self) -> str: def device_id(self) -> str:
@ -288,6 +308,13 @@ class SmartStripPlug(SmartPlug):
info = self._get_child_info() info = self._get_child_info()
return info["alias"] return info["alias"]
@property # type: ignore
@requires_update
def next_action(self) -> Dict:
"""Return next scheduled(?) action."""
info = self._get_child_info()
return info["next_action"]
@property # type: ignore @property # type: ignore
@requires_update @requires_update
def on_since(self) -> datetime.datetime: def on_since(self) -> datetime.datetime:

View File

@ -92,13 +92,12 @@ def dev(request):
Provides a device (given --ip) or parametrized fixture for the supported devices. Provides a device (given --ip) or parametrized fixture for the supported devices.
The initial update is called automatically before returning the device. The initial update is called automatically before returning the device.
""" """
loop = asyncio.get_event_loop()
file = request.param file = request.param
ip = request.config.getoption("--ip") ip = request.config.getoption("--ip")
if ip: if ip:
d = loop.run_until_complete(Discover.discover_single(ip)) d = asyncio.run(Discover.discover_single(ip))
loop.run_until_complete(d.update()) asyncio.run(d.update())
print(d.model) print(d.model)
if d.model in file: if d.model in file:
return d return d
@ -125,7 +124,7 @@ def dev(request):
model = basename(file) model = basename(file)
p = device_for_file(model)(host="123.123.123.123") p = device_for_file(model)(host="123.123.123.123")
p.protocol = FakeTransportProtocol(sysinfo) p.protocol = FakeTransportProtocol(sysinfo)
loop.run_until_complete(p.update()) asyncio.run(p.update())
yield p yield p

View File

@ -1,25 +1,26 @@
import asyncio import pytest
from click.testing import CliRunner
from asyncclick.testing import CliRunner
from kasa import SmartDevice from kasa import SmartDevice
from kasa.cli import alias, brightness, emeter, raw_command, state, sysinfo from kasa.cli import alias, brightness, emeter, raw_command, state, sysinfo
from .conftest import handle_turn_on, turn_on from .conftest import handle_turn_on, turn_on
pytestmark = pytest.mark.asyncio
def test_sysinfo(dev):
async def test_sysinfo(dev):
runner = CliRunner() runner = CliRunner()
res = runner.invoke(sysinfo, obj=dev) res = await runner.invoke(sysinfo, obj=dev)
assert "System info" in res.output assert "System info" in res.output
assert dev.alias in res.output assert dev.alias in res.output
@turn_on @turn_on
def test_state(dev, turn_on): async def test_state(dev, turn_on):
asyncio.run(handle_turn_on(dev, turn_on)) await handle_turn_on(dev, turn_on)
runner = CliRunner() runner = CliRunner()
res = runner.invoke(state, obj=dev) res = await runner.invoke(state, obj=dev)
print(res.output) print(res.output)
if dev.is_on: if dev.is_on:
@ -31,36 +32,36 @@ def test_state(dev, turn_on):
assert "Device has no emeter" in res.output assert "Device has no emeter" in res.output
def test_alias(dev): async def test_alias(dev):
runner = CliRunner() runner = CliRunner()
res = runner.invoke(alias, obj=dev) res = await runner.invoke(alias, obj=dev)
assert f"Alias: {dev.alias}" in res.output assert f"Alias: {dev.alias}" in res.output
new_alias = "new alias" new_alias = "new alias"
res = runner.invoke(alias, [new_alias], obj=dev) res = await runner.invoke(alias, [new_alias], obj=dev)
assert f"Setting alias to {new_alias}" in res.output assert f"Setting alias to {new_alias}" in res.output
res = runner.invoke(alias, obj=dev) res = await runner.invoke(alias, obj=dev)
assert f"Alias: {new_alias}" in res.output assert f"Alias: {new_alias}" in res.output
def test_raw_command(dev): async def test_raw_command(dev):
runner = CliRunner() runner = CliRunner()
res = runner.invoke(raw_command, ["system", "get_sysinfo"], obj=dev) res = await runner.invoke(raw_command, ["system", "get_sysinfo"], obj=dev)
assert res.exit_code == 0 assert res.exit_code == 0
assert dev.alias in res.output assert dev.alias in res.output
res = runner.invoke(raw_command, obj=dev) res = await runner.invoke(raw_command, obj=dev)
assert res.exit_code != 0 assert res.exit_code != 0
assert "Usage" in res.output assert "Usage" in res.output
def test_emeter(dev: SmartDevice, mocker): async def test_emeter(dev: SmartDevice, mocker):
runner = CliRunner() runner = CliRunner()
res = runner.invoke(emeter, obj=dev) res = await runner.invoke(emeter, obj=dev)
if not dev.has_emeter: if not dev.has_emeter:
assert "Device has no emeter" in res.output assert "Device has no emeter" in res.output
return return
@ -68,52 +69,52 @@ def test_emeter(dev: SmartDevice, mocker):
assert "Current State" in res.output assert "Current State" in res.output
monthly = mocker.patch.object(dev, "get_emeter_monthly") monthly = mocker.patch.object(dev, "get_emeter_monthly")
res = runner.invoke(emeter, ["--year", "1900"], obj=dev) res = await runner.invoke(emeter, ["--year", "1900"], obj=dev)
assert "For year" in res.output assert "For year" in res.output
monthly.assert_called() monthly.assert_called()
daily = mocker.patch.object(dev, "get_emeter_daily") daily = mocker.patch.object(dev, "get_emeter_daily")
res = runner.invoke(emeter, ["--month", "1900-12"], obj=dev) res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev)
assert "For month" in res.output assert "For month" in res.output
daily.assert_called() daily.assert_called()
def test_brightness(dev): async def test_brightness(dev):
runner = CliRunner() runner = CliRunner()
res = runner.invoke(brightness, obj=dev) res = await runner.invoke(brightness, obj=dev)
if not dev.is_dimmable: if not dev.is_dimmable:
assert "This device does not support brightness." in res.output assert "This device does not support brightness." in res.output
return return
res = runner.invoke(brightness, obj=dev) res = await runner.invoke(brightness, obj=dev)
assert f"Brightness: {dev.brightness}" in res.output assert f"Brightness: {dev.brightness}" in res.output
res = runner.invoke(brightness, ["12"], obj=dev) res = await runner.invoke(brightness, ["12"], obj=dev)
assert "Setting brightness" in res.output assert "Setting brightness" in res.output
res = runner.invoke(brightness, obj=dev) res = await runner.invoke(brightness, obj=dev)
assert f"Brightness: 12" in res.output assert f"Brightness: 12" in res.output
def test_temperature(dev): async def test_temperature(dev):
pass pass
def test_hsv(dev): async def test_hsv(dev):
pass pass
def test_led(dev): async def test_led(dev):
pass pass
def test_on(dev): async def test_on(dev):
pass pass
def test_off(dev): async def test_off(dev):
pass pass
def test_reboot(dev): async def test_reboot(dev):
pass pass

View File

@ -425,6 +425,26 @@ async def test_children_on_since(dev):
assert plug.on_since assert plug.on_since
@strip
async def test_get_plug_by_name(dev: SmartStrip):
name = dev.plugs[0].alias
assert dev.get_plug_by_name(name) == dev.plugs[0]
with pytest.raises(SmartDeviceException):
dev.get_plug_by_name("NONEXISTING NAME")
@strip
async def test_get_plug_by_index(dev: SmartStrip):
assert dev.get_plug_by_index(0) == dev.plugs[0]
with pytest.raises(SmartDeviceException):
dev.get_plug_by_index(-1)
with pytest.raises(SmartDeviceException):
dev.get_plug_by_index(len(dev.plugs))
@pytest.mark.skip("this test will wear out your relays") @pytest.mark.skip("this test will wear out your relays")
async def test_all_binary_states(dev): async def test_all_binary_states(dev):
# test every binary state # test every binary state

View File

@ -1,4 +1,3 @@
click asyncclick
click-datetime
pre-commit pre-commit
voluptuous voluptuous

View File

@ -3,6 +3,6 @@ pytest-azurepipelines
pytest-cov pytest-cov
pytest-asyncio pytest-asyncio
pytest-mock pytest-mock
click # needed for test_cli asyncclick
voluptuous voluptuous
codecov codecov

View File

@ -12,7 +12,7 @@ setup(
author_email="", author_email="",
license="GPLv3", license="GPLv3",
packages=["kasa"], packages=["kasa"],
install_requires=["click"], install_requires=["asyncclick"],
python_requires=">=3.7", python_requires=">=3.7",
entry_points={"console_scripts": ["kasa=kasa.cli:cli"]}, entry_points={"console_scripts": ["kasa=kasa.cli:cli"]},
zip_safe=False, zip_safe=False,

View File

@ -10,7 +10,7 @@ skip_missing_interpreters = True
passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH
deps = -r{toxinidir}/requirements_test.txt deps = -r{toxinidir}/requirements_test.txt
commands= commands=
py.test --cov --cov-config=tox.ini kasa pytest --cov --cov-config=tox.ini kasa
[testenv:flake8] [testenv:flake8]
deps= deps=