Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 40 additions & 34 deletions kasa/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
EncryptType,
Feature,
KasaException,
Light,
Module,
UnsupportedDeviceError,
)
from kasa.discover import DiscoveryResult
Expand Down Expand Up @@ -859,18 +859,18 @@ async def usage(dev: Device, year, month, erase):
@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: Light, brightness: int, transition: int):
async def brightness(dev: Device, brightness: int, transition: int):
"""Get or set brightness."""
if not dev.is_dimmable:
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
echo("This device does not support brightness.")
return

if brightness is None:
echo(f"Brightness: {dev.brightness}")
return dev.brightness
echo(f"Brightness: {light.brightness}")
return light.brightness
else:
echo(f"Setting brightness to {brightness}")
return await dev.set_brightness(brightness, transition=transition)
return await light.set_brightness(brightness, transition=transition)


@cli.command()
Expand All @@ -879,47 +879,50 @@ async def brightness(dev: Light, brightness: int, transition: int):
)
@click.option("--transition", type=int, required=False)
@pass_dev
async def temperature(dev: Light, temperature: int, transition: int):
async def temperature(dev: Device, temperature: int, transition: int):
"""Get or set color temperature."""
if not dev.is_variable_color_temp:
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
echo("Device does not support color temperature")
return

if temperature is None:
echo(f"Color temperature: {dev.color_temp}")
valid_temperature_range = dev.valid_temperature_range
echo(f"Color temperature: {light.color_temp}")
valid_temperature_range = light.valid_temperature_range
if valid_temperature_range != (0, 0):
echo("(min: {}, max: {})".format(*valid_temperature_range))
else:
echo(
"Temperature range unknown, please open a github issue"
f" or a pull request for model '{dev.model}'"
)
return dev.valid_temperature_range
return light.valid_temperature_range
else:
echo(f"Setting color temperature to {temperature}")
return await dev.set_color_temp(temperature, transition=transition)
return await light.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):
async def effect(dev: Device, ctx, effect):
"""Set an effect."""
if not dev.has_effects:
if not (light_effect := dev.modules.get(Module.LightEffect)):
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}",
"Setting an effect requires a named built-in effect: "
+ f"{light_effect.effect_list}",
ctx,
)
if effect not in dev.effect_list:
raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx)
if effect not in light_effect.effect_list:
raise click.BadArgumentUsage(
f"Effect must be one of: {light_effect.effect_list}", ctx
)

echo(f"Setting Effect: {effect}")
return await dev.set_effect(effect)
return await light_effect.set_effect(effect)


@cli.command()
Expand All @@ -929,33 +932,36 @@ async def effect(dev, ctx, effect):
@click.option("--transition", type=int, required=False)
@click.pass_context
@pass_dev
async def hsv(dev, ctx, h, s, v, transition):
async def hsv(dev: Device, ctx, h, s, v, transition):
"""Get or set color in HSV."""
if not dev.is_color:
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
echo("Device does not support colors")
return

if h is None or s is None or v is None:
echo(f"Current HSV: {dev.hsv}")
return dev.hsv
if h is None and s is None and v is None:
echo(f"Current HSV: {light.hsv}")
return light.hsv
elif s is None or v is None:
raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx)
else:
echo(f"Setting HSV: {h} {s} {v}")
return await dev.set_hsv(h, s, v, transition=transition)
return await light.set_hsv(h, s, v, transition=transition)


@cli.command()
@click.argument("state", type=bool, required=False)
@pass_dev
async def led(dev, state):
async def led(dev: Device, state):
"""Get or set (Plug's) led state."""
if not (led := dev.modules.get(Module.Led)):
echo("Device does not support led.")
return
if state is not None:
echo(f"Turning led to {state}")
return await dev.set_led(state)
return await led.set_led(state)
else:
echo(f"LED state: {dev.led}")
return dev.led
echo(f"LED state: {led.led}")
return led.led


@cli.command()
Expand All @@ -975,8 +981,8 @@ async def time(dev):
async def on(dev: Device, 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:
echo("Index and name are only for power strips!")
if not dev.children:
echo("Index and name are only for devices with children.")
return

if index is not None:
Expand All @@ -996,8 +1002,8 @@ async def on(dev: Device, index: int, name: str, transition: int):
async def off(dev: Device, 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:
echo("Index and name are only for power strips!")
if not dev.children:
echo("Index and name are only for devices with children.")
return

if index is not None:
Expand All @@ -1017,8 +1023,8 @@ async def off(dev: Device, index: int, name: str, transition: int):
async def toggle(dev: Device, index: int, name: str, transition: int):
"""Toggle the device on/off."""
if index is not None or name is not None:
if not dev.is_strip:
echo("Index and name are only for power strips!")
if not dev.children:
echo("Index and name are only for devices with children.")
return

if index is not None:
Expand Down
125 changes: 114 additions & 11 deletions kasa/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
DeviceError,
EmeterStatus,
KasaException,
Module,
UnsupportedDeviceError,
)
from kasa.cli import (
Expand All @@ -21,11 +22,15 @@
brightness,
cli,
cmd_command,
effect,
emeter,
hsv,
led,
raw_command,
reboot,
state,
sysinfo,
temperature,
toggle,
update_credentials,
wifi,
Expand All @@ -34,7 +39,6 @@
from kasa.iot import IotDevice

from .conftest import (
device_iot,
device_smart,
get_device_for_fixture_protocol,
handle_turn_on,
Expand Down Expand Up @@ -78,11 +82,10 @@ async def test_update_called_by_cli(dev, mocker, runner):
update.assert_called()


@device_iot
async def test_sysinfo(dev, runner):
async def test_sysinfo(dev: Device, runner):
res = await runner.invoke(sysinfo, obj=dev)
assert "System info" in res.output
assert dev.alias in res.output
assert dev.model in res.output


@turn_on
Expand All @@ -108,7 +111,6 @@ async def test_toggle(dev, turn_on, runner):
assert dev.is_on != turn_on


@device_iot
async def test_alias(dev, runner):
res = await runner.invoke(alias, obj=dev)
assert f"Alias: {dev.alias}" in res.output
Expand Down Expand Up @@ -308,15 +310,14 @@ async def test_emeter(dev: Device, mocker, runner):
daily.assert_called_with(year=1900, month=12)


@device_iot
async def test_brightness(dev, runner):
async def test_brightness(dev: Device, runner):
res = await runner.invoke(brightness, obj=dev)
if not dev.is_dimmable:
if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable:
assert "This device does not support brightness." in res.output
return

res = await runner.invoke(brightness, obj=dev)
assert f"Brightness: {dev.brightness}" in res.output
assert f"Brightness: {light.brightness}" in res.output

res = await runner.invoke(brightness, ["12"], obj=dev)
assert "Setting brightness" in res.output
Expand All @@ -326,7 +327,110 @@ async def test_brightness(dev, runner):
assert "Brightness: 12" in res.output


@device_iot
async def test_color_temperature(dev: Device, runner):
res = await runner.invoke(temperature, obj=dev)
if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp:
assert "Device does not support color temperature" in res.output
return

res = await runner.invoke(temperature, obj=dev)
assert f"Color temperature: {light.color_temp}" in res.output
valid_range = light.valid_temperature_range
assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output

val = int((valid_range.min + valid_range.max) / 2)
res = await runner.invoke(temperature, [str(val)], obj=dev)
assert "Setting color temperature to " in res.output
await dev.update()

res = await runner.invoke(temperature, obj=dev)
assert f"Color temperature: {val}" in res.output
assert res.exit_code == 0

invalid_max = valid_range.max + 100
# Lights that support the maximum range will not get past the click cli range check
# So can't be tested for the internal range check.
if invalid_max < 9000:
res = await runner.invoke(temperature, [str(invalid_max)], obj=dev)
assert res.exit_code == 1
assert isinstance(res.exception, ValueError)

res = await runner.invoke(temperature, [str(9100)], obj=dev)
assert res.exit_code == 2


async def test_color_hsv(dev: Device, runner: CliRunner):
res = await runner.invoke(hsv, obj=dev)
if not (light := dev.modules.get(Module.Light)) or not light.is_color:
assert "Device does not support colors" in res.output
return

res = await runner.invoke(hsv, obj=dev)
assert f"Current HSV: {light.hsv}" in res.output

res = await runner.invoke(hsv, ["180", "50", "50"], obj=dev)
assert "Setting HSV: 180 50 50" in res.output
assert res.exit_code == 0
await dev.update()

res = await runner.invoke(hsv, ["180", "50"], obj=dev)
assert "Setting a color requires 3 values." in res.output
assert res.exit_code == 2


async def test_light_effect(dev: Device, runner: CliRunner):
res = await runner.invoke(effect, obj=dev)
if not (light_effect := dev.modules.get(Module.LightEffect)):
assert "Device does not support effects" in res.output
return

# Start off with a known state of off
await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF)
await dev.update()
assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF

res = await runner.invoke(effect, obj=dev)
msg = (
"Setting an effect requires a named built-in effect: "
+ f"{light_effect.effect_list}"
)
assert msg in res.output
assert res.exit_code == 2

res = await runner.invoke(effect, [light_effect.effect_list[1]], obj=dev)
assert f"Setting Effect: {light_effect.effect_list[1]}" in res.output
assert res.exit_code == 0
await dev.update()
assert light_effect.effect == light_effect.effect_list[1]

res = await runner.invoke(effect, ["foobar"], obj=dev)
assert f"Effect must be one of: {light_effect.effect_list}" in res.output
assert res.exit_code == 2


async def test_led(dev: Device, runner: CliRunner):
res = await runner.invoke(led, obj=dev)
if not (led_module := dev.modules.get(Module.Led)):
assert "Device does not support led" in res.output
return

res = await runner.invoke(led, obj=dev)
assert f"LED state: {led_module.led}" in res.output
assert res.exit_code == 0

res = await runner.invoke(led, ["on"], obj=dev)
assert "Turning led to True" in res.output
assert res.exit_code == 0
await dev.update()
assert led_module.led is True

res = await runner.invoke(led, ["off"], obj=dev)
assert "Turning led to False" in res.output
assert res.exit_code == 0
await dev.update()
assert led_module.led is False


async def test_json_output(dev: Device, mocker, runner):
"""Test that the json output produces correct output."""
mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev})
Expand Down Expand Up @@ -375,7 +479,6 @@ async def _state(dev: Device):
assert "Username:foo Password:bar\n" in res.output


@device_iot
async def test_without_device_type(dev, mocker, runner):
"""Test connecting without the device type."""
discovery_mock = mocker.patch(
Expand Down