python-kasa/tests/test_cli.py
2024-12-21 09:20:12 +00:00

1378 lines
41 KiB
Python

import json
import os
import re
from datetime import datetime
from unittest.mock import ANY, PropertyMock, patch
from zoneinfo import ZoneInfo
import asyncclick as click
import pytest
from asyncclick.testing import CliRunner
from pytest_mock import MockerFixture
from kasa import (
AuthenticationError,
ColorTempRange,
Credentials,
Device,
DeviceError,
DeviceType,
EmeterStatus,
KasaException,
Module,
)
from kasa.cli.device import (
alias,
factory_reset,
led,
reboot,
state,
sysinfo,
toggle,
update_credentials,
)
from kasa.cli.light import (
brightness,
effect,
hsv,
presets,
presets_modify,
temperature,
)
from kasa.cli.main import TYPES, _legacy_type_to_class, cli, cmd_command, raw_command
from kasa.cli.time import time
from kasa.cli.usage import energy
from kasa.cli.wifi import wifi
from kasa.discover import Discover, DiscoveryResult, redact_data
from kasa.iot import IotDevice
from kasa.json import dumps as json_dumps
from kasa.smart import SmartDevice
from kasa.smartcam import SmartCamDevice
from .conftest import (
device_smart,
get_device_for_fixture_protocol,
handle_turn_on,
new_discovery,
turn_on,
)
# The cli tests should be testing the cli logic rather than a physical device
# so mark the whole file for skipping with real devices.
pytestmark = [pytest.mark.requires_dummy]
@pytest.fixture
def runner():
"""Runner fixture that unsets the KASA_ environment variables for tests."""
KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")}
runner = CliRunner(env=KASA_VARS)
return runner
async def test_help(runner):
"""Test that all the lazy modules are correctly names."""
res = await runner.invoke(cli, ["--help"])
assert res.exit_code == 0, "--help failed, check lazy module names"
@pytest.mark.parametrize(
("device_family", "encrypt_type"),
[
pytest.param(None, None, id="No connect params"),
pytest.param("SMART.TAPOPLUG", None, id="Only device_family"),
],
)
async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_type):
"""Test that device update is called on main."""
update = mocker.patch.object(dev, "update")
# These will mock the features to avoid accessing non-existing
mocker.patch("kasa.device.Device.features", return_value={})
mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={})
mocker.patch("kasa.discover.Discover.discover_single", return_value=dev)
res = await runner.invoke(
cli,
[
"--host",
"127.0.0.1",
"--username",
"foo",
"--password",
"bar",
"--device-family",
device_family,
"--encrypt-type",
encrypt_type,
],
catch_exceptions=False,
)
assert res.exit_code == 0
update.assert_called()
async def test_list_devices(discovery_mock, runner):
"""Test that device update is called on main."""
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar", "discover", "list"],
catch_exceptions=False,
)
assert res.exit_code == 0
header = (
f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} "
f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}"
)
row = (
f"{discovery_mock.ip:<15} {discovery_mock.model:<9} {discovery_mock.device_type:<20} "
f"{discovery_mock.encrypt_type:<7} {discovery_mock.https:<5} "
f"{discovery_mock.login_version or '-':<3}"
)
assert header in res.output
assert row in res.output
async def test_discover_raw(discovery_mock, runner, mocker):
"""Test the discover raw command."""
redact_spy = mocker.patch(
"kasa.protocols.protocol.redact_data", side_effect=redact_data
)
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar", "discover", "raw"],
catch_exceptions=False,
)
assert res.exit_code == 0
expected = {
"discovery_response": discovery_mock.discovery_data,
"meta": {"ip": "127.0.0.123", "port": discovery_mock.discovery_port},
}
assert res.output == json_dumps(expected, indent=True) + "\n"
redact_spy.assert_not_called()
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar", "discover", "raw", "--redact"],
catch_exceptions=False,
)
assert res.exit_code == 0
redact_spy.assert_called()
@pytest.mark.parametrize(
("exception", "expected"),
[
pytest.param(
AuthenticationError("Failed to authenticate"),
"Authentication failed",
id="auth",
),
pytest.param(TimeoutError(), "Timed out", id="timeout"),
pytest.param(Exception("Foobar"), "Error: Foobar", id="other-error"),
],
)
@new_discovery
async def test_list_update_failed(discovery_mock, mocker, runner, exception, expected):
"""Test that device update is called on main."""
device_class = Discover._get_device_class(discovery_mock.discovery_data)
mocker.patch.object(
device_class,
"update",
side_effect=exception,
)
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar", "discover", "list"],
catch_exceptions=False,
)
assert res.exit_code == 0
header = (
f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} "
f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}"
)
row = (
f"{discovery_mock.ip:<15} {discovery_mock.model:<9} {discovery_mock.device_type:<20} "
f"{discovery_mock.encrypt_type:<7} {discovery_mock.https:<5} "
f"{discovery_mock.login_version or '-':<3} - {expected}"
)
assert header in res.output.replace("\n", "")
assert row in res.output.replace("\n", "")
async def test_list_unsupported(unsupported_device_info, runner):
"""Test that device update is called on main."""
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar", "discover", "list"],
catch_exceptions=False,
)
assert res.exit_code == 0
header = (
f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} "
f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}"
)
row = f"{'127.0.0.1':<15} UNSUPPORTED DEVICE"
assert header in res.output
assert row in res.output
async def test_sysinfo(dev: Device, runner):
res = await runner.invoke(sysinfo, obj=dev)
assert "System info" in res.output
assert dev.model in res.output
@turn_on
async def test_state(dev, turn_on, runner):
await handle_turn_on(dev, turn_on)
await dev.update()
res = await runner.invoke(state, obj=dev)
if dev.is_on:
assert "Device state: True" in res.output
else:
assert "Device state: False" in res.output
@turn_on
async def test_toggle(dev, turn_on, runner):
if isinstance(dev, SmartCamDevice) and dev.device_type == DeviceType.Hub:
pytest.skip(reason="Hub cannot toggle state")
await handle_turn_on(dev, turn_on)
await dev.update()
assert dev.is_on == turn_on
await runner.invoke(toggle, obj=dev)
await dev.update()
assert dev.is_on != turn_on
async def test_alias(dev, runner):
res = await runner.invoke(alias, obj=dev)
assert f"Alias: {dev.alias}" in res.output
old_alias = dev.alias
new_alias = "new alias"
res = await runner.invoke(alias, [new_alias], obj=dev)
assert f"Setting alias to {new_alias}" in res.output
await dev.update()
res = await runner.invoke(alias, obj=dev)
assert f"Alias: {new_alias}" in res.output
# If alias is None set it back to empty string
await dev.set_alias(old_alias or "")
async def test_raw_command(dev, mocker, runner):
update = mocker.patch.object(dev, "update")
from kasa.smart import SmartDevice
if isinstance(dev, SmartCamDevice):
params = ["na", "getDeviceInfo"]
elif isinstance(dev, SmartDevice):
params = ["na", "get_device_info"]
else:
params = ["system", "get_sysinfo"]
res = await runner.invoke(raw_command, params, obj=dev)
# Make sure that update was not called for wifi
with pytest.raises(AssertionError):
update.assert_called()
assert res.exit_code == 0
assert dev.model in res.output
res = await runner.invoke(raw_command, obj=dev)
assert res.exit_code != 0
assert "Usage" in res.output
async def test_command_with_child(dev, mocker, runner):
"""Test 'command' command with --child."""
update_mock = mocker.patch.object(dev, "update")
# create_autospec for device slows tests way too much, so we use a dummy here
class DummyDevice(dev.__class__):
def __init__(self):
super().__init__("127.0.0.1")
# device_type and _info initialised for repr
self._device_type = Device.Type.StripSocket
self._info = {}
async def _query_helper(*_, **__):
return {"dummy": "response"}
dummy_child = DummyDevice()
mocker.patch.object(dev, "_children", {"XYZ": [dummy_child]})
mocker.patch.object(dev, "get_child_device", return_value=dummy_child)
res = await runner.invoke(
cmd_command,
["--child", "XYZ", "command", "'params'"],
obj=dev,
catch_exceptions=False,
)
update_mock.assert_called()
assert '{"dummy": "response"}' in res.output
assert res.exit_code == 0
@device_smart
async def test_reboot(dev, mocker, runner):
"""Test that reboot works on SMART devices."""
query_mock = mocker.patch.object(dev.protocol, "query")
res = await runner.invoke(
reboot,
obj=dev,
)
query_mock.assert_called()
assert res.exit_code == 0
@device_smart
async def test_factory_reset(dev, mocker, runner):
"""Test that factory reset works on SMART devices."""
query_mock = mocker.patch.object(dev.protocol, "query")
res = await runner.invoke(
factory_reset,
obj=dev,
input="y\n",
)
query_mock.assert_called()
assert res.exit_code == 0
@device_smart
async def test_wifi_scan(dev, runner):
res = await runner.invoke(wifi, ["scan"], obj=dev)
assert res.exit_code == 0
assert re.search(r"Found [\d]+ wifi networks!", res.output)
@device_smart
async def test_wifi_join(dev, mocker, runner):
update = mocker.patch.object(dev, "update")
res = await runner.invoke(
wifi,
["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"],
obj=dev,
)
# Make sure that update was not called for wifi
with pytest.raises(AssertionError):
update.assert_called()
assert res.exit_code == 0
assert "Asking the device to connect to FOOBAR" in res.output
@device_smart
async def test_wifi_join_no_creds(dev, runner):
dev.protocol._transport._credentials = None
res = await runner.invoke(
wifi,
["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"],
obj=dev,
)
assert res.exit_code != 0
assert isinstance(res.exception, AuthenticationError)
@device_smart
async def test_wifi_join_exception(dev, mocker, runner):
mocker.patch.object(dev.protocol, "query", side_effect=DeviceError(error_code=9999))
res = await runner.invoke(
wifi,
["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"],
obj=dev,
)
assert res.exit_code != 0
assert isinstance(res.exception, KasaException)
@device_smart
async def test_update_credentials(dev, runner):
res = await runner.invoke(
update_credentials,
["--username", "foo", "--password", "bar"],
input="y\n",
obj=dev,
)
assert res.exit_code == 0
assert (
"Do you really want to replace the existing credentials? [y/N]: y\n"
in res.output
)
async def test_time_get(dev, runner):
"""Test time get command."""
res = await runner.invoke(
time,
obj=dev,
)
assert res.exit_code == 0
assert "Current time: " in res.output
async def test_time_sync(dev, mocker, runner):
"""Test time sync command."""
update = mocker.patch.object(dev, "update")
set_time_mock = mocker.spy(dev.modules[Module.Time], "set_time")
res = await runner.invoke(
time,
["sync"],
obj=dev,
)
set_time_mock.assert_called()
update.assert_called()
assert res.exit_code == 0
assert "Old time: " in res.output
assert "New time: " in res.output
async def test_time_set(dev: Device, mocker, runner):
"""Test time set command."""
time_mod = dev.modules[Module.Time]
set_time_mock = mocker.spy(time_mod, "set_time")
dt = datetime(2024, 10, 15, 8, 15)
res = await runner.invoke(
time,
["set", str(dt.year), str(dt.month), str(dt.day), str(dt.hour), str(dt.minute)],
obj=dev,
)
set_time_mock.assert_called()
assert time_mod.time == dt.replace(tzinfo=time_mod.timezone)
assert res.exit_code == 0
assert "Old time: " in res.output
assert "New time: " in res.output
zone = ZoneInfo("Europe/Berlin")
dt = dt.replace(tzinfo=zone)
res = await runner.invoke(
time,
[
"set",
str(dt.year),
str(dt.month),
str(dt.day),
str(dt.hour),
str(dt.minute),
"--timezone",
zone.key,
],
input="y\n",
obj=dev,
)
assert time_mod.time == dt
assert res.exit_code == 0
assert "Old time: " in res.output
assert "New time: " in res.output
async def test_emeter(dev: Device, mocker, runner):
mocker.patch("kasa.Discover.discover_single", return_value=dev)
base_cmd = ["--host", "dummy", "energy"]
res = await runner.invoke(cli, base_cmd, obj=dev)
if not (energy := dev.modules.get(Module.Energy)):
assert "Device has no energy module." in res.output
return
assert "== Energy ==" in res.output
if dev.device_type is not DeviceType.Strip:
res = await runner.invoke(cli, [*base_cmd, "--index", "0"], obj=dev)
assert f"Device: {dev.host} does not have children" in res.output
res = await runner.invoke(cli, [*base_cmd, "--name", "mock"], obj=dev)
assert f"Device: {dev.host} does not have children" in res.output
if dev.device_type is DeviceType.Strip and len(dev.children) > 0:
child_energy = dev.children[0].modules.get(Module.Energy)
assert child_energy
with patch.object(
type(child_energy), "status", new_callable=PropertyMock
) as child_status:
child_status.return_value = EmeterStatus({"voltage_mv": 122066})
res = await runner.invoke(cli, [*base_cmd, "--index", "0"], obj=dev)
assert "Voltage: 122.066 V" in res.output
child_status.assert_called()
assert child_status.call_count == 1
res = await runner.invoke(
cli, [*base_cmd, "--name", dev.children[0].alias], obj=dev
)
assert "Voltage: 122.066 V" in res.output
assert child_status.call_count == 2
if isinstance(dev, IotDevice):
monthly = mocker.patch.object(energy, "get_monthly_stats")
monthly.return_value = {1: 1234}
res = await runner.invoke(cli, [*base_cmd, "--year", "1900"], obj=dev)
if not isinstance(dev, IotDevice):
assert "Device does not support historical statistics" in res.output
return
assert "For year" in res.output
assert "1, 1234" in res.output
monthly.assert_called_with(year=1900)
if isinstance(dev, IotDevice):
daily = mocker.patch.object(energy, "get_daily_stats")
daily.return_value = {1: 1234}
res = await runner.invoke(cli, [*base_cmd, "--month", "1900-12"], obj=dev)
if not isinstance(dev, IotDevice):
assert "Device has no historical statistics" in res.output
return
assert "For month" in res.output
assert "1, 1234" in res.output
daily.assert_called_with(year=1900, month=12)
async def test_brightness(dev: Device, runner):
res = await runner.invoke(brightness, obj=dev)
if not (light := dev.modules.get(Module.Light)) or not light.has_feature(
"brightness"
):
assert "This device does not support brightness." in res.output
return
res = await runner.invoke(brightness, obj=dev)
assert f"Brightness: {light.brightness}" in res.output
res = await runner.invoke(brightness, ["12"], obj=dev)
assert "Setting brightness" in res.output
await dev.update()
res = await runner.invoke(brightness, obj=dev)
assert "Brightness: 12" in res.output
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 (
color_temp_feat := light.get_feature("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 = color_temp_feat.range
assert isinstance(valid_range, ColorTempRange)
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.has_feature("hsv"):
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)
assert f"Light effect: {light_effect.effect}" in res.output
assert res.exit_code == 0
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_light_preset(dev: Device, runner: CliRunner):
res = await runner.invoke(presets, obj=dev)
if not (light_preset := dev.modules.get(Module.LightPreset)):
assert "Device does not support light presets" in res.output
return
if len(light_preset.preset_states_list) == 0:
pytest.skip(
"Some fixtures do not have presets and"
" the api doesn'tsupport creating them"
)
# Start off with a known state
first_name = light_preset.preset_list[1]
await light_preset.set_preset(first_name)
await dev.update()
assert light_preset.preset == first_name
res = await runner.invoke(presets, obj=dev)
assert "Brightness" in res.output
assert res.exit_code == 0
res = await runner.invoke(
presets_modify,
[
"0",
"--brightness",
"12",
],
obj=dev,
)
await dev.update()
assert light_preset.preset_states_list[0].brightness == 12
res = await runner.invoke(
presets_modify,
[
"0",
],
obj=dev,
)
await dev.update()
assert "Need to supply at least one option to modify." in res.output
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_single", return_value=dev)
# These will mock the features to avoid accessing non-existing ones
mocker.patch("kasa.device.Device.features", return_value={})
mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={})
res = await runner.invoke(cli, ["--host", "127.0.0.1", "--json", "state"], obj=dev)
assert res.exit_code == 0
assert json.loads(res.output) == dev.internal_state
@new_discovery
async def test_credentials(discovery_mock, mocker, runner):
"""Test credentials are passed correctly from cli to device."""
# Patch state to echo username and password
pass_dev = click.make_pass_decorator(Device)
@pass_dev
async def _state(dev: Device):
if dev.credentials:
click.echo(
f"Username:{dev.credentials.username} Password:{dev.credentials.password}"
)
mocker.patch("kasa.cli.device.state", new=_state)
dr = DiscoveryResult.from_dict(discovery_mock.discovery_data["result"])
res = await runner.invoke(
cli,
[
"--host",
"127.0.0.123",
"--username",
"foo",
"--password",
"bar",
"--device-family",
dr.device_type,
"--encrypt-type",
dr.mgt_encrypt_schm.encrypt_type,
"--login-version",
dr.mgt_encrypt_schm.lv or 1,
],
)
assert res.exit_code == 0
assert "Username:foo Password:bar\n" in res.output
async def test_without_device_type(dev, mocker, runner):
"""Test connecting without the device type."""
discovery_mock = mocker.patch(
"kasa.discover.Discover.discover_single", return_value=dev
)
# These will mock the features to avoid accessing non-existing
mocker.patch("kasa.device.Device.features", return_value={})
mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={})
res = await runner.invoke(
cli,
[
"--host",
"127.0.0.1",
"--username",
"foo",
"--password",
"bar",
"--discovery-timeout",
"7",
],
)
assert res.exit_code == 0
discovery_mock.assert_called_once_with(
"127.0.0.1",
port=None,
credentials=Credentials("foo", "bar"),
timeout=5,
discovery_timeout=7,
on_unsupported=ANY,
on_discovered_raw=ANY,
)
@pytest.mark.parametrize("auth_param", ["--username", "--password"])
async def test_invalid_credential_params(auth_param, runner):
"""Test for handling only one of username or password supplied."""
res = await runner.invoke(
cli,
[
"--host",
"127.0.0.1",
"--type",
"plug",
auth_param,
"foo",
],
)
assert res.exit_code == 2
assert (
"Error: Using authentication requires both --username and --password"
in res.output
)
async def test_duplicate_target_device(runner):
"""Test that defining both --host or --alias gives an error."""
res = await runner.invoke(
cli,
[
"--host",
"127.0.0.1",
"--alias",
"foo",
],
)
assert res.exit_code == 2
assert "Error: Use either --alias or --host, not both." in res.output
async def test_discover(discovery_mock, mocker, runner):
"""Test discovery output."""
# These will mock the features to avoid accessing non-existing
mocker.patch("kasa.device.Device.features", return_value={})
mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={})
res = await runner.invoke(
cli,
[
"--discovery-timeout",
0,
"--username",
"foo",
"--password",
"bar",
"--verbose",
"discover",
],
)
assert res.exit_code == 0
async def test_discover_host(discovery_mock, mocker, runner):
"""Test discovery output."""
# These will mock the features to avoid accessing non-existing
mocker.patch("kasa.device.Device.features", return_value={})
mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={})
res = await runner.invoke(
cli,
[
"--discovery-timeout",
0,
"--host",
"127.0.0.123",
"--username",
"foo",
"--password",
"bar",
"--verbose",
],
)
assert res.exit_code == 0
async def test_discover_unsupported(unsupported_device_info, runner):
"""Test discovery output."""
res = await runner.invoke(
cli,
[
"--discovery-timeout",
0,
"--username",
"foo",
"--password",
"bar",
"--verbose",
"discover",
],
)
assert res.exit_code == 0
assert "== Unsupported device ==" in res.output
async def test_host_unsupported(unsupported_device_info, runner):
"""Test discovery output."""
host = "127.0.0.1"
res = await runner.invoke(
cli,
[
"--host",
host,
"--username",
"foo",
"--password",
"bar",
"--debug",
],
)
assert res.exit_code != 0
assert "== Unsupported device ==" in res.output
@new_discovery
async def test_discover_auth_failed(discovery_mock, mocker, runner):
"""Test discovery output."""
host = "127.0.0.1"
discovery_mock.ip = host
device_class = Discover._get_device_class(discovery_mock.discovery_data)
mocker.patch.object(
device_class,
"update",
side_effect=AuthenticationError("Failed to authenticate"),
)
res = await runner.invoke(
cli,
[
"--discovery-timeout",
0,
"--username",
"foo",
"--password",
"bar",
"--verbose",
"discover",
],
)
assert res.exit_code == 0
assert "== Authentication failed for device ==" in res.output
assert "== Discovery Result ==" in res.output
@new_discovery
async def test_host_auth_failed(discovery_mock, mocker, runner):
"""Test discovery output."""
host = "127.0.0.1"
discovery_mock.ip = host
device_class = Discover._get_device_class(discovery_mock.discovery_data)
mocker.patch.object(
device_class,
"update",
side_effect=AuthenticationError("Failed to authenticate"),
)
res = await runner.invoke(
cli,
[
"--host",
host,
"--username",
"foo",
"--password",
"bar",
"--debug",
],
)
assert res.exit_code != 0
assert isinstance(res.exception, AuthenticationError)
@pytest.mark.parametrize("device_type", TYPES)
async def test_type_param(device_type, mocker, runner):
"""Test for handling only one of username or password supplied."""
result_device = FileNotFoundError
pass_dev = click.make_pass_decorator(Device)
@pass_dev
async def _state(dev: Device):
nonlocal result_device
result_device = dev
mocker.patch("kasa.cli.device.state", new=_state)
if device_type == "camera":
expected_type = SmartCamDevice
elif device_type == "smart":
expected_type = SmartDevice
else:
expected_type = _legacy_type_to_class(device_type)
mocker.patch.object(expected_type, "update")
res = await runner.invoke(
cli,
["--type", device_type, "--host", "127.0.0.1"],
)
assert res.exit_code == 0
assert isinstance(result_device, expected_type)
@pytest.mark.skip(
"Skip until pytest-asyncio supports pytest 8.0, https://github.com/pytest-dev/pytest-asyncio/issues/737"
)
async def test_shell(dev: Device, mocker, runner):
"""Test that the shell commands tries to embed a shell."""
mocker.patch("kasa.Discover.discover", return_value=[dev])
# repl = mocker.patch("ptpython.repl")
mocker.patch.dict(
"sys.modules",
{"ptpython": mocker.MagicMock(), "ptpython.repl": mocker.MagicMock()},
)
embed = mocker.patch("ptpython.repl.embed")
res = await runner.invoke(cli, ["shell"], obj=dev)
assert res.exit_code == 0
embed.assert_called()
async def test_errors(mocker, runner):
err = KasaException("Foobar")
# Test masking
mocker.patch("kasa.Discover.discover", side_effect=err)
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar"],
)
assert res.exit_code == 1
assert "Raised error: Foobar" in res.output
assert "Run with --debug enabled to see stacktrace" in res.output
assert isinstance(res.exception, SystemExit)
# Test --debug
res = await runner.invoke(
cli,
["--debug"],
)
assert res.exit_code == 1
assert "Raised error: Foobar" in res.output
assert res.exception == err
# Test no device passed to subcommand
mocker.patch("kasa.Discover.discover", return_value={})
res = await runner.invoke(
cli,
["sysinfo"],
)
assert res.exit_code == 1
assert (
"Only discover is available without --host or --alias"
in res.output.replace("\n", "") # Remove newlines from rich formatting
)
assert isinstance(res.exception, SystemExit)
# Test click error
res = await runner.invoke(
cli,
["--foobar"],
)
assert res.exit_code == 2
assert "Raised error:" not in res.output
async def test_feature(mocker, runner):
"""Test feature command."""
dummy_device = await get_device_for_fixture_protocol(
"P300(EU)_1.0_1.0.13.json", "SMART"
)
mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device)
res = await runner.invoke(
cli,
["--host", "127.0.0.123", "--debug", "feature"],
catch_exceptions=False,
)
assert "LED" in res.output
assert "== Child " in res.output # child listing
assert res.exit_code == 0
async def test_features_all(discovery_mock, mocker, runner):
"""Test feature command on all fixtures."""
res = await runner.invoke(
cli,
["--host", "127.0.0.123", "--debug", "feature"],
catch_exceptions=False,
)
assert "== Primary features ==" in res.output
assert "== Information ==" in res.output
assert "== Configuration ==" in res.output
assert "== Debug ==" in res.output
assert res.exit_code == 0
async def test_feature_single(mocker, runner):
"""Test feature command returning single value."""
dummy_device = await get_device_for_fixture_protocol(
"P300(EU)_1.0_1.0.13.json", "SMART"
)
mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device)
res = await runner.invoke(
cli,
["--host", "127.0.0.123", "--debug", "feature", "led"],
catch_exceptions=False,
)
assert "LED" in res.output
assert "== Features ==" not in res.output
assert res.exit_code == 0
async def test_feature_missing(mocker, runner):
"""Test feature command returning single value."""
dummy_device = await get_device_for_fixture_protocol(
"P300(EU)_1.0_1.0.13.json", "SMART"
)
mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device)
res = await runner.invoke(
cli,
["--host", "127.0.0.123", "--debug", "feature", "missing"],
catch_exceptions=False,
)
assert "No feature by name 'missing'" in res.output
assert "== Features ==" not in res.output
assert res.exit_code == 1
async def test_feature_set(mocker, runner):
"""Test feature command's set value."""
dummy_device = await get_device_for_fixture_protocol(
"P300(EU)_1.0_1.0.13.json", "SMART"
)
led_setter = mocker.patch("kasa.smart.modules.led.Led.set_led")
mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device)
res = await runner.invoke(
cli,
["--host", "127.0.0.123", "--debug", "feature", "led", "True"],
catch_exceptions=False,
)
led_setter.assert_called_with(True)
assert "Changing led from True to True" in res.output
assert res.exit_code == 0
async def test_feature_set_child(mocker, runner):
"""Test feature command's set value."""
dummy_device = await get_device_for_fixture_protocol(
"P300(EU)_1.0_1.0.13.json", "SMART"
)
setter = mocker.patch("kasa.smart.smartdevice.SmartDevice.set_state")
mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device)
get_child_device = mocker.spy(dummy_device, "get_child_device")
child_id = "SCRUBBED_CHILD_DEVICE_ID_1"
res = await runner.invoke(
cli,
[
"--host",
"127.0.0.123",
"--debug",
"feature",
"--child",
child_id,
"state",
"True",
],
catch_exceptions=False,
)
get_child_device.assert_called()
setter.assert_called_with(True)
assert f"Targeting child device {child_id}"
assert "Changing state from False to True" in res.output
assert res.exit_code == 0
async def test_cli_child_commands(
dev: Device, runner: CliRunner, mocker: MockerFixture
):
if not dev.children:
res = await runner.invoke(alias, ["--child-index", "0"], obj=dev)
assert f"Device: {dev.host} does not have children" in res.output
assert res.exit_code == 1
res = await runner.invoke(alias, ["--index", "0"], obj=dev)
assert f"Device: {dev.host} does not have children" in res.output
assert res.exit_code == 1
res = await runner.invoke(alias, ["--child", "Plug 2"], obj=dev)
assert f"Device: {dev.host} does not have children" in res.output
assert res.exit_code == 1
res = await runner.invoke(alias, ["--name", "Plug 2"], obj=dev)
assert f"Device: {dev.host} does not have children" in res.output
assert res.exit_code == 1
if dev.children:
child_alias = dev.children[0].alias
assert child_alias
child_device_id = dev.children[0].device_id
child_count = len(dev.children)
child_update_method = dev.children[0].update
# Test child retrieval
res = await runner.invoke(alias, ["--child-index", "0"], obj=dev)
assert f"Targeting child device {child_alias}" in res.output
assert res.exit_code == 0
res = await runner.invoke(alias, ["--index", "0"], obj=dev)
assert f"Targeting child device {child_alias}" in res.output
assert res.exit_code == 0
res = await runner.invoke(alias, ["--child", child_alias], obj=dev)
assert f"Targeting child device {child_alias}" in res.output
assert res.exit_code == 0
res = await runner.invoke(alias, ["--name", child_alias], obj=dev)
assert f"Targeting child device {child_alias}" in res.output
assert res.exit_code == 0
res = await runner.invoke(alias, ["--child", child_device_id], obj=dev)
assert f"Targeting child device {child_alias}" in res.output
assert res.exit_code == 0
res = await runner.invoke(alias, ["--name", child_device_id], obj=dev)
assert f"Targeting child device {child_alias}" in res.output
assert res.exit_code == 0
# Test invalid name and index
res = await runner.invoke(alias, ["--child-index", "-1"], obj=dev)
assert f"Invalid index -1, device has {child_count} children" in res.output
assert res.exit_code == 1
res = await runner.invoke(alias, ["--child-index", str(child_count)], obj=dev)
assert (
f"Invalid index {child_count}, device has {child_count} children"
in res.output
)
assert res.exit_code == 1
res = await runner.invoke(alias, ["--child", "foobar"], obj=dev)
assert "No child device found with device_id or name: foobar" in res.output
assert res.exit_code == 1
# Test using both options:
res = await runner.invoke(
alias, ["--child", child_alias, "--child-index", "0"], obj=dev
)
assert "Use either --child or --child-index, not both." in res.output
assert res.exit_code == 2
# Test child with no parameter interactive prompt
res = await runner.invoke(alias, ["--child"], obj=dev, input="0\n")
assert "Enter the index number of the child device:" in res.output
assert f"Alias: {child_alias}" in res.output
assert res.exit_code == 0
# Test values and updates
res = await runner.invoke(alias, ["foo", "--child", child_device_id], obj=dev)
assert "Alias set to: foo" in res.output
assert res.exit_code == 0
# Test help has command options plus child options
res = await runner.invoke(energy, ["--help"], obj=dev)
assert "--year" in res.output
assert "--child" in res.output
assert "--child-index" in res.output
assert res.exit_code == 0
# Test child update patching calls parent and is undone on exit
parent_update_spy = mocker.spy(dev, "update")
res = await runner.invoke(alias, ["bar", "--child", child_device_id], obj=dev)
assert "Alias set to: bar" in res.output
assert res.exit_code == 0
parent_update_spy.assert_called_once()
assert dev.children[0].update == child_update_method
async def test_discover_config(dev: Device, mocker, runner):
"""Test that device config is returned."""
host = "127.0.0.1"
mocker.patch("kasa.device_factory._connect", side_effect=[Exception, dev])
res = await runner.invoke(
cli,
[
"--username",
"foo",
"--password",
"bar",
"--host",
host,
"discover",
"config",
],
catch_exceptions=False,
)
assert res.exit_code == 0
cparam = dev.config.connection_type
expected = f"--device-family {cparam.device_family.value} --encrypt-type {cparam.encryption_type.value} {'--https' if cparam.https else '--no-https'}"
assert expected in res.output
assert re.search(
r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ failed",
res.output.replace("\n", ""),
)
assert re.search(
r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ succeeded",
res.output.replace("\n", ""),
)
async def test_discover_config_invalid(mocker, runner):
"""Test the device config command with invalids."""
host = "127.0.0.1"
mocker.patch("kasa.discover.Discover.try_connect_all", return_value=None)
res = await runner.invoke(
cli,
[
"--username",
"foo",
"--password",
"bar",
"--host",
host,
"discover",
"config",
],
catch_exceptions=False,
)
assert res.exit_code == 1
assert f"Unable to connect to {host}" in res.output
res = await runner.invoke(
cli,
["--username", "foo", "--password", "bar", "discover", "config"],
catch_exceptions=False,
)
assert res.exit_code == 1
assert "--host option must be supplied to discover config" in res.output
res = await runner.invoke(
cli,
[
"--username",
"foo",
"--password",
"bar",
"--host",
host,
"--target",
"127.0.0.2",
"discover",
"config",
],
catch_exceptions=False,
)
assert res.exit_code == 1
assert "--target is not a valid option for single host discovery" in res.output