From 67b5d7de83452240a2e277cd8d330e762c69e355 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 May 2024 08:38:21 +0100 Subject: [PATCH] Update cli to use common modules and remove iot specific cli testing (#913) --- kasa/cli.py | 74 +++++++++++++----------- kasa/tests/test_cli.py | 125 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 154 insertions(+), 45 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index d51679a2..d1b40a9e 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -27,7 +27,7 @@ from kasa import ( EncryptType, Feature, KasaException, - Light, + Module, UnsupportedDeviceError, ) from kasa.discover import DiscoveryResult @@ -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() @@ -879,15 +879,15 @@ 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: @@ -895,31 +895,34 @@ async def temperature(dev: Light, temperature: int, transition: int): "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() @@ -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() @@ -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: @@ -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: @@ -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: diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index a438aa97..422010ba 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -13,6 +13,7 @@ from kasa import ( DeviceError, EmeterStatus, KasaException, + Module, UnsupportedDeviceError, ) from kasa.cli import ( @@ -21,11 +22,15 @@ from kasa.cli import ( brightness, cli, cmd_command, + effect, emeter, + hsv, + led, raw_command, reboot, state, sysinfo, + temperature, toggle, update_credentials, wifi, @@ -34,7 +39,6 @@ from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice from .conftest import ( - device_iot, device_smart, get_device_for_fixture_protocol, handle_turn_on, @@ -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 @@ -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 @@ -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 @@ -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}) @@ -375,7 +479,6 @@ async def test_credentials(discovery_mock, mocker, runner): 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(