From 8db08a4c466c5ae7bbbb818de199c95cd55ea8cc Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sun, 18 Feb 2024 22:19:09 +0100 Subject: [PATCH 1/4] Add --child option to feature command This allows displaying or changing the values for child device features. --- kasa/cli.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index c8624966e..8c8595217 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1156,18 +1156,34 @@ async def shell(dev: Device): @cli.command(name="feature") @click.argument("name", required=False) @click.argument("value", required=False) +@click.option("--child", required=False) @pass_dev -async def feature(dev, name: str, value): +async def feature(dev, child, name: str, value): """Access and modify features. If no *name* is given, lists available features and their values. If only *name* is given, the value of named feature is returned. If both *name* and *value* are set, the described setting is changed. """ + if child is not None: + echo(f"Targeting child device {child}") + dev = dev.children[child] if not name: + + def _print_features(dev): + for name, feat in dev.features.items(): + try: + echo(f"\t{feat.name} ({name}): {feat.value}") + except Exception as ex: + echo(f"\t{feat.name} ({name}): [red]{ex}[/red]") + echo("[bold]== Features ==[/bold]") - for name, feat in dev.features.items(): - echo(f"{feat.name} ({name}): {feat.value}") + _print_features(dev) + + if dev.children: + for child in dev.children: + echo(f"[bold]== Child {child.alias} ==") + _print_features(child) return if name not in dev.features: From 0855ef03ac66a3c7099aa1bedc6121916883ef08 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 23 Feb 2024 18:27:45 +0100 Subject: [PATCH 2/4] Add tests --- kasa/cli.py | 12 ++++--- kasa/tests/conftest.py | 2 +- kasa/tests/test_cli.py | 77 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 8c8595217..848389a48 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1158,7 +1158,7 @@ async def shell(dev: Device): @click.argument("value", required=False) @click.option("--child", required=False) @pass_dev -async def feature(dev, child, name: str, value): +async def feature(dev: Device, child: str, name: str, value): """Access and modify features. If no *name* is given, lists available features and their values. @@ -1167,7 +1167,7 @@ async def feature(dev, child, name: str, value): """ if child is not None: echo(f"Targeting child device {child}") - dev = dev.children[child] + dev = dev.get_child_device(child) if not name: def _print_features(dev): @@ -1181,12 +1181,14 @@ def _print_features(dev): _print_features(dev) if dev.children: - for child in dev.children: - echo(f"[bold]== Child {child.alias} ==") - _print_features(child) + for child_dev in dev.children: + echo(f"[bold]== Child {child_dev.alias} ==") + _print_features(child_dev) + return if name not in dev.features: + raise echo(f"No feature by name {name}") return diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 39d5daf5c..50b3bb733 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -573,7 +573,7 @@ async def mock_discover(self): yield discovery_data -@pytest.fixture() +@pytest.fixture(scope="session") def dummy_protocol(): """Return a smart protocol instance with a mocking-ready dummy transport.""" diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 4c0d17e13..d3d8b5ee7 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -32,7 +32,14 @@ from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice -from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on +from .conftest import ( + device_iot, + device_smart, + get_device_for_file, + handle_turn_on, + new_discovery, + turn_on, +) async def test_update_called_by_cli(dev, mocker): @@ -684,3 +691,71 @@ async def test_errors(mocker): ) assert res.exit_code == 2 assert "Raised error:" not in res.output + + +async def test_feature(mocker): + """Test feature command.""" + dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + runner = CliRunner() + 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_feature_set(mocker): + """Test feature command's set value.""" + dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + led_setter = mocker.patch("kasa.smart.modules.ledmodule.LedModule.set_led") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + + runner = CliRunner() + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "led", "True"], + catch_exceptions=False, + ) + + led_setter.assert_called_with(True) + assert "Setting led to True" in res.output + assert res.exit_code == 0 + + +async def test_feature_set_child(mocker): + """Test feature command's set value.""" + dummy_device = await get_device_for_file("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 = "000000000000000000000000000000000000000001" + + runner = CliRunner() + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.123", + "--debug", + "feature", + "--child", + child_id, + "state", + "False", + ], + catch_exceptions=False, + ) + + get_child_device.assert_called() + setter.assert_called_with(False) + + assert f"Targeting child device {child_id}" + assert "Setting state to False" in res.output + assert res.exit_code == 0 From b4f32c82c134f0a8ccbab05ed09609c88be7a448 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 23 Feb 2024 18:37:25 +0100 Subject: [PATCH 3/4] A couple of more tests --- kasa/cli.py | 3 +-- kasa/tests/test_cli.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 848389a48..83980ec20 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1188,8 +1188,7 @@ def _print_features(dev): return if name not in dev.features: - raise - echo(f"No feature by name {name}") + echo(f"No feature by name '{name}'") return feat = dev.features[name] diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index d3d8b5ee7..6d156aec4 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -709,6 +709,34 @@ async def test_feature(mocker): assert res.exit_code == 0 +async def test_feature_single(mocker): + """Test feature command returning single value.""" + dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + runner = CliRunner() + 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): + """Test feature command returning single value.""" + dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + runner = CliRunner() + 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 == 0 + async def test_feature_set(mocker): """Test feature command's set value.""" dummy_device = await get_device_for_file("P300(EU)_1.0_1.0.13.json", "SMART") From 40a1b55aeb75ba7b23f2aaeb34bdab8dbfcd3dc3 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 23 Feb 2024 23:26:54 +0100 Subject: [PATCH 4/4] Remove fixture scoping --- kasa/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 50b3bb733..c67641081 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -573,7 +573,7 @@ async def mock_discover(self): yield discovery_data -@pytest.fixture(scope="session") +@pytest.fixture def dummy_protocol(): """Return a smart protocol instance with a mocking-ready dummy transport."""