From e638c7b189db6e889fbc13b5eaf8112d49e222f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 16:11:23 -0500 Subject: [PATCH 01/27] Update connect_single to allow passing in the device type --- kasa/discover.py | 37 +++++++++++++++++++++---------- kasa/smartdevice.py | 24 +++++++++++++------- kasa/tests/test_discovery.py | 40 +++++++++++++++++++++++++++++++++- kasa/tests/test_smartdevice.py | 11 ++++++++++ 4 files changed, 91 insertions(+), 21 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index b43df57b3..90904d308 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -15,7 +15,7 @@ from kasa.json import loads as json_loads from kasa.protocol import TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb -from kasa.smartdevice import SmartDevice, SmartDeviceException +from kasa.smartdevice import DeviceType, SmartDevice, SmartDeviceException from kasa.smartdimmer import SmartDimmer from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug @@ -27,6 +27,14 @@ OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] DeviceDict = Dict[str, SmartDevice] +DEVICE_TYPE_TO_CLASS = { + DeviceType.Plug: SmartPlug, + DeviceType.Bulb: SmartBulb, + DeviceType.Strip: SmartStrip, + DeviceType.Dimmer: SmartDimmer, + DeviceType.LightStrip: SmartLightStrip, +} + class _DiscoverProtocol(asyncio.DatagramProtocol): """Implementation of the discovery protocol handler. @@ -317,6 +325,7 @@ async def connect_single( port: Optional[int] = None, timeout=5, credentials: Optional[Credentials] = None, + device_type: Optional[DeviceType] = None, ) -> SmartDevice: """Connect to a single device by the given IP address. @@ -334,17 +343,21 @@ async def connect_single( :rtype: SmartDevice :return: Object for querying/controlling found device. """ - unknown_dev = SmartDevice( - host=host, port=port, credentials=credentials, timeout=timeout - ) - await unknown_dev.update() - device_class = Discover._get_device_class(unknown_dev.internal_state) - dev = device_class( - host=host, port=port, credentials=credentials, timeout=timeout - ) - # Reuse the connection from the unknown device - # so we don't have to reconnect - dev.protocol = unknown_dev.protocol + if device_type and (klass := DEVICE_TYPE_TO_CLASS.get(device_type)): + dev = klass(host=host, port=port, credentials=credentials, timeout=timeout) + else: + unknown_dev = SmartDevice( + host=host, port=port, credentials=credentials, timeout=timeout + ) + await unknown_dev.update() + device_class = Discover._get_device_class(unknown_dev.internal_state) + dev = device_class( + host=host, port=port, credentials=credentials, timeout=timeout + ) + # Reuse the connection from the unknown device + # so we don't have to reconnect + dev.protocol = unknown_dev.protocol + await dev.update() return dev @staticmethod diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 3e9bd9532..342bc27c3 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -17,7 +17,7 @@ import logging from dataclasses import dataclass from datetime import datetime, timedelta -from enum import Enum, auto +from enum import Enum from typing import Any, Dict, List, Optional, Set from .credentials import Credentials @@ -32,13 +32,21 @@ class DeviceType(Enum): """Device type enum.""" - Plug = auto() - Bulb = auto() - Strip = auto() - StripSocket = auto() - Dimmer = auto() - LightStrip = auto() - Unknown = -1 + Plug = "Plug" + Bulb = "Bulb" + Strip = "Strip" + StripSocket = "StripSocket" + Dimmer = "Dimmer" + LightStrip = "LightStrip" + Unknown = "Unknown" + + @staticmethod + def from_value(name: str) -> "DeviceType": + """Return device type from string value.""" + for device_type in DeviceType: + if device_type.value == name: + return device_type + return DeviceType.Unknown @dataclass diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 3039f30cf..d9bc9a1bb 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -4,7 +4,18 @@ import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 -from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException, protocol +from kasa import ( + DeviceType, + Discover, + SmartBulb, + SmartDevice, + SmartDeviceException, + SmartDimmer, + SmartLightStrip, + SmartPlug, + SmartStrip, + protocol, +) from kasa.discover import _DiscoverProtocol, json_dumps from kasa.exceptions import UnsupportedDeviceException @@ -85,6 +96,33 @@ async def test_connect_single(discovery_data: dict, mocker, custom_port): assert dev.port == custom_port or dev.port == 9999 +@pytest.mark.parametrize("custom_port", [123, None]) +@pytest.mark.parametrize( + ("device_type", "klass"), + ( + (DeviceType.Plug, SmartPlug), + (DeviceType.Bulb, SmartBulb), + (DeviceType.Dimmer, SmartDimmer), + (DeviceType.LightStrip, SmartLightStrip), + (DeviceType.Unknown, SmartDevice), + ), +) +async def test_connect_single_passed_device_type( + discovery_data: dict, + mocker, + device_type: DeviceType, + klass: type[SmartDevice], + custom_port, +): + """Make sure that connect_single with a passed device type.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + + dev = await Discover.connect_single(host, port=custom_port, device_type=device_type) + assert isinstance(dev, klass) + assert dev.port == custom_port or dev.port == 9999 + + async def test_connect_single_query_fails(discovery_data: dict, mocker): """Make sure that connect_single fails when query fails.""" host = "127.0.0.1" diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index f6f470b82..24a3dd0fa 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -6,6 +6,7 @@ import kasa from kasa import Credentials, SmartDevice, SmartDeviceException +from kasa.smartdevice import DeviceType from kasa.smartstrip import SmartStripPlug from .conftest import handle_turn_on, has_emeter, no_emeter, turn_on @@ -58,6 +59,16 @@ async def test_initial_update_no_emeter(dev, mocker): assert spy.call_count == 2 +async def test_smart_device_from_value(): + """Make sure that every device type can be created from its value.""" + for name in DeviceType: + assert DeviceType.from_value(name.value) is not None + + assert DeviceType.from_value("nonexistent") is DeviceType.Unknown + assert DeviceType.from_value("Plug") is DeviceType.Plug + assert DeviceType.Plug.value == "Plug" + + async def test_query_helper(dev): with pytest.raises(SmartDeviceException): await dev._query_helper("test", "testcmd", {}) From 5e290633ed90334114df72dddd4685c919b421f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 16:21:51 -0500 Subject: [PATCH 02/27] fix test --- kasa/tests/test_discovery.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index d9bc9a1bb..f3b2630a5 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,6 +1,6 @@ # type: ignore import re -import sys +from typing import Type import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 @@ -13,7 +13,6 @@ SmartDimmer, SmartLightStrip, SmartPlug, - SmartStrip, protocol, ) from kasa.discover import _DiscoverProtocol, json_dumps @@ -111,7 +110,7 @@ async def test_connect_single_passed_device_type( discovery_data: dict, mocker, device_type: DeviceType, - klass: type[SmartDevice], + klass: Type[SmartDevice], custom_port, ): """Make sure that connect_single with a passed device type.""" From a63e0bed2d021c2776223b82075fbb6d4567fdb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 20:06:02 -0500 Subject: [PATCH 03/27] match names to cli --- kasa/cli.py | 13 +++++-------- kasa/discover.py | 1 + kasa/smartdevice.py | 14 +++++++------- kasa/tests/test_smartdevice.py | 16 ++++++++++++++-- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 3bc779346..0058aef39 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -12,14 +12,13 @@ from kasa import ( Credentials, + DeviceType, Discover, SmartBulb, SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, SmartStrip, ) +from kasa.discover import DEVICE_TYPE_TO_CLASS try: from rich import print as _do_echo @@ -43,11 +42,9 @@ def wrapper(message=None, *args, **kwargs): echo = _do_echo TYPE_TO_CLASS = { - "plug": SmartPlug, - "bulb": SmartBulb, - "dimmer": SmartDimmer, - "strip": SmartStrip, - "lightstrip": SmartLightStrip, + device_type.value: DEVICE_TYPE_TO_CLASS[device_type] + for device_type in DeviceType + if device_type in DEVICE_TYPE_TO_CLASS } click.anyio_backend = "asyncio" diff --git a/kasa/discover.py b/kasa/discover.py index 90904d308..ab7a51ce4 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -340,6 +340,7 @@ async def connect_single( The device type is discovered by querying the device. :param host: Hostname of device to query + :param device_type: Device type to use for the device. :rtype: SmartDevice :return: Object for querying/controlling found device. """ diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 342bc27c3..a0faf795a 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -32,13 +32,13 @@ class DeviceType(Enum): """Device type enum.""" - Plug = "Plug" - Bulb = "Bulb" - Strip = "Strip" - StripSocket = "StripSocket" - Dimmer = "Dimmer" - LightStrip = "LightStrip" - Unknown = "Unknown" + Plug = "plug" + Bulb = "bulb" + Strip = "strip" + StripSocket = "stripsocket" + Dimmer = "dimmer" + LightStrip = "lightstrip" + Unknown = "unknown" @staticmethod def from_value(name: str) -> "DeviceType": diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 24a3dd0fa..f0ece094c 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -65,8 +65,20 @@ async def test_smart_device_from_value(): assert DeviceType.from_value(name.value) is not None assert DeviceType.from_value("nonexistent") is DeviceType.Unknown - assert DeviceType.from_value("Plug") is DeviceType.Plug - assert DeviceType.Plug.value == "Plug" + assert DeviceType.from_value("plug") is DeviceType.Plug + assert DeviceType.Plug.value == "plug" + + assert DeviceType.from_value("bulb") is DeviceType.Bulb + assert DeviceType.Bulb.value == "bulb" + + assert DeviceType.from_value("dimmer") is DeviceType.Dimmer + assert DeviceType.Dimmer.value == "dimmer" + + assert DeviceType.from_value("strip") is DeviceType.Strip + assert DeviceType.Strip.value == "strip" + + assert DeviceType.from_value("lightstrip") is DeviceType.LightStrip + assert DeviceType.LightStrip.value == "lightstrip" async def test_query_helper(dev): From 1dcb5e72abd96ad5e99d2a3b7a7ba918f7eafd58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 20:07:40 -0500 Subject: [PATCH 04/27] match names to cli --- kasa/smartdevice.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index a0faf795a..31db342ac 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -32,6 +32,8 @@ class DeviceType(Enum): """Device type enum.""" + # The values match what the cli has historically used + Plug = "plug" Bulb = "bulb" Strip = "strip" From 6573ee0f8a014815030729b2e663b8b0e0888165 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 21:20:54 -0500 Subject: [PATCH 05/27] Update smartdevice.py --- kasa/smartdevice.py | 1 - 1 file changed, 1 deletion(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 31db342ac..e8142a5df 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -33,7 +33,6 @@ class DeviceType(Enum): """Device type enum.""" # The values match what the cli has historically used - Plug = "plug" Bulb = "bulb" Strip = "strip" From bcdcd5f982bfccf39c33c03300ebdaeb88d8dd09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Nov 2023 18:01:09 -0500 Subject: [PATCH 06/27] adjust per review --- kasa/discover.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index ab7a51ce4..0dc7cee85 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -341,23 +341,30 @@ async def connect_single( :param host: Hostname of device to query :param device_type: Device type to use for the device. + If not given, the device type is discovered by querying the device. + If the device type is already known, it is preferred to pass it + to avoid the extra query to the device to discover its type. :rtype: SmartDevice :return: Object for querying/controlling found device. """ if device_type and (klass := DEVICE_TYPE_TO_CLASS.get(device_type)): - dev = klass(host=host, port=port, credentials=credentials, timeout=timeout) - else: - unknown_dev = SmartDevice( - host=host, port=port, credentials=credentials, timeout=timeout - ) - await unknown_dev.update() - device_class = Discover._get_device_class(unknown_dev.internal_state) - dev = device_class( + dev: SmartDevice = klass( host=host, port=port, credentials=credentials, timeout=timeout ) - # Reuse the connection from the unknown device - # so we don't have to reconnect - dev.protocol = unknown_dev.protocol + await dev.update() + return dev + + unknown_dev = SmartDevice( + host=host, port=port, credentials=credentials, timeout=timeout + ) + await unknown_dev.update() + device_class = Discover._get_device_class(unknown_dev.internal_state) + dev = device_class( + host=host, port=port, credentials=credentials, timeout=timeout + ) + # Reuse the connection from the unknown device + # so we don't have to reconnect + dev.protocol = unknown_dev.protocol await dev.update() return dev From 418e5088add78e96f60550ee8e5dddf81f38bdf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 09:56:23 -0600 Subject: [PATCH 07/27] move method --- kasa/device_factory.py | 101 +++++++++++++++++++++++++++++++++++++++++ kasa/device_type.py | 25 ++++++++++ kasa/discover.py | 92 ++----------------------------------- kasa/smartdevice.py | 62 ++++++++++++++++--------- 4 files changed, 170 insertions(+), 110 deletions(-) create mode 100755 kasa/device_factory.py create mode 100755 kasa/device_type.py diff --git a/kasa/device_factory.py b/kasa/device_factory.py new file mode 100755 index 000000000..a18b09122 --- /dev/null +++ b/kasa/device_factory.py @@ -0,0 +1,101 @@ +"""Device creation by type.""" + +from typing import Optional, Type + +from .credentials import Credentials +from .device_type import DeviceType +from .smartbulb import SmartBulb +from .smartdevice import SmartDevice, SmartDeviceException +from .smartdimmer import SmartDimmer +from .smartlightstrip import SmartLightStrip +from .smartplug import SmartPlug +from .smartstrip import SmartStrip + +DEVICE_TYPE_TO_CLASS = { + DeviceType.Plug: SmartPlug, + DeviceType.Bulb: SmartBulb, + DeviceType.Strip: SmartStrip, + DeviceType.Dimmer: SmartDimmer, + DeviceType.LightStrip: SmartLightStrip, +} + + +def get_device_class_from_type(device_type: DeviceType) -> Type[SmartDevice]: + """Find SmartDevice subclass for device described by passed data.""" + return DEVICE_TYPE_TO_CLASS[device_type] + + +async def connect( + host: str, + *, + port: Optional[int] = None, + timeout=5, + credentials: Optional[Credentials] = None, + device_type: Optional[DeviceType] = None, +) -> "SmartDevice": + """Connect to a single device by the given IP address. + + This method avoids the UDP based discovery process and + will connect directly to the device to query its type. + + It is generally preferred to avoid :func:`discover_single()` and + use this function instead as it should perform better when + the WiFi network is congested or the device is not responding + to discovery requests. + + The device type is discovered by querying the device. + + :param host: Hostname of device to query + :param device_type: Device type to use for the device. + If not given, the device type is discovered by querying the device. + If the device type is already known, it is preferred to pass it + to avoid the extra query to the device to discover its type. + :rtype: SmartDevice + :return: Object for querying/controlling found device. + """ + if device_type and (klass := DEVICE_TYPE_TO_CLASS.get(device_type)): + dev: SmartDevice = klass( + host=host, port=port, credentials=credentials, timeout=timeout + ) + await dev.update() + return dev + + unknown_dev = SmartDevice( + host=host, port=port, credentials=credentials, timeout=timeout + ) + await unknown_dev.update() + device_class = get_device_class_from_info(unknown_dev.internal_state) + dev = device_class(host=host, port=port, credentials=credentials, timeout=timeout) + # Reuse the connection from the unknown device + # so we don't have to reconnect + dev.protocol = unknown_dev.protocol + await dev.update() + return dev + + +def get_device_class_from_info(info: dict) -> Type[SmartDevice]: + """Find SmartDevice subclass for device described by passed data.""" + if "system" not in info or "get_sysinfo" not in info["system"]: + raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") + + sysinfo = info["system"]["get_sysinfo"] + type_ = sysinfo.get("type", sysinfo.get("mic_type")) + if type_ is None: + raise SmartDeviceException("Unable to find the device type field!") + + if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: + return SmartDimmer + + if "smartplug" in type_.lower(): + if "children" in sysinfo: + return SmartStrip + + return SmartPlug + + if "smartbulb" in type_.lower(): + if "length" in sysinfo: # strips have length + return SmartLightStrip + + return SmartBulb + + raise SmartDeviceException("Unknown device type: %s" % type_) diff --git a/kasa/device_type.py b/kasa/device_type.py new file mode 100755 index 000000000..162fc4f27 --- /dev/null +++ b/kasa/device_type.py @@ -0,0 +1,25 @@ +"""TP-Link device types.""" + + +from enum import Enum + + +class DeviceType(Enum): + """Device type enum.""" + + # The values match what the cli has historically used + Plug = "plug" + Bulb = "bulb" + Strip = "strip" + StripSocket = "stripsocket" + Dimmer = "dimmer" + LightStrip = "lightstrip" + Unknown = "unknown" + + @staticmethod + def from_value(name: str) -> "DeviceType": + """Return device type from string value.""" + for device_type in DeviceType: + if device_type.value == name: + return device_type + return DeviceType.Unknown diff --git a/kasa/discover.py b/kasa/discover.py index db7235b1a..2580b6991 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -15,12 +15,9 @@ from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads from kasa.protocol import TPLinkSmartHomeProtocol -from kasa.smartbulb import SmartBulb -from kasa.smartdevice import DeviceType, SmartDevice, SmartDeviceException -from kasa.smartdimmer import SmartDimmer -from kasa.smartlightstrip import SmartLightStrip -from kasa.smartplug import SmartPlug -from kasa.smartstrip import SmartStrip +from kasa.smartdevice import SmartDevice, SmartDeviceException + +from .device_factory import get_device_class_from_info _LOGGER = logging.getLogger(__name__) @@ -28,14 +25,6 @@ OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] DeviceDict = Dict[str, SmartDevice] -DEVICE_TYPE_TO_CLASS = { - DeviceType.Plug: SmartPlug, - DeviceType.Bulb: SmartBulb, - DeviceType.Strip: SmartStrip, - DeviceType.Dimmer: SmartDimmer, - DeviceType.LightStrip: SmartLightStrip, -} - class _DiscoverProtocol(asyncio.DatagramProtocol): """Implementation of the discovery protocol handler. @@ -345,80 +334,7 @@ async def discover_single( else: raise SmartDeviceException(f"Unable to get discovery response for {host}") - @staticmethod - async def connect_single( - host: str, - *, - port: Optional[int] = None, - timeout=5, - credentials: Optional[Credentials] = None, - device_type: Optional[DeviceType] = None, - ) -> SmartDevice: - """Connect to a single device by the given IP address. - - This method avoids the UDP based discovery process and - will connect directly to the device to query its type. - - It is generally preferred to avoid :func:`discover_single()` and - use this function instead as it should perform better when - the WiFi network is congested or the device is not responding - to discovery requests. - - The device type is discovered by querying the device. - - :param host: Hostname of device to query - :param device_type: Device type to use for the device. - If not given, the device type is discovered by querying the device. - If the device type is already known, it is preferred to pass it - to avoid the extra query to the device to discover its type. - :rtype: SmartDevice - :return: Object for querying/controlling found device. - """ - if device_type and (klass := DEVICE_TYPE_TO_CLASS.get(device_type)): - dev: SmartDevice = klass( - host=host, port=port, credentials=credentials, timeout=timeout - ) - await dev.update() - return dev - - unknown_dev = SmartDevice( - host=host, port=port, credentials=credentials, timeout=timeout - ) - await unknown_dev.update() - device_class = Discover._get_device_class(unknown_dev.internal_state) - dev = device_class( - host=host, port=port, credentials=credentials, timeout=timeout - ) - # Reuse the connection from the unknown device - # so we don't have to reconnect - dev.protocol = unknown_dev.protocol - await dev.update() - return dev - @staticmethod def _get_device_class(info: dict) -> Type[SmartDevice]: """Find SmartDevice subclass for device described by passed data.""" - if "system" not in info or "get_sysinfo" not in info["system"]: - raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") - - sysinfo = info["system"]["get_sysinfo"] - type_ = sysinfo.get("type", sysinfo.get("mic_type")) - if type_ is None: - raise SmartDeviceException("Unable to find the device type field!") - - if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return SmartDimmer - - if "smartplug" in type_.lower(): - if "children" in sysinfo: - return SmartStrip - - return SmartPlug - - if "smartbulb" in type_.lower(): - if "length" in sysinfo: # strips have length - return SmartLightStrip - - return SmartBulb - - raise SmartDeviceException("Unknown device type: %s" % type_) + return get_device_class_from_info(info) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index e8142a5df..4f850b5ba 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -17,10 +17,10 @@ import logging from dataclasses import dataclass from datetime import datetime, timedelta -from enum import Enum from typing import Any, Dict, List, Optional, Set from .credentials import Credentials +from .device_type import DeviceType from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .modules import Emeter, Module @@ -29,27 +29,6 @@ _LOGGER = logging.getLogger(__name__) -class DeviceType(Enum): - """Device type enum.""" - - # The values match what the cli has historically used - Plug = "plug" - Bulb = "bulb" - Strip = "strip" - StripSocket = "stripsocket" - Dimmer = "dimmer" - LightStrip = "lightstrip" - Unknown = "unknown" - - @staticmethod - def from_value(name: str) -> "DeviceType": - """Return device type from string value.""" - for device_type in DeviceType: - if device_type.value == name: - return device_type - return DeviceType.Unknown - - @dataclass class WifiNetwork: """Wifi network container.""" @@ -767,3 +746,42 @@ def __repr__(self): f" ({self.alias}), is_on: {self.is_on}" f" - dev specific: {self.state_information}>" ) + + @staticmethod + async def connect( + host: str, + *, + port: Optional[int] = None, + timeout=5, + credentials: Optional[Credentials] = None, + device_type: Optional[DeviceType] = None, + ) -> "SmartDevice": + """Connect to a single device by the given IP address. + + This method avoids the UDP based discovery process and + will connect directly to the device to query its type. + + It is generally preferred to avoid :func:`discover_single()` and + use this function instead as it should perform better when + the WiFi network is congested or the device is not responding + to discovery requests. + + The device type is discovered by querying the device. + + :param host: Hostname of device to query + :param device_type: Device type to use for the device. + If not given, the device type is discovered by querying the device. + If the device type is already known, it is preferred to pass it + to avoid the extra query to the device to discover its type. + :rtype: SmartDevice + :return: Object for querying/controlling found device. + """ + from .device_factory import connect # pylint: disable=import-outside-toplevel + + return await connect( + host=host, + port=port, + timeout=timeout, + credentials=credentials, + device_type=device_type, + ) From 88d3b5e32baf721895a8964819fecc9525d2606a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 08:02:43 -0600 Subject: [PATCH 08/27] tweaks --- kasa/smartdevice.py | 6 ++++++ kasa/tests/test_smartdevice.py | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 4f850b5ba..e7b7a38f1 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -393,6 +393,12 @@ def alias(self) -> str: sys_info = self.sys_info return str(sys_info["alias"]) + @property # type: ignore + @requires_update + def has_children(self) -> bool: + """Check if the device has children devices.""" + return bool(self._sys_info.get("child_num")) + async def set_alias(self, alias: str) -> None: """Set the device name (alias).""" return await self._query_helper("system", "set_dev_alias", {"alias": alias}) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index f0ece094c..6e7cf80fb 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -170,12 +170,14 @@ async def test_representation(dev): assert pattern.match(str(dev)) -async def test_childrens(dev): +async def test_children(dev): """Make sure that children property is exposed by every device.""" if dev.is_strip: assert len(dev.children) > 0 + assert dev.has_children is True else: assert len(dev.children) == 0 + assert dev.has_children is False async def test_internal_state(dev): From a9ae4f028f0c3f41279de05d8b5f7e13cf86fb92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 09:58:43 -0600 Subject: [PATCH 09/27] tweak --- kasa/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/cli.py b/kasa/cli.py index 0058aef39..9af554454 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -18,7 +18,7 @@ SmartDevice, SmartStrip, ) -from kasa.discover import DEVICE_TYPE_TO_CLASS +from kasa.device_factory import DEVICE_TYPE_TO_CLASS try: from rich import print as _do_echo From e135289ed7318bc5568464c5afac000d6ca77d9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 09:59:10 -0600 Subject: [PATCH 10/27] preen --- kasa/device_factory.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index a18b09122..1d29312bf 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -20,11 +20,6 @@ } -def get_device_class_from_type(device_type: DeviceType) -> Type[SmartDevice]: - """Find SmartDevice subclass for device described by passed data.""" - return DEVICE_TYPE_TO_CLASS[device_type] - - async def connect( host: str, *, From c076bc72ff2bb2b409d5e7a22c721501133d4cea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 10:00:34 -0600 Subject: [PATCH 11/27] typing --- kasa/device_factory.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 1d29312bf..f18591e8d 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -1,6 +1,6 @@ """Device creation by type.""" -from typing import Optional, Type +from typing import Any, Dict, Optional, Type from .credentials import Credentials from .device_type import DeviceType @@ -68,12 +68,12 @@ async def connect( return dev -def get_device_class_from_info(info: dict) -> Type[SmartDevice]: +def get_device_class_from_info(info: Dict[str, Any]) -> Type[SmartDevice]: """Find SmartDevice subclass for device described by passed data.""" if "system" not in info or "get_sysinfo" not in info["system"]: raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") - sysinfo = info["system"]["get_sysinfo"] + sysinfo: Dict[str, Any] = info["system"]["get_sysinfo"] type_ = sysinfo.get("type", sysinfo.get("mic_type")) if type_ is None: raise SmartDeviceException("Unable to find the device type field!") From 542554e5d4449f4f677e2d18c660f65bc99ce8d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 10:00:55 -0600 Subject: [PATCH 12/27] typing --- kasa/device_factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index f18591e8d..b2de926b1 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -74,7 +74,7 @@ def get_device_class_from_info(info: Dict[str, Any]) -> Type[SmartDevice]: raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") sysinfo: Dict[str, Any] = info["system"]["get_sysinfo"] - type_ = sysinfo.get("type", sysinfo.get("mic_type")) + type_: Optional[str] = sysinfo.get("type", sysinfo.get("mic_type")) if type_ is None: raise SmartDeviceException("Unable to find the device type field!") From 9030a3ea770b9e92cfdb040848e7dc826e73fc67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 10:04:30 -0600 Subject: [PATCH 13/27] relo --- kasa/tests/test_device_factory.py | 71 +++++++++++++++++++++++++++++++ kasa/tests/test_device_type.py | 35 +++++++++++++++ kasa/tests/test_discovery.py | 49 +-------------------- 3 files changed, 107 insertions(+), 48 deletions(-) create mode 100644 kasa/tests/test_device_factory.py create mode 100644 kasa/tests/test_device_type.py diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py new file mode 100644 index 000000000..ec03e5bcd --- /dev/null +++ b/kasa/tests/test_device_factory.py @@ -0,0 +1,71 @@ +# type: ignore +import re +import socket +import sys +from typing import Type + +import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 + +from kasa import ( + DeviceType, + Discover, + SmartBulb, + SmartDevice, + SmartDeviceException, + SmartDimmer, + SmartLightStrip, + SmartPlug, + protocol, +) +from kasa.device_factory import DEVICE_TYPE_TO_CLASS, connect +from kasa.discover import _DiscoverProtocol, json_dumps +from kasa.exceptions import UnsupportedDeviceException + +from .conftest import bulb, dimmer, lightstrip, plug, strip + + +@pytest.mark.parametrize("custom_port", [123, None]) +async def test_connect(discovery_data: dict, mocker, custom_port): + """Make sure that connect_single returns an initialized SmartDevice instance.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + + dev = await connect(host, port=custom_port) + assert issubclass(dev.__class__, SmartDevice) + assert dev.port == custom_port or dev.port == 9999 + + +@pytest.mark.parametrize("custom_port", [123, None]) +@pytest.mark.parametrize( + ("device_type", "klass"), + ( + (DeviceType.Plug, SmartPlug), + (DeviceType.Bulb, SmartBulb), + (DeviceType.Dimmer, SmartDimmer), + (DeviceType.LightStrip, SmartLightStrip), + (DeviceType.Unknown, SmartDevice), + ), +) +async def test_connect_passed_device_type( + discovery_data: dict, + mocker, + device_type: DeviceType, + klass: Type[SmartDevice], + custom_port, +): + """Make sure that connect_single with a passed device type.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + + dev = await connect(host, port=custom_port, device_type=device_type) + assert isinstance(dev, klass) + assert dev.port == custom_port or dev.port == 9999 + + +async def test_connect_query_fails(discovery_data: dict, mocker): + """Make sure that connect_single fails when query fails.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) + + with pytest.raises(SmartDeviceException): + await connect(host) diff --git a/kasa/tests/test_device_type.py b/kasa/tests/test_device_type.py new file mode 100644 index 000000000..8058d27e7 --- /dev/null +++ b/kasa/tests/test_device_type.py @@ -0,0 +1,35 @@ +import inspect +from datetime import datetime +from unittest.mock import patch + +import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 + +import kasa +from kasa import Credentials, SmartDevice, SmartDeviceException +from kasa.smartdevice import DeviceType +from kasa.smartstrip import SmartStripPlug + +from .conftest import handle_turn_on, has_emeter, no_emeter, turn_on +from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol + + +async def test_device_type_from_value(): + """Make sure that every device type can be created from its value.""" + for name in DeviceType: + assert DeviceType.from_value(name.value) is not None + + assert DeviceType.from_value("nonexistent") is DeviceType.Unknown + assert DeviceType.from_value("plug") is DeviceType.Plug + assert DeviceType.Plug.value == "plug" + + assert DeviceType.from_value("bulb") is DeviceType.Bulb + assert DeviceType.Bulb.value == "bulb" + + assert DeviceType.from_value("dimmer") is DeviceType.Dimmer + assert DeviceType.Dimmer.value == "dimmer" + + assert DeviceType.from_value("strip") is DeviceType.Strip + assert DeviceType.Strip.value == "strip" + + assert DeviceType.from_value("lightstrip") is DeviceType.LightStrip + assert DeviceType.LightStrip.value == "lightstrip" diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index ff427837c..79852d881 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,8 +1,8 @@ # type: ignore import re -from typing import Type import socket import sys +from typing import Type import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 @@ -111,53 +111,6 @@ def mock_discover(self): x = await Discover.discover_single(host) -@pytest.mark.parametrize("custom_port", [123, None]) -async def test_connect_single(discovery_data: dict, mocker, custom_port): - """Make sure that connect_single returns an initialized SmartDevice instance.""" - host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - - dev = await Discover.connect_single(host, port=custom_port) - assert issubclass(dev.__class__, SmartDevice) - assert dev.port == custom_port or dev.port == 9999 - - -@pytest.mark.parametrize("custom_port", [123, None]) -@pytest.mark.parametrize( - ("device_type", "klass"), - ( - (DeviceType.Plug, SmartPlug), - (DeviceType.Bulb, SmartBulb), - (DeviceType.Dimmer, SmartDimmer), - (DeviceType.LightStrip, SmartLightStrip), - (DeviceType.Unknown, SmartDevice), - ), -) -async def test_connect_single_passed_device_type( - discovery_data: dict, - mocker, - device_type: DeviceType, - klass: Type[SmartDevice], - custom_port, -): - """Make sure that connect_single with a passed device type.""" - host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) - - dev = await Discover.connect_single(host, port=custom_port, device_type=device_type) - assert isinstance(dev, klass) - assert dev.port == custom_port or dev.port == 9999 - - -async def test_connect_single_query_fails(discovery_data: dict, mocker): - """Make sure that connect_single fails when query fails.""" - host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) - - with pytest.raises(SmartDeviceException): - await Discover.connect_single(host) - - UNSUPPORTED = { "result": { "device_id": "xx", From 0f463862a7ced9df8dfea34b171fcca2a7c53bd2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 10:07:53 -0600 Subject: [PATCH 14/27] make sure its a thin wrapper --- kasa/tests/test_smartdevice.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 6e7cf80fb..17cb79e2e 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -1,6 +1,6 @@ import inspect from datetime import datetime -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 @@ -228,3 +228,25 @@ async def test_create_smart_device_with_timeout(): """Make sure timeout is passed to the protocol.""" dev = SmartDevice(host="127.0.0.1", timeout=100) assert dev.protocol.timeout == 100 + + +async def test_create_thin_wrapper(): + """Make sure thin wrapper is created with the correct device type.""" + mock = Mock() + with patch("kasa.device_factory.connect", return_value=mock) as connect: + dev = await SmartDevice.connect( + host="test_host", + port=1234, + timeout=100, + credentials=Credentials("username", "password"), + device_type=DeviceType.Strip, + ) + assert dev is mock + + connect.assert_called_once_with( + host="test_host", + port=1234, + timeout=100, + credentials=None, + device_type=DeviceType.Unknown, + ) From 0dfb322c5c95d36ce29415dc28cb35dd66987b1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 10:08:16 -0600 Subject: [PATCH 15/27] make sure its a thin wrapper --- kasa/tests/test_smartdevice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 17cb79e2e..17517524f 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -247,6 +247,6 @@ async def test_create_thin_wrapper(): host="test_host", port=1234, timeout=100, - credentials=None, - device_type=DeviceType.Unknown, + credentials=Credentials("username", "password"), + device_type=DeviceType.Strip, ) From f55428549f86648e1d25d44f5196c50075e8f54a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 10:47:21 -0600 Subject: [PATCH 16/27] tweaks --- kasa/device_factory.py | 25 +++++++++++++++++++++++++ kasa/tests/test_device_factory.py | 29 ++++++++++++++++------------- kasa/tests/test_device_type.py | 12 ------------ 3 files changed, 41 insertions(+), 25 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index b2de926b1..e0424b5e5 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -1,5 +1,7 @@ """Device creation by type.""" +import logging +import time from typing import Any, Dict, Optional, Type from .credentials import Credentials @@ -19,6 +21,8 @@ DeviceType.LightStrip: SmartLightStrip, } +_LOGGER = logging.getLogger(__name__) + async def connect( host: str, @@ -48,11 +52,24 @@ async def connect( :rtype: SmartDevice :return: Object for querying/controlling found device. """ + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + + if debug_enabled: + start_time = time.perf_counter() + if device_type and (klass := DEVICE_TYPE_TO_CLASS.get(device_type)): dev: SmartDevice = klass( host=host, port=port, credentials=credentials, timeout=timeout ) await dev.update() + if debug_enabled: + end_time = time.perf_counter() + _LOGGER.debug( + "Device %s with known type (%s) took %.2f seconds to connect", + host, + device_type.value, + end_time - start_time, + ) return dev unknown_dev = SmartDevice( @@ -65,6 +82,14 @@ async def connect( # so we don't have to reconnect dev.protocol = unknown_dev.protocol await dev.update() + if debug_enabled: + end_time = time.perf_counter() + _LOGGER.debug( + "Device %s with unknown type (%s) took %.2f seconds to connect", + host, + dev.device_type.value, + end_time - start_time, + ) return dev diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index ec03e5bcd..3a08857a9 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -1,32 +1,24 @@ # type: ignore -import re -import socket -import sys +import logging from typing import Type import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from kasa import ( DeviceType, - Discover, SmartBulb, SmartDevice, SmartDeviceException, SmartDimmer, SmartLightStrip, SmartPlug, - protocol, ) -from kasa.device_factory import DEVICE_TYPE_TO_CLASS, connect -from kasa.discover import _DiscoverProtocol, json_dumps -from kasa.exceptions import UnsupportedDeviceException - -from .conftest import bulb, dimmer, lightstrip, plug, strip +from kasa.device_factory import connect @pytest.mark.parametrize("custom_port", [123, None]) async def test_connect(discovery_data: dict, mocker, custom_port): - """Make sure that connect_single returns an initialized SmartDevice instance.""" + """Make sure that connect returns an initialized SmartDevice instance.""" host = "127.0.0.1" mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) @@ -53,7 +45,7 @@ async def test_connect_passed_device_type( klass: Type[SmartDevice], custom_port, ): - """Make sure that connect_single with a passed device type.""" + """Make sure that connect with a passed device type.""" host = "127.0.0.1" mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) @@ -63,9 +55,20 @@ async def test_connect_passed_device_type( async def test_connect_query_fails(discovery_data: dict, mocker): - """Make sure that connect_single fails when query fails.""" + """Make sure that connect fails when query fails.""" host = "127.0.0.1" mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) with pytest.raises(SmartDeviceException): await connect(host) + + +async def test_connect_logs_connect_time( + discovery_data: dict, caplog: pytest.LogCaptureFixture, mocker +): + """Test that the connect time is logged when debug logging is enabled.""" + host = "127.0.0.1" + mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=discovery_data) + logging.getLogger("kasa").setLevel(logging.DEBUG) + await connect(host) + assert "seconds to connect" in caplog.text diff --git a/kasa/tests/test_device_type.py b/kasa/tests/test_device_type.py index 8058d27e7..da1707dc7 100644 --- a/kasa/tests/test_device_type.py +++ b/kasa/tests/test_device_type.py @@ -1,16 +1,4 @@ -import inspect -from datetime import datetime -from unittest.mock import patch - -import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 - -import kasa -from kasa import Credentials, SmartDevice, SmartDeviceException from kasa.smartdevice import DeviceType -from kasa.smartstrip import SmartStripPlug - -from .conftest import handle_turn_on, has_emeter, no_emeter, turn_on -from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol async def test_device_type_from_value(): From 5fdec8ceb6446a2a3994a18f561cba4b190d07ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 10:51:05 -0600 Subject: [PATCH 17/27] tweak --- kasa/cli.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 9af554454..58f022417 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -10,14 +10,7 @@ import asyncclick as click -from kasa import ( - Credentials, - DeviceType, - Discover, - SmartBulb, - SmartDevice, - SmartStrip, -) +from kasa import Credentials, DeviceType, Discover, SmartBulb, SmartDevice, SmartStrip from kasa.device_factory import DEVICE_TYPE_TO_CLASS try: @@ -41,11 +34,11 @@ def wrapper(message=None, *args, **kwargs): # --json has set it to _nop_echo echo = _do_echo -TYPE_TO_CLASS = { - device_type.value: DEVICE_TYPE_TO_CLASS[device_type] +DEVICE_TYPES = [ + device_type.value for device_type in DeviceType if device_type in DEVICE_TYPE_TO_CLASS -} +] click.anyio_backend = "asyncio" @@ -125,7 +118,7 @@ def _device_to_serializable(val: SmartDevice): "--type", envvar="KASA_TYPE", default=None, - type=click.Choice(list(TYPE_TO_CLASS), case_sensitive=False), + type=click.Choice(DEVICE_TYPES, case_sensitive=False), ) @click.option( "--json", default=False, is_flag=True, help="Output raw device response as JSON." @@ -231,7 +224,10 @@ def _nop_echo(*args, **kwargs): return await ctx.invoke(discover, timeout=discovery_timeout) if type is not None: - dev = TYPE_TO_CLASS[type](host, credentials=credentials) + device_type = DeviceType.from_value(type) + dev = SmartDevice.connect( + host, credentials=credentials, device_type=device_type + ) else: echo("No --type defined, discovering..") dev = await Discover.discover_single( From 541cd531f82b5efe48c346ea0b78b77f4979de4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 10:53:34 -0600 Subject: [PATCH 18/27] fixes --- kasa/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 58f022417..982e01fe0 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -225,7 +225,7 @@ def _nop_echo(*args, **kwargs): if type is not None: device_type = DeviceType.from_value(type) - dev = SmartDevice.connect( + dev = await SmartDevice.connect( host, credentials=credentials, device_type=device_type ) else: @@ -235,8 +235,8 @@ def _nop_echo(*args, **kwargs): port=port, credentials=credentials, ) + await dev.update() - await dev.update() ctx.obj = dev if ctx.invoked_subcommand is None: From 1492e140770b8fca5bf2832ee31f10fdced3e09b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 10:58:30 -0600 Subject: [PATCH 19/27] fix test --- kasa/tests/test_cli.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 009632d7b..fd76d9335 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -7,7 +7,6 @@ from kasa import SmartDevice, TPLinkSmartHomeProtocol from kasa.cli import ( - TYPE_TO_CLASS, alias, brightness, cli, @@ -17,6 +16,8 @@ sysinfo, toggle, ) +from kasa.device_factory import DEVICE_TYPE_TO_CLASS +from kasa.device_type import DeviceType from kasa.discover import Discover from .conftest import handle_turn_on, turn_on @@ -154,14 +155,9 @@ async def _state(dev: SmartDevice): ) mocker.patch("kasa.cli.state", new=_state) - - # Get the type string parameter from the discovery_info - for cli_device_type in { # noqa: B007 - i - for i in TYPE_TO_CLASS - if TYPE_TO_CLASS[i] == Discover._get_device_class(discovery_data) - }: - break + cli_device_type = Discover._get_device_class(discovery_data)( + "any" + ).device_type.value runner = CliRunner() res = await runner.invoke( From ff756a7a79b13c724fc01f0db5769d58c7dda30c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 11:08:31 -0600 Subject: [PATCH 20/27] make sure it calls discover single without type --- kasa/tests/test_cli.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index fd76d9335..49bc0f848 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -1,5 +1,4 @@ import json -import sys import asyncclick as click import pytest @@ -16,8 +15,6 @@ sysinfo, toggle, ) -from kasa.device_factory import DEVICE_TYPE_TO_CLASS -from kasa.device_type import DeviceType from kasa.discover import Discover from .conftest import handle_turn_on, turn_on @@ -177,6 +174,24 @@ async def _state(dev: SmartDevice): assert res.output == "Username:foo Password:bar\n" +async def test_without_device_type(discovery_data: dict, dev, mocker): + """Test connecting without the device type.""" + runner = CliRunner() + mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--username", + "foo", + "--password", + "bar", + ], + ) + assert res.exit_code == 0 + + @pytest.mark.parametrize("auth_param", ["--username", "--password"]) async def test_invalid_credential_params(auth_param): """Test for handling only one of username or password supplied.""" From 6f821707a0a55a9b26505d679a62df4451b510a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 11:10:01 -0600 Subject: [PATCH 21/27] strip unused --- kasa/smartdevice.py | 6 ------ kasa/tests/test_smartdevice.py | 10 ---------- 2 files changed, 16 deletions(-) diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index e7b7a38f1..4f850b5ba 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -393,12 +393,6 @@ def alias(self) -> str: sys_info = self.sys_info return str(sys_info["alias"]) - @property # type: ignore - @requires_update - def has_children(self) -> bool: - """Check if the device has children devices.""" - return bool(self._sys_info.get("child_num")) - async def set_alias(self, alias: str) -> None: """Set the device name (alias).""" return await self._query_helper("system", "set_dev_alias", {"alias": alias}) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 17517524f..33cee68b1 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -170,16 +170,6 @@ async def test_representation(dev): assert pattern.match(str(dev)) -async def test_children(dev): - """Make sure that children property is exposed by every device.""" - if dev.is_strip: - assert len(dev.children) > 0 - assert dev.has_children is True - else: - assert len(dev.children) == 0 - assert dev.has_children is False - - async def test_internal_state(dev): """Make sure the internal state returns the last update results.""" assert dev.internal_state == dev._last_update From 38f21946ae5e281e46d035b677b3707fe7f053dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 11:11:17 -0600 Subject: [PATCH 22/27] strip unused --- kasa/tests/test_smartdevice.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 33cee68b1..5d0505d46 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -170,6 +170,14 @@ async def test_representation(dev): assert pattern.match(str(dev)) +async def test_childrens(dev): + """Make sure that children property is exposed by every device.""" + if dev.is_strip: + assert len(dev.children) > 0 + else: + assert len(dev.children) == 0 + + async def test_internal_state(dev): """Make sure the internal state returns the last update results.""" assert dev.internal_state == dev._last_update From 911ea4ed8ec245daea8e2ddda55098d598611fd2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Nov 2023 11:12:05 -0600 Subject: [PATCH 23/27] strip unused --- kasa/tests/test_smartdevice.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 5d0505d46..3292d6b28 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -7,7 +7,6 @@ import kasa from kasa import Credentials, SmartDevice, SmartDeviceException from kasa.smartdevice import DeviceType -from kasa.smartstrip import SmartStripPlug from .conftest import handle_turn_on, has_emeter, no_emeter, turn_on from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol @@ -59,28 +58,6 @@ async def test_initial_update_no_emeter(dev, mocker): assert spy.call_count == 2 -async def test_smart_device_from_value(): - """Make sure that every device type can be created from its value.""" - for name in DeviceType: - assert DeviceType.from_value(name.value) is not None - - assert DeviceType.from_value("nonexistent") is DeviceType.Unknown - assert DeviceType.from_value("plug") is DeviceType.Plug - assert DeviceType.Plug.value == "plug" - - assert DeviceType.from_value("bulb") is DeviceType.Bulb - assert DeviceType.Bulb.value == "bulb" - - assert DeviceType.from_value("dimmer") is DeviceType.Dimmer - assert DeviceType.Dimmer.value == "dimmer" - - assert DeviceType.from_value("strip") is DeviceType.Strip - assert DeviceType.Strip.value == "strip" - - assert DeviceType.from_value("lightstrip") is DeviceType.LightStrip - assert DeviceType.LightStrip.value == "lightstrip" - - async def test_query_helper(dev): with pytest.raises(SmartDeviceException): await dev._query_helper("test", "testcmd", {}) From f0e6ad6482673dff8419ebc62cb69e21401a8c45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Nov 2023 15:28:03 +0100 Subject: [PATCH 24/27] manually merge changes --- kasa/device_factory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index e0424b5e5..c3ed4de3b 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -6,6 +6,7 @@ from .credentials import Credentials from .device_type import DeviceType +from .exceptions import UnsupportedDeviceException from .smartbulb import SmartBulb from .smartdevice import SmartDevice, SmartDeviceException from .smartdimmer import SmartDimmer @@ -117,5 +118,4 @@ def get_device_class_from_info(info: Dict[str, Any]) -> Type[SmartDevice]: return SmartLightStrip return SmartBulb - - raise SmartDeviceException("Unknown device type: %s" % type_) + raise UnsupportedDeviceException("Unknown device type: %s" % type_) From 38411cd49e34e0b3f7a0b67a4d74930e31a40d14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Nov 2023 15:34:48 +0100 Subject: [PATCH 25/27] add initialization --- docs/source/design.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/source/design.rst b/docs/source/design.rst index 8acbfea69..a34e10d9d 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -12,6 +12,18 @@ or if you are just looking to access some information that is not currently expo .. contents:: Contents :local: +.. _initialization: + +Initialization +************** + +When the IP Address of a device is known, it can be connected with :meth:`~kasa.SmartDevice.connect()`. +If the IP Address is not known, it can be discovered using :func:`~kasa.Discover.discover_devices`. + +When connecting a device with the :meth:`~kasa.SmartDevice.connect()` method, it is recommended to +pass the device type as well as this allows the library to use the correct device class for the +device without having to query the device. + .. _update_cycle: Update Cycle From db833da7e0bdd71ecdabe30f0ec0989434970c13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Nov 2023 23:10:50 +0100 Subject: [PATCH 26/27] Update docs/source/design.rst Co-authored-by: Teemu R. --- docs/source/design.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/design.rst b/docs/source/design.rst index a34e10d9d..16936250a 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -18,7 +18,7 @@ Initialization ************** When the IP Address of a device is known, it can be connected with :meth:`~kasa.SmartDevice.connect()`. -If the IP Address is not known, it can be discovered using :func:`~kasa.Discover.discover_devices`. +If the IP Address is not known, it can be discovered using :func:`~kasa.Discover.discover`. When connecting a device with the :meth:`~kasa.SmartDevice.connect()` method, it is recommended to pass the device type as well as this allows the library to use the correct device class for the From 3bab4f61786ca97f70bd1e0af25b8f8def25fa8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Nov 2023 23:14:06 +0100 Subject: [PATCH 27/27] tweaks --- docs/source/design.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/source/design.rst b/docs/source/design.rst index 16936250a..5679943d2 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -17,8 +17,11 @@ or if you are just looking to access some information that is not currently expo Initialization ************** -When the IP Address of a device is known, it can be connected with :meth:`~kasa.SmartDevice.connect()`. -If the IP Address is not known, it can be discovered using :func:`~kasa.Discover.discover`. +Use :func:`~kasa.Discover.discover` to perform udp-based broadcast discovery on the network. +This will return you a list of device instances based on the discovery replies. + +If the device's host is already known, you can use to construct a device instance with +:meth:`~kasa.SmartDevice.connect()`. When connecting a device with the :meth:`~kasa.SmartDevice.connect()` method, it is recommended to pass the device type as well as this allows the library to use the correct device class for the