From 209b3721bbe8f23df58f84341d6621efe8adecc0 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 14 Jul 2026 14:58:06 -0400 Subject: [PATCH] Refactor discovery and connection routing --- README.md | 6 +- SUPPORTED.md | 1 + devtools/README.md | 43 +- devtools/dump_devinfo.py | 215 ++- devtools/update_fixtures.py | 9 +- docs/source/cli.rst | 53 +- docs/source/reference.md | 9 + docs/source/topics.md | 87 +- docs/tutorial.py | 2 +- kasa/__init__.py | 4 + kasa/cli/device.py | 4 +- kasa/cli/discover.py | 485 +++++- kasa/cli/main.py | 201 ++- kasa/device.py | 49 +- kasa/device_factory.py | 764 +++++++-- kasa/deviceconfig.py | 29 +- kasa/discover.py | 1508 ++++++++++++----- kasa/exceptions.py | 19 + kasa/iot/iotdevice.py | 95 +- kasa/smart/smartdevice.py | 22 +- tests/discovery_fixtures.py | 81 +- tests/fixtures/iot/HS300(US)_2.0_1.1.2.json | 115 ++ .../deviceconfig_iot-new-klap.json | 11 + tests/iot/test_iotdevice.py | 24 + tests/test_cli.py | 665 +++++++- tests/test_device.py | 91 +- tests/test_device_factory.py | 277 ++- tests/test_deviceconfig.py | 10 + tests/test_devtools.py | 127 +- tests/test_discovery.py | 864 +++++++++- 30 files changed, 4895 insertions(+), 975 deletions(-) create mode 100644 tests/fixtures/iot/HS300(US)_2.0_1.1.2.json create mode 100644 tests/fixtures/serialization/deviceconfig_iot-new-klap.json diff --git a/README.md b/README.md index f2e7db84e..8b185e954 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ If your device requires authentication to control it, you need to pass the credentials using `--username` and `--password` options or define `KASA_USERNAME` and `KASA_PASSWORD` environment variables. > [!NOTE] -> If your system has multiple network interfaces, you can specify the broadcast address using the `--target` option. +> If your system has multiple network interfaces, specify the subnet broadcast +> address with `--target`, for example +> `kasa --target 192.168.1.255 discover`. The `discover` command will automatically execute the `state` command on all the discovered devices: @@ -187,7 +189,7 @@ The following devices have been tested and confirmed as working. If your device ### Supported Kasa devices - **Plugs**: EP10, EP25[^2], HS100[^2], HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M[^1], KP401 -- **Power Strips**: EP40, EP40M[^1], HS107, HS300, KP200, KP303, KP400 +- **Power Strips**: EP40, EP40M[^1], HS107, HS300[^2], KP200, KP303, KP400 - **Wall Switches**: ES20M, HS200[^2], HS210, HS220[^2], KP405, KS200, KS200M, KS205[^1], KS220, KS220M, KS225[^1], KS230, KS240[^1] - **Bulbs**: KL110, KL110B, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110, LB130 - **Light Strips**: KL400L10, KL400L5, KL420L5, KL430 diff --git a/SUPPORTED.md b/SUPPORTED.md index a3d9c3364..e2fe26aa8 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -68,6 +68,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th - Hardware: 1.0 (US) / Firmware: 1.0.21 - Hardware: 2.0 (US) / Firmware: 1.0.12 - Hardware: 2.0 (US) / Firmware: 1.0.3 + - Hardware: 2.0 (US) / Firmware: 1.1.2[^1] - **KP200** - Hardware: 3.0 (US) / Firmware: 1.0.3 - **KP303** diff --git a/devtools/README.md b/devtools/README.md index f59ea374c..ffbe9fa55 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -3,7 +3,8 @@ This directory contains some simple scripts that can be useful for developers. ## dump_devinfo -* Queries the device (if --host is given) or discover devices and creates fixture files that can be added to the test suite. +* Queries one device with `--host`, or discovers devices with `--target`, and creates fixture files that can be added to the test suite. +* Direct connection options are available for devices that cannot be discovered. ```shell Usage: python -m devtools.dump_devinfo [OPTIONS] @@ -13,14 +14,40 @@ Usage: python -m devtools.dump_devinfo [OPTIONS] Use --host (for a single device) or --target (for a complete network). Options: - --host TEXT Target host. - --target TEXT Target network for discovery. - --username TEXT Username/email address to authenticate to device. - --password TEXT Password to use to authenticate to device. - --basedir TEXT Base directory for the git repository - --autosave Save without prompting + --host TEXT Target host. + --target TEXT Target network for discovery. + --username TEXT Username/email address to authenticate to + device. + --password TEXT Password to use to authenticate to device. + --credentials-hash TEXT Hashed credentials used to authenticate to + the device. + --basedir TEXT Base directory for the git repository + --autosave Save without prompting + --batch-size INTEGER Number of batched requests to send at once -d, --debug - --help Show this message and exit. + -di, --discovery-info TEXT Bypass discovery by passing an accurate + discovery result json escaped string. Do not + use this flag unless you are sure you know + what it means. + --discovery-timeout INTEGER Timeout for discovery. [default: 10] + -df, --device-family [iot.smartplugswitch|iot.smartbulb|iot.ipcamera|smart.kasaplug|smart.kasaswitch|smart.tapoplug|smart.tapobulb|smart.taposwitch|smart.tapohub|smart.kasahub|smart.ipcamera|smart.taporobovac|smart.tapochime|smart.tapodoorbell] + Exact device family for an advanced direct + connection. + -e, --encrypt-type [klap|aes|xor] + Encryption type for an advanced direct + connection. + -lv, --login-version INTEGER RANGE + Login version for an advanced direct + connection. [x>=1] + -kv, --klap-version INTEGER RANGE + IOT KLAP handshake version for an advanced + direct connection. [x>=1] + --https / --no-https Whether an advanced direct connection uses + HTTPS. + --timeout INTEGER Timeout for queries. + --port INTEGER RANGE Connection and UDP discovery port override. + [1<=x<=65535] + --help Show this message and exit. ``` ## create_module_fixtures diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index fff57370f..51588756d 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -33,15 +33,19 @@ DeviceConfig, DeviceConnectionParameters, Discover, + DiscoveryAuthenticationError, KasaException, TimeoutError, + UnsupportedAuthenticationError, + UnsupportedDeviceError, ) from kasa.device_factory import get_protocol from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily from kasa.discover import ( - NEW_DISCOVERY_REDACTORS, + TDP_DISCOVERY_REDACTORS, DiscoveredRaw, DiscoveryResult, + select_discovery_response, ) from kasa.exceptions import SmartErrorCode from kasa.protocols import IotProtocol @@ -75,11 +79,12 @@ NO_GIT_FIXTURE_FOLDER = "kasa-fixtures" ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] +DEVICE_FAMILIES = [device_family.value for device_family in DeviceFamily] _LOGGER = logging.getLogger(__name__) -def _wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]): +def wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]): """Wrap the redactors for dump_devinfo. Will replace all partial REDACT_ values with zeros. @@ -146,7 +151,7 @@ async def handle_device( ) else: fixture_results = [ - await get_legacy_fixture(protocol, discovery_info=discovery_info) + await get_iot_fixture(protocol, discovery_info=discovery_info) ] for fixture_result in fixture_results: @@ -201,6 +206,13 @@ async def handle_device( envvar="KASA_PASSWORD", help="Password to use to authenticate to device.", ) +@click.option( + "--credentials-hash", + default=None, + required=False, + envvar="KASA_CREDENTIALS_HASH", + help="Hashed credentials used to authenticate to the device.", +) @click.option("--basedir", help="Base directory for the git repository", default=".") @click.option("--autosave", is_flag=True, default=False, help="Save without prompting") @click.option( @@ -223,35 +235,45 @@ async def handle_device( show_default=True, help="Timeout for discovery.", ) +@click.option( + "-df", + "--device-family", + envvar="KASA_DEVICE_FAMILY", + default=None, + type=click.Choice(DEVICE_FAMILIES, case_sensitive=False), + help="Exact device family for an advanced direct connection.", +) @click.option( "-e", "--encrypt-type", envvar="KASA_ENCRYPT_TYPE", default=None, type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), -) -@click.option( - "-df", - "--device-family", - envvar="KASA_DEVICE_FAMILY", - default="SMART.TAPOPLUG", - help="Device family type, e.g. `SMART.KASASWITCH`.", + help="Encryption type for an advanced direct connection.", ) @click.option( "-lv", "--login-version", envvar="KASA_LOGIN_VERSION", - default=2, - type=int, - help="The login version for device authentication. Defaults to 2", + default=None, + type=click.IntRange(min=1), + help="Login version for an advanced direct connection.", +) +@click.option( + "-kv", + "--klap-version", + envvar="KASA_KLAP_VERSION", + default=None, + type=click.IntRange(min=1), + help="IOT KLAP handshake version for an advanced direct connection.", ) @click.option( "--https/--no-https", envvar="KASA_HTTPS", - default=False, + default=None, is_flag=True, type=bool, - help="Set flag if the device encryption uses https.", + help="Whether an advanced direct connection uses HTTPS.", ) @click.option( "--timeout", @@ -259,24 +281,30 @@ async def handle_device( default=15, help="Timeout for queries.", ) -@click.option("--port", help="Port override", type=int) +@click.option( + "--port", + help="Connection and UDP discovery port override.", + type=click.IntRange(min=1, max=65535), +) async def cli( host, target, - basedir, - autosave, - debug, username, - discovery_timeout, password, + credentials_hash, + basedir, + autosave, batch_size, + debug, discovery_info, - encrypt_type, - https, + discovery_timeout, device_family, + encrypt_type, login_version, - port, + klap_version, + https, timeout, + port, ): """Generate devinfo files for devices. @@ -285,30 +313,90 @@ async def cli( if debug: logging.basicConfig(level=logging.DEBUG) - raw_discovery = {} + if bool(username) != bool(password): + raise click.UsageError( + "Using authentication requires both --username and --password" + ) + + connection_options = { + "device-family": device_family, + "encrypt-type": encrypt_type, + "login-version": login_version, + "klap-version": klap_version, + "https": https, + } + supplied_connection_options = { + option: value + for option, value in connection_options.items() + if value is not None + } + if (discovery_info or supplied_connection_options) and host is None: + raise click.UsageError("Direct connection options require --host") + if discovery_info and supplied_connection_options: + raise click.UsageError( + "Use either --discovery-info or explicit connection options, not both" + ) + if supplied_connection_options and (not device_family or not encrypt_type): + raise click.UsageError( + "Advanced direct connections require both --device-family and " + "--encrypt-type" + ) + + raw_discovery: dict[str, list[DiscoveredRaw]] = {} + unsupported: dict[str, UnsupportedDeviceError] = {} + authentication_failed: dict[str, DiscoveryAuthenticationError] = {} def capture_raw(discovered: DiscoveredRaw): - raw_discovery[discovered["meta"]["ip"]] = discovered["discovery_response"] + meta = discovered["meta"] + raw_discovery.setdefault(meta["ip"], []).append(discovered) + + async def capture_unsupported(ex: UnsupportedDeviceError) -> None: + host_key = ex.host or "unknown" + unsupported[host_key] = ex + if isinstance(ex, UnsupportedAuthenticationError): + source = ex.onboarding_source or "unknown" + click.echo( + f"Unsupported authentication at {host_key} " + f"(onboarding source: {source}): {ex}" + ) + else: + click.echo(f"Unsupported device at {host_key}: {ex}") + + async def capture_authentication_error( + ex: DiscoveryAuthenticationError, + ) -> None: + host_key = ex.host or "unknown" + authentication_failed[host_key] = ex + click.echo(f"Authentication failed for device at {host_key}: {ex}") + + def get_discovery_response(host_key: str) -> dict: + responses = raw_discovery.get(host_key) + if responses is None and len(raw_discovery) == 1: + responses = next(iter(raw_discovery.values())) + if not responses: + raise KasaException( + f"No decoded discovery response captured for {host_key}" + ) + return select_discovery_response(responses)["discovery_response"] - credentials = Credentials(username=username, password=password) + credentials = ( + Credentials(username=username, password=password) + if username and password + else None + ) if host is not None: if discovery_info: click.echo(f"Host and discovery info given, trying connect on {host}.") di = json.loads(discovery_info) dr = DiscoveryResult.from_dict(di) - connection_type = DeviceConnectionParameters.from_values( - dr.device_type, - dr.mgt_encrypt_schm.encrypt_type, - login_version=dr.mgt_encrypt_schm.lv, - https=dr.mgt_encrypt_schm.is_support_https, - http_port=dr.mgt_encrypt_schm.http_port, - ) + connection_type = dr.to_connection_parameters() dc = DeviceConfig( host=host, connection_type=connection_type, port_override=port, credentials=credentials, + credentials_hash=credentials_hash, timeout=timeout, ) device = await Device.connect(config=dc) @@ -320,20 +408,39 @@ def capture_raw(discovered: DiscoveredRaw): batch_size=batch_size, ) elif device_family and encrypt_type: + try: + family = DeviceFamily(device_family) + encryption = DeviceEncryptionType(encrypt_type) + except ValueError as ex: + raise click.BadParameter( + "Invalid device connection parameters", + param_hint="--device-family/--encrypt-type", + ) from ex + if klap_version is not None: + if encryption is not DeviceEncryptionType.Klap: + raise click.UsageError( + "--klap-version requires --encrypt-type KLAP" + ) + if not family.value.startswith("IOT."): + raise click.UsageError("--klap-version is only used by IOT devices") + if login_version is None and family.value.startswith("SMART."): + login_version = 2 ctype = DeviceConnectionParameters( - DeviceFamily(device_family), - DeviceEncryptionType(encrypt_type), + family, + encryption, login_version, - https, + bool(https), + klap_version=klap_version, ) config = DeviceConfig( host=host, port_override=port, credentials=credentials, + credentials_hash=credentials_hash, connection_type=ctype, timeout=timeout, ) - if protocol := get_protocol(config): + if protocol := get_protocol(config, strict=True): await handle_device(basedir, autosave, protocol, batch_size=batch_size) else: raise KasaException( @@ -344,12 +451,17 @@ def capture_raw(discovered: DiscoveredRaw): device = await Discover.discover_single( host, credentials=credentials, + credentials_hash=credentials_hash, port=port, discovery_timeout=discovery_timeout, timeout=timeout, + on_unsupported=capture_unsupported, + on_authentication_error=capture_authentication_error, on_discovered_raw=capture_raw, ) - discovery_info = raw_discovery[device.host] + if device is None: + return + discovery_info = get_discovery_response(device.host) if decrypted_data := device._discovery_info.get("decrypted_data"): discovery_info["result"]["decrypted_data"] = decrypted_data await handle_device( @@ -367,13 +479,18 @@ def capture_raw(discovered: DiscoveredRaw): devices = await Discover.discover( target=target, credentials=credentials, + credentials_hash=credentials_hash, + port=port, discovery_timeout=discovery_timeout, timeout=timeout, + on_unsupported=capture_unsupported, + on_authentication_error=capture_authentication_error, on_discovered_raw=capture_raw, ) - click.echo(f"Detected {len(devices)} devices") + detected_hosts = set(devices) | set(unsupported) | set(authentication_failed) + click.echo(f"Detected {len(detected_hosts)} devices") for dev in devices.values(): - discovery_info = raw_discovery[dev.host] + discovery_info = get_discovery_response(dev.host) if decrypted_data := dev._discovery_info.get("decrypted_data"): discovery_info["result"]["decrypted_data"] = decrypted_data @@ -386,10 +503,10 @@ def capture_raw(discovered: DiscoveredRaw): ) -async def get_legacy_fixture( +async def get_iot_fixture( protocol: IotProtocol, *, discovery_info: dict[str, dict[str, Any]] | None ) -> FixtureResult: - """Get fixture for legacy IOT style protocol.""" + """Get a fixture for an IOT protocol device.""" items = [ Call(module="system", method="get_sysinfo"), Call(module="emeter", method="get_realtime"), @@ -459,7 +576,7 @@ async def get_legacy_fixture( finally: await protocol.close() - final = redact_data(final, _wrap_redactors(IOT_REDACTORS)) + final = redact_data(final, wrap_redactors(IOT_REDACTORS)) # Scrub the child device ids if children := final.get("system", {}).get("get_sysinfo", {}).get("children"): @@ -471,7 +588,7 @@ async def get_legacy_fixture( if discovery_info and not discovery_info.get("system"): final["discovery_result"] = redact_data( - discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS) + discovery_info, wrap_redactors(TDP_DISCOVERY_REDACTORS) ) click.echo(f"Got {len(successes)} successes") @@ -989,10 +1106,10 @@ async def get_smart_fixtures( # scrub the child ids scrubbed_child_id_map = scrub_child_device_ids(final, child_responses) - # Redact data from the main device response. _wrap_redactors ensure we do + # Redact data from the main device response. wrap_redactors ensures we do # not redact the scrubbed child device ids and replaces REDACTED_partial_id # with zeros - final = redact_data(final, _wrap_redactors(SMART_REDACTORS)) + final = redact_data(final, wrap_redactors(SMART_REDACTORS)) # smart cam child devices provide more information in getChildDeviceList on the # parent than they return when queried directly for getDeviceInfo so we will store @@ -1026,7 +1143,7 @@ async def get_smart_fixtures( and parent_model and child_model != parent_model ): - response = redact_data(response, _wrap_redactors(SMART_REDACTORS)) + response = redact_data(response, wrap_redactors(SMART_REDACTORS)) model_info = SmartDevice._get_device_info(response, None) fixture_results.append( get_smart_child_fixture( @@ -1044,7 +1161,7 @@ async def get_smart_fixtures( and parent_model and child_model != parent_model ): - response = redact_data(response, _wrap_redactors(SMART_REDACTORS)) + response = redact_data(response, wrap_redactors(SMART_REDACTORS)) # There is more info in the childDeviceList on the parent # particularly the region is needed here. child_info_from_parent = child_infos_on_parent[scrubbed_child_id] @@ -1063,7 +1180,7 @@ async def get_smart_fixtures( discovery_result = None if discovery_info: final["discovery_result"] = redact_data( - discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS) + discovery_info, wrap_redactors(TDP_DISCOVERY_REDACTORS) ) discovery_result = discovery_info["result"] diff --git a/devtools/update_fixtures.py b/devtools/update_fixtures.py index 13b9996ef..8f68f5283 100644 --- a/devtools/update_fixtures.py +++ b/devtools/update_fixtures.py @@ -7,9 +7,10 @@ import asyncclick as click -from devtools.dump_devinfo import _wrap_redactors -from kasa.discover import NEW_DISCOVERY_REDACTORS, redact_data +from devtools.dump_devinfo import wrap_redactors +from kasa.discover import TDP_DISCOVERY_REDACTORS from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.protocol import redact_data from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS FIXTURE_FOLDER = "tests/fixtures/" @@ -96,9 +97,9 @@ def _redactor_result_update(info) -> bool: if not isinstance(val, dict): continue if key == "discovery_result": - info[key] = redact_data(val, _wrap_redactors(NEW_DISCOVERY_REDACTORS)) + info[key] = redact_data(val, wrap_redactors(TDP_DISCOVERY_REDACTORS)) else: - info[key] = redact_data(val, _wrap_redactors(redactors)) + info[key] = redact_data(val, wrap_redactors(redactors)) diffs: dict[str, tuple[str, str]] = {} _diff_data(key, val, info[key], diffs) if diffs: diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 7d4eb0806..7e44c3295 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -5,13 +5,23 @@ The package is shipped with a console tool named ``kasa``, refer to ``kasa --hel The device to which the commands are sent is chosen by ``KASA_HOST`` environment variable or passing ``--host
`` as an option. To see what is being sent to and received from the device, specify option ``--debug``. -To avoid discovering the devices when executing commands its type can be passed as an option (e.g., ``--type plug`` for plugs, ``--type bulb`` for bulbs, ..). -If no type is manually given, its type will be discovered automatically which causes a short delay. -Note that the ``--type`` parameter only works for legacy devices using port 9999. - -To avoid discovering the devices for newer KASA or TAPO devices using port 20002 for discovery the ``--device-family``, ``-encrypt-type`` and optional -``-login-version`` options can be passed and the devices will probably require authentication via ``--username`` and ``--password``. -Refer to ``kasa --help`` for detailed usage. +To avoid discovery when the connection details are known, pass ``--type`` for +one of the common connection types. For other connections, pass both +``-df``/``--device-family`` and ``-e``/``--encrypt-type``. Some devices also +require values for ``-lv``/``--login-version``, ``-kv``/``--klap-version``, or +``--https``. +These advanced options require ``--host`` and cannot be used with the +``discover`` command. + +``-lv``/``--login-version`` and ``-kv``/``--klap-version`` are independent +connection settings and should only be supplied when they are known. Use +``kasa --host
discover config`` to show the connection configuration +advertised by the device. If no discovery response is received, the command +falls back to direct connection probing and identifies that fallback in its +output. A response that was received remains authoritative and is not replaced +by probing. Devices that require authentication can use ``--username`` and +``--password``, or ``--credentials-hash`` when an existing credential hash is +available. If no command is given, the ``state`` command will be executed to query the device state. @@ -25,15 +35,36 @@ Discovery ********* The tool can automatically discover supported devices using a broadcast-based discovery protocol. -This works by sending an UDP datagram on ports 9999 and 20002 to the broadcast address (defaulting to ``255.255.255.255``). +This sends discovery queries to UDP port 9999 and TDP ports 20002 and 20004 at +the broadcast address, which defaults to ``255.255.255.255``. If a device +responds through both UDP and TDP, the TDP response is used. The global +``--port`` option changes the UDP discovery and device connection port; the +TDP discovery ports remain fixed. -Newer devices that respond on port 20002 will require TP-Link cloud credentials to be passed (unless they have never been connected +Devices that respond using TDP will generally require TP-Link cloud credentials to be passed (unless they have never been connected to the TP-Link cloud) or they will report as having failed authentication when trying to query the device. Use ``--username`` and ``--password`` options to specify credentials. These values can also be set as environment variables via ``KASA_USERNAME`` and ``KASA_PASSWORD``. -On multihomed systems, you can use ``--target`` option to specify the broadcast target. -For example, if your devices reside in network ``10.0.0.0/24`` you can use ``kasa --target 10.0.0.255 discover`` to discover them. +The default output and ``discover list`` update supported devices before +displaying them. The default summary also reports devices that could not be +queried because of an authentication failure and devices that are not +supported by the library. + +Broadcast routing and multiple interfaces +----------------------------------------- + +The default ``255.255.255.255`` address is a limited broadcast. On systems with +multiple active network interfaces, the operating system may send it through +an interface that cannot reach the devices. + +Use ``--target`` to send discovery to the subnet-directed broadcast address of the network containing the devices. +For example, if the devices reside on ``10.0.0.0/24``, use:: + + kasa --target 10.0.0.255 discover + +If directed discovery works but the default does not, review the host's network +interface and routing configuration. .. note:: diff --git a/docs/source/reference.md b/docs/source/reference.md index 90493c9c2..f17f0791c 100644 --- a/docs/source/reference.md +++ b/docs/source/reference.md @@ -120,6 +120,15 @@ .. autoclass:: kasa.exceptions.AuthenticationError :members: :undoc-members: + +.. autoclass:: kasa.exceptions.UnsupportedAuthenticationError + :members: + :undoc-members: + +.. autoclass:: kasa.exceptions.DiscoveryAuthenticationError + :members: + :undoc-members: + ``` ```{eval-rst} diff --git a/docs/source/topics.md b/docs/source/topics.md index f7d0cdd50..e46d3ca7c 100644 --- a/docs/source/topics.md +++ b/docs/source/topics.md @@ -12,54 +12,54 @@ or if you are just looking to access some information that is not currently expo (topics-initialization)= ## Initialization -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. +Use {func}`~kasa.Discover.discover` to perform broadcast discovery on the network. +This returns a dictionary of device instances keyed by IP address. -If the device's host is already known, you can use to construct a device instance with -{meth}`~kasa.Device.connect()`. - -The {meth}`~kasa.Device.connect()` also enables support for connecting to new -KASA SMART protocol and TAPO devices directly using the parameter {class}`~kasa.DeviceConfig`. -Simply serialize the {attr}`~kasa.Device.config` property via {meth}`~kasa.DeviceConfig.to_dict()` -and then deserialize it later with {func}`~kasa.DeviceConfig.from_dict()` -and then pass it into {meth}`~kasa.Device.connect()`. +If the device's host is known, use {meth}`~kasa.Device.connect` to connect +directly. Supplying a {class}`~kasa.DeviceConfig` avoids probing for the +connection settings. A config obtained from {attr}`~kasa.Device.config` can be +serialized with {meth}`~kasa.DeviceConfig.to_dict` and restored with +{meth}`~kasa.DeviceConfig.from_dict`. (topics-discovery)= ## Discovery -Discovery works by sending broadcast UDP packets to two known TP-link discovery ports, 9999 and 20002. -Port 9999 is used for legacy devices that do not use strong encryption and 20002 is for newer devices that use different -levels of encryption. -If a device uses port 20002 for discovery you will obtain some basic information from the device via discovery, but you -will need to await {func}`Device.update() ` to get full device information. -Credentials will most likely be required for port 20002 devices although if the device has never been connected to the tplink -cloud it may work without credentials. +Discovery queries the known UDP and TDP discovery endpoints. UDP uses port 9999, +while TDP uses ports 20002 and 20004 for different device populations. If the +same host responds through UDP and TDP, the TDP response is used. + +Each response is normalized into connection and discovery information before it +is passed to the device factory. The factory selects the protocol, transport, +and concrete device class. When a discovery response identifies only a broad +device family, the factory can query the device for the information needed to +select the concrete class. -To query or update the device requires authentication via {class}`Credentials ` and if this is invalid or not provided it -will raise an {class}`AuthenticationException `. +The discovery API initializes each device with the information available in its +response, but it does not perform a full {meth}`~kasa.Device.update`. Call +``update()`` before using properties that are not available from discovery. +Credentials are generally required for TDP devices. -If discovery encounters an unsupported device when calling via {meth}`Discover.discover_single() ` -it will raise a {class}`UnsupportedDeviceException `. -If discovery encounters a device when calling {func}`Discover.discover() `, -you can provide a callback to the ``on_unsupported`` parameter -to handle these. +Responses that cannot be represented by a {class}`~kasa.Device` can be reported +through the ``on_unsupported`` and ``on_authentication_error`` callbacks. +Without the corresponding callback, {meth}`~kasa.Discover.discover_single` +raises the error for the requested device. (topics-deviceconfig)= ## DeviceConfig -The {class}`DeviceConfig` class can be used to initialise devices with parameters to allow them to be connected to without using -discovery. -This is required for newer KASA and TAPO devices that use different protocols for communication and will not respond -on port 9999 but instead use different encryption protocols over http port 80. -Currently there are three known types of encryption for TP-Link devices and two different protocols. -Devices with automatic firmware updates enabled may update to newer versions of the encryption without separate notice, -so discovery can be helpful to determine the correct config. +The {class}`~kasa.DeviceConfig` class contains the settings needed to connect to +a device without discovery. Pass it to {meth}`~kasa.Device.connect` to create +and initialize the device. -To connect directly pass a {class}`DeviceConfig` object to {meth}`Device.connect()`. +The safest way to obtain a config is from {attr}`~kasa.Device.config` after +discovery. It can be serialized with {meth}`~kasa.DeviceConfig.to_dict` and +reused with {meth}`~kasa.DeviceConfig.from_dict`. A config can also be created +manually when the exact connection parameters are known. -A {class}`DeviceConfig` can be constucted manually if you know the {attr}`DeviceConfig.connection_type` values for the device or -alternatively the config can be retrieved from {attr}`Device.config` post discovery and then re-used. +The ``login_version`` and ``klap_version`` connection parameters are +independent. ``klap_version`` selects the advertised IOT KLAP handshake version; +the encryption type remains {attr}`~kasa.DeviceEncryptionType.Klap`. (topics-update-cycle)= ## Update Cycle @@ -95,17 +95,17 @@ Modules tend to provide richer functionality but using the features does not req (topics-protocols-and-transports)= ## Protocols and Transports -The library supports two different TP-Link protocols, ``IOT`` and ``SMART``. -``IOT`` is the original Kasa protocol and ``SMART`` is the newer protocol supported by TAPO devices and newer KASA devices. -The original protocol has a ``target``, ``command``, ``args`` interface whereas the new protocol uses a different set of -commands and has a ``method``, ``parameters`` interface. +The library distinguishes three TP-Link protocol families: ``IOT``, ``SMART``, and ``SMARTCAM``. +``IOT`` is used by the original Kasa devices, ``SMART`` by Tapo and newer Kasa devices, and ``SMARTCAM`` by cameras and some hubs and doorbells. +The IOT protocol has a ``target``, ``command``, ``args`` interface, whereas SMART and SMARTCAM use method-based requests. Confusingly TP-Link originally called the Kasa line "Kasa Smart" and hence this library used "Smart" in a lot of the module and class names but actually they were built to work with the ``IOT`` protocol. In 2021 TP-Link started updating the underlying communication transport used by Kasa devices to make them more secure. It switched from a TCP connection with static XOR type of encryption to a transport called ``KLAP`` which communicates -over http and uses handshakes to negotiate a dynamic encryption cipher. -This automatic update was put on hold and only seemed to affect UK HS100 models. +over HTTP and uses handshakes to negotiate a dynamic encryption cipher. +IOT devices can use either KLAP handshake version without changing +their device family or concrete device class. In 2023 TP-Link started updating the underlying communication transport used by Tapo devices to make them more secure. It switched from AES encryption via public key exchange to use ``KLAP`` encryption and negotiation due to concerns @@ -122,12 +122,16 @@ The classes providing this functionality are: - {class}`BaseProtocol ` - {class}`IotProtocol ` - {class}`SmartProtocol ` +- {class}`SmartCamProtocol ` - {class}`BaseTransport ` - {class}`XorTransport ` - {class}`AesTransport ` - {class}`KlapTransport ` - {class}`KlapTransportV2 ` +- {class}`SslTransport ` +- {class}`SslAesTransport ` +- {class}`LinkieTransportV2 ` (topics-errors-and-exceptions)= ## Errors and Exceptions @@ -137,6 +141,7 @@ The base exception for all library errors is {class}`KasaException ` which will usually contain an ``error_code`` with the detail. - If the device fails to authenticate the library raises an {class}`AuthenticationError ` which is derived from {class}`DeviceError ` and could contain an ``error_code`` depending on the type of failure. -- If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError `. +- If authentication fails for a device discovered with an unsupported onboarding source, the library raises {class}`UnsupportedAuthenticationError `, which derives from both {class}`AuthenticationError ` and {class}`UnsupportedDeviceError `. +- If the library encounters an unsupported device it raises an {class}`UnsupportedDeviceError `. - If the device fails to respond within a timeout the library raises a {class}`TimeoutError `. - All other failures will raise the base {class}`KasaException ` class. diff --git a/docs/tutorial.py b/docs/tutorial.py index 4f4397ab1..86b05c47a 100644 --- a/docs/tutorial.py +++ b/docs/tutorial.py @@ -5,7 +5,7 @@ :func:`~kasa.Discover.discover` returns a dict[str,Device] of devices on your network: >>> devices = await Discover.discover(username="user@example.com", password="great_password") ->>> for dev in devices.values(): +>>> for dev in sorted(devices.values(), key=lambda dev: dev.host): ... await dev.update() ... print(dev.host) 127.0.0.1 diff --git a/kasa/__init__.py b/kasa/__init__.py index b8871f997..c256574f6 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -30,8 +30,10 @@ from kasa.exceptions import ( AuthenticationError, DeviceError, + DiscoveryAuthenticationError, KasaException, TimeoutError, + UnsupportedAuthenticationError, UnsupportedDeviceError, ) from kasa.feature import Feature @@ -67,7 +69,9 @@ "Module", "KasaException", "AuthenticationError", + "DiscoveryAuthenticationError", "DeviceError", + "UnsupportedAuthenticationError", "UnsupportedDeviceError", "TimeoutError", "Credentials", diff --git a/kasa/cli/device.py b/kasa/cli/device.py index 7610a7cdf..7775fccbd 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -81,11 +81,11 @@ async def state(ctx, dev: Device): echo("\n\t[bold]== Protocol information ==[/bold]") echo(f"\tCredentials hash: {dev.credentials_hash}") echo() - from .discover import _echo_discovery_info + from .discover import echo_discovery_info if TYPE_CHECKING: assert dev._discovery_info - _echo_discovery_info(dev._discovery_info) + echo_discovery_info(dev._discovery_info) return dev.internal_state diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index af367e32b..7f27a48bc 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +import builtins +from dataclasses import dataclass from pprint import pformat as pf -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast import asyncclick as click @@ -12,20 +14,26 @@ AuthenticationError, Credentials, Device, + DeviceConnectionParameters, Discover, + DiscoveryAuthenticationError, + KasaException, + UnsupportedAuthenticationError, UnsupportedDeviceError, ) +from kasa.device_factory import ConnectAttempt from kasa.discover import ( - NEW_DISCOVERY_REDACTORS, - ConnectAttempt, + TDP_DISCOVERY_REDACTORS, DeviceDict, DiscoveredRaw, DiscoveryResult, + OnAuthenticationErrorCallable, OnDiscoveredCallable, OnDiscoveredRawCallable, OnUnsupportedCallable, + select_discovery_response, ) -from kasa.iot.iotdevice import _extract_sys_info +from kasa.iot.iotdevice import extract_sys_info from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS from kasa.protocols.protocol import redact_data @@ -59,60 +67,269 @@ def _discover_is_root_cmd(ctx: click.Context) -> bool: ) +@dataclass(frozen=True) +class _DiscoveryListInfo: + """Connection information displayed by ``discover list``.""" + + host: str + model: str | None = None + device_family: str | None = None + encryption_type: str | None = None + https: bool | None = None + login_version: int | None = None + klap_version: int | None = None + + +def _format_connection_options(cparams: DeviceConnectionParameters) -> str: + """Return CLI options that recreate direct connection parameters.""" + options = [ + f"--device-family {cparams.device_family.value}", + f"--encrypt-type {cparams.encryption_type.value}", + ] + if cparams.login_version is not None: + options.append(f"--login-version {cparams.login_version}") + if cparams.klap_version is not None: + options.append(f"--klap-version {cparams.klap_version}") + options.append("--https" if cparams.https else "--no-https") + return " ".join(options) + + +def _display_value(value: Any) -> str: + """Return one display-table value, preserving false and zero values.""" + return "-" if value is None or value == "" else str(value) + + +def _get_discovery_list_info( + host: str, + *, + device: Device | None = None, + discovery_info: dict[str, Any] | None = None, +) -> _DiscoveryListInfo: + """Return consistent list information from a device or discovery response.""" + info = _DiscoveryListInfo(host=host) + if isinstance(discovery_info, dict): + if sysinfo := extract_sys_info(discovery_info): + device_family = sysinfo.get("mic_type", sysinfo.get("type")) + model = sysinfo.get("model") + if isinstance(model, str): + model, _, _ = model.partition("(") + info = _DiscoveryListInfo( + host=host, + model=model, + device_family=device_family, + encryption_type="XOR", + https=device_family == "IOT.IPCAMERA", + login_version=( + sysinfo.get("stream_version") + if device_family == "IOT.IPCAMERA" + else None + ), + ) + else: + result = discovery_info.get("result", discovery_info) + if isinstance(result, dict): + model = result.get("device_model") + if isinstance(model, str): + model, _, _ = model.partition("(") + device_family = result.get("device_type") + encryption_scheme = result.get("mgt_encrypt_schm") + if not isinstance(encryption_scheme, dict): + encryption_scheme = {} + encrypt_info = result.get("encrypt_info") + if not isinstance(encrypt_info, dict): + encrypt_info = {} + encryption_type = encryption_scheme.get( + "encrypt_type" + ) or encrypt_info.get("sym_schm") + info = _DiscoveryListInfo( + host=host, + model=model, + device_family=device_family, + encryption_type=encryption_type, + https=encryption_scheme.get("is_support_https"), + login_version=encryption_scheme.get("lv"), + klap_version=( + encryption_scheme.get("new_klap") + if isinstance(device_family, str) + and device_family.startswith("IOT.") + and encryption_type == "KLAP" + else None + ), + ) + + if device is None: + return info + + cparams = device.config.connection_type + return _DiscoveryListInfo( + host=host, + model=info.model or device.model, + device_family=info.device_family or cparams.device_family.value, + encryption_type=info.encryption_type or cparams.encryption_type.value, + https=info.https if info.https is not None else cparams.https, + login_version=( + info.login_version + if info.login_version is not None + else cparams.login_version + ), + klap_version=( + info.klap_version if info.klap_version is not None else cparams.klap_version + ), + ) + + +def _format_discovery_source(response: DiscoveredRaw | None) -> str: + """Return a compact discovery source and port label.""" + if response is None: + return "-" + meta = response["meta"] + source = meta.get("source") + if source is None: + source = ( + "tdp" + if meta["port"] in (Discover.DISCOVERY_PORT_2, Discover.DISCOVERY_PORT_3) + else "udp" + ) + return f"{source.upper()}/{meta['port']}" + + +def _format_discovery_list_row( + info: _DiscoveryListInfo, + *, + source: str, + result: str, +) -> str: + """Format one complete ``discover list`` row.""" + return ( + f"{info.host:<15} {_display_value(info.model):<9} " + f"{_display_value(info.device_family):<20} " + f"{_display_value(info.encryption_type):<7} " + f"{_display_value(info.https):<5} " + f"{_display_value(info.login_version):<3} " + f"{_display_value(info.klap_version):<3} " + f"{source:<9} {result}" + ) + + +def _connection_parameters_from_discovery( + response: DiscoveredRaw, + device: Device | None, +) -> DeviceConnectionParameters | None: + """Return connection parameters from an authoritative raw response.""" + if device is not None: + return device.config.connection_type + + meta = response["meta"] + source = meta.get("source") + if source is None: + source = ( + "tdp" + if meta["port"] in (Discover.DISCOVERY_PORT_2, Discover.DISCOVERY_PORT_3) + else "udp" + ) + discovery_info = response["discovery_response"] + if source == "tdp": + result = discovery_info.get("result") + if not isinstance(result, dict): + return None + try: + return DiscoveryResult.from_dict(result).to_connection_parameters() + except Exception: # pragma: no cover - defensive raw-response handling + return None + + if not (sysinfo := extract_sys_info(discovery_info)): + return None + device_family = sysinfo.get("mic_type", sysinfo.get("type")) + if not isinstance(device_family, str): + return None + try: + return DeviceConnectionParameters.from_values( + device_family, + "XOR", + login_version=( + sysinfo.get("stream_version") + if device_family == "IOT.IPCAMERA" + else None + ), + https=device_family == "IOT.IPCAMERA", + ) + except KasaException: + return None + + @discover.command() @click.pass_context async def detail(ctx: click.Context) -> DeviceDict: - """Discover devices in the network using udp broadcasts.""" - unsupported = [] - auth_failed = [] + """Discover devices in the network using broadcast discovery.""" + unsupported: dict[str, UnsupportedDeviceError] = {} + auth_failed: dict[str, DiscoveryAuthenticationError] = {} sem = asyncio.Semaphore() async def print_unsupported(unsupported_exception: UnsupportedDeviceError) -> None: - unsupported.append(unsupported_exception) + host = unsupported_exception.host or "unknown" + unsupported[host] = unsupported_exception async with sem: - if unsupported_exception.discovery_result: - echo("== Unsupported device ==") - _echo_discovery_info(unsupported_exception.discovery_result) - echo() + if isinstance(unsupported_exception, UnsupportedAuthenticationError): + echo("== Unsupported device authentication ==") else: echo("== Unsupported device ==") + if unsupported_exception.discovery_result: + echo_discovery_info(unsupported_exception.discovery_result) + else: echo(f"\t{unsupported_exception}") - echo() + if isinstance(unsupported_exception, UnsupportedAuthenticationError): + source = unsupported_exception.onboarding_source or "unknown" + echo(f"\tOnboarding source: {source}") + echo( + "\tReset and provision this device using a supported " + "TP-Link onboarding method." + ) + echo() + + async def print_authentication_error( + authentication_error: DiscoveryAuthenticationError, + ) -> None: + host = authentication_error.host or "unknown" + auth_failed[host] = authentication_error + async with sem: + echo("== Authentication failed for device ==") + if authentication_error.discovery_result: + echo_discovery_info(authentication_error.discovery_result) + else: + echo(f"\t{authentication_error}") + echo() + echo() from .device import state async def print_discovered(dev: Device) -> None: if TYPE_CHECKING: assert ctx.parent + await dev.update() async with sem: - try: - await dev.update() - except AuthenticationError: - if TYPE_CHECKING: - assert dev._discovery_info - auth_failed.append(dev._discovery_info) - echo("== Authentication failed for device ==") - _echo_discovery_info(dev._discovery_info) - echo() - else: - ctx.parent.obj = dev - await ctx.parent.invoke(state) + ctx.parent.obj = dev + await ctx.parent.invoke(state) echo() discovered = await _discover( ctx, print_discovered=print_discovered if _discover_is_root_cmd(ctx) else None, print_unsupported=print_unsupported, + print_authentication_error=print_authentication_error, ) if ctx.find_root().params["host"]: + if unsupported or auth_failed: + for dev in discovered.values(): + await dev.disconnect() + raise click.ClickException("The requested device could not be queried") return discovered - echo(f"Found {len(discovered)} devices") + found_hosts = set(discovered) | set(unsupported) | set(auth_failed) + echo(f"Found {len(found_hosts)} devices") if unsupported: echo(f"Found {len(unsupported)} unsupported devices") if auth_failed: echo(f"Found {len(auth_failed)} devices that failed to authenticate") - return discovered @@ -130,11 +347,16 @@ async def raw(ctx: click.Context, redact: bool) -> DeviceDict: def print_raw(discovered: DiscoveredRaw): if redact: - redactors = ( - NEW_DISCOVERY_REDACTORS - if discovered["meta"]["port"] == Discover.DISCOVERY_PORT_2 - else IOT_REDACTORS - ) + meta = discovered["meta"] + source = meta.get("source") + if source is None: + source = ( + "tdp" + if meta["port"] + in (Discover.DISCOVERY_PORT_2, Discover.DISCOVERY_PORT_3) + else "udp" + ) + redactors = TDP_DISCOVERY_REDACTORS if source == "tdp" else IOT_REDACTORS discovered["discovery_response"] = redact_data( discovered["discovery_response"], redactors ) @@ -146,42 +368,87 @@ def print_raw(discovered: DiscoveredRaw): @discover.command() @click.pass_context async def list(ctx: click.Context) -> DeviceDict: - """List devices in the network in a table using udp broadcasts.""" - sem = asyncio.Semaphore() + """List devices in the network in a table using broadcast discovery.""" + devices_by_host: dict[str, Device] = {} + discovery_info_by_host: dict[str, dict[str, Any]] = {} + raw_responses_by_host: dict[str, builtins.list[DiscoveredRaw]] = {} + results_by_host: dict[str, str] = {} + + def capture_raw(response: DiscoveredRaw) -> None: + """Retain discovery source metadata for the final list row.""" + raw_responses_by_host.setdefault(response["meta"]["ip"], []).append(response) async def print_discovered(dev: Device): - cparams = dev.config.connection_type - infostr = ( - f"{dev.host:<15} {dev.model:<9} {cparams.device_family.value:<20} " - f"{cparams.encryption_type.value:<7} {cparams.https:<5} " - f"{cparams.login_version or '-':<3}" - ) - async with sem: - try: - await dev.update() - except AuthenticationError: - echo(f"{infostr} - Authentication failed") - except TimeoutError: - echo(f"{infostr} - Timed out") - except Exception as ex: - echo(f"{infostr} - Error: {ex}") - else: - echo(f"{infostr} {dev.alias}") + devices_by_host[dev.host] = dev + try: + await dev.update() + except TimeoutError: + results_by_host[dev.host] = "Timed out" + except (AuthenticationError, UnsupportedDeviceError): + raise + except Exception as ex: # pragma: no cover - defensive CLI reporting + results_by_host[dev.host] = f"Error: {ex}" + else: + results_by_host[dev.host] = dev.alias or "-" async def print_unsupported(unsupported_exception: UnsupportedDeviceError): - if host := unsupported_exception.host: - echo(f"{host:<15} UNSUPPORTED DEVICE") + host = unsupported_exception.host or "unknown" + if unsupported_exception.discovery_result: + discovery_info_by_host[host] = unsupported_exception.discovery_result + if isinstance(unsupported_exception, UnsupportedAuthenticationError): + source = unsupported_exception.onboarding_source or "unknown" + results_by_host[host] = f"Unsupported authentication ({source})" + else: + results_by_host[host] = "Unsupported device" + + async def print_authentication_error( + authentication_error: DiscoveryAuthenticationError, + ) -> None: + host = authentication_error.host or "unknown" + if authentication_error.discovery_result: + discovery_info_by_host[host] = authentication_error.discovery_result + results_by_host[host] = "Authentication failed" echo( f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " - f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + f"{'HTTPS':<5} {'LV':<3} {'KV':<3} {'SOURCE':<9} {'ALIAS / RESULT'}" ) discovered = await _discover( ctx, print_discovered=print_discovered, print_unsupported=print_unsupported, + print_authentication_error=print_authentication_error, + print_raw=capture_raw, do_echo=False, ) + + display_hosts = ( + set(devices_by_host) + | set(discovery_info_by_host) + | set(raw_responses_by_host) + | set(results_by_host) + ) + for host in sorted(display_hosts): + device = devices_by_host.get(host) + responses = raw_responses_by_host.get(host, []) + selected_response = select_discovery_response(responses) if responses else None + discovery_info = ( + selected_response["discovery_response"] + if selected_response is not None + else discovery_info_by_host.get(host) + ) + info = _get_discovery_list_info( + host, + device=device, + discovery_info=discovery_info, + ) + echo( + _format_discovery_list_row( + info, + source=_format_discovery_source(selected_response), + result=results_by_host.get(host, "-"), + ) + ) return discovered @@ -190,6 +457,7 @@ async def _discover( *, print_discovered: OnDiscoveredCallable | None = None, print_unsupported: OnUnsupportedCallable | None = None, + print_authentication_error: OnAuthenticationErrorCallable | None = None, print_raw: OnDiscoveredRawCallable | None = None, do_echo=True, ) -> DeviceDict: @@ -197,6 +465,7 @@ async def _discover( target = params["target"] username = params["username"] password = params["password"] + credentials_hash = params["credentials_hash"] discovery_timeout = params["discovery_timeout"] timeout = params["timeout"] host = params["host"] @@ -211,14 +480,15 @@ async def _discover( host, port=port, credentials=credentials, + credentials_hash=credentials_hash, timeout=timeout, discovery_timeout=discovery_timeout, + on_discovered=print_discovered, on_unsupported=print_unsupported, + on_authentication_error=print_authentication_error, on_discovered_raw=print_raw, ) if dev: - if print_discovered: - await print_discovered(dev) return {host: dev} else: return {} @@ -229,9 +499,11 @@ async def _discover( discovery_timeout=discovery_timeout, on_discovered=print_discovered, on_unsupported=print_unsupported, + on_authentication_error=print_authentication_error, port=port, timeout=timeout, credentials=credentials, + credentials_hash=credentials_hash, on_discovered_raw=print_raw, ) @@ -241,15 +513,17 @@ async def _discover( @discover.command() @click.pass_context async def config(ctx: click.Context) -> DeviceDict: - """Bypass udp discovery and try to show connection config for a device. + """Show the authoritative connection config for a device. - Bypasses udp discovery and shows the parameters required to connect - directly to the device. + Uses targeted discovery first and falls back to direct connection probing + when no discovery response is received. """ params = ctx.find_root().params username = params["username"] password = params["password"] + credentials_hash = params["credentials_hash"] timeout = params["timeout"] + discovery_timeout = params["discovery_timeout"] host = params["host"] port = params["port"] @@ -258,29 +532,86 @@ async def config(ctx: click.Context) -> DeviceDict: credentials = Credentials(username, password) if username and password else None + raw_responses: builtins.list[DiscoveredRaw] = [] + + echo(f"Discovering device {host} for {discovery_timeout} seconds") + device: Device | None = None + discovery_error: KasaException | None = None + try: + device = await Discover.discover_single( + host, + port=port, + credentials=credentials, + credentials_hash=credentials_hash, + timeout=timeout, + discovery_timeout=discovery_timeout, + on_discovered_raw=raw_responses.append, + ) + except KasaException as ex: + discovery_error = ex + + if raw_responses: + response = select_discovery_response(raw_responses) + if connection_type := _connection_parameters_from_discovery(response, device): + meta = response["meta"] + echo( + f"Using {_format_discovery_source(response)} discovery response " + f"from {meta['ip']}" + ) + echo("CLI options for the discovered connection are:") + echo(_format_connection_options(connection_type)) + return {host: device} if device is not None else {} + error( + "Unable to determine a connection configuration from the " + f"{_format_discovery_source(response)} discovery response" + ) + + if device is not None: + # A device without a raw callback is unexpected, but its normalized + # config is still more authoritative than speculative direct probing. + echo("CLI options for the discovered connection are:") + echo(_format_connection_options(device.config.connection_type)) + return {host: device} + + if discovery_error is not None: + echo(f"Discovery did not provide a usable connection config: {discovery_error}") + else: + echo("Discovery did not provide a usable connection config") + echo("Trying direct connection routes instead") + host_port = host + (f":{port}" if port else "") def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: - prot, tran, dev, https = connect_attempt + prot = connect_attempt.protocol + tran = connect_attempt.transport + dev = connect_attempt.device + https = connect_attempt.https key_str = ( f"{prot.__name__} + {tran.__name__} + {dev.__name__}" f" + {'https' if https else 'http'}" ) + connection_type = connect_attempt.connection_type + key_str += ( + f" [{connection_type.device_family.value}, " + f"{connection_type.encryption_type.value}, " + f"lv={connection_type.login_version or '-'}, " + f"kv={connection_type.klap_version or '-'}]" + ) result = "succeeded" if success else "failed" msg = f"Attempt to connect to {host_port} with {key_str} {result}" echo(msg) dev = await Discover.try_connect_all( - host, credentials=credentials, timeout=timeout, port=port, on_attempt=on_attempt + host, + credentials=credentials, + credentials_hash=credentials_hash, + timeout=timeout, + port=port, + on_attempt=on_attempt, ) if dev: - cparams = dev.config.connection_type - echo("Managed to connect, cli options to connect are:") - echo( - f"--device-family {cparams.device_family.value} " - f"--encrypt-type {cparams.encryption_type.value} " - f"{'--https' if cparams.https else '--no-https'}" - ) + echo("Managed to connect using direct probing, CLI options are:") + echo(_format_connection_options(dev.config.connection_type)) return {host: dev} else: error(f"Unable to connect to {host}") @@ -294,17 +625,22 @@ def _echo_dictionary(discovery_info: dict) -> None: echo(f"\t{key_name_and_spaces}{value}") -def _echo_discovery_info(discovery_info: dict) -> None: +def echo_discovery_info(discovery_info: dict) -> None: + """Print decoded discovery information for CLI commands.""" # We don't have discovery info when all connection params are passed manually if discovery_info is None: return - if sysinfo := _extract_sys_info(discovery_info): + if sysinfo := extract_sys_info(discovery_info): _echo_dictionary(sysinfo) return + tdp_info = discovery_info.get("result", discovery_info) + if not isinstance(tdp_info, dict): + _echo_dictionary(discovery_info) + return try: - dr = DiscoveryResult.from_dict(discovery_info) + dr = DiscoveryResult.from_dict(tdp_info) except Exception: _echo_dictionary(discovery_info) return @@ -334,6 +670,7 @@ def _conditional_echo(label, value): _conditional_echo("Supports HTTPS", mgt_encrypt_schm.is_support_https) _conditional_echo("HTTP Port", mgt_encrypt_schm.http_port) _conditional_echo("Login version", mgt_encrypt_schm.lv) + _conditional_echo("KLAP version", mgt_encrypt_schm.new_klap) _conditional_echo("Encrypt info", pf(dr.encrypt_info) if dr.encrypt_info else None) _conditional_echo("Decrypted", pf(dr.decrypted_data) if dr.decrypted_data else None) @@ -341,8 +678,11 @@ def _conditional_echo(label, value): async def find_dev_from_alias( alias: str, credentials: Credentials | None, + credentials_hash: str | None = None, target: str = "255.255.255.255", timeout: int = 5, + discovery_timeout: int = 5, + port: int | None = None, attempts: int = 3, ) -> Device | None: """Discover a device identified by its alias.""" @@ -374,6 +714,9 @@ async def do_discover(): target=target, timeout=timeout, credentials=credentials, + credentials_hash=credentials_hash, + discovery_timeout=discovery_timeout, + port=port, on_discovered=on_discovered, ) if found_event.is_set(): diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 3e32c1f45..e90ec695a 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from kasa import Device -from kasa.deviceconfig import DeviceEncryptionType +from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily from .common import ( SKIP_UPDATE_COMMANDS, @@ -40,10 +40,11 @@ ] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] +DEVICE_FAMILIES = [device_family.value for device_family in DeviceFamily] DEFAULT_TARGET = "255.255.255.255" -def _legacy_type_to_class(_type: str) -> Any: +def _iot_type_to_class(_type: str) -> Any: from kasa.iot import ( IotBulb, IotDimmer, @@ -108,8 +109,11 @@ def _legacy_type_to_class(_type: str) -> Any: "--port", envvar="KASA_PORT", required=False, - type=int, - help="The port of the device to connect to.", + type=click.IntRange(min=1, max=65535), + help=( + "The device connection port. During discovery this also overrides the " + "UDP discovery port; TDP ports 20002 and 20004 are reserved." + ), ) @click.option( "--alias", @@ -147,7 +151,10 @@ def _legacy_type_to_class(_type: str) -> Any: envvar="KASA_TYPE", default=None, type=click.Choice(TYPES, case_sensitive=False), - help="The device type in order to bypass discovery. Use `smart` for newer devices", + help=( + "Device type used to bypass discovery. IOT types select their concrete " + "class; `smart` and `camera` select protocol defaults." + ), ) @click.option( "--json/--no-json", @@ -156,35 +163,48 @@ def _legacy_type_to_class(_type: str) -> Any: is_flag=True, help="Output raw device response as JSON.", ) +@click.option( + "-df", + "--device-family", + envvar="KASA_DEVICE_FAMILY", + default=None, + type=click.Choice(DEVICE_FAMILIES, case_sensitive=False), + help=( + "Exact device family for an advanced direct connection, e.g. " + "`SMART.KASASWITCH`." + ), +) @click.option( "-e", "--encrypt-type", envvar="KASA_ENCRYPT_TYPE", default=None, type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), -) -@click.option( - "-df", - "--device-family", - envvar="KASA_DEVICE_FAMILY", - default="SMART.TAPOPLUG", - help="Device family type, e.g. `SMART.KASASWITCH`. Deprecated use `--type smart`", + help="Encryption type for an advanced direct connection.", ) @click.option( "-lv", "--login-version", envvar="KASA_LOGIN_VERSION", - default=2, - type=int, - help="The login version for device authentication. Defaults to 2", + default=None, + type=click.IntRange(min=1), + help="Login version for an advanced direct connection.", +) +@click.option( + "-kv", + "--klap-version", + envvar="KASA_KLAP_VERSION", + default=None, + type=click.IntRange(min=1), + help="IOT KLAP handshake version for an advanced direct connection.", ) @click.option( "--https/--no-https", envvar="KASA_HTTPS", - default=False, + default=None, is_flag=True, type=bool, - help="Set flag if the device encryption uses https.", + help="Whether an advanced direct connection uses HTTPS.", ) @click.option( "--timeout", @@ -234,11 +254,12 @@ async def cli( verbose, debug, type, - encrypt_type, - https, + json, device_family, + encrypt_type, login_version, - json, + klap_version, + https, timeout, discovery_timeout, username, @@ -273,9 +294,6 @@ async def cli( # but this keeps mypy happy for now logging.basicConfig(**logging_config) # type: ignore - if ctx.invoked_subcommand == "discover": - return - if alias is not None and host is not None: raise click.BadOptionUsage("alias", "Use either --alias or --host, not both.") @@ -284,6 +302,66 @@ async def cli( "username", "Using authentication requires both --username and --password" ) + connection_options = { + "type": type, + "device-family": device_family, + "encrypt-type": encrypt_type, + "login-version": login_version, + "klap-version": klap_version, + "https": https, + } + supplied_connection_options = { + option: value + for option, value in connection_options.items() + if value is not None + } + + if ctx.invoked_subcommand == "discover": + if supplied_connection_options: + option = next(iter(supplied_connection_options)) + raise click.BadOptionUsage( + option, + f"--{option} configures a direct connection and cannot be used " + "with discover", + ) + return + + if supplied_connection_options and host is None: + raise click.UsageError("Direct connection options require --host") + + advanced_connection_options = { + option: value + for option, value in connection_options.items() + if option != "type" and value is not None + } + if type is not None and type not in {"smart", "camera"}: + if advanced_connection_options: + option = next(iter(advanced_connection_options)) + raise click.BadOptionUsage( + option, f"--{option} is not used with IOT --type {type}" + ) + elif type == "camera": + incompatible = { + option: value + for option, value in advanced_connection_options.items() + if option != "login-version" + } + if incompatible: + option = next(iter(incompatible)) + raise click.BadOptionUsage( + option, + f"--{option} is fixed by --type camera", + ) + elif ( + type is None + and advanced_connection_options + and (not device_family or not encrypt_type) + ): + raise click.UsageError( + "Advanced direct connections require both --device-family and " + "--encrypt-type" + ) + if username: from kasa.credentials import Credentials @@ -304,32 +382,77 @@ async def cli( device_discovered = False if type is not None and type not in {"smart", "camera"}: - from kasa.deviceconfig import DeviceConfig + from kasa.deviceconfig import DeviceConfig, DeviceConnectionParameters + + iot_family = ( + DeviceFamily.IotSmartBulb + if type in {"bulb", "lightstrip"} + else DeviceFamily.IotSmartPlugSwitch + ) - config = DeviceConfig(host=host, port_override=port, timeout=timeout) - dev = _legacy_type_to_class(type)(host, config=config) + config = DeviceConfig( + host=host, + port_override=port, + timeout=timeout, + credentials=credentials, + credentials_hash=credentials_hash, + connection_type=DeviceConnectionParameters( + iot_family, + DeviceEncryptionType.Xor, + ), + ) + dev = _iot_type_to_class(type)(host, config=config) elif type in {"smart", "camera"} or (device_family and encrypt_type): if type == "camera": encrypt_type = "AES" https = True device_family = "SMART.IPCAMERA" + if login_version is None: + login_version = 2 + elif type == "smart": + device_family = device_family or "SMART.TAPOPLUG" + encrypt_type = encrypt_type or "KLAP" + https = bool(https) from kasa.device import Device from kasa.deviceconfig import ( DeviceConfig, DeviceConnectionParameters, - DeviceEncryptionType, - DeviceFamily, ) - if not encrypt_type: - encrypt_type = "KLAP" + try: + family = DeviceFamily(device_family) + encryption = DeviceEncryptionType(encrypt_type) + except ValueError as ex: + raise click.BadParameter( + "Invalid device connection parameters", + param_hint="--device-family/--encrypt-type", + ) from ex + + if type == "smart" and not family.value.startswith("SMART."): + raise click.BadOptionUsage( + "device-family", "--type smart requires a SMART device family" + ) + if klap_version is not None: + if encryption is not DeviceEncryptionType.Klap: + raise click.BadOptionUsage( + "klap-version", + "--klap-version requires --encrypt-type KLAP", + ) + if not family.value.startswith("IOT."): + raise click.BadOptionUsage( + "klap-version", + "--klap-version is only used by IOT devices", + ) + if login_version is None and family.value.startswith("SMART."): + login_version = 2 ctype = DeviceConnectionParameters( - DeviceFamily(device_family), - DeviceEncryptionType(encrypt_type), + family, + encryption, login_version, - https, + bool(https), + klap_version=klap_version, ) config = DeviceConfig( host=host, @@ -339,6 +462,10 @@ async def cli( timeout=timeout, connection_type=ctype, ) + from kasa.device_factory import is_connection_type_supported + + if not is_connection_type_supported(ctype, strict=True): + raise click.UsageError("Unsupported direct connection option combination") dev = await Device.connect(config=config) device_updated = True elif alias: @@ -347,7 +474,13 @@ async def cli( from .discover import find_dev_from_alias dev = await find_dev_from_alias( - alias=alias, target=target, credentials=credentials + alias=alias, + target=target, + credentials=credentials, + credentials_hash=credentials_hash, + timeout=timeout, + discovery_timeout=discovery_timeout, + port=port, ) if not dev: echo(f"No device with name {alias} found") diff --git a/kasa/device.py b/kasa/device.py index 18dd53140..6f5d10e50 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -123,7 +123,11 @@ DeviceEncryptionType, DeviceFamily, ) -from .exceptions import KasaException +from .exceptions import ( + AuthenticationError, + KasaException, + UnsupportedAuthenticationError, +) from .feature import Feature from .module import Module from .protocols import BaseProtocol, IotProtocol @@ -154,6 +158,38 @@ class WifiNetwork: _LOGGER = logging.getLogger(__name__) +_SUPPORTED_ONBOARDING_SOURCES = frozenset({"apple", "matter", "tplink"}) + + +def get_unsupported_authentication_error( + host: str, + discovery_info: dict[str, Any] | None, + error: AuthenticationError, +) -> UnsupportedAuthenticationError | None: + """Return an unsupported-onboarding error when discovery proves one applies. + + Discovery information alone cannot distinguish unsupported onboarding from + incorrect credentials. This helper must only be called after a real + authentication attempt has failed. + """ + if not isinstance(discovery_info, dict): + return None + onboarding_info = discovery_info.get("result", discovery_info) + if not isinstance(onboarding_info, dict): + return None + onboarding_source = onboarding_info.get("obd_src") + if not isinstance(onboarding_source, str) or not onboarding_source: + return None + if onboarding_source in _SUPPORTED_ONBOARDING_SOURCES: + return None + return UnsupportedAuthenticationError( + *error.args, + discovery_result=discovery_info, + error_code=error.error_code, + host=host, + onboarding_source=onboarding_source, + ) + @dataclass class DeviceInfo: @@ -233,7 +269,7 @@ async def connect( ) -> Device: """Connect to a single device by the given hostname or device configuration. - This method avoids the UDP based discovery process and + This method avoids the broadcast discovery process and will connect directly to the device. It is generally preferred to avoid :func:`discover_single()` and @@ -325,8 +361,13 @@ def device_type(self) -> DeviceType: return self._device_type @abstractmethod - def update_from_discover_info(self, info: dict) -> None: - """Update state from info from the discover call.""" + def update_from_discover_info( + self, + info: dict, + *, + device_info: dict | None = None, + ) -> None: + """Update state from discovery and optional full device information.""" @property def config(self) -> DeviceConfig: diff --git a/kasa/device_factory.py b/kasa/device_factory.py index ecb0d0a13..5e33973b3 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -4,12 +4,23 @@ import logging import time -from typing import Any +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass, replace +from enum import Enum +from typing import Any, NamedTuple -from .device import Device +from aiohttp import ClientSession + +from .credentials import Credentials +from .device import Device, get_unsupported_authentication_error from .device_type import DeviceType -from .deviceconfig import DeviceConfig, DeviceEncryptionType, DeviceFamily -from .exceptions import KasaException, UnsupportedDeviceError +from .deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) +from .exceptions import AuthenticationError, KasaException, UnsupportedDeviceError from .iot import ( IotBulb, IotDevice, @@ -40,32 +51,328 @@ _LOGGER = logging.getLogger(__name__) -GET_SYSINFO_QUERY: dict[str, dict[str, dict]] = { +_GET_SYSINFO_QUERY: dict[str, dict[str, dict]] = { "system": {"get_sysinfo": {}}, } +_IOT_DEVICE_CLASSES: dict[DeviceType, type[IotDevice]] = { + DeviceType.Bulb: IotBulb, + DeviceType.Plug: IotPlug, + DeviceType.Dimmer: IotDimmer, + DeviceType.Strip: IotStrip, + DeviceType.WallSwitch: IotWallSwitch, + DeviceType.LightStrip: IotLightStrip, +} + + +class ConnectAttempt(NamedTuple): + """Describe one direct connection attempt.""" + + protocol: type[BaseProtocol] + transport: type[BaseTransport] + device: type[Device] + https: bool + connection_type: DeviceConnectionParameters + + +OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] + + +class _ProtocolType(Enum): + """Internal protocol types used to resolve protocol and transport classes.""" + + Iot = "IOT" + Smart = "SMART" + SmartCam = "SMARTCAM" + + +@dataclass(frozen=True) +class _DeviceFamilyInfo: + """Describe how an advertised device family is represented by the library.""" + + device_class: type[Device] | None + protocol_type: _ProtocolType + requires_iot_sysinfo: bool = False + https_device_class: type[Device] | None = None + https_protocol_type: _ProtocolType | None = None + probe: bool = False + + def for_connection(self, *, https: bool) -> _DeviceFamilyInfo: + """Resolve connection-specific class and protocol overrides.""" + if not https: + return self + return _DeviceFamilyInfo( + device_class=self.https_device_class or self.device_class, + protocol_type=self.https_protocol_type or self.protocol_type, + requires_iot_sysinfo=self.requires_iot_sysinfo, + probe=self.probe, + ) + + +_DEVICE_FAMILIES: dict[DeviceFamily, _DeviceFamilyInfo] = { + # IOT.SMARTPLUGSWITCH and IOT.SMARTBULB are advertised family buckets, + # not concrete Python device classes. The default classes preserve the + # synchronous family lookup API, while every construction path resolves + # the final class from get_sysinfo when requires_iot_sysinfo is true. + DeviceFamily.IotSmartPlugSwitch: _DeviceFamilyInfo( + IotPlug, + _ProtocolType.Iot, + requires_iot_sysinfo=True, + probe=True, + ), + DeviceFamily.IotSmartBulb: _DeviceFamilyInfo( + IotBulb, _ProtocolType.Iot, requires_iot_sysinfo=True + ), + # IOT cameras are recognized for routing but remain disabled as a concrete + # device class until their API implementation is complete. + DeviceFamily.IotIpCamera: _DeviceFamilyInfo(None, _ProtocolType.Iot), + DeviceFamily.SmartTapoPlug: _DeviceFamilyInfo( + SmartDevice, _ProtocolType.Smart, probe=True + ), + DeviceFamily.SmartTapoBulb: _DeviceFamilyInfo(SmartDevice, _ProtocolType.Smart), + DeviceFamily.SmartTapoSwitch: _DeviceFamilyInfo(SmartDevice, _ProtocolType.Smart), + DeviceFamily.SmartKasaPlug: _DeviceFamilyInfo(SmartDevice, _ProtocolType.Smart), + DeviceFamily.SmartTapoHub: _DeviceFamilyInfo( + SmartDevice, + _ProtocolType.Smart, + https_device_class=SmartCamDevice, + https_protocol_type=_ProtocolType.SmartCam, + ), + DeviceFamily.SmartKasaHub: _DeviceFamilyInfo(SmartDevice, _ProtocolType.Smart), + DeviceFamily.SmartKasaSwitch: _DeviceFamilyInfo(SmartDevice, _ProtocolType.Smart), + DeviceFamily.SmartTapoChime: _DeviceFamilyInfo(SmartDevice, _ProtocolType.Smart), + DeviceFamily.SmartIpCamera: _DeviceFamilyInfo( + None, + _ProtocolType.SmartCam, + https_device_class=SmartCamDevice, + probe=True, + ), + DeviceFamily.SmartTapoDoorbell: _DeviceFamilyInfo( + None, + _ProtocolType.SmartCam, + https_device_class=SmartCamDevice, + ), + DeviceFamily.SmartTapoRobovac: _DeviceFamilyInfo( + None, + _ProtocolType.Smart, + https_device_class=SmartDevice, + probe=True, + ), +} + + +@dataclass(frozen=True) +class _ConnectionRoute: + """Map normalized connection parameters to protocol and transport classes. + + New transport variants should first be represented by + :class:`DeviceConnectionParameters`, then added here as a route. Discovery, + direct connection, and protocol creation all consume this same table. + """ + + protocol_type: _ProtocolType + encryption_type: DeviceEncryptionType + https: bool + protocol: type[BaseProtocol] + transport: type[BaseTransport] + device_family: DeviceFamily | None = None + fixed_encryption: bool = False + match_https: bool = True + login_version: int | None = None + klap_version: int | None = None + match_klap_version: bool = False + + def create_connection_parameters( + self, + device_family: DeviceFamily, + ) -> DeviceConnectionParameters: + """Create complete parameters for a direct connection attempt.""" + return DeviceConnectionParameters( + device_family=device_family, + encryption_type=self.encryption_type, + login_version=self.login_version, + https=self.https, + klap_version=self.klap_version, + ) + + def matches_identity( + self, + connection_type: DeviceConnectionParameters, + family_info: _DeviceFamilyInfo, + ) -> bool: + """Return whether this route applies apart from encryption.""" + if self.device_family is not None: + if self.device_family is not connection_type.device_family: + return False + elif self.protocol_type is not family_info.protocol_type: + return False + if self.match_https and self.https is not connection_type.https: + return False + return not self.match_klap_version or bool(self.klap_version) == bool( + connection_type.klap_version + ) + + def matches( + self, + connection_type: DeviceConnectionParameters, + family_info: _DeviceFamilyInfo, + *, + strict: bool, + ) -> bool: + """Return whether this route supports the connection parameters.""" + if not self.matches_identity(connection_type, family_info): + return False + return self.encryption_type is connection_type.encryption_type or ( + self.fixed_encryption and not strict + ) + + +_CONNECTION_ROUTES: tuple[_ConnectionRoute, ...] = ( + # Exact family routes are listed before generic protocol routes. + _ConnectionRoute( + _ProtocolType.SmartCam, + DeviceEncryptionType.Aes, + True, + SmartCamProtocol, + SslAesTransport, + device_family=DeviceFamily.SmartIpCamera, + fixed_encryption=True, + match_https=False, + login_version=2, + ), + _ConnectionRoute( + _ProtocolType.SmartCam, + DeviceEncryptionType.Aes, + True, + SmartCamProtocol, + SslAesTransport, + device_family=DeviceFamily.SmartTapoDoorbell, + fixed_encryption=True, + match_https=False, + login_version=2, + ), + _ConnectionRoute( + _ProtocolType.Iot, + DeviceEncryptionType.Xor, + True, + IotProtocol, + LinkieTransportV2, + device_family=DeviceFamily.IotIpCamera, + fixed_encryption=True, + match_https=False, + ), + _ConnectionRoute( + _ProtocolType.Smart, + DeviceEncryptionType.Aes, + True, + SmartProtocol, + SslTransport, + device_family=DeviceFamily.SmartTapoRobovac, + match_https=False, + login_version=2, + ), + # SMART.TAPOHUB with KLAP and HTTPS used SmartProtocol on master even + # though AES/HTTPS hubs use SmartCamProtocol. Keep this exact route before + # the generic SmartCam routes so stored DeviceConfigs retain that behavior. + _ConnectionRoute( + _ProtocolType.Smart, + DeviceEncryptionType.Klap, + True, + SmartProtocol, + KlapTransportV2, + device_family=DeviceFamily.SmartTapoHub, + login_version=2, + ), + _ConnectionRoute( + _ProtocolType.Iot, + DeviceEncryptionType.Xor, + False, + IotProtocol, + XorTransport, + ), + # IOT new_klap is independent from login_version. Its presence selects + # the versioned handshake; an absent value retains the original handshake. + _ConnectionRoute( + _ProtocolType.Iot, + DeviceEncryptionType.Klap, + False, + IotProtocol, + KlapTransport, + match_klap_version=True, + ), + _ConnectionRoute( + _ProtocolType.Iot, + DeviceEncryptionType.Klap, + False, + IotProtocol, + KlapTransportV2, + login_version=2, + klap_version=1, + match_klap_version=True, + ), + _ConnectionRoute( + _ProtocolType.Smart, + DeviceEncryptionType.Aes, + False, + SmartProtocol, + AesTransport, + login_version=2, + ), + _ConnectionRoute( + _ProtocolType.Smart, + DeviceEncryptionType.Aes, + True, + SmartCamProtocol, + SslAesTransport, + login_version=2, + ), + _ConnectionRoute( + _ProtocolType.Smart, + DeviceEncryptionType.Klap, + False, + SmartProtocol, + KlapTransportV2, + login_version=2, + ), + _ConnectionRoute( + _ProtocolType.Smart, + DeviceEncryptionType.Klap, + True, + SmartProtocol, + KlapTransportV2, + login_version=2, + ), + _ConnectionRoute( + _ProtocolType.SmartCam, + DeviceEncryptionType.Aes, + True, + SmartCamProtocol, + SslAesTransport, + login_version=2, + ), +) + async def connect(*, host: str | None = None, config: DeviceConfig) -> Device: """Connect to a single device by the given hostname or device configuration. - This method avoids the UDP based discovery process and - will connect directly to the device. + This method avoids broadcast discovery and connects directly to the device. 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. - Do not use this function directly, use SmartDevice.connect() + Do not use this function directly; use :meth:`Device.connect`. :param host: Hostname of device to query :param config: Connection parameters to ensure the correct protocol and connection options are used. - :rtype: SmartDevice + :rtype: Device :return: Object for querying/controlling found device. """ if host and config or (not host and not config): - raise KasaException("One of host or config must be provded and not both") + raise KasaException("One of host or config must be provided, but not both") if host: config = DeviceConfig(host=host) @@ -101,79 +408,142 @@ def _perf_log(has_params: bool, perf_type: str) -> None: ) start_time = time.perf_counter() - device_class: type[Device] | None - device: Device | None = None + device = await create_device(config, protocol=protocol) + await device.update() + _perf_log(True, "update") + return device + + +async def create_device( + config: DeviceConfig, + *, + protocol: BaseProtocol | None = None, + device_info: dict[str, Any] | None = None, + discovery_info: dict[str, Any] | None = None, +) -> Device: + """Create and initialize a device without performing a full update. + + The factory owns protocols it creates and closes them if initialization + fails. A caller-supplied protocol remains owned by the caller. + + :param config: Complete connection configuration for the device. + :param protocol: Optional pre-created protocol for connection attempts. + :param device_info: Optional full device information supplied by the + selected discovery method, such as an IOT ``get_sysinfo`` response + contained in UDP discovery. + :param discovery_info: Optional source discovery information used to + initialize devices when full device information is not available. + """ + owns_protocol = protocol is None + if protocol is None: + protocol = get_protocol(config) + if protocol is None: + raise UnsupportedDeviceError( + f"Unsupported device for {config.host}: " + f"{config.connection_type.to_dict()}", + discovery_result=discovery_info, + host=config.host, + ) - if isinstance(protocol, IotProtocol) and isinstance( - protocol._transport, XorTransport - ): - info = await protocol.query(GET_SYSINFO_QUERY) - _perf_log(True, "get_sysinfo") - device_class = get_device_class_from_sys_info(info) - device = device_class(config.host, protocol=protocol) - device.update_from_discover_info(info) - await device.update() - _perf_log(True, "update") - return device - elif device_class := get_device_class_from_family( - config.connection_type.device_family.value, https=config.connection_type.https - ): - device = device_class(host=config.host, protocol=protocol) - await device.update() - _perf_log(True, "update") + try: + device_class, resolved_device_info = await _resolve_device_class( + config.connection_type.device_family.value, + https=config.connection_type.https, + protocol=protocol, + device_info=device_info, + ) + config = _normalize_iot_connection_type( + config, device_class, resolved_device_info + ) + device = device_class(config.host, config=config, protocol=protocol) + if discovery_info is not None: + device.update_from_discover_info( + discovery_info, + device_info=resolved_device_info, + ) + elif resolved_device_info is not None: + device.update_from_discover_info(resolved_device_info) return device - else: - raise UnsupportedDeviceError( - f"Unsupported device for {config.host}: " - + f"{config.connection_type.device_family.value}", - host=config.host, + except UnsupportedDeviceError as ex: + if ex.discovery_result is None: + ex.discovery_result = discovery_info + if ex.host is None: + ex.host = config.host + if owns_protocol: + await protocol.close() + raise + except AuthenticationError as ex: + unsupported_error = get_unsupported_authentication_error( + config.host, discovery_info, ex ) + if owns_protocol: + await protocol.close() + if unsupported_error is not None: + raise unsupported_error from ex + raise + except BaseException: + if owns_protocol: + await protocol.close() + raise def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: - """Find SmartDevice subclass for device described by passed data.""" - TYPE_TO_CLASS = { - DeviceType.Bulb: IotBulb, - DeviceType.Plug: IotPlug, - DeviceType.Dimmer: IotDimmer, - DeviceType.Strip: IotStrip, - DeviceType.WallSwitch: IotWallSwitch, - DeviceType.LightStrip: IotLightStrip, - # Disabled until properly implemented - # DeviceType.Camera: IotCamera, - } - return TYPE_TO_CLASS[IotDevice._get_device_type_from_sys_info(sysinfo)] + """Return the concrete IOT class described by a sysinfo response.""" + device_type = IotDevice._get_device_type_from_sys_info(sysinfo) + if device_class := _IOT_DEVICE_CLASSES.get(device_type): + return device_class + raise UnsupportedDeviceError( + f"Unsupported IOT device type: {device_type.value}", + discovery_result=sysinfo, + ) + + +def _normalize_iot_connection_type( + config: DeviceConfig, + device_class: type[Device], + device_info: dict[str, Any] | None, +) -> DeviceConfig: + """Return a config whose broad IOT family matches its concrete class.""" + if device_info is None or not issubclass(device_class, IotDevice): + return config + + advertised_family = config.connection_type.device_family + if advertised_family not in { + DeviceFamily.IotSmartPlugSwitch, + DeviceFamily.IotSmartBulb, + }: + return config + + device_type = IotDevice._get_device_type_from_sys_info(device_info) + resolved_family = ( + DeviceFamily.IotSmartBulb + if device_type in {DeviceType.Bulb, DeviceType.LightStrip} + else DeviceFamily.IotSmartPlugSwitch + ) + if resolved_family is advertised_family: + return config + + return replace( + config, + connection_type=replace( + config.connection_type, + device_family=resolved_family, + ), + ) def get_device_class_from_family( device_type: str, *, https: bool, require_exact: bool = False ) -> type[Device] | None: """Return the device class from the type name.""" - supported_device_types: dict[str, type[Device]] = { - "SMART.TAPOPLUG": SmartDevice, - "SMART.TAPOBULB": SmartDevice, - "SMART.TAPOSWITCH": SmartDevice, - "SMART.KASAPLUG": SmartDevice, - "SMART.TAPOHUB": SmartDevice, - "SMART.TAPOHUB.HTTPS": SmartCamDevice, - "SMART.KASAHUB": SmartDevice, - "SMART.KASASWITCH": SmartDevice, - "SMART.IPCAMERA.HTTPS": SmartCamDevice, - "SMART.TAPODOORBELL.HTTPS": SmartCamDevice, - "SMART.TAPOROBOVAC.HTTPS": SmartDevice, - "IOT.SMARTPLUGSWITCH": IotPlug, - "IOT.SMARTBULB": IotBulb, - # Disabled until properly implemented - # "IOT.IPCAMERA": IotCamera, - } - lookup_key = f"{device_type}{'.HTTPS' if https else ''}" - if ( - (cls := supported_device_types.get(lookup_key)) is None - and device_type.startswith("SMART.") - and not require_exact - ): + family_info = _get_device_family_info(device_type, https=https) + if family_info is not None and family_info.device_class is not None: + cls: type[Device] | None = family_info.device_class + elif device_type.startswith("SMART.") and not require_exact: _LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type) cls = SmartDevice + else: + cls = None if cls is not None: _LOGGER.debug("Using %s for %s", cls.__name__, device_type) @@ -181,6 +551,54 @@ def get_device_class_from_family( return cls +def _get_device_family_info( + device_type: str, + *, + https: bool, +) -> _DeviceFamilyInfo | None: + """Return internal device and protocol information for an advertised family.""" + try: + family = DeviceFamily(device_type) + except ValueError: + return None + if family_info := _DEVICE_FAMILIES.get(family): + return family_info.for_connection(https=https) + return None + + +async def _resolve_device_class( + device_type: str, + *, + https: bool, + protocol: BaseProtocol, + device_info: dict[str, Any] | None = None, +) -> tuple[type[Device], dict[str, Any] | None]: + """Resolve the concrete device class and any required system information. + + IOT family names group several concrete device types. They must always be + resolved from get_sysinfo instead of using the family mapping's default + class. UDP discovery supplies that response as part of its own candidate; + discovery methods without system information query it through their + selected protocol. + """ + family_info = _get_device_family_info(device_type, https=https) + if family_info is None: + if device_class := get_device_class_from_family(device_type, https=https): + return device_class, device_info + raise UnsupportedDeviceError(f"Unsupported device family: {device_type}") + if family_info.device_class is None: + if device_class := get_device_class_from_family(device_type, https=https): + return device_class, device_info + raise UnsupportedDeviceError(f"Unsupported device family: {device_type}") + + if not family_info.requires_iot_sysinfo: + return family_info.device_class, device_info + + if device_info is None: + device_info = await protocol.query(_GET_SYSINFO_QUERY) + return get_device_class_from_sys_info(device_info), device_info + + def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol | None: """Return the protocol from the device config. @@ -193,50 +611,174 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol """ _LOGGER.debug("Finding protocol for %s", config.host) ctype = config.connection_type - protocol_name = ctype.device_family.value.split(".")[0] _LOGGER.debug("Finding protocol for %s", ctype.device_family) - if ctype.device_family in { - DeviceFamily.SmartIpCamera, - DeviceFamily.SmartTapoDoorbell, - }: - if strict and ctype.encryption_type is not DeviceEncryptionType.Aes: - return None - return SmartCamProtocol(transport=SslAesTransport(config=config)) - - if ctype.device_family is DeviceFamily.IotIpCamera: - if strict and ctype.encryption_type is not DeviceEncryptionType.Xor: - return None - return IotProtocol(transport=LinkieTransportV2(config=config)) - - # Older FW used a different transport - if ( - ctype.device_family is DeviceFamily.SmartTapoRobovac - and ctype.encryption_type is DeviceEncryptionType.Aes - ): - return SmartProtocol(transport=SslTransport(config=config)) + family_info = _get_device_family_info(ctype.device_family.value, https=ctype.https) + if family_info is None: + return None + if route := _get_connection_route(ctype, family_info, strict=strict): + _LOGGER.debug("Using connection route %s", route) + return route.protocol(transport=route.transport(config=config)) + return None + + +def _get_connection_route( + connection_type: DeviceConnectionParameters, + family_info: _DeviceFamilyInfo, + *, + strict: bool, +) -> _ConnectionRoute | None: + """Return the single route selected for normalized parameters.""" + fixed_route_mismatch = False + for route in _CONNECTION_ROUTES: + if route.device_family is not connection_type.device_family: + continue + if not route.matches_identity(connection_type, family_info): + continue + if route.matches(connection_type, family_info, strict=strict): + return route + fixed_route_mismatch |= route.fixed_encryption + + # A fixed-encryption family route is authoritative. In strict mode a + # mismatch is invalid instead of falling through to a generic transport. + if strict and fixed_route_mismatch: + return None - protocol_transport_key = ( - protocol_name - + "." - + ctype.encryption_type.value - + (".HTTPS" if ctype.https else "") + for route in _CONNECTION_ROUTES: + if route.device_family is None and route.matches( + connection_type, family_info, strict=strict + ): + return route + return None + + +def is_connection_type_supported( + connection_type: DeviceConnectionParameters, + *, + strict: bool = False, +) -> bool: + """Return whether parameters select a constructible device and route.""" + family_info = _get_device_family_info( + connection_type.device_family.value, + https=connection_type.https, + ) + return ( + family_info is not None + and family_info.device_class is not None + and _get_connection_route(connection_type, family_info, strict=strict) + is not None ) - _LOGGER.debug("Finding transport for %s", protocol_transport_key) - supported_device_protocols: dict[ - str, tuple[type[BaseProtocol], type[BaseTransport]] - ] = { - "IOT.XOR": (IotProtocol, XorTransport), - "IOT.KLAP": (IotProtocol, KlapTransport), - "SMART.AES": (SmartProtocol, AesTransport), - "SMART.KLAP": (SmartProtocol, KlapTransportV2), - "SMART.KLAP.HTTPS": (SmartProtocol, KlapTransportV2), - # H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use - # https to distuingish from SmartProtocol devices - "SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport), - } - if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): - return None - protocol_cls, transport_cls = prot_tran_cls - return protocol_cls(transport=transport_cls(config=config)) + +def _get_connection_type_candidates() -> list[DeviceConnectionParameters]: + """Return direct-connect candidates from the supported connection routes.""" + candidates: list[DeviceConnectionParameters] = [] + + for family, family_definition in sorted( + _DEVICE_FAMILIES.items(), key=lambda item: item[0].value + ): + if not family_definition.probe: + continue + for route in _CONNECTION_ROUTES: + family_info = family_definition.for_connection(https=route.https) + if family_info.device_class is None: + continue + if route.device_family is not None: + if route.device_family is not family: + continue + elif route.protocol_type is not family_info.protocol_type: + continue + connection_type = route.create_connection_parameters(family) + if connection_type not in candidates: + candidates.append(connection_type) + + return candidates + + +async def _iter_connection_attempts( + host: str, + *, + port: int | None, + timeout: int | None, + credentials: Credentials | None, + credentials_hash: str | None, + http_client: ClientSession | None, +) -> AsyncIterator[tuple[ConnectAttempt, BaseProtocol, DeviceConfig]]: + """Yield each supported direct connection attempt lazily.""" + for connection_type in _get_connection_type_candidates(): + config = DeviceConfig( + host=host, + connection_type=connection_type, + timeout=timeout, + port_override=port, + credentials=credentials, + credentials_hash=credentials_hash, + http_client=http_client, + ) + device_class = get_device_class_from_family( + connection_type.device_family.value, + https=connection_type.https, + require_exact=True, + ) + if device_class is None: + continue + protocol = get_protocol(config, strict=True) + if protocol is None: + continue + + attempt = ConnectAttempt( + type(protocol), + type(protocol._transport), + device_class, + connection_type.https, + connection_type, + ) + yield attempt, protocol, config + + +async def try_connect_all( + host: str, + *, + port: int | None = None, + timeout: int | None = None, + credentials: Credentials | None = None, + credentials_hash: str | None = None, + http_client: ClientSession | None = None, + on_attempt: OnConnectAttemptCallable | None = None, +) -> Device | None: + """Try each distinct supported connection route for a known host.""" + async for attempt, protocol, config in _iter_connection_attempts( + host, + port=port, + timeout=timeout, + credentials=credentials, + credentials_hash=credentials_hash, + http_client=http_client, + ): + try: + _LOGGER.debug("Trying to connect with %s", protocol.__class__.__name__) + device = await _connect(config, protocol) + except Exception as ex: + _LOGGER.debug( + "Unable to connect with %s: %s", + protocol.__class__.__name__, + ex, + ) + try: + if on_attempt is not None: + on_attempt(attempt, False) + finally: + await protocol.close() + except BaseException: + await protocol.close() + raise + else: + try: + if on_attempt is not None: + on_attempt(attempt._replace(device=type(device)), True) + except BaseException: + await protocol.close() + raise + _LOGGER.debug("Found working protocol %s", protocol.__class__.__name__) + return device + return None diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index ff0fbf8fe..1dc59cad6 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -3,9 +3,9 @@ If you are connecting to a newer KASA or TAPO device you can get the device via discovery or connect directly with :class:`DeviceConfig`. -Discovery returns a list of discovered devices: +Discovery returns a dictionary of discovered devices keyed by IP address: ->>> from kasa import Discover, Device +>>> from kasa import Device, DeviceConfig, Discover >>> device = await Discover.discover_single( ... "127.0.0.3", ... username="user@example.com", @@ -22,7 +22,7 @@ : {'device_family': 'SMART.TAPOBULB', 'encryption_type': 'KLAP', 'login_version': 2, \ 'https': False, 'http_port': 80}} ->>> later_device = await Device.connect(config=Device.Config.from_dict(config_dict)) +>>> later_device = await Device.connect(config=DeviceConfig.from_dict(config_dict)) >>> print(later_device.alias) # Alias is available as connect() calls update() Living Room Bulb @@ -65,7 +65,7 @@ class DeviceEncryptionType(Enum): class DeviceFamily(Enum): - """Encrypt type enum.""" + """Supported device-family identifiers advertised by discovery.""" IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH" IotSmartBulb = "IOT.SMARTBULB" @@ -94,13 +94,20 @@ class Config(BaseConfig): @dataclass class DeviceConnectionParameters(_DeviceConfigBaseMixin): - """Class to hold the the parameters determining connection type.""" + """Parameters that select a device protocol and transport.""" + #: Device-family identifier advertised by discovery device_family: DeviceFamily + #: Encryption scheme advertised by discovery encryption_type: DeviceEncryptionType + #: Login version advertised by discovery, when present login_version: int | None = None + #: Whether the transport uses HTTPS https: bool = False + #: HTTP port advertised by discovery, when present http_port: int | None = None + #: IOT KLAP handshake version advertised as ``new_klap``, when present + klap_version: int | None = None @staticmethod def from_values( @@ -110,6 +117,7 @@ def from_values( login_version: int | None = None, https: bool | None = None, http_port: int | None = None, + klap_version: int | None = None, ) -> DeviceConnectionParameters: """Return connection parameters from string values.""" try: @@ -121,6 +129,7 @@ def from_values( login_version, https, http_port=http_port, + klap_version=klap_version, ) except (ValueError, TypeError) as ex: raise KasaException( @@ -131,24 +140,24 @@ def from_values( @dataclass class DeviceConfig(_DeviceConfigBaseMixin): - """Class to represent paramaters that determine how to connect to devices.""" + """Class to represent parameters that determine how to connect to devices.""" DEFAULT_TIMEOUT = 5 #: IP address or hostname host: str #: Timeout for querying the device timeout: int | None = DEFAULT_TIMEOUT - #: Override the default 9999 port to support port forwarding + #: Override the protocol's default connection port to support port forwarding port_override: int | None = None #: Credentials for devices requiring authentication credentials: Credentials | None = None #: Credentials hash for devices requiring authentication. - #: If credentials are also supplied they take precendence over credentials_hash. + #: If credentials are also supplied they take precedence over credentials_hash. #: Credentials hash can be retrieved from :attr:`Device.credentials_hash` credentials_hash: str | None = None - #: The protocol specific type of connection. Defaults to the legacy type. + #: The batch size for protocols supporting multiple request batches. batch_size: int | None = None - #: The batch size for protoools supporting multiple request batches. + #: The protocol-specific connection type. Defaults to IOT over XOR. connection_type: DeviceConnectionParameters = field( default_factory=lambda: DeviceConnectionParameters( DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor diff --git a/kasa/discover.py b/kasa/discover.py index 911c005c9..6e11d51a2 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -2,27 +2,27 @@ The main entry point for this library is :meth:`Discover.discover()`, which returns a dictionary of the found devices. The key is the IP address -of the device and the value contains ready-to-use, SmartDevice-derived -device object. +of the device and the value is a :class:`Device` instance initialized from +the selected discovery response. :meth:`discover_single()` can be used to initialize a single device given its IP address. If the :class:`DeviceConfig` of the device is already known, you can initialize the corresponding device class directly without discovery. -The protocol uses UDP broadcast datagrams on port 9999 and 20002 for discovery. -Legacy devices support discovery on port 9999 and newer devices on 20002. +Discovery queries UDP port 9999 and TDP ports 20002 and 20004. If a host +responds through both UDP and TDP, the TDP response is used. -Newer devices that respond on port 20002 will most likely require TP-Link cloud -credentials to be passed if queries or updates are to be performed on the returned -devices. +Devices that respond to TDP discovery will most likely require TP-Link cloud +credentials to be passed if queries or updates are to be performed on the +returned devices. Discovery returns a dict of {ip: discovered devices}: >>> from kasa import Discover, Credentials >>> >>> found_devices = await Discover.discover() ->>> [dev.model for dev in found_devices.values()] -['KP303', 'HS110', 'L530E', 'KL430', 'HS220', 'H200'] +>>> sorted(dev.model for dev in found_devices.values()) +['H200', 'HS110', 'HS220', 'KL430', 'KP303', 'L530E'] You can pass username and password for devices requiring authentication @@ -58,17 +58,21 @@ >>> dev.alias 'Living Room Bulb' -It is also possible to pass a coroutine to be executed for each found device: +It is also possible to pass a coroutine to be executed for each found device. +Callbacks can complete in any order: ->>> async def print_dev_info(dev): +>>> discovered = [] +>>> async def collect_dev_info(dev): ... await dev.update() -... print(f"Discovered {dev.alias} (model: {dev.model})") +... discovered.append(f"Discovered {dev.alias} (model: {dev.model})") >>> ->>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds) -Discovered Bedroom Power Strip (model: KP303) +>>> devices = await Discover.discover(on_discovered=collect_dev_info, credentials=creds) +>>> for info in sorted(discovered): +... print(info) Discovered Bedroom Lamp Plug (model: HS110) -Discovered Living Room Bulb (model: L530) Discovered Bedroom Lightstrip (model: KL430) +Discovered Bedroom Power Strip (model: KP303) +Discovered Living Room Bulb (model: L530) Discovered Living Room Dimmer Switch (model: HS220) Discovered Tapo Hub (model: H200) @@ -85,6 +89,7 @@ import asyncio import base64 import binascii +import builtins import ipaddress import logging import secrets @@ -93,13 +98,16 @@ from asyncio import timeout as asyncio_timeout from asyncio.transports import DatagramTransport from collections.abc import Callable, Coroutine -from dataclasses import dataclass +from copy import deepcopy +from dataclasses import dataclass, field +from enum import Enum from pprint import pformat as pf from typing import ( TYPE_CHECKING, Annotated, Any, - NamedTuple, + NotRequired, + Protocol, TypedDict, cast, ) @@ -111,21 +119,24 @@ from kasa import Device from kasa.credentials import Credentials from kasa.device_factory import ( - get_device_class_from_family, - get_device_class_from_sys_info, - get_protocol, + OnConnectAttemptCallable, + create_device, + try_connect_all, ) from kasa.deviceconfig import ( DeviceConfig, DeviceConnectionParameters, DeviceEncryptionType, + DeviceFamily, ) from kasa.exceptions import ( + AuthenticationError, + DiscoveryAuthenticationError, KasaException, TimeoutError, UnsupportedDeviceError, ) -from kasa.iot.iotdevice import IotDevice, _extract_sys_info +from kasa.iot.iotdevice import extract_sys_info from kasa.json import DataClassJSONMixin from kasa.json import dumps as json_dumps from kasa.json import loads as json_loads @@ -136,29 +147,17 @@ _LOGGER = logging.getLogger(__name__) -if TYPE_CHECKING: - from kasa import BaseProtocol - from kasa.transports import BaseTransport - - -class ConnectAttempt(NamedTuple): - """Try to connect attempt.""" - - protocol: type - transport: type - device: type - https: bool - class DiscoveredMeta(TypedDict): - """Meta info about discovery response.""" + """Identify the address and discovery method that produced a raw response.""" ip: str port: int + source: NotRequired[str] class DiscoveredRaw(TypedDict): - """Try to connect attempt.""" + """Decoded response supplied to a raw discovery callback.""" meta: DiscoveredMeta discovery_response: dict @@ -167,16 +166,214 @@ class DiscoveredRaw(TypedDict): OnDiscoveredCallable = Callable[[Device], Coroutine] OnDiscoveredRawCallable = Callable[[DiscoveredRaw], None] OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Coroutine] -OnConnectAttemptCallable = Callable[[ConnectAttempt, bool], None] +OnAuthenticationErrorCallable = Callable[[DiscoveryAuthenticationError], Coroutine] DeviceDict = dict[str, Device] + +class _DiscoverySource(Enum): + """Discovery methods supported by the library.""" + + Udp = "udp" + Tdp = "tdp" + + +def select_discovery_response(responses: list[DiscoveredRaw]) -> DiscoveredRaw: + """Prefer a TDP response, otherwise return the first decoded response.""" + if not responses: + raise KasaException("No decoded discovery responses available") + + def get_source(response: DiscoveredRaw) -> str: + meta = response["meta"] + if source := meta.get("source"): + return source + return ( + _DiscoverySource.Tdp.value + if meta["port"] in (Discover.DISCOVERY_PORT_2, Discover.DISCOVERY_PORT_3) + else _DiscoverySource.Udp.value + ) + + tdp_responses = [ + response + for response in responses + if get_source(response) == _DiscoverySource.Tdp.value + ] + if not tdp_responses: + return responses[0] + + tdp_ports = {response["meta"]["port"] for response in tdp_responses} + if len(tdp_ports) > 1: + first = tdp_responses[0] + _LOGGER.warning( + "Host %s unexpectedly produced responses on multiple TDP ports; " + "preserving the first endpoint response", + first["meta"]["ip"], + ) + return tdp_responses[0] + + +@dataclass(frozen=True) +class _DiscoveryConnection: + """Connection information advertised by a discovery response.""" + + device_family: str + encryption_type: str + login_version: int | None = None + klap_version: int | None = None + https: bool = False + http_port: int | None = None + + @classmethod + def from_tdp_result( + cls, + discovery_result: DiscoveryResult, + ) -> _DiscoveryConnection: + """Extract normalized connection information from a TDP result.""" + if (encrypt_scheme := discovery_result.mgt_encrypt_schm) is None: + raise UnsupportedDeviceError( + f"Unsupported device {discovery_result.ip} of type " + f"{discovery_result.device_type} with no mgt_encrypt_schm", + discovery_result=discovery_result.to_dict(), + host=discovery_result.ip, + ) + + encrypt_type = encrypt_scheme.encrypt_type + if not encrypt_type and (encrypt_info := discovery_result.encrypt_info): + encrypt_type = encrypt_info.sym_schm + + login_version = encrypt_scheme.lv + if not login_version and (supported_types := discovery_result.encrypt_type): + try: + login_version = max(int(value) for value in supported_types) + except ValueError as ex: + raise UnsupportedDeviceError( + f"Unsupported login versions for {discovery_result.ip}: " + f"{supported_types}", + discovery_result=discovery_result.to_dict(), + host=discovery_result.ip, + ) from ex + + if not encrypt_type: + raise UnsupportedDeviceError( + f"Unsupported device {discovery_result.ip} of type " + f"{discovery_result.device_type} with no encryption type", + discovery_result=discovery_result.to_dict(), + host=discovery_result.ip, + ) + + # new_klap selects an IOT KLAP handshake variant. It is independent + # from the advertised login version and is not used to select SMART + # KLAP, which already has its own family-specific route. + klap_version = ( + encrypt_scheme.new_klap or None + if discovery_result.device_type.startswith("IOT.") + and encrypt_type == DeviceEncryptionType.Klap.value + else None + ) + + return cls( + device_family=discovery_result.device_type, + encryption_type=encrypt_type, + login_version=login_version, + klap_version=klap_version, + https=encrypt_scheme.is_support_https, + http_port=encrypt_scheme.http_port, + ) + + def to_connection_parameters(self) -> DeviceConnectionParameters: + """Create public connection parameters from advertised values.""" + return DeviceConnectionParameters.from_values( + self.device_family, + self.encryption_type, + login_version=self.login_version, + klap_version=self.klap_version, + https=self.https, + http_port=self.http_port, + ) + + +@dataclass(frozen=True) +class _DiscoveryCandidate: + """Normalized result returned by a discovery method.""" + + source: _DiscoverySource + source_port: int + ip: str + device_family: str + device_model: str | None + connection: _DiscoveryConnection + discovery_info: dict[str, Any] + device_info: dict[str, Any] | None = None + discovery_result: DiscoveryResult | None = None + + +@dataclass(frozen=True) +class _DiscoveryResponseError: + """Error produced while processing one discovery response.""" + + source: _DiscoverySource + source_port: int + error: KasaException + + +@dataclass +class _DiscoveryEndpointState: + """Response collection state for one host and discovery endpoint.""" + + deferred_datagrams: list[bytes] = field(default_factory=list) + response_errors: list[_DiscoveryResponseError] = field(default_factory=list) + first_decoded_response: dict[str, Any] | None = None + candidate: _DiscoveryCandidate | None = None + raw_emitted: bool = False + + +class _DiscoveryMethod(Protocol): + """Internal contract implemented by one discovery endpoint.""" + + source: _DiscoverySource + port: int + defer_processing: bool + suppresses: frozenset[_DiscoverySource] + + def create_query(self) -> bytes: + """Create the query sent to this endpoint.""" + + def parse_response(self, data: bytes, ip: str) -> dict[str, Any]: + """Decode a response from this endpoint.""" + + def create_candidate( + self, + info: dict[str, Any], + ip: str, + port: int, + ) -> _DiscoveryCandidate: + """Normalize a decoded response.""" + + +@dataclass +class _DiscoveryHostState: + """Collection, resolution, and publication state for one host.""" + + endpoints: dict[int, _DiscoveryEndpointState] = field(default_factory=dict) + suppressed_sources: set[_DiscoverySource] = field(default_factory=set) + # Devices belong to one TDP endpoint population. This records that endpoint + # so an unexpected same-IP response on the other port cannot merge state. + tdp_port: int | None = None + ignored_tdp_ports: set[int] = field(default_factory=set) + processing_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + device: Device | None = None + unsupported_error: UnsupportedDeviceError | None = None + authentication_error: DiscoveryAuthenticationError | None = None + invalid_error: KasaException | None = None + outcome_finalized: bool = False + + DECRYPTED_REDACTORS: dict[str, Callable[[Any], Any] | None] = { "connect_ssid": lambda x: "#MASKED_SSID#" if x else "", "device_id": lambda x: "REDACTED_" + x[9::], "owner": lambda x: "REDACTED_" + x[9::], } -NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { +TDP_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = { "device_id": lambda x: "REDACTED_" + x[9::], "device_name": lambda x: "#MASKED_NAME#" if x else "", "owner": lambda x: "REDACTED_" + x[9::], @@ -189,17 +386,22 @@ class DiscoveredRaw(TypedDict): "decrypted_data": lambda x: redact_data(x, DECRYPTED_REDACTORS), } +# Compatibility name used by devtools and external callers before the source +# was explicitly named TDP discovery. +NEW_DISCOVERY_REDACTORS = TDP_DISCOVERY_REDACTORS + class _AesDiscoveryQuery: - keypair: KeyPair | None = None + """Create a TDP discovery query and retain its response keypair.""" - @classmethod - def generate_query(cls) -> bytearray: - if not cls.keypair: - cls.keypair = KeyPair.create_key_pair(key_size=2048) + def __init__(self) -> None: + self.keypair = KeyPair.create_key_pair(key_size=2048) + + def generate_query(self) -> bytearray: + """Generate a TDP discovery query for this discovery operation.""" secret = secrets.token_bytes(4) - key_payload = {"params": {"rsa_key": cls.keypair.get_public_pem().decode()}} + key_payload = {"params": {"rsa_key": self.keypair.get_public_pem().decode()}} key_payload_bytes = json_dumps(key_payload).encode() # https://labs.withsecure.com/advisories/tp-link-ac1750-pwn2own-2019 @@ -250,8 +452,10 @@ def __init__( discovery_timeout: int = 5, interface: str | None = None, on_unsupported: OnUnsupportedCallable | None = None, + on_authentication_error: OnAuthenticationErrorCallable | None = None, port: int | None = None, credentials: Credentials | None = None, + credentials_hash: str | None = None, timeout: int | None = None, ) -> None: self.transport: DatagramTransport | None = None @@ -261,45 +465,105 @@ def __init__( self.port = port self.discovery_port = port or Discover.DISCOVERY_PORT + if self.discovery_port in { + Discover.DISCOVERY_PORT_2, + Discover.DISCOVERY_PORT_3, + }: + raise KasaException( + f"UDP discovery port {self.discovery_port} is reserved for " + "TDP discovery" + ) self.target = target - self.target_1 = (target, self.discovery_port) - self.target_2 = (target, Discover.DISCOVERY_PORT_2) - self.target_3 = (target, Discover.DISCOVERY_PORT_3) + + self._discoveries: tuple[_DiscoveryMethod, ...] = ( + _UdpDiscovery(self.discovery_port), + # These are independent endpoints for separate device populations. + _TdpDiscovery(Discover.DISCOVERY_PORT_2), + _TdpDiscovery(Discover.DISCOVERY_PORT_3), + ) + self._discovery_by_port = { + discovery.port: discovery for discovery in self._discoveries + } + self._target_complete = asyncio.Event() self.discovered_devices = {} - self.unsupported_device_exceptions: dict = {} - self.invalid_device_exceptions: dict = {} + self.unsupported_device_exceptions: dict[str, UnsupportedDeviceError] = {} + self.authentication_exceptions: dict[str, DiscoveryAuthenticationError] = {} + self.invalid_device_exceptions: dict[str, KasaException] = {} + self._hosts: dict[str, _DiscoveryHostState] = {} + self._processing_tasks: list[asyncio.Task] = [] + self._processed_task_count = 0 self.on_unsupported = on_unsupported + self.on_authentication_error = on_authentication_error self.on_discovered_raw = on_discovered_raw self.credentials = credentials + self.credentials_hash = credentials_hash self.timeout = timeout self.discovery_timeout = discovery_timeout - self.seen_hosts: set[str] = set() self.discover_task: asyncio.Task | None = None self.callback_tasks: list[asyncio.Task] = [] - self.target_discovered: bool = False + self._processed_callback_task_count = 0 self._started_event = asyncio.Event() + self._accepting_responses = True def _run_callback_task(self, coro: Coroutine) -> None: task: asyncio.Task = asyncio.create_task(coro) self.callback_tasks.append(task) + def _run_processing_task(self, ip: str, port: int) -> None: + """Schedule processing for an accepted immediate candidate.""" + task = asyncio.create_task(self._process_candidate(ip, port)) + self._processing_tasks.append(task) + + async def _wait_for_processing(self) -> None: + """Wait for all candidate-processing tasks scheduled so far.""" + while self._processed_task_count < len(self._processing_tasks): + tasks = self._processing_tasks[self._processed_task_count :] + self._processed_task_count = len(self._processing_tasks) + await asyncio.gather(*tasks) + + async def _wait_for_callbacks(self) -> None: + """Wait once for every callback task, including completed tasks.""" + while self._processed_callback_task_count < len(self.callback_tasks): + tasks = self.callback_tasks[self._processed_callback_task_count :] + self._processed_callback_task_count = len(self.callback_tasks) + await asyncio.gather(*tasks) + + async def cancel_pending_tasks(self) -> None: + """Cancel and await discovery-owned processing and callback tasks.""" + tasks = [*self._processing_tasks, *self.callback_tasks] + if self.discover_task is not None and not self.discover_task.done(): + tasks.append(self.discover_task) + for task in tasks: + if not task.done(): + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + async def close_discovered_devices(self) -> None: + """Close all device protocols owned by an aborted discovery call.""" + await asyncio.gather( + *(device.protocol.close() for device in self.discovered_devices.values()), + return_exceptions=True, + ) + async def wait_for_discovery_to_complete(self) -> None: """Wait for the discovery task to complete.""" # Give some time for connection_made event to be received async with asyncio_timeout(self.DISCOVERY_START_TIMEOUT): await self._started_event.wait() - try: - if TYPE_CHECKING: - assert isinstance(self.discover_task, asyncio.Task) - - await self.discover_task - except asyncio.CancelledError: - # if target_discovered then cancel was called internally - if not self.target_discovered: - raise - # Wait for any pending callbacks to complete - await asyncio.gather(*self.callback_tasks) + if TYPE_CHECKING: + assert isinstance(self.discover_task, asyncio.Task) + + await self.discover_task + # Freeze the receive set before awaiting any final device creation. A + # datagram arriving during finalization must not mutate the independent + # source results or race host-level authority selection. + self._accepting_responses = False + await self._finalize_discovery() + # A callback can synchronously invoke an error callback, so drain until + # no newly scheduled callback tasks remain. + await self._wait_for_callbacks() def connection_made(self, transport: DatagramTransport) -> None: # type: ignore[override] """Set socket options for broadcasting.""" @@ -323,19 +587,35 @@ def connection_made(self, transport: DatagramTransport) -> None: # type: ignore async def do_discover(self) -> None: """Send number of discovery datagrams.""" - req = json_dumps(Discover.DISCOVERY_QUERY) - _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) - encrypted_req = XorEncryption.encrypt(req) sleep_between_packets = self.discovery_timeout / self.discovery_packets - aes_discovery_query = _AesDiscoveryQuery.generate_query() + queries = [ + (discovery, discovery.create_query()) for discovery in self._discoveries + ] + for discovery, _ in queries: + _LOGGER.debug( + "[DISCOVERY] %s >> %s/%s", + self.target, + discovery.source.value, + discovery.port, + ) for _ in range(self.discovery_packets): - if self.target in self.seen_hosts: # Stop sending for discover_single - break - self.transport.sendto(encrypted_req[4:], self.target_1) # type: ignore - self.transport.sendto(aes_discovery_query, self.target_2) # type: ignore - self.transport.sendto(aes_discovery_query, self.target_3) # type: ignore - await asyncio.sleep(sleep_between_packets) + for discovery, query in queries: + self.transport.sendto( # type: ignore[union-attr] + query, (self.target, discovery.port) + ) + # Let immediate TDP processing publish a verified target before + # another packet round begins, including when the timeout is zero. + await asyncio.sleep(0) + if self._target_complete.is_set(): + return + try: + async with asyncio_timeout(sleep_between_packets): + await self._target_complete.wait() + except builtins.TimeoutError: + pass + else: + return def datagram_received( self, @@ -343,66 +623,457 @@ def datagram_received( addr: tuple[str, int], ) -> None: """Handle discovery responses.""" - if TYPE_CHECKING: - assert _AesDiscoveryQuery.keypair + if not self._accepting_responses: + return + source_ip, port = addr + if (discovery := self._discovery_by_port.get(port)) is None: + return + try: + ip = str(ipaddress.ip_address(source_ip)) + except ValueError: + _LOGGER.debug( + "[DISCOVERY] Unable to normalize source address %s", source_ip + ) + ip = source_ip + host_state = self._hosts.setdefault(ip, _DiscoveryHostState()) - ip, port = addr - # Prevent multiple entries due multiple broadcasts - if ip in self.seen_hosts: + if discovery.source in host_state.suppressed_sources: return - self.seen_hosts.add(ip) - device: Device | None = None + if discovery.suppresses: + host_state.suppressed_sources.update(discovery.suppresses) + for endpoint_port, endpoint_state in host_state.endpoints.items(): + endpoint_method = self._discovery_by_port[endpoint_port] + if endpoint_method.source in discovery.suppresses: + endpoint_state.deferred_datagrams.clear() + + if discovery.source is _DiscoverySource.Tdp: + if host_state.tdp_port is None: + host_state.tdp_port = port + elif host_state.tdp_port != port: + if port not in host_state.ignored_tdp_ports: + _LOGGER.warning( + "Host %s unexpectedly responded on TDP ports %s and %s; " + "ignoring the second endpoint response", + ip, + host_state.tdp_port, + port, + ) + host_state.ignored_tdp_ports.add(port) + return - config = DeviceConfig(host=ip, port_override=self.port) - if self.credentials: - config.credentials = self.credentials - if self.timeout: - config.timeout = self.timeout + if host_state.outcome_finalized: + return + + endpoint_state = host_state.endpoints.setdefault( + port, _DiscoveryEndpointState() + ) + + if discovery.defer_processing: + # Deferred datagrams remain opaque until the receive window closes. + # An immediate method can suppress them without triggering parsing, + # normalization, callbacks, or device construction. + endpoint_state.deferred_datagrams.append(data) + return + + self._process_datagram(data, ip, port, discovery) + + def _process_datagram( + self, + data: bytes, + ip: str, + port: int, + discovery: _DiscoveryMethod, + ) -> None: + """Decode and normalize one discovery datagram.""" + host_state = self._hosts[ip] + if host_state.outcome_finalized: + return + endpoint_state = host_state.endpoints.setdefault( + port, _DiscoveryEndpointState() + ) + if endpoint_state.candidate is not None: + return try: - if port == self.discovery_port: - json_func = Discover._get_discovery_json_legacy - device_func = Discover._get_device_instance_legacy - elif port in (Discover.DISCOVERY_PORT_2, Discover.DISCOVERY_PORT_3): - json_func = Discover._get_discovery_json - device_func = Discover._get_device_instance - else: - return - info = json_func(data, ip) - if self.on_discovered_raw is not None: - self.on_discovered_raw( - { - "discovery_response": info, - "meta": {"ip": ip, "port": port}, - } + info = discovery.parse_response(data, ip) + except KasaException as ex: + _LOGGER.debug( + "[DISCOVERY] Unable to parse response from %s on port %s " + "(%s bytes): %s", + ip, + port, + len(data), + ex, + ) + endpoint_state.response_errors.append( + _DiscoveryResponseError( + source=discovery.source, + source_port=port, + error=ex, ) - device = device_func(info, config) - except UnsupportedDeviceError as udex: - _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) - self.unsupported_device_exceptions[ip] = udex - if self.on_unsupported is not None: - self._run_callback_task(self.on_unsupported(udex)) - self._handle_discovered_event() + ) + return + + if endpoint_state.first_decoded_response is None: + endpoint_state.first_decoded_response = info + + try: + candidate = discovery.create_candidate(info, ip, port) + except UnsupportedDeviceError as ex: + _LOGGER.debug( + "[DISCOVERY] Unsupported response from %s on port %s: %s", + ip, + port, + ex, + ) + endpoint_state.response_errors.append( + _DiscoveryResponseError( + source=discovery.source, + source_port=port, + error=ex, + ) + ) return except KasaException as ex: - _LOGGER.debug("[DISCOVERY] Unable to find device type for %s: %s", ip, ex) - self.invalid_device_exceptions[ip] = ex - self._handle_discovered_event() + _LOGGER.debug( + "[DISCOVERY] Unable to normalize response from %s on port %s: %s", + ip, + port, + ex, + ) + endpoint_state.response_errors.append( + _DiscoveryResponseError( + source=discovery.source, + source_port=port, + error=ex, + ) + ) return - self.discovered_devices[ip] = device + endpoint_state.candidate = candidate + self._emit_raw_response(ip, discovery, endpoint_state, info) + if not discovery.defer_processing: + self._run_processing_task(ip, port) + + def _emit_raw_response( + self, + ip: str, + discovery: _DiscoveryMethod, + endpoint_state: _DiscoveryEndpointState, + info: dict[str, Any], + ) -> None: + """Emit one representative decoded response for an endpoint.""" + if self.on_discovered_raw is None or endpoint_state.raw_emitted: + return + endpoint_state.raw_emitted = True + self.on_discovered_raw( + { + "discovery_response": deepcopy(info), + "meta": { + "ip": ip, + "port": discovery.port, + "source": discovery.source.value, + }, + } + ) + + def _emit_diagnostic_raw_response( + self, + ip: str, + discovery: _DiscoveryMethod, + endpoint_state: _DiscoveryEndpointState, + ) -> None: + """Emit the first decoded response when an endpoint had no usable result.""" + if endpoint_state.first_decoded_response is not None: + self._emit_raw_response( + ip, + discovery, + endpoint_state, + endpoint_state.first_decoded_response, + ) + + async def _finalize_discovery(self) -> None: + """Process unsuppressed UDP datagrams and finalize remaining hosts.""" + self._accepting_responses = False + await self._wait_for_processing() + # Iterate a stable snapshot. datagram_received is disabled before this + # method is called, and processing tasks scheduled from accepted + # datagrams have been drained above. + for ip, host_state in list(self._hosts.items()): + if host_state.outcome_finalized: + continue + + if host_state.tdp_port is not None: + port = host_state.tdp_port + discovery = self._discovery_by_port[port] + endpoint_state = host_state.endpoints[port] + if endpoint_state.candidate is not None: + await self._process_candidate(ip, port) + else: + self._emit_diagnostic_raw_response(ip, discovery, endpoint_state) + if response_error := self._select_response_error(endpoint_state): + self._record_response_error(ip, response_error.error) + else: # pragma: no cover - every datagram has an outcome + self._record_invalid( + ip, + KasaException( + f"No usable TDP discovery response received from {ip}" + ), + ) + continue + + for port, endpoint_state in host_state.endpoints.items(): + discovery = self._discovery_by_port[port] + if discovery.source in host_state.suppressed_sources: + endpoint_state.deferred_datagrams.clear() + continue + for data in endpoint_state.deferred_datagrams: + self._process_datagram(data, ip, port, discovery) + if endpoint_state.candidate is not None: + break + endpoint_state.deferred_datagrams.clear() + + if endpoint_state.candidate is not None: + await self._process_candidate(ip, port) + break + + self._emit_diagnostic_raw_response(ip, discovery, endpoint_state) + if response_error := self._select_response_error(endpoint_state): + self._record_response_error(ip, response_error.error) + break + + async def _process_candidate(self, ip: str, port: int) -> None: + """Create a device from the accepted candidate for one endpoint.""" + host_state = self._hosts[ip] + async with host_state.processing_lock: + if host_state.outcome_finalized: + return + + candidate = host_state.endpoints[port].candidate + if candidate is None: + return + try: + device = await self._create_device(candidate) + except UnsupportedDeviceError as ex: + self._record_unsupported(ip, ex) + except AuthenticationError as ex: + self._record_authentication( + ip, self._as_discovery_authentication_error(ip, ex, candidate) + ) + except KasaException as ex: + self._record_invalid(ip, ex) + else: + self._record_device(ip, device) + + @staticmethod + def _select_response_error( + endpoint_state: _DiscoveryEndpointState, + ) -> _DiscoveryResponseError | None: + """Return the most useful error from repeated endpoint responses.""" + return next( + ( + response_error + for response_error in endpoint_state.response_errors + if isinstance(response_error.error, UnsupportedDeviceError) + ), + endpoint_state.response_errors[0] + if endpoint_state.response_errors + else None, + ) + + def _record_response_error(self, ip: str, ex: KasaException) -> None: + """Classify an endpoint error after response collection completes.""" + if isinstance(ex, UnsupportedDeviceError): + self._record_unsupported(ip, ex) + else: + self._record_invalid(ip, ex) + + async def _on_discovered(self, device: Device) -> None: + """Run the device callback and classify supported callback failures.""" + if TYPE_CHECKING: + assert self.on_discovered is not None + try: + await self.on_discovered(device) + except UnsupportedDeviceError as ex: + if ex.host is None: + ex.host = device.host + if ex.discovery_result is None: + ex.discovery_result = device._discovery_info + self._store_unsupported(device.host, ex, terminal=False) + if self.on_unsupported is None: + raise + await self.on_unsupported(ex) + except AuthenticationError as ex: + discovery_error = self._as_discovery_authentication_error( + device.host, + ex, + discovery_info=device._discovery_info, + ) + self._store_authentication(device.host, discovery_error, terminal=False) + if self.on_authentication_error is None: + raise + await self.on_authentication_error(discovery_error) + + @staticmethod + def _as_discovery_authentication_error( + ip: str, + ex: AuthenticationError, + candidate: _DiscoveryCandidate | None = None, + *, + discovery_info: dict[str, Any] | None = None, + ) -> DiscoveryAuthenticationError: + """Add discovery context to an authentication error.""" + if isinstance(ex, DiscoveryAuthenticationError): + if ex.host is None: + ex.host = ip + if ex.discovery_result is None: + ex.discovery_result = discovery_info or ( + candidate.discovery_info if candidate is not None else None + ) + return ex + if discovery_info is None and candidate is not None: + discovery_info = candidate.discovery_info + discovery_error = DiscoveryAuthenticationError( + *ex.args, + discovery_result=discovery_info, + host=ip, + error_code=ex.error_code, + ) + discovery_error.__cause__ = ex + return discovery_error + + def _record_device(self, ip: str, device: Device) -> None: + """Record and emit one supported device.""" + host_state = self._hosts[ip] + if host_state.outcome_finalized: + return + host_state.outcome_finalized = True + host_state.device = device + self.discovered_devices[ip] = device if self.on_discovered is not None: - self._run_callback_task(self.on_discovered(device)) + self._run_callback_task(self._on_discovered(device)) + if ip == self.target: + self._target_complete.set() - self._handle_discovered_event() + def _record_unsupported(self, ip: str, ex: UnsupportedDeviceError) -> None: + """Record and emit one authoritative unsupported response.""" + if not self._store_unsupported(ip, ex, terminal=True): + return + if self.on_unsupported is not None: + self._run_callback_task(self.on_unsupported(ex)) - def _handle_discovered_event(self) -> None: - """If target is in seen_hosts cancel discover_task.""" - if self.target in self.seen_hosts: - self.target_discovered = True - if self.discover_task: - self.discover_task.cancel() + def _store_unsupported( + self, + ip: str, + ex: UnsupportedDeviceError, + *, + terminal: bool, + ) -> bool: + """Store an unsupported outcome and optionally finalize discovery.""" + host_state = self._hosts[ip] + if host_state.unsupported_error is not None: + return False + if terminal and host_state.outcome_finalized: + return False + _LOGGER.debug("Unsupported device found at %s << %s", ip, ex) + host_state.unsupported_error = ex + host_state.outcome_finalized = host_state.outcome_finalized or terminal + self.unsupported_device_exceptions[ip] = ex + if terminal and ip == self.target: + self._target_complete.set() + return True + + def _record_authentication(self, ip: str, ex: DiscoveryAuthenticationError) -> None: + """Record and emit an authentication-blocked discovery outcome.""" + if not self._store_authentication(ip, ex, terminal=True): + return + if self.on_authentication_error is not None: + self._run_callback_task(self.on_authentication_error(ex)) + + def _store_authentication( + self, + ip: str, + ex: DiscoveryAuthenticationError, + *, + terminal: bool, + ) -> bool: + """Store an authentication outcome and optionally finalize discovery.""" + host_state = self._hosts[ip] + if host_state.authentication_error is not None: + return False + if terminal and host_state.outcome_finalized: + return False + host_state.authentication_error = ex + host_state.outcome_finalized = host_state.outcome_finalized or terminal + self.authentication_exceptions[ip] = ex + if terminal and ip == self.target: + self._target_complete.set() + return True + + def _record_invalid(self, ip: str, ex: KasaException) -> None: + """Record one response that cannot create a device.""" + host_state = self._hosts[ip] + if host_state.outcome_finalized: + return + _LOGGER.debug("[DISCOVERY] Unable to create device for %s: %s", ip, ex) + host_state.outcome_finalized = True + host_state.invalid_error = ex + self.invalid_device_exceptions[ip] = ex + if ip == self.target: + self._target_complete.set() + + async def _create_device( + self, + candidate: _DiscoveryCandidate, + ) -> Device: + """Create a device from the selected normalized discovery response.""" + config = DeviceConfig(host=candidate.ip, port_override=self.port) + if self.credentials: + config.credentials = self.credentials + if self.credentials_hash: + config.credentials_hash = self.credentials_hash + if self.timeout: + config.timeout = self.timeout + + try: + config.connection_type = candidate.connection.to_connection_parameters() + except KasaException as ex: + raise UnsupportedDeviceError( + f"Unsupported device {candidate.ip} of type " + f"{candidate.device_family} with connection parameters " + f"{candidate.connection}", + discovery_result=candidate.discovery_info, + host=candidate.ip, + ) from ex + + if candidate.discovery_result is not None: + discovery_info = candidate.discovery_result.to_dict() + discovery_info["model"], _, _ = ( + candidate.discovery_result.device_model.partition("(") + ) + else: + discovery_info = candidate.discovery_info + + device = await create_device( + config, + device_info=candidate.device_info, + discovery_info=discovery_info, + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + redactors = ( + TDP_DISCOVERY_REDACTORS + if candidate.source is _DiscoverySource.Tdp + else IOT_REDACTORS + ) + data = ( + redact_data(candidate.discovery_info, redactors) + if Discover._redact_data + else candidate.discovery_info + ) + _LOGGER.debug("[DISCOVERY] %s << %s", candidate.ip, pf(data)) + return device def error_received(self, ex: Exception) -> None: """Handle asyncio.Protocol errors.""" @@ -439,7 +1110,9 @@ async def discover( discovery_packets: int = 3, interface: str | None = None, on_unsupported: OnUnsupportedCallable | None = None, + on_authentication_error: OnAuthenticationErrorCallable | None = None, credentials: Credentials | None = None, + credentials_hash: str | None = None, username: str | None = None, password: str | None = None, port: int | None = None, @@ -447,10 +1120,9 @@ async def discover( ) -> DeviceDict: """Discover supported devices. - Sends discovery message to 255.255.255.255:9999 and - 255.255.255.255:20002 in order - to detect available supported devices in the local network, - and waits for given timeout for answers from devices. + Sends discovery messages to UDP port 9999 and TDP ports 20002 and + 20004, then waits for responses from supported devices. If a host + responds through both UDP and TDP, the TDP response is used. If you have multiple interfaces, you can use *target* parameter to specify the network for discovery. @@ -459,23 +1131,30 @@ async def discover( The results of the discovery are returned as a dict of :class:`Device`-derived objects keyed with IP addresses. - The devices are already initialized and all but emeter-related properties - can be accessed directly. + The devices are initialized from discovery information. Call + :meth:`Device.update` before accessing information not included in the + discovery response. :param target: The target address where to send the broadcast discovery queries if multi-homing (e.g. 192.168.xxx.255). :param on_discovered: coroutine to execute on discovery - :param on_discovered_raw: Optional callback once discovered json is loaded - before any attempt to deserialize it and create devices + :param on_discovered_raw: Optional callback for decoded discovery responses. + At most one response is emitted for each host and endpoint. Callback + metadata identifies the ``udp`` or ``tdp`` source. :param discovery_timeout: Seconds to wait for responses, defaults to 5 :param discovery_packets: Number of discovery packets to broadcast :param interface: Bind to specific interface :param on_unsupported: Optional callback when unsupported devices are discovered + :param on_authentication_error: Optional callback when authentication prevents + discovery from creating or updating a device :param credentials: Credentials for devices that require authentication. username and password are ignored if provided. + :param credentials_hash: Hashed credentials for devices that require + authentication. Explicit credentials take precedence when both are given. :param username: Username for devices that require authentication :param password: Password for devices that require authentication - :param port: Override the discovery port for devices listening on 9999 + :param port: Override the UDP discovery and device connection port. + TDP discovery remains on ports 20002 and 20004. :param timeout: Query timeout in seconds for devices returned by discovery :return: dictionary with discovered devices """ @@ -489,8 +1168,10 @@ async def discover( discovery_packets=discovery_packets, interface=interface, on_unsupported=on_unsupported, + on_authentication_error=on_authentication_error, on_discovered_raw=on_discovered_raw, credentials=credentials, + credentials_hash=credentials_hash, timeout=timeout, discovery_timeout=discovery_timeout, port=port, @@ -502,10 +1183,10 @@ async def discover( try: _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) await protocol.wait_for_discovery_to_complete() - except (KasaException, asyncio.CancelledError) as ex: - for device in protocol.discovered_devices.values(): - await device.protocol.close() - raise ex + except BaseException: + await protocol.cancel_pending_tasks() + await protocol.close_discovered_devices() + raise finally: transport.close() @@ -521,10 +1202,13 @@ async def discover_single( port: int | None = None, timeout: int | None = None, credentials: Credentials | None = None, + credentials_hash: str | None = None, username: str | None = None, password: str | None = None, + on_discovered: OnDiscoveredCallable | None = None, on_discovered_raw: OnDiscoveredRawCallable | None = None, on_unsupported: OnUnsupportedCallable | None = None, + on_authentication_error: OnAuthenticationErrorCallable | None = None, ) -> Device | None: """Discover a single device by the given IP address. @@ -535,16 +1219,23 @@ async def discover_single( :param host: Hostname of device to query :param discovery_timeout: Timeout in seconds for discovery - :param port: Optionally set a different port for legacy devices using port 9999 - :param timeout: Timeout in seconds device for devices queries + :param port: Override the UDP discovery and device connection port. + TDP discovery remains on ports 20002 and 20004. + :param timeout: Query timeout in seconds for the constructed device :param credentials: Credentials for devices that require authentication. username and password are ignored if provided. + :param credentials_hash: Hashed credentials for devices that require + authentication. Explicit credentials take precedence when both are given. :param username: Username for devices that require authentication :param password: Password for devices that require authentication - :param on_discovered_raw: Optional callback once discovered json is loaded - before any attempt to deserialize it and create devices + :param on_discovered: Optional coroutine to execute for the discovered device + :param on_discovered_raw: Optional callback for decoded discovery responses. + At most one response is emitted for each host and endpoint. Callback + metadata identifies the ``udp`` or ``tdp`` source. :param on_unsupported: Optional callback when unsupported devices are discovered - :rtype: SmartDevice + :param on_authentication_error: Optional callback when authentication prevents + discovery from creating a device + :rtype: Device :return: Object for querying/controlling found device. """ if not credentials and username and password: @@ -578,8 +1269,12 @@ async def discover_single( target=ip, port=port, credentials=credentials, + credentials_hash=credentials_hash, timeout=timeout, discovery_timeout=discovery_timeout, + on_discovered=on_discovered, + on_unsupported=on_unsupported, + on_authentication_error=on_authentication_error, on_discovered_raw=on_discovered_raw, ), local_addr=("0.0.0.0", 0), # noqa: S104 @@ -591,6 +1286,10 @@ async def discover_single( "Waiting a total of %s seconds for responses...", discovery_timeout ) await protocol.wait_for_discovery_to_complete() + except BaseException: + await protocol.cancel_pending_tasks() + await protocol.close_discovered_devices() + raise finally: transport.close() @@ -600,10 +1299,13 @@ async def discover_single( return dev elif ip in protocol.unsupported_device_exceptions: if on_unsupported: - await on_unsupported(protocol.unsupported_device_exceptions[ip]) return None else: raise protocol.unsupported_device_exceptions[ip] + elif ip in protocol.authentication_exceptions: + if on_authentication_error: + return None + raise protocol.authentication_exceptions[ip] elif ip in protocol.invalid_device_exceptions: raise protocol.invalid_device_exceptions[ip] else: @@ -616,325 +1318,35 @@ async def try_connect_all( port: int | None = None, timeout: int | None = None, credentials: Credentials | None = None, + credentials_hash: str | None = None, http_client: ClientSession | None = None, on_attempt: OnConnectAttemptCallable | None = None, ) -> Device | None: """Try to connect directly to a device with all possible parameters. - This method can be used when udp is not working due to network issues. - After succesfully connecting use the device config and + This method can be used when broadcast discovery is unavailable. + After successfully connecting use the device config and :meth:`Device.connect()` for future connections. :param host: Hostname of device to query - :param port: Optionally set a different port for legacy devices using port 9999 - :param timeout: Timeout in seconds device for devices queries + :param port: Optionally override the device's connection port + :param timeout: Query timeout in seconds for each connection attempt :param credentials: Credentials for devices that require authentication. - :param http_client: Optional client session for devices that use http. - username and password are ignored if provided. + :param credentials_hash: Hashed credentials for devices that require + authentication. Explicit credentials take precedence when both are given. + :param http_client: Optional client session for devices that use HTTP + :param on_attempt: Optional callback invoked after every attempted route """ - from .device_factory import _connect - - main_device_families = { - Device.Family.SmartTapoPlug, - Device.Family.IotSmartPlugSwitch, - Device.Family.SmartIpCamera, - Device.Family.SmartTapoRobovac, - Device.Family.IotIpCamera, - } - candidates: dict[ - tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool], - tuple[BaseProtocol, DeviceConfig], - ] = { - (type(protocol), type(protocol._transport), device_class, https): ( - protocol, - config, - ) - for encrypt in Device.EncryptionType - for device_family in main_device_families - for https in (True, False) - for login_version in (None, 2) - if ( - conn_params := DeviceConnectionParameters( - device_family=device_family, - encryption_type=encrypt, - login_version=login_version, - https=https, - ) - ) - and ( - config := DeviceConfig( - host=host, - connection_type=conn_params, - timeout=timeout, - port_override=port, - credentials=credentials, - http_client=http_client, - ) - ) - and (protocol := get_protocol(config, strict=True)) - and ( - device_class := get_device_class_from_family( - device_family.value, https=https, require_exact=True - ) - ) - } - for key, val in candidates.items(): - try: - prot, config = val - _LOGGER.debug("Trying to connect with %s", prot.__class__.__name__) - dev = await _connect(config, prot) - except Exception as ex: - _LOGGER.debug( - "Unable to connect with %s: %s", - prot.__class__.__name__, - ex, - ) - if on_attempt: - ca = tuple.__new__(ConnectAttempt, key) - on_attempt(ca, False) - else: - if on_attempt: - ca = tuple.__new__(ConnectAttempt, key) - on_attempt(ca, True) - _LOGGER.debug("Found working protocol %s", prot.__class__.__name__) - return dev - finally: - await prot.close() - return None - - @staticmethod - def _get_device_class(info: dict) -> type[Device]: - """Find SmartDevice subclass for device described by passed data.""" - if "result" in info: - discovery_result = DiscoveryResult.from_dict(info["result"]) - https = ( - discovery_result.mgt_encrypt_schm.is_support_https - if discovery_result.mgt_encrypt_schm - else False - ) - dev_class = get_device_class_from_family( - discovery_result.device_type, https=https - ) - if not dev_class: - raise UnsupportedDeviceError( - f"Unknown device type: {discovery_result.device_type}", - discovery_result=info, - ) - return dev_class - else: - return get_device_class_from_sys_info(info) - - @staticmethod - def _get_discovery_json_legacy(data: bytes, ip: str) -> dict: - """Get discovery json from legacy 9999 response.""" - try: - info = json_loads(XorEncryption.decrypt(data)) - except Exception as ex: - raise KasaException( - f"Unable to read response from device: {ip}: {ex}" - ) from ex - return info - - @staticmethod - def _get_device_instance_legacy(info: dict, config: DeviceConfig) -> Device: - """Get IotDevice from legacy 9999 response.""" - if _LOGGER.isEnabledFor(logging.DEBUG): - data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) - - device_class = cast(type[IotDevice], Discover._get_device_class(info)) - device = device_class(config.host, config=config) - sys_info = _extract_sys_info(info) - device_type = sys_info.get("mic_type", sys_info.get("type")) - if device_type is None: - raise UnsupportedDeviceError("type nor mic_type found in sysinfo response") - login_version = ( - sys_info.get("stream_version") if device_type == "IOT.IPCAMERA" else None - ) - config.connection_type = DeviceConnectionParameters.from_values( - device_family=device_type, - encryption_type=DeviceEncryptionType.Xor.value, - https=device_type == "IOT.IPCAMERA", - login_version=login_version, - ) - device.protocol = get_protocol(config) # type: ignore[assignment] - device.update_from_discover_info(info) - return device - - @staticmethod - def _decrypt_discovery_data(discovery_result: DiscoveryResult) -> None: - debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) - if TYPE_CHECKING: - assert discovery_result.encrypt_info - assert _AesDiscoveryQuery.keypair - encryped_key = discovery_result.encrypt_info.key - encrypted_data = discovery_result.encrypt_info.data - - key_and_iv = _AesDiscoveryQuery.keypair.decrypt_discovery_key( - base64.b64decode(encryped_key.encode()) + return await try_connect_all( + host, + port=port, + timeout=timeout, + credentials=credentials, + credentials_hash=credentials_hash, + http_client=http_client, + on_attempt=on_attempt, ) - key, iv = key_and_iv[:16], key_and_iv[16:] - - session = AesEncyptionSession(key, iv) - decrypted_data = session.decrypt(encrypted_data) - - result = json_loads(decrypted_data) - if debug_enabled: - data = ( - redact_data(result, DECRYPTED_REDACTORS) - if Discover._redact_data - else result - ) - _LOGGER.debug( - "Decrypted encrypt_info for %s: %s", - discovery_result.ip, - pf(data), - ) - discovery_result.decrypted_data = result - - @staticmethod - def _get_discovery_json(data: bytes, ip: str) -> dict: - """Get discovery json from the new 20002 response.""" - try: - info = json_loads(data[16:]) - except Exception as ex: - _LOGGER.debug("Got invalid response from device %s: %s", ip, data) - raise KasaException( - f"Unable to read response from device: {ip}: {ex}" - ) from ex - return info - - @staticmethod - def _get_connection_parameters( - discovery_result: DiscoveryResult, - ) -> DeviceConnectionParameters: - """Get connection parameters from the discovery result.""" - type_ = discovery_result.device_type - if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None: - raise UnsupportedDeviceError( - f"Unsupported device {discovery_result.ip} of type {type_} " - "with no mgt_encrypt_schm", - discovery_result=discovery_result.to_dict(), - host=discovery_result.ip, - ) - - if not (encrypt_type := encrypt_schm.encrypt_type) and ( - encrypt_info := discovery_result.encrypt_info - ): - encrypt_type = encrypt_info.sym_schm - - if not (login_version := encrypt_schm.lv) and ( - et := discovery_result.encrypt_type - ): - # Known encrypt types are ["1","2"] and ["3"] - # Reuse the login_version attribute to pass the max to transport - login_version = max([int(i) for i in et]) - - if not encrypt_type: - raise UnsupportedDeviceError( - f"Unsupported device {discovery_result.ip} of type {type_} " - + "with no encryption type", - discovery_result=discovery_result.to_dict(), - host=discovery_result.ip, - ) - return DeviceConnectionParameters.from_values( - type_, - encrypt_type, - login_version=login_version, - https=encrypt_schm.is_support_https, - http_port=encrypt_schm.http_port, - ) - - @staticmethod - def _get_device_instance( - info: dict, - config: DeviceConfig, - ) -> Device: - """Get SmartDevice from the new 20002 response.""" - debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) - - try: - discovery_result = DiscoveryResult.from_dict(info["result"]) - except Exception as ex: - if debug_enabled: - data = ( - redact_data(info, NEW_DISCOVERY_REDACTORS) - if Discover._redact_data - else info - ) - _LOGGER.debug( - "Unable to parse discovery from device %s: %s", - config.host, - pf(data), - ) - raise UnsupportedDeviceError( - f"Unable to parse discovery from device: {config.host}: {ex}", - host=config.host, - ) from ex - - # Decrypt the data - if ( - encrypt_info := discovery_result.encrypt_info - ) and encrypt_info.sym_schm == "AES": - try: - Discover._decrypt_discovery_data(discovery_result) - except Exception: - _LOGGER.exception( - "Unable to decrypt discovery data %s: %s", - config.host, - redact_data(info, NEW_DISCOVERY_REDACTORS), - ) - type_ = discovery_result.device_type - try: - conn_params = Discover._get_connection_parameters(discovery_result) - config.connection_type = conn_params - except KasaException as ex: - if isinstance(ex, UnsupportedDeviceError): - raise - raise UnsupportedDeviceError( - f"Unsupported device {config.host} of type {type_} " - + f"with encrypt_scheme {discovery_result.mgt_encrypt_schm}", - discovery_result=discovery_result.to_dict(), - host=config.host, - ) from ex - - if ( - device_class := get_device_class_from_family(type_, https=conn_params.https) - ) is None: - _LOGGER.debug("Got unsupported device type: %s", type_) - raise UnsupportedDeviceError( - f"Unsupported device {config.host} of type {type_}: {info}", - discovery_result=discovery_result.to_dict(), - host=config.host, - ) - - if (protocol := get_protocol(config)) is None: - _LOGGER.debug( - "Got unsupported connection type: %s", config.connection_type.to_dict() - ) - raise UnsupportedDeviceError( - f"Unsupported encryption scheme {config.host} of " - + f"type {config.connection_type.to_dict()}: {info}", - discovery_result=discovery_result.to_dict(), - host=config.host, - ) - - if debug_enabled: - data = ( - redact_data(info, NEW_DISCOVERY_REDACTORS) - if Discover._redact_data - else info - ) - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) - - device = device_class(config.host, protocol=protocol) - - di = discovery_result.to_dict() - di["model"], _, _ = discovery_result.device_model.partition("(") - device.update_from_discover_info(di) - return device - class _DiscoveryBaseMixin(DataClassJSONMixin): """Base class for serialization mixin.""" @@ -949,17 +1361,18 @@ class Config(BaseConfig): @dataclass class EncryptionScheme(_DiscoveryBaseMixin): - """Base model for encryption scheme of discovery result.""" + """Connection encryption fields advertised by TDP discovery.""" is_support_https: bool encrypt_type: str | None = None http_port: int | None = None lv: int | None = None + new_klap: int | None = None @dataclass class EncryptionInfo(_DiscoveryBaseMixin): - """Base model for encryption info of discovery result.""" + """Encrypted supplemental information in a TDP discovery response.""" sym_schm: str key: str @@ -968,7 +1381,7 @@ class EncryptionInfo(_DiscoveryBaseMixin): @dataclass class DiscoveryResult(_DiscoveryBaseMixin): - """Base model for discovery result.""" + """Decoded device information returned by TDP discovery.""" device_type: str device_model: str @@ -989,3 +1402,204 @@ class DiscoveryResult(_DiscoveryBaseMixin): is_support_iot_cloud: bool | None = None obd_src: str | None = None factory_default: bool | None = None + + def to_connection_parameters(self) -> DeviceConnectionParameters: + """Convert the advertised TDP fields into connection parameters.""" + return _DiscoveryConnection.from_tdp_result(self).to_connection_parameters() + + +class _UdpDiscovery: + """XOR-encoded UDP discovery implementation for port 9999.""" + + source = _DiscoverySource.Udp + defer_processing = True + suppresses: frozenset[_DiscoverySource] = frozenset() + + def __init__(self, port: int) -> None: + self.port = port + + @staticmethod + def create_query() -> bytes: + """Create an XOR-encoded UDP discovery query.""" + request = json_dumps(Discover.DISCOVERY_QUERY) + return XorEncryption.encrypt(request)[4:] + + @staticmethod + def parse_response(data: bytes, ip: str) -> dict[str, Any]: + """Decode an XOR-encoded UDP discovery response.""" + try: + response = json_loads(XorEncryption.decrypt(data)) + except Exception as ex: + raise KasaException( + f"Unable to read UDP discovery response from device: {ip}: {ex}" + ) from ex + if not isinstance(response, dict): + raise KasaException( + f"UDP discovery response from {ip} did not contain a JSON object" + ) + return response + + def create_candidate( + self, + info: dict[str, Any], + ip: str, + port: int, + ) -> _DiscoveryCandidate: + """Normalize a decoded UDP discovery response.""" + sys_info = extract_sys_info(info) + if not sys_info: + raise KasaException("No 'system' or 'get_sysinfo' in response") + device_family = sys_info.get("mic_type", sys_info.get("type")) + if device_family is None: + raise UnsupportedDeviceError( + "Unable to find the device type field", + discovery_result=info, + host=ip, + ) + login_version = ( + sys_info.get("stream_version") + if device_family == DeviceFamily.IotIpCamera.value + else None + ) + connection = _DiscoveryConnection( + device_family=device_family, + encryption_type=DeviceEncryptionType.Xor.value, + login_version=login_version, + https=device_family == DeviceFamily.IotIpCamera.value, + ) + return _DiscoveryCandidate( + source=self.source, + source_port=port, + ip=ip, + device_family=device_family, + device_model=sys_info.get("model"), + connection=connection, + discovery_info=info, + device_info=info, + ) + + +class _TdpDiscovery: + """TDP v2 discovery for one independently managed endpoint population.""" + + source = _DiscoverySource.Tdp + defer_processing = False + suppresses = frozenset({_DiscoverySource.Udp}) + + def __init__(self, port: int) -> None: + self.port = port + self._query = _AesDiscoveryQuery() + + def create_query(self) -> bytes: + """Create a TDP discovery query for this discovery operation.""" + return bytes(self._query.generate_query()) + + @staticmethod + def parse_response(data: bytes, ip: str) -> dict[str, Any]: + """Decode a TDP discovery response.""" + try: + response = json_loads(data[16:]) + except Exception as ex: + raise KasaException( + f"Unable to read TDP discovery response from device: {ip}: {ex}" + ) from ex + if not isinstance(response, dict): + raise KasaException( + f"TDP discovery response from {ip} did not contain a JSON object" + ) + return response + + @staticmethod + def decrypt_discovery_data( + discovery_result: DiscoveryResult, + *, + keypair: KeyPair, + ) -> None: + """Decrypt encrypted supplemental information in a TDP response.""" + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + if TYPE_CHECKING: + assert discovery_result.encrypt_info + encrypted_key = discovery_result.encrypt_info.key + encrypted_data = discovery_result.encrypt_info.data + + key_and_iv = keypair.decrypt_discovery_key( + base64.b64decode(encrypted_key.encode()) + ) + key, iv = key_and_iv[:16], key_and_iv[16:] + session = AesEncyptionSession(key, iv) + decrypted_data = session.decrypt(encrypted_data) + result = json_loads(decrypted_data) + if not isinstance(result, dict): + raise KasaException( + f"Decrypted TDP discovery data from {discovery_result.ip} " + "did not contain a JSON object" + ) + if debug_enabled: + redacted = ( + redact_data(result, DECRYPTED_REDACTORS) + if Discover._redact_data + else result + ) + _LOGGER.debug( + "Decrypted encrypt_info for %s: %s", + discovery_result.ip, + pf(redacted), + ) + discovery_result.decrypted_data = result + + def create_candidate( + self, + info: dict[str, Any], + ip: str, + port: int, + ) -> _DiscoveryCandidate: + """Normalize a decoded TDP discovery response.""" + result = info.get("result") + if not isinstance(result, dict) or not { + "device_type", + "ip", + }.issubset(result): + raise KasaException( + f"Response from {ip} is not a recognizable TDP device result" + ) + try: + discovery_result = DiscoveryResult.from_dict(result) + except Exception as ex: + raise KasaException( + f"Unable to parse discovery from device: {ip}: {ex}", + ) from ex + + if discovery_result.ip != ip: + _LOGGER.debug( + "TDP response source address differs from its advertised address; " + "using source address %s", + ip, + ) + discovery_result.ip = ip + + if ( + encrypt_info := discovery_result.encrypt_info + ) and encrypt_info.sym_schm == "AES": + try: + self.decrypt_discovery_data( + discovery_result, + keypair=self._query.keypair, + ) + except Exception: + _LOGGER.exception( + "Unable to decrypt discovery data %s: %s", + ip, + redact_data(info, TDP_DISCOVERY_REDACTORS), + ) + + connection = _DiscoveryConnection.from_tdp_result(discovery_result) + return _DiscoveryCandidate( + source=self.source, + source_port=port, + ip=ip, + device_family=discovery_result.device_type, + device_model=discovery_result.device_model, + connection=connection, + discovery_info=info, + discovery_result=discovery_result, + ) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 1c764ad7a..0da47fbfb 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -55,6 +55,25 @@ class AuthenticationError(DeviceError): """Base exception for device authentication errors.""" +class UnsupportedAuthenticationError(UnsupportedDeviceError, AuthenticationError): + """Authentication error for a device using unsupported onboarding.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.discovery_result = kwargs.get("discovery_result") + self.host = kwargs.get("host") + self.onboarding_source: str | None = kwargs.get("onboarding_source") + AuthenticationError.__init__(self, *args, **kwargs) + + +class DiscoveryAuthenticationError(AuthenticationError): + """Authentication error that prevented discovery from creating a device.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.discovery_result = kwargs.get("discovery_result") + self.host = kwargs.get("host") + super().__init__(*args, **kwargs) + + class _RetryableError(DeviceError): """Retryable exception for device errors.""" diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index a74e44c40..5aad9d39d 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -22,10 +22,15 @@ from typing import TYPE_CHECKING, Any, cast from warnings import warn -from ..device import Device, DeviceInfo, WifiNetwork +from ..device import ( + Device, + DeviceInfo, + WifiNetwork, + get_unsupported_authentication_error, +) from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..exceptions import KasaException, UnsupportedDeviceError +from ..exceptions import AuthenticationError, KasaException, UnsupportedDeviceError from ..feature import Feature from ..module import Module from ..modulemapping import ModuleMapping, ModuleName @@ -70,12 +75,17 @@ def _parse_features(features: str) -> set[str]: return set(features.split(":")) -def _extract_sys_info(info: dict[str, Any]) -> dict[str, Any]: - """Return the system info structure.""" - sysinfo_default = info.get("system", {}).get("get_sysinfo", {}) - sysinfo_nest = sysinfo_default.get("system", {}) +def extract_sys_info(info: dict[str, Any]) -> dict[str, Any]: + """Return a validated system information structure, if one is present.""" + system = info.get("system") + if not isinstance(system, dict): + return {} + sysinfo_default = system.get("get_sysinfo") + if not isinstance(sysinfo_default, dict): + return {} + sysinfo_nest = sysinfo_default.get("system") - if len(sysinfo_nest) > len(sysinfo_default) and isinstance(sysinfo_nest, dict): + if isinstance(sysinfo_nest, dict) and len(sysinfo_nest) > len(sysinfo_default): return sysinfo_nest return sysinfo_default @@ -304,6 +314,17 @@ async def update(self, update_children: bool = True) -> None: Needed for properties that are decorated with `requires_update`. """ + try: + await self._update_device(update_children) + except AuthenticationError as ex: + if unsupported_error := get_unsupported_authentication_error( + self.host, self._discovery_info, ex + ): + raise unsupported_error from ex + raise + + async def _update_device(self, update_children: bool) -> None: + """Perform the protocol-specific update operation.""" req = {} req.update(self._create_request("system", "get_sysinfo")) @@ -314,14 +335,14 @@ async def update(self, update_children: bool = True) -> None: _LOGGER.debug("Performing the initial update to obtain sysinfo") response = await self.protocol.query(req) self._last_update = response - self._set_sys_info(_extract_sys_info(response)) + self._set_sys_info(extract_sys_info(response)) if not self._modules: await self._initialize_modules() await self._modular_update(req) - self._set_sys_info(_extract_sys_info(self._last_update)) + self._set_sys_info(extract_sys_info(self._last_update)) for module in self._modules.values(): await module._post_update_hook() @@ -444,19 +465,29 @@ async def _modular_update(self, req: dict) -> None: self._supported_modules = supported - def update_from_discover_info(self, info: dict[str, Any]) -> None: - """Update state from info from the discover call.""" + def update_from_discover_info( + self, + info: dict[str, Any], + *, + device_info: dict[str, Any] | None = None, + ) -> None: + """Update state from discovery and optional full device information.""" self._discovery_info = info - if "system" in info and (sys_info := info["system"].get("get_sysinfo")): - self._last_update = info + initialization_info = device_info or info + if sys_info := extract_sys_info(initialization_info): + self._last_update = initialization_info self._set_sys_info(sys_info) else: # This allows setting of some info properties directly # from partial discovery info that will then be found # by the requires_update decorator - discovery_model = info["device_model"] + discovery_model = initialization_info.get("device_model") + if not isinstance(discovery_model, str): + raise KasaException( + "Discovery response contained neither sysinfo nor a device model" + ) no_region_model, _, _ = discovery_model.partition("(") - self._set_sys_info({**info, "model": no_region_model}) + self._set_sys_info({**initialization_info, "model": no_region_model}) def _set_sys_info(self, sys_info: dict[str, Any]) -> None: """Set sys_info.""" @@ -717,13 +748,16 @@ def internal_state(self) -> Any: @staticmethod def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: """Find SmartDevice subclass for device described by passed data.""" - if "system" in info.get("system", {}).get("get_sysinfo", {}): - return DeviceType.Camera - - if "system" not in info or "get_sysinfo" not in info["system"]: + system = info.get("system") + if not isinstance(system, dict): raise KasaException("No 'system' or 'get_sysinfo' in response") + raw_sysinfo = system.get("get_sysinfo") + if not isinstance(raw_sysinfo, dict): + raise KasaException("No 'system' or 'get_sysinfo' in response") + if isinstance(raw_sysinfo.get("system"), dict): + return DeviceType.Camera - sysinfo: dict[str, Any] = _extract_sys_info(info) + sysinfo: dict[str, Any] = extract_sys_info(info) type_: str | None = sysinfo.get("type", sysinfo.get("mic_type")) if type_ is None: raise KasaException("Unable to find the device type field!") @@ -744,19 +778,23 @@ def _get_device_type_from_sys_info(info: dict[str, Any]) -> DeviceType: return DeviceType.Bulb - _LOGGER.warning("Unknown device type %s, falling back to plug", type_) - return DeviceType.Plug + _LOGGER.warning("Unknown IOT device type %s", type_) + return DeviceType.Unknown @staticmethod def _get_device_info( info: dict[str, Any], discovery_info: dict[str, Any] | None ) -> DeviceInfo: """Get model information for a device.""" - sys_info = _extract_sys_info(info) + sys_info = extract_sys_info(info) + if not sys_info: + raise KasaException("No 'system' or 'get_sysinfo' in response") # Get model and region info region = None - device_model = sys_info["model"] + device_model = sys_info.get("model") + if not isinstance(device_model, str): + raise UnsupportedDeviceError("model not found in sysinfo response") long_name, _, region = device_model.partition("(") if region: # All iot devices have region but just in case region = region.replace(")", "") @@ -767,11 +805,16 @@ def _get_device_info( raise UnsupportedDeviceError("type nor mic_type found in sysinfo response") device_type = IotDevice._get_device_type_from_sys_info(info) - fw_version_full = sys_info["sw_ver"] + fw_version_full = sys_info.get("sw_ver") + if not isinstance(fw_version_full, str): + raise UnsupportedDeviceError("sw_ver not found in sysinfo response") if " " in fw_version_full: firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) else: firmware_version, firmware_build = fw_version_full, None + hardware_version = sys_info.get("hw_ver") + if not isinstance(hardware_version, str): + raise UnsupportedDeviceError("hw_ver not found in sysinfo response") auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info)) return DeviceInfo( @@ -780,7 +823,7 @@ def _get_device_info( brand="kasa", device_family=device_family, device_type=device_type, - hardware_version=sys_info["hw_ver"], + hardware_version=hardware_version, firmware_version=firmware_version, firmware_build=firmware_build, requires_auth=auth, diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6be2392ce..54977dfed 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -10,7 +10,12 @@ from datetime import UTC, datetime, timedelta, tzinfo from typing import TYPE_CHECKING, Any, TypeAlias, cast -from ..device import Device, DeviceInfo, WifiNetwork +from ..device import ( + Device, + DeviceInfo, + WifiNetwork, + get_unsupported_authentication_error, +) from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode @@ -34,7 +39,6 @@ from .smartchilddevice import SmartChildDevice _LOGGER = logging.getLogger(__name__) - # List of modules that non hub devices with children, i.e. ks240/P300, report on # the child but only work on the parent. See longer note below in _initialize_modules. # This list should be updated when creating new modules that could have the @@ -262,7 +266,17 @@ async def update(self, update_children: bool = True) -> None: """Update the device.""" if self.credentials is None and self.credentials_hash is None: raise AuthenticationError("Tapo plug requires authentication.") + try: + await self._update_device(update_children) + except AuthenticationError as ex: + if unsupported_error := get_unsupported_authentication_error( + self.host, self._discovery_info, ex + ): + raise unsupported_error from ex + raise + async def _update_device(self, update_children: bool) -> None: + """Perform the protocol-specific update operation.""" first_update = self._last_update_time is None now = time.monotonic() self._last_update_time = now @@ -729,8 +743,10 @@ async def turn_off(self, **kwargs: Any) -> dict: def update_from_discover_info( self, info: dict, + *, + device_info: dict | None = None, ) -> None: - """Update state from info from the discover call.""" + """Update state from discovery information.""" self._discovery_info = info self._info = info diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index 3cf726f48..e4eaef15b 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -3,12 +3,18 @@ import asyncio import copy from collections.abc import Coroutine +from contextlib import suppress from dataclasses import dataclass from json import dumps as json_dumps from typing import Any, TypedDict import pytest +from kasa import Device +from kasa.device_factory import ( + get_device_class_from_family, + get_device_class_from_sys_info, +) from kasa.transports.xortransport import XorEncryption from .fakeprotocol_iot import FakeIotProtocol @@ -19,6 +25,24 @@ DISCOVERY_MOCK_IP = "127.0.0.123" +def get_device_class_from_discovery( + info: dict[str, Any], device_info: dict[str, Any] | None = None +) -> type[Device]: + """Return the expected device class for test discovery data.""" + if result := info.get("result"): + encryption = result.get("mgt_encrypt_schm") or {} + if result["device_type"].startswith("IOT.") and device_info is not None: + return get_device_class_from_sys_info(device_info) + device_class = get_device_class_from_family( + result["device_type"], + https=encryption.get("is_support_https", False), + ) + if device_class is None: + raise AssertionError(f"Unsupported test device family: {result}") + return device_class + return get_device_class_from_sys_info(info) + + class DiscoveryResponse(TypedDict): result: dict[str, Any] error_code: int @@ -97,11 +121,6 @@ def _make_unsupported( "FOO", omit_keys={"mgt_encrypt_schm": "encrypt_type", "encrypt_info": None}, ), - "unable_to_parse": _make_unsupported( - "SMART.TAPOBULB", - "FOO", - omit_keys={"device_id": None}, - ), "invalidinstance": _make_unsupported( "IOT.SMARTPLUGSWITCH", "KLAP", @@ -164,6 +183,7 @@ class _DiscoveryMock: login_version: int | None = None port_override: int | None = None http_port: int | None = None + klap_version: int | None = None @property def model(self) -> str: @@ -200,6 +220,11 @@ def _datagram(self) -> bytes: login_version = max([int(i) for i in et]) https = discovery_result["mgt_encrypt_schm"]["is_support_https"] http_port = discovery_result["mgt_encrypt_schm"].get("http_port") + klap_version = ( + discovery_result["mgt_encrypt_schm"].get("new_klap") or None + if device_type.startswith("IOT.") and encrypt_type == "KLAP" + else None + ) if not http_port: # noqa: SIM108 # Not all discovery responses set the http port, i.e. smartcam. default_port = 443 if https else 80 @@ -216,6 +241,7 @@ def _datagram(self) -> bytes: https, login_version, http_port=http_port, + klap_version=klap_version, ) else: sys_info = fixture_data["system"]["get_sysinfo"] @@ -257,28 +283,22 @@ def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker): # Mock _run_callback_task so the tasks complete in the order they started. # Otherwise test output is non-deterministic which affects readme examples. - callback_queue: asyncio.Queue = asyncio.Queue() - exception_queue: asyncio.Queue = asyncio.Queue() - - async def process_callback_queue(finished_event: asyncio.Event) -> None: - while (finished_event.is_set() is False) or callback_queue.qsize(): - coro = await callback_queue.get() - try: - await coro - except Exception as ex: - await exception_queue.put(ex) - else: - await exception_queue.put(None) - callback_queue.task_done() - - async def wait_for_coro(): - await callback_queue.join() - if ex := exception_queue.get_nowait(): - raise ex + callback_tail: asyncio.Task | None = None def _run_callback_task(self, coro: Coroutine) -> None: - callback_queue.put_nowait(coro) - task = asyncio.create_task(wait_for_coro()) + nonlocal callback_tail + previous = callback_tail + + async def run_ordered() -> None: + if previous is not None: + # asyncio.gather() still propagates the earlier task's + # exception, but later callbacks must not be skipped. + with suppress(Exception): + await previous + await coro + + task = asyncio.create_task(run_ordered()) + callback_tail = task self.callback_tasks.append(task) mocker.patch( @@ -292,9 +312,6 @@ async def mock_discover(self): Handles test cases modifying the ip and hostname of the first fixture for discover_single testing. """ - finished_event = asyncio.Event() - asyncio.create_task(process_callback_queue(finished_event)) - for ip, dm in discovery_mocks.items(): first_ip = list(discovery_mocks.values())[0].ip fixture_info = fixture_infos[ip] @@ -305,13 +322,15 @@ async def mock_discover(self): else: host = dm.ip # update the protos for any host testing or the test overriding the first ip - protos[host] = ( + protocol = ( FakeSmartProtocol(fixture_info.data, fixture_info.name) if fixture_info.protocol in {"SMART", "SMART.CHILD"} else FakeSmartCamProtocol(fixture_info.data, fixture_info.name) if fixture_info.protocol in {"SMARTCAM", "SMARTCAM.CHILD"} else FakeIotProtocol(fixture_info.data, fixture_info.name) ) + protos[host] = protocol + protos[dm.ip] = protocol port = ( dm.port_override if dm.port_override and dm.discovery_port != 20002 @@ -321,8 +340,6 @@ async def mock_discover(self): dm._datagram, (dm.ip, port), ) - # Setting this event will stop the processing of callbacks - finished_event.set() mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) @@ -346,7 +363,7 @@ def _getaddrinfo(host, *_, **__): # Mock decrypt so it doesn't error with unencryptable empty data in the # fixtures. The discovery result will already contain the decrypted data # deserialized from the fixture - mocker.patch("kasa.discover.Discover._decrypt_discovery_data") + mocker.patch("kasa.discover._TdpDiscovery.decrypt_discovery_data") # Only return the first discovery mock to be used for testing discover single return discovery_mocks[first_ip] diff --git a/tests/fixtures/iot/HS300(US)_2.0_1.1.2.json b/tests/fixtures/iot/HS300(US)_2.0_1.1.2.json new file mode 100644 index 000000000..48f5cd6b4 --- /dev/null +++ b/tests/fixtures/iot/HS300(US)_2.0_1.1.2.json @@ -0,0 +1,115 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS300(US)", + "device_type": "IOT.SMARTPLUGSWITCH", + "factory_default": false, + "hw_ver": "2.0", + "ip": "127.0.0.123", + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "ANS": false, + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2, + "new_klap": 1 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000", + "protocol_version": 1 + } + }, + "emeter": { + "get_realtime": { + "current_ma": 119, + "err_code": 0, + "power_mw": 9021, + "slot_id": 0, + "total_wh": 4013, + "voltage_mv": 121937 + } + }, + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 6, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 1760, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 1759, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", + "next_action": { + "type": -1 + }, + "on_time": 1758, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", + "next_action": { + "type": -1 + }, + "on_time": 1758, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "lnk_on": 0, + "longitude_i": 0, + "mac": "F0:A7:31:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "rssi": -45, + "status": "new", + "sw_ver": "1.1.2 Build 241220 Rel.171333", + "updating": 0 + } + } +} diff --git a/tests/fixtures/serialization/deviceconfig_iot-new-klap.json b/tests/fixtures/serialization/deviceconfig_iot-new-klap.json new file mode 100644 index 000000000..288d28e10 --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_iot-new-klap.json @@ -0,0 +1,11 @@ +{ + "connection_type": { + "device_family": "IOT.SMARTPLUGSWITCH", + "encryption_type": "KLAP", + "https": false, + "klap_version": 1, + "login_version": 2 + }, + "host": "127.0.0.1", + "timeout": 5 +} diff --git a/tests/iot/test_iotdevice.py b/tests/iot/test_iotdevice.py index 19625a641..6c21b8f50 100644 --- a/tests/iot/test_iotdevice.py +++ b/tests/iot/test_iotdevice.py @@ -19,6 +19,7 @@ from kasa import DeviceType, KasaException, Module from kasa.iot import IotDevice +from kasa.iot.iotdevice import extract_sys_info from kasa.iot.iotmodule import _merge_dict from tests.conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on from tests.device_fixtures import device_iot, has_emeter_iot, no_emeter_iot @@ -29,6 +30,29 @@ ) +@pytest.mark.parametrize( + "response", + [ + {}, + {"system": []}, + {"system": {"get_sysinfo": []}}, + {"system": {"get_sysinfo": None}}, + ], +) +def test_malformed_sysinfo_is_handled(response: dict) -> None: + """Malformed IOT response layers never leak dictionary access errors.""" + assert extract_sys_info(response) == {} + with pytest.raises(KasaException, match="system.*get_sysinfo"): + IotDevice._get_device_type_from_sys_info(response) + + +def test_malformed_discovery_initialization_is_handled() -> None: + """IOT discovery initialization reports missing sysinfo and model cleanly.""" + device = IotDevice("127.0.0.1") + with pytest.raises(KasaException, match="neither sysinfo nor a device model"): + device.update_from_discover_info({"system": []}) + + def check_mac(x): if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): return x diff --git a/tests/test_cli.py b/tests/test_cli.py index 54b345159..67d9e9101 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,9 +16,12 @@ Device, DeviceError, DeviceType, + DiscoveryAuthenticationError, EmeterStatus, KasaException, Module, + UnsupportedAuthenticationError, + UnsupportedDeviceError, ) from kasa.cli.device import ( alias, @@ -30,6 +33,7 @@ toggle, update_credentials, ) +from kasa.cli.discover import _format_connection_options, echo_discovery_info from kasa.cli.light import ( brightness, effect, @@ -38,15 +42,23 @@ presets_modify, temperature, ) -from kasa.cli.main import TYPES, _legacy_type_to_class, cli, cmd_command, raw_command +from kasa.cli.main import TYPES, _iot_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.device_factory import get_protocol +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) +from kasa.discover import DiscoveryResult, redact_data +from kasa.iot import IotDevice, IotPlug from kasa.json import dumps as json_dumps from kasa.smart import SmartDevice from kasa.smartcam import SmartCamDevice +from kasa.transports import KlapTransport, KlapTransportV2 from .conftest import ( device_iot, @@ -58,26 +70,96 @@ parametrize_combine, turn_on, ) +from .discovery_fixtures import get_device_class_from_discovery # 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] +def _iot_klap_tdp_response(host: str = "127.0.0.1") -> dict: + """Return a representative IOT KLAP TDP discovery response.""" + return { + "discovery_response": { + "result": { + "device_type": "IOT.SMARTPLUGSWITCH", + "device_model": "HS300(US)", + "device_id": "device-id", + "ip": host, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": "KLAP", + "http_port": 80, + "lv": 2, + "new_klap": 1, + }, + }, + "error_code": 0, + }, + "meta": {"ip": host, "port": 20002, "source": "tdp"}, + } + + +def test_echo_wrapped_tdp_discovery_info(mocker) -> None: + """Wrapped TDP results use the structured CLI formatter.""" + echo = mocker.patch("kasa.cli.discover.echo") + discovery_info = { + "result": { + "device_type": "IOT.SMARTPLUGSWITCH", + "device_model": "KP115(US)", + "device_id": "device-id", + "ip": "127.0.0.1", + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": "KLAP", + "http_port": 80, + "lv": 2, + }, + }, + "error_code": 0, + } + + echo_discovery_info(discovery_info) + + assert any("Discovery Result" in call.args[0] for call in echo.call_args_list) + assert not any( + "Discovery information" in call.args[0] for call in echo.call_args_list + ) + + +def test_connection_options_use_canonical_long_names() -> None: + """Generated connection options use stable, descriptive names.""" + config = DeviceConfig( + host="127.0.0.1", + connection_type=DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, + DeviceEncryptionType.Klap, + login_version=2, + klap_version=1, + ), + ) + assert _format_connection_options(config.connection_type) == ( + "--device-family IOT.SMARTPLUGSWITCH --encrypt-type KLAP " + "--login-version 2 --klap-version 1 --no-https" + ) + + async def test_help(runner): - """Test that all the lazy modules are correctly names.""" + """Test that lazy modules and direct connection aliases are exposed.""" res = await runner.invoke(cli, ["--help"]) assert res.exit_code == 0, "--help failed, check lazy module names" + for option_names in ( + "-df, --device-family", + "-e, --encrypt-type", + "-lv, --login-version", + "-kv, --klap-version", + ): + assert option_names in res.output -@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): +async def test_update_called_by_cli(dev, mocker, runner): """Test that device update is called on main.""" update = mocker.patch.object(dev, "update") @@ -96,10 +178,6 @@ async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_ "foo", "--password", "bar", - "--device-family", - device_family, - "--encrypt-type", - encrypt_type, ], catch_exceptions=False, ) @@ -107,6 +185,24 @@ async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_ update.assert_called() +@pytest.mark.parametrize( + "option", + [ + pytest.param(("--device-family", "SMART.TAPOPLUG"), id="device-family"), + pytest.param(("--encrypt-type", "KLAP"), id="encrypt-type"), + pytest.param(("--login-version", "2"), id="login-version"), + pytest.param(("--klap-version", "1"), id="klap-version"), + pytest.param(("--https",), id="https"), + ], +) +async def test_incomplete_direct_connection_options_are_rejected(runner, option): + """Advanced direct connection options must not be silently ignored.""" + res = await runner.invoke(cli, ["--host", "127.0.0.1", *option]) + + assert res.exit_code == 2 + assert "require both --device-family and --encrypt-type" in res.output + + async def test_list_devices(discovery_mock, runner): """Test that device update is called on main.""" res = await runner.invoke( @@ -117,15 +213,79 @@ async def test_list_devices(discovery_mock, runner): assert res.exit_code == 0 header = ( f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " - f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + f"{'HTTPS':<5} {'LV':<3} {'KV':<3} {'SOURCE':<9} {'ALIAS / RESULT'}" + ) + source = ( + "UDP/9999" + if discovery_mock.discovery_port == 9999 + else f"TDP/{discovery_mock.discovery_port}" ) 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}" + f"{discovery_mock.encrypt_type:<7} {str(discovery_mock.https):<5} " + f"{discovery_mock.login_version or '-':<3} " + f"{discovery_mock.klap_version or '-':<3} " + f"{source:<9}" + ) + output = res.output.replace("\n", "") + assert header in output + assert row in output + + +async def test_list_hostname_uses_single_resolved_host(mocker, runner): + """A targeted hostname produces one row keyed by the resolved address.""" + host = "device.local" + ip = "127.0.0.1" + connection_type = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, + DeviceEncryptionType.Xor, + ) + device = mocker.MagicMock(spec=Device) + device.host = ip + device.model = "HS100" + device.alias = "Plug" + device.config = DeviceConfig(ip, connection_type=connection_type) + device.update = mocker.AsyncMock() + device.disconnect = mocker.AsyncMock() + raw_response = { + "meta": {"ip": ip, "port": 9999, "source": "udp"}, + "discovery_response": { + "system": { + "get_sysinfo": { + "type": DeviceFamily.IotSmartPlugSwitch.value, + "model": device.model, + } + } + }, + } + + async def discover_single(requested_host, **kwargs): + kwargs["on_discovered_raw"](raw_response) + await kwargs["on_discovered"](device) + device.host = requested_host + return device + + mocker.patch( + "kasa.cli.discover.Discover.discover_single", + side_effect=discover_single, + ) + + res = await runner.invoke( + cli, + ["--host", host, "discover", "list"], + catch_exceptions=False, ) - assert header in res.output - assert row in res.output + + assert res.exit_code == 0 + expected = ( + f"{ip:<15} {'HS100':<9} {DeviceFamily.IotSmartPlugSwitch.value:<20} " + f"{'XOR':<7} {'False':<5} {'-':<3} {'-':<3} " + f"{'UDP/9999':<9} Plug" + ) + output = res.output.replace("\n", "") + assert expected in output + assert output.count(DeviceFamily.IotSmartPlugSwitch.value) == 1 + device.disconnect.assert_awaited_once() async def test_discover_raw(discovery_mock, runner, mocker): @@ -140,7 +300,11 @@ async def test_discover_raw(discovery_mock, runner, mocker): expected = { "discovery_response": discovery_mock.discovery_data, - "meta": {"ip": "127.0.0.123", "port": discovery_mock.discovery_port}, + "meta": { + "ip": "127.0.0.123", + "port": discovery_mock.discovery_port, + "source": "udp" if discovery_mock.discovery_port == 9999 else "tdp", + }, } assert res.output == json_dumps(expected, indent=True) + "\n" @@ -164,6 +328,11 @@ async def test_discover_raw(discovery_mock, runner, mocker): "Authentication failed", id="auth", ), + pytest.param( + UnsupportedDeviceError("Unsupported after discovery"), + "Unsupported device", + id="unsupported", + ), pytest.param(TimeoutError(), "Timed out", id="timeout"), pytest.param(Exception("Foobar"), "Error: Foobar", id="other-error"), ], @@ -171,7 +340,9 @@ async def test_discover_raw(discovery_mock, runner, mocker): @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) + device_class = get_device_class_from_discovery( + discovery_mock.discovery_data, discovery_mock.query_data + ) mocker.patch.object( device_class, "update", @@ -185,12 +356,19 @@ async def test_list_update_failed(discovery_mock, mocker, runner, exception, exp assert res.exit_code == 0 header = ( f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " - f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + f"{'HTTPS':<5} {'LV':<3} {'KV':<3} {'SOURCE':<9} {'ALIAS / RESULT'}" + ) + source = ( + "UDP/9999" + if discovery_mock.discovery_port == 9999 + else f"TDP/{discovery_mock.discovery_port}" ) 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}" + f"{discovery_mock.encrypt_type:<7} {str(discovery_mock.https):<5} " + f"{discovery_mock.login_version or '-':<3} " + f"{discovery_mock.klap_version or '-':<3} " + f"{source:<9} {expected}" ) assert header in res.output.replace("\n", "") assert row in res.output.replace("\n", "") @@ -206,11 +384,46 @@ async def test_list_unsupported(unsupported_device_info, runner): assert res.exit_code == 0 header = ( f"{'HOST':<15} {'MODEL':<9} {'DEVICE FAMILY':<20} {'ENCRYPT':<7} " - f"{'HTTPS':<5} {'LV':<3} {'ALIAS'}" + f"{'HTTPS':<5} {'LV':<3} {'KV':<3} {'SOURCE':<9} {'ALIAS / RESULT'}" + ) + output = res.output.replace("\n", "") + assert header in output + assert "127.0.0.1" in res.output + assert "TDP/20002" in res.output + assert "Unsupported device" in res.output + + +async def test_list_authentication_failure_before_device_creation(mocker, runner): + """List uses the same complete row when authentication blocks creation.""" + host = "127.0.0.1" + raw_response = _iot_klap_tdp_response(host) + + async def discover(**kwargs): + kwargs["on_discovered_raw"](raw_response) + await kwargs["on_authentication_error"]( + DiscoveryAuthenticationError( + "Authentication failed", + host=host, + discovery_result=raw_response["discovery_response"], + ) + ) + return {} + + mocker.patch("kasa.cli.discover.Discover.discover", side_effect=discover) + + res = await runner.invoke( + cli, + ["--discovery-timeout", "0", "discover", "list"], + catch_exceptions=False, + ) + + assert res.exit_code == 0 + expected = ( + f"{host:<15} {'HS300':<9} {'IOT.SMARTPLUGSWITCH':<20} " + f"{'KLAP':<7} {'False':<5} {'2':<3} {'1':<3} " + f"{'TDP/20002':<9} Authentication failed" ) - row = f"{'127.0.0.1':<15} UNSUPPORTED DEVICE" - assert header in res.output - assert row in res.output + assert expected in res.output.replace("\n", "") async def test_sysinfo(dev: Device, runner): @@ -776,22 +989,28 @@ async def _state(dev: Device): mocker.patch("kasa.cli.device.state", new=_state) dr = DiscoveryResult.from_dict(discovery_mock.discovery_data["result"]) + connection_type = dr.to_connection_parameters() + args = [ + "--host", + "127.0.0.123", + "--username", + "foo", + "--password", + "bar", + "--device-family", + connection_type.device_family.value, + "--encrypt-type", + connection_type.encryption_type.value, + ] + if connection_type.login_version is not None: + args += ["--login-version", str(connection_type.login_version)] + if connection_type.klap_version is not None: + args += ["--klap-version", str(connection_type.klap_version)] + args.append("--https" if connection_type.https else "--no-https") + 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, - ], + args, ) assert res.exit_code == 0 @@ -825,13 +1044,38 @@ async def test_without_device_type(dev, mocker, runner): "127.0.0.1", port=None, credentials=Credentials("foo", "bar"), + credentials_hash=None, timeout=5, discovery_timeout=7, + on_discovered=ANY, on_unsupported=ANY, + on_authentication_error=ANY, on_discovered_raw=ANY, ) +async def test_credentials_hash_is_passed_to_discovery(mocker, runner): + """The global credentials hash must not be ignored by discovery.""" + discover_single = mocker.patch( + "kasa.discover.Discover.discover_single", return_value=None + ) + + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--credentials-hash", + "hashed-credentials", + "discover", + "raw", + ], + ) + + assert res.exit_code == 0, res.output + assert discover_single.call_args.kwargs["credentials_hash"] == "hashed-credentials" + + @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.""" @@ -930,6 +1174,8 @@ async def test_discover_unsupported(unsupported_device_info, runner): ) assert res.exit_code == 0 assert "== Unsupported device ==" in res.output + assert "Found 1 devices" in res.output + assert "Found 1 unsupported devices" in res.output async def test_host_unsupported(unsupported_device_info, runner): @@ -958,7 +1204,9 @@ 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) + device_class = get_device_class_from_discovery( + discovery_mock.discovery_data, discovery_mock.query_data + ) mocker.patch.object( device_class, "update", @@ -981,6 +1229,83 @@ async def test_discover_auth_failed(discovery_mock, mocker, runner): assert res.exit_code == 0 assert "== Authentication failed for device ==" in res.output assert "== Discovery Result ==" in res.output + assert "Found 1 devices" in res.output + assert "Found 1 devices that failed to authenticate" in res.output + + +@new_discovery +async def test_discover_update_unsupported(discovery_mock, mocker, runner): + """A regular unsupported update outcome remains in the device total.""" + host = "127.0.0.1" + discovery_mock.ip = host + device_class = get_device_class_from_discovery( + discovery_mock.discovery_data, discovery_mock.query_data + ) + mocker.patch.object( + device_class, + "update", + side_effect=UnsupportedDeviceError("Unsupported after discovery"), + ) + + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--username", + "foo", + "--password", + "bar", + "discover", + ], + ) + + assert res.exit_code == 0 + assert "== Unsupported device ==" in res.output + assert "Found 1 devices" in res.output + assert "Found 1 unsupported devices" in res.output + + +@new_discovery +async def test_discover_update_unsupported_authentication( + discovery_mock, mocker, runner +): + """Unsupported onboarding receives specific reset and provisioning advice.""" + host = "127.0.0.1" + discovery_mock.ip = host + device_class = get_device_class_from_discovery( + discovery_mock.discovery_data, discovery_mock.query_data + ) + mocker.patch.object( + device_class, + "update", + side_effect=UnsupportedAuthenticationError( + "Unsupported authentication", + host=host, + onboarding_source="amazon", + ), + ) + + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--username", + "foo", + "--password", + "bar", + "discover", + ], + ) + + assert res.exit_code == 0 + assert "== Unsupported device authentication ==" in res.output + assert "Onboarding source: amazon" in res.output + assert "Reset and provision this device" in res.output + assert "Found 1 devices" in res.output + assert "Found 1 unsupported devices" in res.output + assert "failed to authenticate" not in res.output @new_discovery @@ -988,7 +1313,9 @@ 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) + device_class = get_device_class_from_discovery( + discovery_mock.discovery_data, discovery_mock.query_data + ) mocker.patch.object( device_class, "update", @@ -1008,7 +1335,7 @@ async def test_host_auth_failed(discovery_mock, mocker, runner): ) assert res.exit_code != 0 - assert isinstance(res.exception, AuthenticationError) + assert "requested device could not be queried" in res.output @pytest.mark.parametrize("device_type", TYPES) @@ -1028,7 +1355,7 @@ async def _state(dev: Device): elif device_type == "smart": expected_type = SmartDevice else: - expected_type = _legacy_type_to_class(device_type) + expected_type = _iot_type_to_class(device_type) mocker.patch.object(expected_type, "update") res = await runner.invoke( cli, @@ -1036,6 +1363,13 @@ async def _state(dev: Device): ) assert res.exit_code == 0 assert isinstance(result_device, expected_type) + if device_type not in {"smart", "camera"}: + expected_family = ( + DeviceFamily.IotSmartBulb + if device_type in {"bulb", "lightstrip"} + else DeviceFamily.IotSmartPlugSwitch + ) + assert result_device.config.connection_type.device_family is expected_family @pytest.mark.parametrize( @@ -1075,6 +1409,146 @@ async def _mock_connect(config: DeviceConfig): assert captured_config.connection_type.login_version == expected_login_version +@pytest.mark.parametrize( + ("login_version", "klap_version", "expected_transport"), + [ + pytest.param(None, None, KlapTransport, id="advertised-values-absent"), + pytest.param(2, None, KlapTransport, id="login-version-is-not-klap-v2"), + pytest.param(2, 1, KlapTransportV2, id="klap-version-selects-v2"), + ], +) +async def test_iot_klap_direct_connection_versions( + login_version, klap_version, expected_transport, mocker, runner +): + """IOT KLAP generation is independent from the login version.""" + captured_config = None + mocker.patch("kasa.cli.device.state") + + async def _mock_connect(config): + nonlocal captured_config + captured_config = config + return IotPlug(config.host, config=config) + + mocker.patch("kasa.device.Device.connect", side_effect=_mock_connect) + args = [ + "--host", + "127.0.0.1", + "-df", + "IOT.SMARTPLUGSWITCH", + "-e", + "KLAP", + ] + if login_version is not None: + args += ["-lv", str(login_version)] + if klap_version is not None: + args += ["-kv", str(klap_version)] + + res = await runner.invoke(cli, args) + + assert res.exit_code == 0, res.output + assert captured_config is not None + assert captured_config.connection_type.login_version == login_version + assert captured_config.connection_type.klap_version == klap_version + protocol = get_protocol(captured_config) + assert protocol is not None + assert isinstance(protocol._transport, expected_transport) + await protocol.close() + + +@pytest.mark.parametrize( + ("args", "expected_error"), + [ + pytest.param( + ["--type", "smart", "discover"], + "--type configures a direct connection and cannot be used with discover", + id="direct-type-discover", + ), + pytest.param( + ["--type", "plug", "--encrypt-type", "KLAP"], + "--encrypt-type is not used with IOT --type plug", + id="iot-unused-encryption", + ), + pytest.param( + [ + "--device-family", + "SMART.TAPOPLUG", + "--encrypt-type", + "KLAP", + "--klap-version", + "1", + ], + "--klap-version is only used by IOT devices", + id="smart-klap-version", + ), + pytest.param( + [ + "--device-family", + "IOT.SMARTPLUGSWITCH", + "--encrypt-type", + "XOR", + "--klap-version", + "1", + ], + "--klap-version requires --encrypt-type KLAP", + id="non-klap-klap-version", + ), + ], +) +async def test_incompatible_direct_connection_options_are_rejected( + runner, args, expected_error +): + """Connection flags that cannot affect the selected path must fail.""" + if "discover" not in args: + args = ["--host", "127.0.0.1", *args] + + res = await runner.invoke(cli, args) + + assert res.exit_code == 2 + assert expected_error in res.output + + +@pytest.mark.parametrize( + "args", + [ + pytest.param(["--port", "0"], id="invalid-port"), + pytest.param( + [ + "--device-family", + "SMART.UNKNOWN", + "--encrypt-type", + "KLAP", + ], + id="unknown-family", + ), + pytest.param( + [ + "--device-family", + "SMART.IPCAMERA", + "--encrypt-type", + "KLAP", + "--https", + ], + id="unsupported-exact-route", + ), + pytest.param( + [ + "--device-family", + "SMART.IPCAMERA", + "--encrypt-type", + "AES", + "--no-https", + ], + id="unconstructible-exact-route", + ), + ], +) +async def test_invalid_connection_option_values_are_rejected(runner, args): + """Direct connection values are constrained to routes the CLI can use.""" + res = await runner.invoke(cli, ["--host", "127.0.0.1", *args]) + + assert res.exit_code == 2 + + @pytest.mark.skip( "Skip until pytest-asyncio supports pytest 8.0, https://github.com/pytest-dev/pytest-asyncio/issues/737" ) @@ -1418,9 +1892,47 @@ async def test_cli_child_commands( assert dev.children[0].update == child_update_method -async def test_discover_config(dev: Device, mocker, runner): - """Test that device config is returned.""" +async def test_discover_config_uses_authoritative_discovery(mocker, runner): + """Discovery config reports TDP parameters even when authentication fails.""" host = "127.0.0.1" + raw_response = _iot_klap_tdp_response(host) + + async def discover_single(*args, **kwargs): + kwargs["on_discovered_raw"](raw_response) + raise DiscoveryAuthenticationError( + "Authentication failed", + host=host, + discovery_result=raw_response["discovery_response"], + ) + + mocker.patch( + "kasa.cli.discover.Discover.discover_single", side_effect=discover_single + ) + try_connect_all = mocker.patch("kasa.cli.discover.Discover.try_connect_all") + + res = await runner.invoke( + cli, + ["--host", host, "discover", "config"], + catch_exceptions=False, + ) + + assert res.exit_code == 0 + assert f"Using TDP/20002 discovery response from {host}" in res.output + assert ( + "--device-family IOT.SMARTPLUGSWITCH --encrypt-type KLAP " + "--login-version 2 --klap-version 1 --no-https" + ) in res.output.replace("\n", "") + assert "direct connection routes" not in res.output + try_connect_all.assert_not_called() + + +async def test_discover_config_falls_back_to_direct_probe(dev: Device, mocker, runner): + """Test that config falls back when discovery provides no usable response.""" + host = "127.0.0.1" + mocker.patch( + "kasa.cli.discover.Discover.discover_single", + side_effect=KasaException("No discovery response"), + ) mocker.patch("kasa.device_factory._connect", side_effect=[Exception, dev]) res = await runner.invoke( @@ -1439,21 +1951,68 @@ async def test_discover_config(dev: Device, mocker, runner): ) 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'}" + expected_options = [ + f"--device-family {cparam.device_family.value}", + f"--encrypt-type {cparam.encryption_type.value}", + ] + if cparam.login_version is not None: + expected_options.append(f"--login-version {cparam.login_version}") + if cparam.klap_version is not None: + expected_options.append(f"--klap-version {cparam.klap_version}") + expected_options.append("--https" if cparam.https else "--no-https") + expected = " ".join(expected_options) assert expected in res.output + assert "Trying direct connection routes instead" in res.output + assert "Managed to connect using direct probing" in res.output assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ failed", + r"Attempt to connect to 127\.0\.0\.1 with .* failed", res.output.replace("\n", ""), ) assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ succeeded", + r"Attempt to connect to 127\.0\.0\.1 with .* succeeded", res.output.replace("\n", ""), ) + assert "SMART." in res.output or "IOT." in res.output + + +async def test_discover_config_does_not_override_a_discovery_response(mocker, runner): + """Direct probing does not replace a received discovery response.""" + host = "127.0.0.1" + raw_response = { + "discovery_response": {"result": "invalid", "error_code": 0}, + "meta": {"ip": host, "port": 20002, "source": "tdp"}, + } + + async def discover_single(*args, **kwargs): + kwargs["on_discovered_raw"](raw_response) + raise UnsupportedDeviceError("Unsupported discovery response", host=host) + + mocker.patch( + "kasa.cli.discover.Discover.discover_single", side_effect=discover_single + ) + try_connect_all = mocker.patch("kasa.cli.discover.Discover.try_connect_all") + + res = await runner.invoke( + cli, + ["--host", host, "discover", "config"], + catch_exceptions=False, + ) + + assert res.exit_code == 1 + assert ( + "Unable to determine a connection configuration from the TDP/20002 " + "discovery response" + ) in res.output.replace("\n", "") + try_connect_all.assert_not_called() async def test_discover_config_invalid(mocker, runner): """Test the device config command with invalids.""" host = "127.0.0.1" + mocker.patch( + "kasa.cli.discover.Discover.discover_single", + side_effect=KasaException("No discovery response"), + ) mocker.patch("kasa.discover.Discover.try_connect_all", return_value=None) res = await runner.invoke( diff --git a/tests/test_device.py b/tests/test_device.py index 842c42028..ae0cc627d 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -8,12 +8,22 @@ import sys import zoneinfo from contextlib import AbstractContextManager, nullcontext -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch import pytest import kasa -from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module +from kasa import ( + AuthenticationError, + Credentials, + Device, + DeviceConfig, + DeviceType, + KasaException, + Module, + UnsupportedAuthenticationError, +) +from kasa.exceptions import SmartErrorCode from kasa.iot import ( IotBulb, IotCamera, @@ -56,6 +66,83 @@ def _get_subclasses(of_class): ) +@pytest.mark.parametrize("device_class", [IotDevice, SmartDevice, SmartCamDevice]) +@pytest.mark.parametrize("onboarding_source", ["amazon", "future-source", "tss"]) +async def test_update_classifies_unsupported_authentication( + device_class: type[Device], onboarding_source: str, mocker +) -> None: + """A failed update identifies unsupported onboarding after authentication.""" + host = "127.0.0.1" + device = device_class( + host, + config=DeviceConfig(host, credentials=Credentials("user", "password")), + ) + discovery_info = {"obd_src": onboarding_source, "device_model": "test"} + device._discovery_info = discovery_info + authentication_error = AuthenticationError( + "Authentication failed", + error_code=SmartErrorCode.LOGIN_FAILED_ERROR, + ) + mocker.patch.object( + device, + "_update_device", + new=AsyncMock(side_effect=authentication_error), + ) + + with pytest.raises(UnsupportedAuthenticationError) as exc_info: + await device.update() + + error = exc_info.value + assert isinstance(error, AuthenticationError) + assert error.host == device.host + assert error.discovery_result is discovery_info + assert error.onboarding_source == onboarding_source + assert error.error_code is SmartErrorCode.LOGIN_FAILED_ERROR + assert error.__cause__ is authentication_error + + +@pytest.mark.parametrize("onboarding_source", [None, "", "apple", "matter", "tplink"]) +async def test_update_preserves_supported_authentication_error( + onboarding_source: str | None, mocker +) -> None: + """Missing and supported onboarding sources keep normal authentication.""" + host = "127.0.0.1" + device = SmartDevice( + host, + config=DeviceConfig(host, credentials=Credentials("user", "password")), + ) + device._discovery_info = {"obd_src": onboarding_source} + authentication_error = AuthenticationError("Authentication failed") + mocker.patch.object( + device, + "_update_device", + new=AsyncMock(side_effect=authentication_error), + ) + + with pytest.raises(AuthenticationError) as exc_info: + await device.update() + + assert exc_info.value is authentication_error + assert not isinstance(exc_info.value, UnsupportedAuthenticationError) + + +async def test_missing_credentials_are_not_unsupported_authentication(mocker) -> None: + """Onboarding information does not classify before an authentication attempt.""" + device = SmartDevice("127.0.0.1") + device._discovery_info = {"obd_src": "tss"} + mocker.patch.object( + Device, "credentials", new_callable=PropertyMock, return_value=None + ) + mocker.patch.object( + Device, "credentials_hash", new_callable=PropertyMock, return_value=None + ) + + with pytest.raises(AuthenticationError) as exc_info: + await device.update() + + assert not isinstance(exc_info.value, UnsupportedAuthenticationError) + + async def test_device_id(dev: Device): """Test all devices have a device id.""" assert dev.device_id diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 19ccfb73d..560f7e6b3 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -6,8 +6,11 @@ the testing fake protocols. """ +import asyncio import logging +from dataclasses import replace from typing import cast +from unittest.mock import AsyncMock, MagicMock import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 @@ -15,20 +18,25 @@ from kasa import ( BaseProtocol, Credentials, - Discover, IotProtocol, KasaException, SmartCamProtocol, SmartProtocol, ) from kasa.device_factory import ( + _CONNECTION_ROUTES, + _DEVICE_FAMILIES, + ConnectAttempt, Device, IotDevice, SmartCamDevice, SmartDevice, connect, + create_device, get_device_class_from_family, + get_device_class_from_sys_info, get_protocol, + try_connect_all, ) from kasa.deviceconfig import ( DeviceConfig, @@ -37,6 +45,7 @@ DeviceFamily, ) from kasa.discover import DiscoveryResult +from kasa.iot import IotPlug, IotStrip from kasa.transports import ( AesTransport, BaseTransport, @@ -49,23 +58,29 @@ ) from .conftest import DISCOVERY_MOCK_IP +from .discovery_fixtures import get_device_class_from_discovery # Device Factory tests are not relevant for real devices which run against # a single device that has already been created via the factory. pytestmark = [pytest.mark.requires_dummy] -def _get_connection_type_device_class(discovery_info): +def _get_connection_type_device_class(discovery_info, device_info): if "result" in discovery_info: - device_class = Discover._get_device_class(discovery_info) + device_type = discovery_info["result"]["device_type"] + device_class = ( + get_device_class_from_sys_info(device_info) + if device_type.startswith("IOT.") + else get_device_class_from_discovery(discovery_info) + ) dr = DiscoveryResult.from_dict(discovery_info["result"]) - connection_type = Discover._get_connection_parameters(dr) + connection_type = dr.to_connection_parameters() else: connection_type = DeviceConnectionParameters.from_values( DeviceFamily.IotSmartPlugSwitch.value, DeviceEncryptionType.Xor.value ) - device_class = Discover._get_device_class(discovery_info) + device_class = get_device_class_from_sys_info(device_info) return connection_type, device_class @@ -77,7 +92,7 @@ async def test_connect( """Test that if the protocol is passed in it gets set correctly.""" host = DISCOVERY_MOCK_IP ctype, device_class = _get_connection_type_device_class( - discovery_mock.discovery_data + discovery_mock.discovery_data, discovery_mock.query_data ) config = DeviceConfig( @@ -93,7 +108,22 @@ async def test_connect( assert isinstance(dev, device_class) assert isinstance(dev.protocol, protocol_class) - assert dev.config == config + if isinstance(dev, IotDevice): + expected_family = ( + DeviceFamily.IotSmartBulb + if dev.device_type in {Device.Type.Bulb, Device.Type.LightStrip} + else DeviceFamily.IotSmartPlugSwitch + ) + expected_config = replace( + config, + connection_type=replace( + config.connection_type, + device_family=expected_family, + ), + ) + assert dev.config == expected_config + else: + assert dev.config == config assert close_mock.call_count == 0 await dev.disconnect() assert close_mock.call_count == 1 @@ -105,7 +135,9 @@ async def test_connect_custom_port(discovery_mock, mocker, custom_port): host = DISCOVERY_MOCK_IP discovery_data = discovery_mock.discovery_data - ctype, _ = _get_connection_type_device_class(discovery_data) + ctype, _ = _get_connection_type_device_class( + discovery_data, discovery_mock.query_data + ) config = DeviceConfig( host=host, port_override=custom_port, @@ -114,8 +146,6 @@ async def test_connect_custom_port(discovery_mock, mocker, custom_port): ) default_port = discovery_mock.default_port - ctype, _ = _get_connection_type_device_class(discovery_data) - dev = await connect(config=config) assert issubclass(dev.__class__, Device) assert dev.port == custom_port or dev.port == default_port @@ -128,7 +158,9 @@ async def test_connect_logs_connect_time( ): """Test that the connect time is logged when debug logging is enabled.""" discovery_data = discovery_mock.discovery_data - ctype, _ = _get_connection_type_device_class(discovery_data) + ctype, _ = _get_connection_type_device_class( + discovery_data, discovery_mock.query_data + ) host = DISCOVERY_MOCK_IP config = DeviceConfig( @@ -148,7 +180,9 @@ async def test_connect_query_fails(discovery_mock, mocker): mocker.patch("kasa.IotProtocol.query", side_effect=KasaException) mocker.patch("kasa.SmartProtocol.query", side_effect=KasaException) - ctype, _ = _get_connection_type_device_class(discovery_data) + ctype, _ = _get_connection_type_device_class( + discovery_data, discovery_mock.query_data + ) config = DeviceConfig( host=host, credentials=Credentials("foor", "bar"), connection_type=ctype ) @@ -160,11 +194,131 @@ async def test_connect_query_fails(discovery_mock, mocker): assert close_mock.call_count == 1 +async def test_create_device_closes_owned_protocol_on_cancellation(mocker): + """Factory-owned protocols are closed when initialization is cancelled.""" + protocol = MagicMock(spec=BaseProtocol) + protocol.close = AsyncMock() + mocker.patch("kasa.device_factory.get_protocol", return_value=protocol) + mocker.patch( + "kasa.device_factory._resolve_device_class", + side_effect=asyncio.CancelledError, + ) + + with pytest.raises(asyncio.CancelledError): + await create_device(DeviceConfig("127.0.0.1")) + + protocol.close.assert_awaited_once() + + +async def test_try_connect_all_closes_protocol_when_success_callback_fails(mocker): + """A callback failure cannot leak a successful attempt's protocol.""" + protocol = MagicMock(spec=BaseProtocol) + protocol.close = AsyncMock() + config = DeviceConfig("127.0.0.1") + attempt = ConnectAttempt( + IotProtocol, + XorTransport, + IotDevice, + False, + config.connection_type, + ) + + async def attempts(*args, **kwargs): + yield attempt, protocol, config + + mocker.patch("kasa.device_factory._iter_connection_attempts", attempts) + mocker.patch( + "kasa.device_factory._connect", new=AsyncMock(return_value=MagicMock()) + ) + + def on_attempt(*args): + raise RuntimeError("callback failed") + + with pytest.raises(RuntimeError, match="callback failed"): + await try_connect_all(config.host, on_attempt=on_attempt) + + protocol.close.assert_awaited_once() + + +async def test_try_connect_all_reports_resolved_device_class(mocker): + """A successful attempt reports the concrete class resolved by the factory.""" + protocol = MagicMock(spec=BaseProtocol) + protocol.close = AsyncMock() + config = DeviceConfig("127.0.0.1") + attempt = ConnectAttempt( + IotProtocol, + XorTransport, + IotPlug, + False, + config.connection_type, + ) + device = object.__new__(IotStrip) + + async def attempts(*args, **kwargs): + yield attempt, protocol, config + + mocker.patch("kasa.device_factory._iter_connection_attempts", attempts) + mocker.patch("kasa.device_factory._connect", new=AsyncMock(return_value=device)) + completed_attempts: list[ConnectAttempt] = [] + + result = await try_connect_all( + config.host, + on_attempt=lambda completed, success: completed_attempts.append(completed), + ) + + assert result is device + assert completed_attempts[0].device is IotStrip + + +async def test_try_connect_all_preserves_complete_connection_identity(mocker): + """Distinct device families are not collapsed by implementation classes.""" + attempts: list[ConnectAttempt] = [] + mocker.patch( + "kasa.device_factory._connect", + new=AsyncMock(side_effect=KasaException("not this route")), + ) + + device = await try_connect_all( + "127.0.0.1", + on_attempt=lambda attempt, success: attempts.append(attempt), + ) + + assert device is None + connection_types = [attempt.connection_type for attempt in attempts] + families = {connection_type.device_family for connection_type in connection_types} + assert DeviceFamily.SmartTapoPlug in families + assert DeviceFamily.SmartTapoRobovac in families + iot_klap_types = [ + connection_type + for connection_type in connection_types + if connection_type.device_family is DeviceFamily.IotSmartPlugSwitch + and connection_type.encryption_type is DeviceEncryptionType.Klap + ] + assert {connection_type.klap_version for connection_type in iot_klap_types} == { + None, + 1, + } + assert ( + next( + connection_type + for connection_type in iot_klap_types + if connection_type.klap_version is None + ).login_version + is None + ) + assert all( + connection_type not in connection_types[:index] + for index, connection_type in enumerate(connection_types) + ) + + async def test_connect_http_client(discovery_mock, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" host = DISCOVERY_MOCK_IP discovery_data = discovery_mock.discovery_data - ctype, _ = _get_connection_type_device_class(discovery_data) + ctype, _ = _get_connection_type_device_class( + discovery_data, discovery_mock.query_data + ) http_client = aiohttp.ClientSession() @@ -220,6 +374,30 @@ async def test_device_class_from_unknown_family(caplog): ET = DeviceEncryptionType +def test_all_supported_families_are_registered() -> None: + """Every public supported family has one declarative factory definition.""" + assert set(_DEVICE_FAMILIES) == set(DeviceFamily) + + +def test_probeable_families_have_connection_routes() -> None: + """Direct probing is derived from routes rather than a separate family list.""" + for family, definition in _DEVICE_FAMILIES.items(): + if not definition.probe: + continue + assert any( + route.device_family is family + or ( + route.device_family is None + and route.protocol_type + in { + definition.protocol_type, + definition.https_protocol_type, + } + ) + for route in _CONNECTION_ROUTES + ) + + @pytest.mark.parametrize( ("conn_params", "expected_protocol", "expected_transport"), [ @@ -229,6 +407,12 @@ async def test_device_class_from_unknown_family(caplog): SslAesTransport, id="smartcam", ), + pytest.param( + CP(DF.SmartIpCamera, ET.Aes, https=False), + SmartCamProtocol, + SslAesTransport, + id="smartcam-master-https-compatibility", + ), pytest.param( CP(DF.SmartTapoHub, ET.Aes, https=True), SmartCamProtocol, @@ -241,12 +425,24 @@ async def test_device_class_from_unknown_family(caplog): SslAesTransport, id="smartcam-doorbell", ), + pytest.param( + CP(DF.SmartTapoDoorbell, ET.Aes, https=False), + SmartCamProtocol, + SslAesTransport, + id="smartcam-doorbell-master-https-compatibility", + ), pytest.param( CP(DF.IotIpCamera, ET.Aes, https=True), IotProtocol, LinkieTransportV2, id="kasacam", ), + pytest.param( + CP(DF.IotIpCamera, ET.Xor, https=False), + IotProtocol, + LinkieTransportV2, + id="kasacam-master-https-compatibility", + ), pytest.param( CP(DF.SmartTapoRobovac, ET.Aes, https=True), SmartProtocol, @@ -254,10 +450,34 @@ async def test_device_class_from_unknown_family(caplog): id="robovac", ), pytest.param( - CP(DF.IotSmartPlugSwitch, ET.Klap, https=False), + CP(DF.SmartTapoRobovac, ET.Aes, https=False), + SmartProtocol, + SslTransport, + id="robovac-master-https-compatibility", + ), + pytest.param( + CP(DF.SmartTapoHub, ET.Klap, https=True), + SmartProtocol, + KlapTransportV2, + id="smart-hub-klap-https-master-compatibility", + ), + pytest.param( + CP(DF.IotSmartPlugSwitch, ET.Klap, login_version=2, https=False), IotProtocol, KlapTransport, - id="iot-klap", + id="iot-klap-login-version-does-not-select-v2", + ), + pytest.param( + CP( + DF.IotSmartPlugSwitch, + ET.Klap, + login_version=2, + https=False, + klap_version=1, + ), + IotProtocol, + KlapTransportV2, + id="iot-klap-version-selects-v2", ), pytest.param( CP(DF.IotSmartPlugSwitch, ET.Xor, https=False), @@ -272,10 +492,16 @@ async def test_device_class_from_unknown_family(caplog): id="smart-aes", ), pytest.param( - CP(DF.SmartTapoPlug, ET.Klap, https=False), + CP(DF.SmartTapoPlug, ET.Aes, https=True), + SmartCamProtocol, + SslAesTransport, + id="smart-aes-https", + ), + pytest.param( + CP(DF.SmartTapoPlug, ET.Klap, login_version=None, https=False), SmartProtocol, KlapTransportV2, - id="smart-klap", + id="smart-klap-does-not-require-login-version", ), pytest.param( CP(DF.SmartTapoChime, ET.Klap, https=False), @@ -295,3 +521,20 @@ async def test_get_protocol( protocol = get_protocol(config) assert isinstance(protocol, expected_protocol) assert isinstance(protocol._transport, expected_transport) + + +@pytest.mark.parametrize( + "conn_params", + [ + CP(DF.SmartIpCamera, ET.Klap, https=True), + CP(DF.SmartTapoDoorbell, ET.Xor, https=False), + CP(DF.IotIpCamera, ET.Klap, https=False), + ], +) +def test_get_protocol_strict_rejects_fixed_encryption_mismatch( + conn_params: DeviceConnectionParameters, +) -> None: + """Strict direct parameters cannot fall through fixed family routes.""" + config = DeviceConfig("127.0.0.1", connection_type=conn_params) + + assert get_protocol(config, strict=True) is None diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py index aebdd3a61..16e8e4ac3 100644 --- a/tests/test_deviceconfig.py +++ b/tests/test_deviceconfig.py @@ -30,6 +30,15 @@ DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True ), ) +IOT_NEW_KLAP_CONFIG = DeviceConfig( + host="127.0.0.1", + connection_type=DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, + DeviceEncryptionType.Klap, + login_version=2, + klap_version=1, + ), +) async def test_serialization(): @@ -49,6 +58,7 @@ async def test_serialization(): ("deviceconfig_plug-xor.json", PLUG_XOR_CONFIG), ("deviceconfig_plug-klap.json", PLUG_KLAP_CONFIG), ("deviceconfig_camera-aes-https.json", CAMERA_AES_CONFIG), + ("deviceconfig_iot-new-klap.json", IOT_NEW_KLAP_CONFIG), ], ids=lambda arg: arg.split("_")[-1] if isinstance(arg, str) else "", ) diff --git a/tests/test_devtools.py b/tests/test_devtools.py index b49268d33..eb6553bf3 100644 --- a/tests/test_devtools.py +++ b/tests/test_devtools.py @@ -5,10 +5,14 @@ import pytest from devtools.dump_devinfo import ( - _wrap_redactors, - get_legacy_fixture, + cli as dump_devinfo_cli, +) +from devtools.dump_devinfo import ( + get_iot_fixture, get_smart_fixtures, + wrap_redactors, ) +from kasa.deviceconfig import DeviceConfig from kasa.iot import IotDevice from kasa.protocols import IotProtocol from kasa.protocols.protocol import redact_data @@ -129,7 +133,7 @@ async def test_smartcam_fixtures(fixture_info: FixtureInfo): # Still check that the created child info from parent was redacted. # only smartcam children generate child_info_from_parent if created_cifp: - redacted_cifp = redact_data(created_cifp, _wrap_redactors(SMART_REDACTORS)) + redacted_cifp = redact_data(created_cifp, wrap_redactors(SMART_REDACTORS)) assert created_cifp == redacted_cifp assert saved_fixture_data == created_child_fixture.data @@ -145,7 +149,7 @@ async def test_iot_fixtures(fixture_info: FixtureInfo): ) assert isinstance(dev.protocol, IotProtocol) - fixture = await get_legacy_fixture( + fixture = await get_iot_fixture( dev.protocol, discovery_info=fixture_info.data.get("discovery_result") ) fixture_result = fixture @@ -157,3 +161,118 @@ async def test_iot_fixtures(fixture_info: FixtureInfo): key: val for key, val in fixture_info.data.items() if "err_code" not in val } assert saved_fixture == created_fixture + + +async def test_dump_devinfo_exact_iot_klap_connection(mocker, runner): + """The devtool preserves independent login and IOT KLAP versions.""" + captured_config: DeviceConfig | None = None + protocol = mocker.MagicMock() + + def capture_protocol(config: DeviceConfig, *, strict: bool): + nonlocal captured_config + assert strict is True + captured_config = config + return protocol + + mocker.patch("devtools.dump_devinfo.get_protocol", side_effect=capture_protocol) + handle_device = mocker.patch( + "devtools.dump_devinfo.handle_device", new_callable=mocker.AsyncMock + ) + + result = await runner.invoke( + dump_devinfo_cli, + [ + "--host", + "127.0.0.1", + "-df", + "IOT.SMARTPLUGSWITCH", + "-e", + "KLAP", + "-lv", + "2", + "-kv", + "1", + "--credentials-hash", + "credential-hash", + "--no-https", + ], + ) + + assert result.exit_code == 0, result.output + assert captured_config is not None + assert captured_config.credentials is None + assert captured_config.credentials_hash == "credential-hash" + assert captured_config.connection_type.login_version == 2 + assert captured_config.connection_type.klap_version == 1 + handle_device.assert_awaited_once() + + +async def test_dump_devinfo_help_includes_direct_connection_aliases(runner): + """The devtool exposes all direct connection option aliases.""" + result = await runner.invoke(dump_devinfo_cli, ["--help"]) + + assert result.exit_code == 0 + for option_names in ( + "-df, --device-family", + "-e, --encrypt-type", + "-lv, --login-version", + "-kv, --klap-version", + ): + assert option_names in result.output + + +@pytest.mark.parametrize( + ("device_family", "encrypt_type", "expected_error"), + [ + pytest.param( + "SMART.TAPOPLUG", + "KLAP", + "--klap-version is only used by IOT devices", + id="smart-klap-version", + ), + pytest.param( + "IOT.SMARTPLUGSWITCH", + "XOR", + "--klap-version requires --encrypt-type KLAP", + id="non-klap-klap-version", + ), + ], +) +async def test_dump_devinfo_klap_version_validation_uses_canonical_name( + runner, device_family, encrypt_type, expected_error +): + """Short-option validation errors use the descriptive option name.""" + result = await runner.invoke( + dump_devinfo_cli, + [ + "--host", + "127.0.0.1", + "-df", + device_family, + "-e", + encrypt_type, + "-kv", + "1", + ], + ) + + assert result.exit_code == 2 + assert expected_error in result.output + + +async def test_dump_devinfo_discovery_options_are_forwarded(mocker, runner): + """Discovery uses the devtool's port and credential-hash options.""" + discover = mocker.patch( + "devtools.dump_devinfo.Discover.discover", + new_callable=mocker.AsyncMock, + return_value={}, + ) + + result = await runner.invoke( + dump_devinfo_cli, + ["--port", "12345", "--credentials-hash", "credential-hash"], + ) + + assert result.exit_code == 0, result.output + assert discover.await_args.kwargs["port"] == 12345 + assert discover.await_args.kwargs["credentials_hash"] == "credential-hash" diff --git a/tests/test_discovery.py b/tests/test_discovery.py index e2b5042a6..5b47a591b 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -8,7 +8,7 @@ import re import socket from asyncio import timeout as asyncio_timeout -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 @@ -23,23 +23,29 @@ IotProtocol, KasaException, ) -from kasa.device_factory import ( - get_device_class_from_family, - get_device_class_from_sys_info, - get_protocol, -) +from kasa.device_factory import get_device_class_from_sys_info, get_protocol from kasa.deviceconfig import ( DeviceConfig, DeviceConnectionParameters, + DeviceFamily, ) from kasa.discover import ( DiscoveryResult, _AesDiscoveryQuery, _DiscoverProtocol, + _DiscoverySource, + _TdpDiscovery, + _UdpDiscovery, json_dumps, + select_discovery_response, +) +from kasa.exceptions import ( + AuthenticationError, + DiscoveryAuthenticationError, + UnsupportedAuthenticationError, + UnsupportedDeviceError, ) -from kasa.exceptions import AuthenticationError, UnsupportedDeviceError -from kasa.iot import IotDevice, IotPlug +from kasa.iot import IotDevice, IotStrip from kasa.transports.aestransport import AesEncyptionSession from kasa.transports.xortransport import XorEncryption, XorTransport @@ -52,6 +58,7 @@ strip_iot, wallswitch_iot, ) +from .discovery_fixtures import get_device_class_from_discovery # A physical device has to respond to discovery for the tests to work. pytestmark = [pytest.mark.requires_dummy] @@ -78,9 +85,40 @@ } +@pytest.mark.parametrize( + ("device_type", "encrypt_type", "new_klap", "expected_klap_version"), + [ + pytest.param("IOT.SMARTPLUGSWITCH", "KLAP", 1, 1, id="iot-versioned"), + pytest.param("IOT.SMARTPLUGSWITCH", "KLAP", 0, None, id="iot-original"), + pytest.param("IOT.SMARTPLUGSWITCH", "AES", 1, None, id="iot-non-klap"), + pytest.param("SMART.TAPOPLUG", "KLAP", 1, None, id="smart-klap"), + ], +) +def test_tdp_klap_version_normalization( + device_type, encrypt_type, new_klap, expected_klap_version +) -> None: + """Only a positive IOT KLAP new_klap value selects the handshake variant.""" + result = DiscoveryResult.from_dict( + { + "device_type": device_type, + "device_model": "MODEL(US)", + "device_id": "device-id", + "ip": "127.0.0.1", + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": encrypt_type, + "new_klap": new_klap, + }, + } + ) + + assert result.to_connection_parameters().klap_version == expected_klap_version + + @wallswitch_iot async def test_type_detection_switch(dev: Device): - d = Discover._get_device_class(dev._last_update)("localhost") + d = get_device_class_from_discovery(dev._last_update)("localhost") with pytest.deprecated_call(match="use device_type property instead"): assert d.is_wallswitch assert d.device_type is DeviceType.WallSwitch @@ -88,13 +126,13 @@ async def test_type_detection_switch(dev: Device): @plug_iot async def test_type_detection_plug(dev: Device): - d = Discover._get_device_class(dev._last_update)("localhost") + d = get_device_class_from_discovery(dev._last_update)("localhost") assert d.device_type == DeviceType.Plug @bulb_iot async def test_type_detection_bulb(dev: Device): - d = Discover._get_device_class(dev._last_update)("localhost") + d = get_device_class_from_discovery(dev._last_update)("localhost") # TODO: light_strip is a special case for now to force bulb tests on it if d.device_type is not DeviceType.LightStrip: @@ -103,30 +141,42 @@ async def test_type_detection_bulb(dev: Device): @strip_iot async def test_type_detection_strip(dev: Device): - d = Discover._get_device_class(dev._last_update)("localhost") + d = get_device_class_from_discovery(dev._last_update)("localhost") assert d.device_type == DeviceType.Strip @dimmer_iot async def test_type_detection_dimmer(dev: Device): - d = Discover._get_device_class(dev._last_update)("localhost") + d = get_device_class_from_discovery(dev._last_update)("localhost") assert d.device_type == DeviceType.Dimmer @lightstrip_iot async def test_type_detection_lightstrip(dev: Device): - d = Discover._get_device_class(dev._last_update)("localhost") + d = get_device_class_from_discovery(dev._last_update)("localhost") assert d.device_type == DeviceType.LightStrip @pytest.mark.xdist_group(name="caplog") async def test_type_unknown(caplog): invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} - assert Discover._get_device_class(invalid_info) is IotPlug - msg = "Unknown device type nosuchtype, falling back to plug" + with pytest.raises(UnsupportedDeviceError) as exc_info: + get_device_class_from_discovery(invalid_info) + assert exc_info.value.discovery_result == invalid_info + msg = "Unknown IOT device type nosuchtype" assert msg in caplog.text +def test_iot_camera_class_is_explicitly_unsupported() -> None: + """Recognized IOT cameras do not escape class resolution as a KeyError.""" + camera_info = {"system": {"get_sysinfo": {"system": {"model": "NC200"}}}} + + with pytest.raises(UnsupportedDeviceError, match="camera") as exc_info: + get_device_class_from_discovery(camera_info) + + assert exc_info.value.discovery_result == camera_info + + @pytest.mark.parametrize("custom_port", [123, None]) async def test_discover_single(discovery_mock, custom_port, mocker): """Make sure that discover_single returns an initialized SmartDevice instance.""" @@ -135,7 +185,7 @@ async def test_discover_single(discovery_mock, custom_port, mocker): discovery_mock.port_override = custom_port disco_data = discovery_mock.discovery_data - device_class = Discover._get_device_class(disco_data) + device_class = get_device_class_from_discovery(disco_data) http_port = ( DiscoveryResult.from_dict(disco_data["result"]).mgt_encrypt_schm.http_port if "result" in disco_data @@ -158,12 +208,18 @@ async def test_discover_single(discovery_mock, custom_port, mocker): # Make sure discovery does not call update() assert update_mock.call_count == 0 if discovery_mock.default_port != 9999: - assert x.alias is None + if isinstance(x, IotDevice): + assert ( + x.alias == discovery_mock.query_data["system"]["get_sysinfo"]["alias"] + ) + else: + assert x.alias is None ct = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, login_version=discovery_mock.login_version, + klap_version=discovery_mock.klap_version, https=discovery_mock.https, http_port=discovery_mock.http_port, ) @@ -182,7 +238,9 @@ async def test_discover_single_hostname(discovery_mock, mocker): ip = "127.0.0.1" discovery_mock.ip = ip - device_class = Discover._get_device_class(discovery_mock.discovery_data) + device_class = get_device_class_from_discovery( + discovery_mock.discovery_data, discovery_mock.query_data + ) update_mock = mocker.patch.object(device_class, "update") x = await Discover.discover_single(host, credentials=Credentials()) @@ -202,8 +260,6 @@ async def test_discover_credentials(mocker): async def mock_discover(self, *_, **__): self.discovered_devices = {host: MagicMock()} - self.seen_hosts.add(host) - self._handle_discovered_event() mocker.patch.object(_DiscoverProtocol, "do_discover", new=mock_discover) dp = mocker.spy(_DiscoverProtocol, "__init__") @@ -222,6 +278,9 @@ async def mock_discover(self, *_, **__): # Only un passed, credentials should be None await Discover.discover(username="Foo", timeout=0) assert dp.mock_calls[3].kwargs["credentials"] is None + # A credential hash is passed independently to the discovery protocol + await Discover.discover(credentials_hash="credential-hash", timeout=0) + assert dp.mock_calls[4].kwargs["credentials_hash"] == "credential-hash" async def test_discover_single_credentials(mocker): @@ -230,8 +289,6 @@ async def test_discover_single_credentials(mocker): async def mock_discover(self, *_, **__): self.discovered_devices = {host: MagicMock()} - self.seen_hosts.add(host) - self._handle_discovered_event() mocker.patch.object(_DiscoverProtocol, "do_discover", new=mock_discover) dp = mocker.spy(_DiscoverProtocol, "__init__") @@ -250,6 +307,9 @@ async def mock_discover(self, *_, **__): # Only un passed, credentials should be None await Discover.discover_single(host, username="Foo", timeout=0) assert dp.mock_calls[3].kwargs["credentials"] is None + # A credential hash is passed independently to the discovery protocol + await Discover.discover_single(host, credentials_hash="credential-hash", timeout=0) + assert dp.mock_calls[4].kwargs["credentials_hash"] == "credential-hash" async def test_discover_single_unsupported(unsupported_device_info, mocker): @@ -273,6 +333,34 @@ async def test_discover_single_no_response(mocker): await Discover.discover_single(host, discovery_timeout=0) +async def test_discover_single_construction_authentication_callback(mocker): + """Targeted construction authentication can be consumed by a callback.""" + host = "127.0.0.1" + mocker.patch( + "kasa.protocols.iotprotocol.IotProtocol.query", + new=AsyncMock(side_effect=AuthenticationError("Authentication failed")), + ) + + async def mock_discover(self): + self.datagram_received( + _tdp_datagram(AUTHENTICATION_DATA_KLAP), + (host, 20002), + ) + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + on_authentication_error = AsyncMock() + + device = await Discover.discover_single( + host, + on_authentication_error=on_authentication_error, + ) + + assert device is None + error = on_authentication_error.await_args.args[0] + assert isinstance(error, DiscoveryAuthenticationError) + assert error.host == host + + INVALIDS = [ ("No 'system' or 'get_sysinfo' in response", {"no": "data"}), ( @@ -304,7 +392,21 @@ async def test_discover_send(mocker): discovery_ports = 3 proto = _DiscoverProtocol(discovery_timeout=discovery_timeout) assert proto.discovery_packets == 3 - assert proto.target_1 == ("255.255.255.255", 9999) + assert [ + (discovery.source, discovery.port, discovery.defer_processing) + for discovery in proto._discoveries + ] == [ + (_DiscoverySource.Udp, 9999, True), + (_DiscoverySource.Tdp, 20002, False), + (_DiscoverySource.Tdp, 20004, False), + ] + tdp_discoveries = [ + discovery + for discovery in proto._discoveries + if discovery.source is _DiscoverySource.Tdp + ] + assert tdp_discoveries[0]._query is not tdp_discoveries[1]._query + assert tdp_discoveries[0]._query.keypair is not tdp_discoveries[1]._query.keypair transport = mocker.patch.object(proto, "transport") await proto.do_discover() assert transport.sendto.call_count == proto.discovery_packets * discovery_ports @@ -314,17 +416,21 @@ async def test_discover_datagram_received(mocker, discovery_data): """Verify that datagram received fills discovered_devices.""" proto = _DiscoverProtocol() - mocker.patch.object(XorEncryption, "decrypt") + mocker.patch.object(_TdpDiscovery, "decrypt_discovery_data") addr = "127.0.0.1" port = 20002 if "result" in discovery_data else 9999 + datagram = ( + _tdp_datagram(discovery_data) + if port == 20002 + else XorEncryption.encrypt(json_dumps(discovery_data))[4:] + ) - mocker.patch("kasa.discover.json_loads", return_value=discovery_data) - proto.datagram_received("", (addr, port)) + proto.datagram_received(datagram, (addr, port)) addr2 = "127.0.0.2" - mocker.patch("kasa.discover.json_loads", return_value=UNSUPPORTED) - proto.datagram_received("", (addr2, 20002)) + proto.datagram_received(_tdp_datagram(UNSUPPORTED), (addr2, 20002)) + await proto._finalize_discovery() # Check that device in discovered_devices is initialized correctly assert len(proto.discovered_devices) == 1 @@ -367,13 +473,34 @@ async def test_discover_invalid_responses(msg, data, mocker): "error_code": 0, } +TDP_DISCOVER_DATA = { + "result": { + "device_id": "tdp-device-id", + "owner": "owner", + "device_type": "SMART.TAPOPLUG", + "device_model": "P100(US)", + "ip": "127.0.0.1", + "mac": "12-34-56-78-90-AB", + "factory_default": False, + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": "AES", + "http_port": 80, + "lv": 2, + }, + }, + "error_code": 0, +} + @new_discovery async def test_discover_single_authentication(discovery_mock, mocker): """Make sure that discover_single handles authenticating devices correctly.""" host = "127.0.0.1" discovery_mock.ip = host - device_class = Discover._get_device_class(discovery_mock.discovery_data) + device_class = get_device_class_from_discovery( + discovery_mock.discovery_data, discovery_mock.query_data + ) mocker.patch.object( device_class, "update", @@ -399,7 +526,7 @@ async def test_discover_single_authentication(discovery_mock, mocker): async def test_device_update_from_new_discovery_info(discovery_mock): """Make sure that new discovery devices update from discovery info correctly.""" discovery_data = discovery_mock.discovery_data - device_class = Discover._get_device_class(discovery_data) + device_class = get_device_class_from_discovery(discovery_data) device = device_class("127.0.0.1") discover_info = DiscoveryResult.from_dict(discovery_data["result"]) @@ -452,7 +579,7 @@ async def test_discover_http_client(discovery_mock, mocker): assert x.protocol._transport._http_client.client == http_client -LEGACY_DISCOVER_DATA = { +UDP_DISCOVER_DATA = { "system": { "get_sysinfo": { "alias": "#MASKED_NAME#", @@ -471,6 +598,616 @@ async def test_discover_http_client(discovery_mock, mocker): } +def _tdp_datagram(info: dict) -> bytes: + return ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(info).encode() + ) + + +@pytest.mark.parametrize( + "ports", + [ + (9999, 20002), + (20002, 9999), + (9999, 20004), + (20004, 9999), + ], +) +async def test_tdp_discovery_takes_precedence(ports): + """A TDP response is authoritative regardless of response order or port.""" + host = "127.0.0.1" + raw_responses = [] + on_discovered = AsyncMock() + proto = _DiscoverProtocol( + on_discovered=on_discovered, + on_discovered_raw=raw_responses.append, + ) + datagrams = { + 9999: XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:], + 20002: _tdp_datagram(TDP_DISCOVER_DATA), + 20004: _tdp_datagram(TDP_DISCOVER_DATA), + } + + for port in ports: + proto.datagram_received(datagrams[port], (host, port)) + await proto._finalize_discovery() + await asyncio.gather(*proto.callback_tasks) + + device = proto.discovered_devices[host] + assert device.config.connection_type.device_family is DeviceFamily.SmartTapoPlug + assert [response["meta"]["source"] for response in raw_responses] == [ + _DiscoverySource.Tdp.value + ] + on_discovered.assert_called_once_with(device) + + +async def test_invalid_udp_response_does_not_hide_tdp() -> None: + """An invalid UDP response does not prevent a valid TDP result from winning.""" + host = "127.0.0.1" + proto = _DiscoverProtocol() + invalid_udp = XorEncryption.encrypt(json_dumps({"invalid": "response"}))[4:] + + proto.datagram_received(invalid_udp, (host, 9999)) + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (host, 20002)) + await proto._finalize_discovery() + + assert host in proto.discovered_devices + assert host not in proto.invalid_device_exceptions + + +async def test_authoritative_unsupported_tdp_does_not_fall_back_to_udp() -> None: + """A decoded unsupported TDP response remains authoritative over UDP.""" + host = "127.0.0.1" + proto = _DiscoverProtocol() + udp_datagram = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] + + proto.datagram_received(udp_datagram, (host, 9999)) + proto.datagram_received(_tdp_datagram(UNSUPPORTED), (host, 20002)) + await proto._finalize_discovery() + + assert host in proto.unsupported_device_exceptions + assert host not in proto.discovered_devices + + +async def test_undecodable_tdp_discards_udp() -> None: + """Any TDP datagram suppresses UDP even when TDP decoding fails.""" + host = "127.0.0.1" + proto = _DiscoverProtocol(target=host) + udp_datagram = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] + + proto.datagram_received(udp_datagram, (host, 9999)) + proto.datagram_received(b"invalid tdp", (host, 20002)) + await proto._wait_for_processing() + proto.datagram_received(udp_datagram, (host, 9999)) + + assert host not in proto.discovered_devices + assert host not in proto.invalid_device_exceptions + assert not proto._hosts[host].endpoints[9999].deferred_datagrams + assert not proto._target_complete.is_set() + + await proto._finalize_discovery() + + assert host in proto.invalid_device_exceptions + assert not proto._hosts[host].endpoints[9999].deferred_datagrams + assert proto._target_complete.is_set() + + +async def test_non_device_tdp_json_discards_udp() -> None: + """Decoded non-device TDP JSON remains authoritative over UDP.""" + host = "127.0.0.1" + proto = _DiscoverProtocol() + udp_datagram = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] + + proto.datagram_received(udp_datagram, (host, 9999)) + proto.datagram_received(_tdp_datagram({"not": "a device"}), (host, 20002)) + await proto._wait_for_processing() + + assert host not in proto.discovered_devices + assert host not in proto.invalid_device_exceptions + await proto._finalize_discovery() + assert host in proto.invalid_device_exceptions + assert not proto._hosts[host].endpoints[9999].deferred_datagrams + + +async def test_unusable_endpoint_emits_first_decoded_diagnostic() -> None: + """Raw discovery retains a decoded diagnostic when no response is usable.""" + host = "127.0.0.1" + response = {"not": "a device"} + raw_responses = [] + proto = _DiscoverProtocol(on_discovered_raw=raw_responses.append) + + proto.datagram_received(_tdp_datagram(response), (host, 20004)) + await proto._finalize_discovery() + + assert raw_responses == [ + { + "discovery_response": response, + "meta": {"ip": host, "port": 20004, "source": "tdp"}, + } + ] + + +@pytest.mark.parametrize( + ("discovery", "datagram", "message"), + [ + ( + _UdpDiscovery(9999), + XorEncryption.encrypt(json_dumps([]))[4:], + "UDP discovery response.*did not contain a JSON object", + ), + ( + _TdpDiscovery(20002), + _tdp_datagram([]), + "TDP discovery response.*did not contain a JSON object", + ), + ], +) +def test_discovery_response_must_be_json_object( + discovery, datagram: bytes, message: str +) -> None: + """Valid non-object JSON is rejected by the endpoint parser.""" + with pytest.raises(KasaException, match=message): + discovery.parse_response(datagram, "127.0.0.1") + + +async def test_tdp_endpoints_handle_separate_device_populations() -> None: + """Ports 20002 and 20004 independently discover devices at different IPs.""" + regular_host = "127.0.0.1" + low_energy_host = "127.0.0.2" + proto = _DiscoverProtocol() + + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (regular_host, 20002)) + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (low_energy_host, 20004)) + await proto._wait_for_processing() + + assert set(proto.discovered_devices) == {regular_host, low_energy_host} + assert proto._hosts[regular_host].tdp_port == 20002 + assert proto._hosts[low_energy_host].tdp_port == 20004 + + +@pytest.mark.xdist_group(name="caplog") +async def test_unexpected_second_tdp_endpoint_is_ignored( + caplog: pytest.LogCaptureFixture, +) -> None: + """A same-IP response on both TDP ports is an invariant violation.""" + host = "127.0.0.1" + raw_responses = [] + proto = _DiscoverProtocol(on_discovered_raw=raw_responses.append) + + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (host, 20002)) + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (host, 20004)) + await proto._wait_for_processing() + + assert proto._hosts[host].tdp_port == 20002 + assert [response["meta"]["port"] for response in raw_responses] == [20002] + assert "unexpectedly responded on TDP ports 20002 and 20004" in caplog.text + assert "tdp-device-id" not in caplog.text + + +async def test_invalid_tdp_schema_is_not_unsupported() -> None: + """A structurally invalid TDP result is a malformed response.""" + host = "127.0.0.1" + invalid_result = { + "result": { + "device_type": "SMART.TAPOPLUG", + "ip": host, + } + } + proto = _DiscoverProtocol() + + proto.datagram_received(_tdp_datagram(invalid_result), (host, 20002)) + await proto._finalize_discovery() + + assert host in proto.invalid_device_exceptions + assert host not in proto.unsupported_device_exceptions + + +async def test_tdp_device_is_emitted_before_finalization() -> None: + """A supported TDP response is emitted as soon as it is verified.""" + host = "127.0.0.1" + on_discovered = AsyncMock() + proto = _DiscoverProtocol(on_discovered=on_discovered) + + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (host, 20002)) + await proto._wait_for_processing() + await asyncio.gather(*proto.callback_tasks) + + device = proto.discovered_devices[host] + on_discovered.assert_awaited_once_with(device) + + +async def test_udp_device_is_held_until_finalization() -> None: + """A UDP-only datagram is not processed before the receive window closes.""" + host = "127.0.0.1" + raw_responses = [] + proto = _DiscoverProtocol(on_discovered_raw=raw_responses.append) + udp_datagram = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] + + proto.datagram_received(udp_datagram, (host, 9999)) + await asyncio.sleep(0) + + assert host not in proto.discovered_devices + assert proto._hosts[host].endpoints[9999].candidate is None + assert len(proto._hosts[host].endpoints[9999].deferred_datagrams) == 1 + assert not raw_responses + await proto._finalize_discovery() + assert host in proto.discovered_devices + assert len(raw_responses) == 1 + + +async def test_duplicate_raw_response_is_emitted_once_per_source_port() -> None: + """Repeated discovery rounds do not duplicate raw callback output.""" + host = "127.0.0.1" + raw_responses = [] + proto = _DiscoverProtocol(on_discovered_raw=raw_responses.append) + udp_datagram = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] + + proto.datagram_received(udp_datagram, (host, 9999)) + proto.datagram_received(udp_datagram, (host, 9999)) + + assert not raw_responses + await proto._finalize_discovery() + assert len(raw_responses) == 1 + + +async def test_raw_callback_cannot_mutate_device_candidate() -> None: + """Raw callback changes do not alter factory initialization data.""" + host = "127.0.0.1" + + def mutate_raw(response) -> None: + response["discovery_response"].clear() + + proto = _DiscoverProtocol(on_discovered_raw=mutate_raw) + udp_datagram = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] + + proto.datagram_received(udp_datagram, (host, 9999)) + await proto._finalize_discovery() + + assert host in proto.discovered_devices + assert proto.discovered_devices[host].model + + +@pytest.mark.parametrize("valid_first", [True, False]) +async def test_udp_uses_first_usable_repeated_response(valid_first: bool) -> None: + """UDP finalization scans repeated responses until the first usable one.""" + host = "127.0.0.1" + raw_responses = [] + proto = _DiscoverProtocol(on_discovered_raw=raw_responses.append) + valid = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] + invalid = XorEncryption.encrypt(json_dumps({"invalid": "response"}))[4:] + + datagrams = (valid, invalid) if valid_first else (invalid, valid) + for datagram in datagrams: + proto.datagram_received(datagram, (host, 9999)) + await proto._finalize_discovery() + + assert host in proto.discovered_devices + assert raw_responses == [ + { + "discovery_response": UDP_DISCOVER_DATA, + "meta": {"ip": host, "port": 9999, "source": "udp"}, + } + ] + + +@pytest.mark.parametrize("valid_first", [True, False]) +async def test_tdp_uses_first_usable_repeated_response(valid_first: bool) -> None: + """A TDP endpoint accepts its first usable repeated response.""" + host = "127.0.0.1" + raw_responses = [] + proto = _DiscoverProtocol(on_discovered_raw=raw_responses.append) + valid = _tdp_datagram(TDP_DISCOVER_DATA) + invalid = _tdp_datagram({"not": "a device"}) + + datagrams = (valid, invalid) if valid_first else (invalid, valid) + for datagram in datagrams: + proto.datagram_received(datagram, (host, 20002)) + await proto._wait_for_processing() + + assert host in proto.discovered_devices + assert raw_responses == [ + { + "discovery_response": TDP_DISCOVER_DATA, + "meta": {"ip": host, "port": 20002, "source": "tdp"}, + } + ] + + +async def test_tdp_endpoint_error_allows_later_usable_response() -> None: + """A response-normalization error does not finalize its TDP endpoint.""" + host = "127.0.0.1" + incomplete = json.loads(json.dumps(TDP_DISCOVER_DATA)) + del incomplete["result"]["mgt_encrypt_schm"] + raw_responses = [] + proto = _DiscoverProtocol(on_discovered_raw=raw_responses.append) + + proto.datagram_received(_tdp_datagram(incomplete), (host, 20002)) + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (host, 20002)) + await proto._wait_for_processing() + + assert host in proto.discovered_devices + assert host not in proto.unsupported_device_exceptions + assert raw_responses[0]["discovery_response"] == TDP_DISCOVER_DATA + + +async def test_tdp_uses_normalized_source_ip_without_mutating_raw() -> None: + """Connections use the source IP while raw data retains the advertised IP.""" + source_ip = "127.0.0.2" + advertised_ip = TDP_DISCOVER_DATA["result"]["ip"] + raw_responses = [] + proto = _DiscoverProtocol(on_discovered_raw=raw_responses.append) + + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (source_ip, 20002)) + await proto._wait_for_processing() + + device = proto.discovered_devices[source_ip] + assert device.host == source_ip + assert device._discovery_info["ip"] == source_ip + assert raw_responses[0]["discovery_response"]["result"]["ip"] == advertised_ip + + +@pytest.mark.parametrize("port", [20002, 20004]) +def test_tdp_ports_cannot_override_udp_discovery(port: int) -> None: + """The UDP override cannot collide with a fixed TDP endpoint.""" + with pytest.raises(KasaException, match="reserved for TDP discovery"): + _DiscoverProtocol(port=port) + + +async def test_finalization_ignores_late_datagrams(mocker) -> None: + """The candidate set is frozen before final device creation awaits.""" + host = "127.0.0.1" + late_host = "127.0.0.2" + proto = _DiscoverProtocol() + udp_datagram = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] + proto.datagram_received(udp_datagram, (host, 9999)) + device = MagicMock(spec=Device) + device.host = host + + async def create_device(*args, **kwargs): + proto.datagram_received(udp_datagram, (late_host, 9999)) + return device + + mocker.patch.object(proto, "_create_device", side_effect=create_device) + await proto._finalize_discovery() + + assert set(proto.discovered_devices) == {host} + assert late_host not in proto._hosts + + +async def test_tdp_discards_held_and_future_udp() -> None: + """TDP erases held UDP and ignores every later UDP datagram.""" + host = "127.0.0.1" + raw_responses = [] + proto = _DiscoverProtocol(on_discovered_raw=raw_responses.append) + udp_datagram = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] + + proto.datagram_received(udp_datagram, (host, 9999)) + assert len(proto._hosts[host].endpoints[9999].deferred_datagrams) == 1 + + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (host, 20002)) + await proto._wait_for_processing() + device = proto.discovered_devices[host] + + proto.datagram_received(udp_datagram, (host, 9999)) + + assert proto.discovered_devices[host] is device + assert device.config.connection_type.device_family is DeviceFamily.SmartTapoPlug + assert proto._hosts[host].endpoints[20002].candidate.source is _DiscoverySource.Tdp + assert not proto._hosts[host].endpoints[9999].deferred_datagrams + assert [response["meta"]["source"] for response in raw_responses] == [ + _DiscoverySource.Tdp.value + ] + + +async def test_unsupported_tdp_is_emitted_at_finalization() -> None: + """An unsupported TDP result allows repeated responses before reporting.""" + host = "127.0.0.1" + on_unsupported = AsyncMock() + proto = _DiscoverProtocol(on_unsupported=on_unsupported) + + proto.datagram_received(_tdp_datagram(UNSUPPORTED), (host, 20002)) + await proto._finalize_discovery() + await asyncio.gather(*proto.callback_tasks) + + error = proto.unsupported_device_exceptions[host] + on_unsupported.assert_awaited_once_with(error) + + +async def test_tdp_iot_family_resolves_concrete_device_class(mocker) -> None: + """A broad IOT TDP family is resolved from get_sysinfo before creation.""" + host = "127.0.0.1" + strip_sysinfo = { + "system": { + "get_sysinfo": { + **UDP_DISCOVER_DATA["system"]["get_sysinfo"], + "children": [{"id": "socket-1"}], + "model": "HS300(US)", + } + } + } + query = mocker.patch( + "kasa.protocols.iotprotocol.IotProtocol.query", + new=AsyncMock(return_value=strip_sysinfo), + ) + proto = _DiscoverProtocol() + + proto.datagram_received( + _tdp_datagram(AUTHENTICATION_DATA_KLAP), + (host, 20002), + ) + await proto._finalize_discovery() + + device = proto.discovered_devices[host] + assert isinstance(device, IotStrip) + assert device._last_update == strip_sysinfo + assert device._discovery_info["device_type"] == "IOT.SMARTPLUGSWITCH" + query.assert_awaited_once_with({"system": {"get_sysinfo": {}}}) + + +async def test_tdp_iot_discards_held_udp_sysinfo(mocker) -> None: + """A TDP IOT candidate erases held UDP before class resolution.""" + host = "127.0.0.1" + strip_sysinfo = { + "system": { + "get_sysinfo": { + **UDP_DISCOVER_DATA["system"]["get_sysinfo"], + "children": [{"id": "socket-1"}], + "model": "HS300(US)", + } + } + } + query = mocker.patch( + "kasa.protocols.iotprotocol.IotProtocol.query", + new=AsyncMock(side_effect=AuthenticationError("Authentication failed")), + ) + on_authentication_error = AsyncMock() + proto = _DiscoverProtocol(on_authentication_error=on_authentication_error) + + udp_datagram = XorEncryption.encrypt(json_dumps(strip_sysinfo))[4:] + proto.datagram_received(udp_datagram, (host, 9999)) + proto.datagram_received( + _tdp_datagram(AUTHENTICATION_DATA_KLAP), + (host, 20002), + ) + await proto._wait_for_processing() + await asyncio.gather(*proto.callback_tasks) + + assert host not in proto.discovered_devices + error = proto.authentication_exceptions[host] + assert error.discovery_result == AUTHENTICATION_DATA_KLAP + on_authentication_error.assert_awaited_once_with(error) + assert proto._hosts[host].endpoints[20002].candidate.source is _DiscoverySource.Tdp + assert not proto._hosts[host].endpoints[9999].deferred_datagrams + query.assert_awaited_once_with({"system": {"get_sysinfo": {}}}) + + +def test_select_discovery_response_uses_source_authority() -> None: + """Shared raw-response selection uses source authority, not input order.""" + udp_response = { + "discovery_response": UDP_DISCOVER_DATA, + "meta": {"ip": "127.0.0.1", "port": 9999, "source": "udp"}, + } + tdp_response = { + "discovery_response": TDP_DISCOVER_DATA, + "meta": {"ip": "127.0.0.1", "port": 20002, "source": "tdp"}, + } + + assert select_discovery_response([tdp_response, udp_response]) is tdp_response + assert select_discovery_response([udp_response, tdp_response]) is tdp_response + + invalid_tdp_response = { + "discovery_response": {"not": "a device"}, + "meta": {"ip": "127.0.0.1", "port": 20002, "source": "tdp"}, + } + assert ( + select_discovery_response([invalid_tdp_response, udp_response]) + is invalid_tdp_response + ) + + tdp_20004_response = { + "discovery_response": TDP_DISCOVER_DATA, + "meta": {"ip": "127.0.0.2", "port": 20004, "source": "tdp"}, + } + assert select_discovery_response([tdp_20004_response]) is tdp_20004_response + + +async def test_tdp_iot_auth_failure_is_emitted_immediately(mocker) -> None: + """TDP-only IOT authentication is reported before finalization.""" + host = "127.0.0.1" + query = mocker.patch( + "kasa.protocols.iotprotocol.IotProtocol.query", + new=AsyncMock(side_effect=AuthenticationError("Authentication failed")), + ) + on_authentication_error = AsyncMock() + proto = _DiscoverProtocol(on_authentication_error=on_authentication_error) + + proto.datagram_received( + _tdp_datagram(AUTHENTICATION_DATA_KLAP), + (host, 20002), + ) + await proto._wait_for_processing() + await asyncio.gather(*proto.callback_tasks) + + error = proto.authentication_exceptions[host] + assert isinstance(error, DiscoveryAuthenticationError) + assert error.host == host + assert error.discovery_result == AUTHENTICATION_DATA_KLAP + assert host not in proto.invalid_device_exceptions + on_authentication_error.assert_awaited_once_with(error) + assert query.await_count == 1 + + +async def test_tdp_iot_unsupported_authentication_is_reported(mocker) -> None: + """IOT class resolution classifies unsupported onboarding after auth fails.""" + host = "127.0.0.1" + discovery_data = json.loads(json.dumps(AUTHENTICATION_DATA_KLAP)) + discovery_data["result"]["obd_src"] = "amazon" + mocker.patch( + "kasa.protocols.iotprotocol.IotProtocol.query", + new=AsyncMock(side_effect=AuthenticationError("Authentication failed")), + ) + on_unsupported = AsyncMock() + proto = _DiscoverProtocol(on_unsupported=on_unsupported) + + proto.datagram_received(_tdp_datagram(discovery_data), (host, 20002)) + await proto._wait_for_processing() + await asyncio.gather(*proto.callback_tasks) + + error = proto.unsupported_device_exceptions[host] + assert isinstance(error, UnsupportedAuthenticationError) + assert error.onboarding_source == "amazon" + assert error.host == host + on_unsupported.assert_awaited_once_with(error) + + +async def test_callback_unsupported_keeps_constructed_device() -> None: + """An unsupported update outcome does not remove a constructed device.""" + host = "127.0.0.1" + on_unsupported = AsyncMock() + + async def on_discovered(device: Device) -> None: + raise UnsupportedDeviceError("Unsupported after creation", host=device.host) + + proto = _DiscoverProtocol( + on_discovered=on_discovered, + on_unsupported=on_unsupported, + ) + proto.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (host, 20002)) + await proto._wait_for_processing() + await asyncio.gather(*proto.callback_tasks) + + assert host in proto.discovered_devices + error = proto.unsupported_device_exceptions[host] + on_unsupported.assert_awaited_once_with(error) + + +async def test_discover_single_uses_callback_error_pipeline(mocker) -> None: + """Targeted discovery uses the same callback classification as broadcast.""" + host = "127.0.0.1" + + async def mock_discover(self) -> None: + self.datagram_received(_tdp_datagram(TDP_DISCOVER_DATA), (host, 20002)) + + async def on_discovered(device: Device) -> None: + raise UnsupportedDeviceError("Unsupported after creation", host=device.host) + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + on_unsupported = AsyncMock() + + device = await Discover.discover_single( + host, + on_discovered=on_discovered, + on_unsupported=on_unsupported, + ) + + assert device is not None + assert device.host == host + error = on_unsupported.await_args.args[0] + assert error.discovery_result["device_id"] == "tdp-device-id" + await device.disconnect() + + class FakeDatagramTransport(asyncio.DatagramTransport): GHOST_PORT = 8888 @@ -480,9 +1217,9 @@ def __init__(self, dp, port, do_not_reply_count, unsupported=False): self.do_not_reply_count = do_not_reply_count self.send_count = 0 if port == 9999: - self.datagram = XorEncryption.encrypt(json_dumps(LEGACY_DISCOVER_DATA))[4:] + self.datagram = XorEncryption.encrypt(json_dumps(UDP_DISCOVER_DATA))[4:] elif port == 20002: - discovery_data = UNSUPPORTED if unsupported else AUTHENTICATION_DATA_KLAP + discovery_data = UNSUPPORTED if unsupported else TDP_DISCOVER_DATA self.datagram = ( b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + json_dumps(discovery_data).encode() @@ -519,9 +1256,10 @@ async def test_do_discover_drop_packets(mocker, port, do_not_reply_count): await dp.wait_for_discovery_to_complete() await asyncio.sleep(0) - assert ft.send_count == do_not_reply_count + 1 + expected_sends = do_not_reply_count + 1 if port == 20002 else dp.discovery_packets + assert ft.send_count == expected_sends assert dp.discover_task.done() - assert dp.discover_task.cancelled() + assert not dp.discover_task.cancelled() @pytest.mark.parametrize( @@ -545,7 +1283,11 @@ async def test_do_discover_invalid(mocker, port, will_timeout): await dp.wait_for_discovery_to_complete() await asyncio.sleep(0) assert dp.discover_task.done() - assert dp.discover_task.cancelled() != will_timeout + assert not dp.discover_task.cancelled() + if will_timeout: + assert not dp._hosts + else: + assert host in dp.unsupported_device_exceptions async def test_discover_propogates_task_exceptions(discovery_mock): @@ -561,6 +1303,38 @@ async def on_discovered(dev): ) +async def test_completed_processing_task_exception_is_propagated(mocker) -> None: + """A processing task that already failed is still consumed by the drain.""" + proto = _DiscoverProtocol() + mocker.patch.object( + proto, + "_process_candidate", + new=AsyncMock(side_effect=KasaException("processing failed")), + ) + + proto._run_processing_task("127.0.0.1", 20002) + await asyncio.sleep(0) + assert proto._processing_tasks[0].done() + + with pytest.raises(KasaException, match="processing failed"): + await proto._wait_for_processing() + + +async def test_completed_callback_task_exception_is_propagated() -> None: + """A callback that already failed is still consumed by the callback drain.""" + proto = _DiscoverProtocol() + + async def fail() -> None: + raise KasaException("callback failed") + + proto._run_callback_task(fail()) + await asyncio.sleep(0) + assert proto.callback_tasks[0].done() + + with pytest.raises(KasaException, match="callback failed"): + await proto._wait_for_callbacks() + + async def test_do_discover_no_connection(mocker): """Make sure that if the datagram connection doesnt start a TimeoutError is raised.""" host = "127.0.0.1" @@ -640,8 +1414,9 @@ async def test_discovery_decryption(): iv = b"9=\xf8\x1bS\xcd0\xb5\x89i\xba\xfd^9\x9f\xfa" key_iv = key + iv - _AesDiscoveryQuery.generate_query() - keypair = _AesDiscoveryQuery.keypair + query = _AesDiscoveryQuery() + query.generate_query() + keypair = query.keypair padding = asymmetric_padding.OAEP( mgf=asymmetric_padding.MGF1(algorithm=hashes.SHA1()), # noqa: S303 @@ -663,20 +1438,21 @@ async def test_discovery_decryption(): } info = {**UNSUPPORTED["result"], "encrypt_info": encrypt_info} dr = DiscoveryResult.from_dict(info) - Discover._decrypt_discovery_data(dr) + _TdpDiscovery.decrypt_discovery_data(dr, keypair=keypair) assert dr.decrypted_data == data_dict async def test_discover_try_connect_all(discovery_mock, mocker): """Test that device update is called on main.""" if "result" in discovery_mock.discovery_data: - dev_class = get_device_class_from_family( - discovery_mock.device_type, https=discovery_mock.https + dev_class = get_device_class_from_discovery( + discovery_mock.discovery_data, discovery_mock.query_data ) cparams = DeviceConnectionParameters.from_values( discovery_mock.device_type, discovery_mock.encrypt_type, login_version=discovery_mock.login_version, + klap_version=discovery_mock.klap_version, https=discovery_mock.https, http_port=discovery_mock.http_port, ) @@ -733,7 +1509,9 @@ async def test_discovery_device_repr(discovery_mock, mocker): ip = "127.0.0.1" discovery_mock.ip = ip - device_class = Discover._get_device_class(discovery_mock.discovery_data) + device_class = get_device_class_from_discovery( + discovery_mock.discovery_data, discovery_mock.query_data + ) update_mock = mocker.patch.object(device_class, "update") dev = await Discover.discover_single(host, credentials=Credentials())