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())