From 17c22fb7535afe4246735eeb8dc7c9be46488cc7 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Mon, 29 Sep 2025 13:28:12 -0400 Subject: [PATCH 01/26] Implement KlapTransportV3 for new_klap --- devtools/dump_devinfo.py | 3 +- kasa/cli/hub.py | 10 +- kasa/device_factory.py | 9 + kasa/deviceconfig.py | 16 ++ kasa/discover.py | 2 + kasa/transports/__init__.py | 3 +- kasa/transports/klaptransport.py | 188 +++++++++++++++++- tests/discovery_fixtures.py | 4 + .../deviceconfig_plug-new-klap.json | 11 + tests/protocols/test_iotprotocol.py | 20 +- tests/smart/modules/test_childsetup.py | 8 +- tests/smart/modules/test_powerprotection.py | 6 +- tests/smartcam/modules/test_childsetup.py | 8 +- tests/test_cli.py | 4 +- tests/test_device_factory.py | 7 + tests/test_deviceconfig.py | 11 + tests/test_discovery.py | 2 + tests/transports/test_klaptransport.py | 89 +++++++-- 18 files changed, 360 insertions(+), 41 deletions(-) create mode 100644 tests/fixtures/serialization/deviceconfig_plug-new-klap.json diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index bbe1e8130..ae8a721e2 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -303,6 +303,7 @@ def capture_raw(discovered: DiscoveredRaw): login_version=dr.mgt_encrypt_schm.lv, https=dr.mgt_encrypt_schm.is_support_https, http_port=dr.mgt_encrypt_schm.http_port, + new_klap=dr.mgt_encrypt_schm.new_klap, ) dc = DeviceConfig( host=host, @@ -468,7 +469,7 @@ async def get_legacy_fixture( else: child["id"] = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" - if discovery_info and not discovery_info.get("system"): + if discovery_info: final["discovery_result"] = redact_data( discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS) ) diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py index de4b60715..0bc45bf5a 100644 --- a/kasa/cli/hub.py +++ b/kasa/cli/hub.py @@ -4,7 +4,7 @@ import asyncclick as click -from kasa import DeviceType, Module, SmartDevice +from kasa import Device, DeviceType, Module from kasa.smart import SmartChildDevice from .common import ( @@ -21,7 +21,7 @@ def pretty_category(cat: str): @click.group() @pass_dev -async def hub(dev: SmartDevice): +async def hub(dev: Device): """Commands controlling hub child device pairing.""" if dev.device_type is not DeviceType.Hub: error(f"{dev} is not a hub.") @@ -32,7 +32,7 @@ async def hub(dev: SmartDevice): @hub.command(name="list") @pass_dev -async def hub_list(dev: SmartDevice): +async def hub_list(dev: Device): """List hub paired child devices.""" for c in dev.children: echo(f"{c.device_id}: {c}") @@ -40,7 +40,7 @@ async def hub_list(dev: SmartDevice): @hub.command(name="supported") @pass_dev -async def hub_supported(dev: SmartDevice): +async def hub_supported(dev: Device): """List supported hub child device categories.""" cs = dev.modules[Module.ChildSetup] @@ -51,7 +51,7 @@ async def hub_supported(dev: SmartDevice): @hub.command(name="pair") @click.option("--timeout", default=10) @pass_dev -async def hub_pair(dev: SmartDevice, timeout: int): +async def hub_pair(dev: Device, timeout: int): """Pair all pairable device. This will pair any child devices currently in pairing mode. diff --git a/kasa/device_factory.py b/kasa/device_factory.py index ecb0d0a13..4337c15c8 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -32,6 +32,7 @@ BaseTransport, KlapTransport, KlapTransportV2, + KlapTransportV3, LinkieTransportV2, SslTransport, XorTransport, @@ -216,6 +217,14 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol ): return SmartProtocol(transport=SslTransport(config=config)) + if ( + protocol_name == "IOT" + and ctype.encryption_type.value == "KLAP" + and ctype.new_klap is not None + and ctype.new_klap > 0 + ): + return IotProtocol(transport=KlapTransportV3(config=config)) + protocol_transport_key = ( protocol_name + "." diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 2b669f809..01f084ad6 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -101,6 +101,7 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin): login_version: int | None = None https: bool = False http_port: int | None = None + new_klap: int | None = None @staticmethod def from_values( @@ -110,6 +111,7 @@ def from_values( login_version: int | None = None, https: bool | None = None, http_port: int | None = None, + new_klap: int | None = None, ) -> DeviceConnectionParameters: """Return connection parameters from string values.""" try: @@ -121,6 +123,7 @@ def from_values( login_version, https, http_port=http_port, + new_klap=new_klap, ) except (ValueError, TypeError) as ex: raise KasaException( @@ -128,6 +131,19 @@ def from_values( + f"{encryption_type}.{login_version}" ) from ex + class Config(_DeviceConfigBaseMixin.Config): + """Mashumaro config for DeviceConnectionParameters.""" + + @classmethod + def pre_serialize(cls, data: dict, obj: DeviceConnectionParameters) -> dict: + """Pre-serialization hook to omit new_klap if None or 0.""" + if "new_klap" in data and ( + data["new_klap"] is None or data["new_klap"] == 0 + ): + data = dict(data) + del data["new_klap"] + return data + @dataclass class DeviceConfig(_DeviceConfigBaseMixin): diff --git a/kasa/discover.py b/kasa/discover.py index 8e2b981af..672741492 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -839,6 +839,7 @@ def _get_connection_parameters( login_version=login_version, https=encrypt_schm.is_support_https, http_port=encrypt_schm.http_port, + new_klap=encrypt_schm.new_klap, ) @staticmethod @@ -950,6 +951,7 @@ class EncryptionScheme(_DiscoveryBaseMixin): encrypt_type: str | None = None http_port: int | None = None lv: int | None = None + new_klap: int | None = None @dataclass diff --git a/kasa/transports/__init__.py b/kasa/transports/__init__.py index 192b4156a..353fd6905 100644 --- a/kasa/transports/__init__.py +++ b/kasa/transports/__init__.py @@ -2,7 +2,7 @@ from .aestransport import AesEncyptionSession, AesTransport from .basetransport import BaseTransport -from .klaptransport import KlapTransport, KlapTransportV2 +from .klaptransport import KlapTransport, KlapTransportV2, KlapTransportV3 from .linkietransport import LinkieTransportV2 from .sslaestransport import SslAesTransport from .ssltransport import SslTransport @@ -16,6 +16,7 @@ "BaseTransport", "KlapTransport", "KlapTransportV2", + "KlapTransportV3", "LinkieTransportV2", "XorTransport", "XorEncryption", diff --git a/kasa/transports/klaptransport.py b/kasa/transports/klaptransport.py index 8253e0aef..5e4efdc47 100644 --- a/kasa/transports/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -85,6 +85,10 @@ def _sha1(payload: bytes) -> bytes: return hashlib.sha1(payload).digest() # noqa: S324 +def _to_hex_ascii(payload: bytes) -> bytes: + return payload.hex().encode("ascii") # noqa: S324 + + class KlapTransport(BaseTransport): """Implementation of the KLAP encryption protocol. @@ -458,11 +462,11 @@ async def _get_ssl_context(self) -> ssl.SSLContext: class KlapTransportV2(KlapTransport): - """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" + """Implementation of the KLAP encryption protocol with v2 handshake hashes.""" @staticmethod def generate_auth_hash(creds: Credentials) -> bytes: - """Generate an md5 auth hash for the protocol on the supplied credentials.""" + """Generate an sha1 auth hash for the protocol on the supplied credentials.""" un = creds.username pw = creds.password @@ -472,17 +476,193 @@ def generate_auth_hash(creds: Credentials) -> bytes: def handshake1_seed_auth_hash( local_seed: bytes, remote_seed: bytes, auth_hash: bytes ) -> bytes: - """Generate an md5 auth hash for the protocol on the supplied credentials.""" + """Generate an sha1 auth hash for the protocol on the supplied credentials.""" return _sha256(local_seed + remote_seed + auth_hash) @staticmethod def handshake2_seed_auth_hash( local_seed: bytes, remote_seed: bytes, auth_hash: bytes ) -> bytes: - """Generate an md5 auth hash for the protocol on the supplied credentials.""" + """Generate an sha1 auth hash for the protocol on the supplied credentials.""" return _sha256(remote_seed + local_seed + auth_hash) +class KlapTransportV3(KlapTransport): + """Implementation of the KLAP encryption protocol with new_klap handshake hashes.""" + + @staticmethod + def generate_auth_hash(creds: Credentials) -> bytes: + """Generate an sha1 auth hash for the protocol on the supplied credentials.""" + un = creds.username + pw = creds.password + return _sha256(_sha1(un.encode()) + _sha1(pw.encode())) + + @staticmethod + def handshake1_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ) -> bytes: + """Generate an sha1 auth hash for the protocol on the supplied credentials.""" + return _sha256(local_seed + remote_seed + auth_hash) + + @staticmethod + def handshake2_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ) -> bytes: + """Generate an sha1 auth hash for the protocol on the supplied credentials.""" + return _sha256(remote_seed + local_seed + auth_hash) + + async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: + """Preform handshake1.""" + local_seed: bytes = secrets.token_bytes(16) + + # Handshake 1 has a payload of local_seed in ASCII hex + # and a response of 16 bytes, followed by 32 bytes + # sha256(local_seed | remote_seed | auth_hash) + + payload = _to_hex_ascii(local_seed) + + url = self._app_url / "handshake1" + + response_status, response_data = await self._http_client.post( + url, data=payload, ssl=await self._get_ssl_context() + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake1 posted at %s. Host is %s, " + "Response status is %s, Request was %s", + datetime.datetime.now(), + self._host, + response_status, + payload.decode("ascii"), + ) + + if response_status != 200: + raise KasaException( + f"Device {self._host} responded with {response_status} to handshake1" + ) + + response_data = cast(bytes, response_data) + remote_seed: bytes = response_data[0:16] + server_hash = response_data[16:] + + if len(response_data) != 48: + raise KasaException( + f"Device {self._host} responded with unexpected klap response " + + f"{response_data!r} to handshake1" + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake1 success at %s. Host is %s, " + "Server remote_seed is: %s, server hash is: %s", + datetime.datetime.now(), + self._host, + remote_seed.hex(), + server_hash.hex(), + ) + + local_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, remote_seed, self._local_auth_hash + ) # type: ignore + + # Check the response from the device with local credentials + if local_seed_auth_hash == server_hash: + _LOGGER.debug("handshake1 hashes match with expected credentials") + return local_seed, remote_seed, self._local_auth_hash # type: ignore + + # Now check against the default setup credentials + for key, value in DEFAULT_CREDENTIALS.items(): + if key not in self._default_credentials_auth_hash: + default_credentials = get_default_credentials(value) + self._default_credentials_auth_hash[key] = self.generate_auth_hash( + default_credentials + ) + + default_credentials_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, + remote_seed, + self._default_credentials_auth_hash[key], # type: ignore + ) + + if default_credentials_seed_auth_hash == server_hash: + _LOGGER.debug( + "Device response did not match our expected hash on ip %s," + "but an authentication with %s default credentials worked", + self._host, + key, + ) + return local_seed, remote_seed, self._default_credentials_auth_hash[key] # type: ignore + + # Finally check against blank credentials if not already blank + blank_creds = Credentials() + if self._credentials != blank_creds: + if not self._blank_auth_hash: + self._blank_auth_hash = self.generate_auth_hash(blank_creds) + + blank_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, + remote_seed, + self._blank_auth_hash, # type: ignore + ) + + if blank_seed_auth_hash == server_hash: + _LOGGER.debug( + "Device response did not match our expected hash on ip %s, " + "but an authentication with blank credentials worked", + self._host, + ) + return local_seed, remote_seed, self._blank_auth_hash # type: ignore + + msg = ( + f"Device response did not match our challenge on ip {self._host}, " + f"check that your e-mail and password (both case-sensitive) are correct. " + ) + _LOGGER.debug(msg) + raise AuthenticationError(msg) + + async def perform_handshake2( + self, local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ) -> KlapEncryptionSession: + """Perform handshake2.""" + # Handshake 2 has the following payload: + # sha256(remote_seed | local_seed | auth_hash) + # which is sent as ASCII hex + + url = self._app_url / "handshake2" + + payload_hash = self.handshake2_seed_auth_hash( + local_seed, remote_seed, auth_hash + ) + payload = _to_hex_ascii(payload_hash) + + response_status, _ = await self._http_client.post( + url, + data=payload, + cookies_dict=self._session_cookie, + ssl=await self._get_ssl_context(), + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake2 posted %s. Host is %s, " + "Response status is %s, Request was %s", + datetime.datetime.now(), + self._host, + response_status, + payload.decode("ascii"), + ) + + if response_status != 200: + # This shouldn't be caused by incorrect + # credentials so don't raise AuthenticationError + raise KasaException( + f"Device {self._host} responded with {response_status} to handshake2" + ) + + return KlapEncryptionSession(local_seed, remote_seed, auth_hash) + + class KlapEncryptionSession: """Class to represent an encryption session and it's internal state. diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index 3cf726f48..a256395e8 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -164,6 +164,7 @@ class _DiscoveryMock: login_version: int | None = None port_override: int | None = None http_port: int | None = None + new_klap: int | None = None @property def model(self) -> str: @@ -205,6 +206,7 @@ def _datagram(self) -> bytes: default_port = 443 if https else 80 else: default_port = http_port + new_klap = discovery_result["mgt_encrypt_schm"].get("new_klap", None) dm = _DiscoveryMock( ip, default_port, @@ -216,6 +218,7 @@ def _datagram(self) -> bytes: https, login_version, http_port=http_port, + new_klap=new_klap, ) else: sys_info = fixture_data["system"]["get_sysinfo"] @@ -233,6 +236,7 @@ def _datagram(self) -> bytes: encrypt_type, False, login_version, + new_klap=None, ) return dm diff --git a/tests/fixtures/serialization/deviceconfig_plug-new-klap.json b/tests/fixtures/serialization/deviceconfig_plug-new-klap.json new file mode 100644 index 000000000..c2c1a3380 --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_plug-new-klap.json @@ -0,0 +1,11 @@ +{ + "connection_type": { + "device_family": "SMART.TAPOPLUG", + "encryption_type": "KLAP", + "https": false, + "login_version": 2, + "new_klap": 1 + }, + "host": "127.0.0.1", + "timeout": 5 +} diff --git a/tests/protocols/test_iotprotocol.py b/tests/protocols/test_iotprotocol.py index fd8facc9e..88eef2f15 100644 --- a/tests/protocols/test_iotprotocol.py +++ b/tests/protocols/test_iotprotocol.py @@ -26,7 +26,11 @@ ) from kasa.transports.aestransport import AesTransport from kasa.transports.basetransport import BaseTransport -from kasa.transports.klaptransport import KlapTransport, KlapTransportV2 +from kasa.transports.klaptransport import ( + KlapTransport, + KlapTransportV2, + KlapTransportV3, +) from kasa.transports.xortransport import XorEncryption, XorTransport from ..conftest import device_iot @@ -753,6 +757,18 @@ def test_transport_init_signature(class_name_obj): "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", id="klapv2-lv-2", ), + pytest.param( + KlapTransportV3, + 1, + "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", + id="klapv3-lv-1", + ), + pytest.param( + KlapTransportV3, + 2, + "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", + id="klapv3-lv-2", + ), pytest.param(XorTransport, None, None, id="xor"), ], ) @@ -786,7 +802,7 @@ async def test_transport_credentials_hash( @pytest.mark.parametrize( "transport_class", - [AesTransport, KlapTransport, KlapTransportV2, XorTransport], + [AesTransport, KlapTransport, KlapTransportV2, KlapTransportV3, XorTransport], ) async def test_transport_credentials_hash_from_config(mocker, transport_class): """Test that credentials_hash provided via config sets correctly.""" diff --git a/tests/smart/modules/test_childsetup.py b/tests/smart/modules/test_childsetup.py index 6f31a9488..afe36b0c0 100644 --- a/tests/smart/modules/test_childsetup.py +++ b/tests/smart/modules/test_childsetup.py @@ -5,7 +5,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Feature, Module, SmartDevice +from kasa import Device, Feature, Module from ...device_fixtures import parametrize @@ -15,7 +15,7 @@ @childsetup -async def test_childsetup_features(dev: SmartDevice): +async def test_childsetup_features(dev: Device): """Test the exposed features.""" cs = dev.modules.get(Module.ChildSetup) assert cs @@ -27,7 +27,7 @@ async def test_childsetup_features(dev: SmartDevice): @childsetup async def test_childsetup_pair( - dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test device pairing.""" caplog.set_level(logging.INFO) @@ -51,7 +51,7 @@ async def test_childsetup_pair( @childsetup async def test_childsetup_unpair( - dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test unpair.""" mock_query_helper = mocker.spy(dev, "_query_helper") diff --git a/tests/smart/modules/test_powerprotection.py b/tests/smart/modules/test_powerprotection.py index b536ee1d6..215df2be5 100644 --- a/tests/smart/modules/test_powerprotection.py +++ b/tests/smart/modules/test_powerprotection.py @@ -1,7 +1,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Module, SmartDevice +from kasa import Device, Module from ...device_fixtures import get_parent_and_child_modules, parametrize @@ -35,7 +35,7 @@ async def test_features(dev, feature, prop_name, type): @powerprotection -async def test_set_enable(dev: SmartDevice, mocker: MockerFixture): +async def test_set_enable(dev: Device, mocker: MockerFixture): """Test enable.""" powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) assert powerprot @@ -88,7 +88,7 @@ async def test_set_enable(dev: SmartDevice, mocker: MockerFixture): @powerprotection -async def test_set_threshold(dev: SmartDevice, mocker: MockerFixture): +async def test_set_threshold(dev: Device, mocker: MockerFixture): """Test enable.""" powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) assert powerprot diff --git a/tests/smartcam/modules/test_childsetup.py b/tests/smartcam/modules/test_childsetup.py index 5b8a7c494..090ea0338 100644 --- a/tests/smartcam/modules/test_childsetup.py +++ b/tests/smartcam/modules/test_childsetup.py @@ -5,7 +5,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Feature, Module, SmartDevice +from kasa import Device, Feature, Module from ...device_fixtures import parametrize @@ -15,7 +15,7 @@ @childsetup -async def test_childsetup_features(dev: SmartDevice): +async def test_childsetup_features(dev: Device): """Test the exposed features.""" cs = dev.modules[Module.ChildSetup] @@ -26,7 +26,7 @@ async def test_childsetup_features(dev: SmartDevice): @childsetup async def test_childsetup_pair( - dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test device pairing.""" caplog.set_level(logging.INFO) @@ -69,7 +69,7 @@ async def test_childsetup_pair( @childsetup async def test_childsetup_unpair( - dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test unpair.""" mock_query_helper = mocker.spy(dev, "_query_helper") diff --git a/tests/test_cli.py b/tests/test_cli.py index 627959e74..9f69d6a5d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -143,7 +143,7 @@ async def test_discover_raw(discovery_mock, runner, mocker): } assert res.output == json_dumps(expected, indent=True) + "\n" - redact_spy.assert_not_called() + call_count_before = redact_spy.call_count res = await runner.invoke( cli, @@ -152,7 +152,7 @@ async def test_discover_raw(discovery_mock, runner, mocker): ) assert res.exit_code == 0 - redact_spy.assert_called() + assert redact_spy.call_count > call_count_before @pytest.mark.parametrize( diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 19ccfb73d..6c069e217 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -42,6 +42,7 @@ BaseTransport, KlapTransport, KlapTransportV2, + KlapTransportV3, LinkieTransportV2, SslAesTransport, SslTransport, @@ -259,6 +260,12 @@ async def test_device_class_from_unknown_family(caplog): KlapTransport, id="iot-klap", ), + pytest.param( + CP(DF.IotSmartPlugSwitch, ET.Klap, https=False, new_klap=1), + IotProtocol, + KlapTransportV3, + id="iot-new-klap", + ), pytest.param( CP(DF.IotSmartPlugSwitch, ET.Xor, https=False), IotProtocol, diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py index aebdd3a61..b53cd5545 100644 --- a/tests/test_deviceconfig.py +++ b/tests/test_deviceconfig.py @@ -24,6 +24,15 @@ DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap, login_version=2 ), ) +PLUG_NEW_KLAP_CONFIG = DeviceConfig( + host="127.0.0.1", + connection_type=DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, + DeviceEncryptionType.Klap, + login_version=2, + new_klap=1, + ), +) CAMERA_AES_CONFIG = DeviceConfig( host="127.0.0.1", connection_type=DeviceConnectionParameters( @@ -48,6 +57,7 @@ async def test_serialization(): [ ("deviceconfig_plug-xor.json", PLUG_XOR_CONFIG), ("deviceconfig_plug-klap.json", PLUG_KLAP_CONFIG), + ("deviceconfig_plug-new-klap.json", PLUG_NEW_KLAP_CONFIG), ("deviceconfig_camera-aes-https.json", CAMERA_AES_CONFIG), ], ids=lambda arg: arg.split("_")[-1] if isinstance(arg, str) else "", @@ -79,6 +89,7 @@ async def test_conn_param_no_https(): } param = DeviceConnectionParameters.from_dict(dict_val) assert param.https is False + assert param.new_klap is None assert param.to_dict() == {**dict_val, "https": False} diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 96c9e9c6b..2171a7e6f 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -166,6 +166,7 @@ async def test_discover_single(discovery_mock, custom_port, mocker): login_version=discovery_mock.login_version, https=discovery_mock.https, http_port=discovery_mock.http_port, + new_klap=discovery_mock.new_klap, ) config = DeviceConfig( host=host, @@ -678,6 +679,7 @@ async def test_discover_try_connect_all(discovery_mock, mocker): login_version=discovery_mock.login_version, https=discovery_mock.https, http_port=discovery_mock.http_port, + new_klap=discovery_mock.new_klap, ) protocol = get_protocol( DeviceConfig(discovery_mock.ip, connection_type=cparams) diff --git a/tests/transports/test_klaptransport.py b/tests/transports/test_klaptransport.py index 26d9f57a4..0e83f1f82 100644 --- a/tests/transports/test_klaptransport.py +++ b/tests/transports/test_klaptransport.py @@ -25,7 +25,9 @@ KlapEncryptionSession, KlapTransport, KlapTransportV2, + KlapTransportV3, _sha256, + _to_hex_ascii, ) DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -323,21 +325,35 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): ids=("client", "blank", "kasa_setup", "shouldfail"), ) @pytest.mark.parametrize( - ("transport_class", "seed_auth_hash_calc"), + ("transport_class", "seed_auth_hash_calc", "client_seed_transform"), [ - pytest.param(KlapTransport, lambda c, s, a: c + a, id="KLAP"), - pytest.param(KlapTransportV2, lambda c, s, a: c + s + a, id="KLAPV2"), + pytest.param(KlapTransport, lambda c, s, a: c + a, lambda c: c, id="KLAP"), + pytest.param( + KlapTransportV2, lambda c, s, a: c + s + a, lambda c: c, id="KLAPV2" + ), + pytest.param( + KlapTransportV3, + lambda c, s, a: c + s + a, + lambda c: bytes.fromhex(c.decode("ascii")), + id="KLAPV3", + ), ], ) async def test_handshake1( - mocker, device_credentials, expectation, transport_class, seed_auth_hash_calc + mocker, + device_credentials, + expectation, + transport_class, + seed_auth_hash_calc, + client_seed_transform, ): async def _return_handshake1_response(url, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash client_seed = data + seed_for_hash = client_seed_transform(client_seed) seed_auth_hash = _sha256( - seed_auth_hash_calc(client_seed, server_seed, device_auth_hash) + seed_auth_hash_calc(seed_for_hash, server_seed, device_auth_hash) ) return _mock_response(200, server_seed + seed_auth_hash) @@ -360,28 +376,58 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): auth_hash, ) = await protocol._transport.perform_handshake1() - assert local_seed == client_seed + if transport_class is KlapTransportV3: + assert client_seed == local_seed.hex().encode("ascii") + assert local_seed == bytes.fromhex(client_seed.decode("ascii")) + else: + assert local_seed == client_seed assert device_remote_seed == server_seed assert device_auth_hash == auth_hash await protocol.close() @pytest.mark.parametrize( - ("transport_class", "seed_auth_hash_calc1", "seed_auth_hash_calc2"), + ( + "transport_class", + "seed_auth_hash_calc1", + "seed_auth_hash_calc2", + "handshake1_payload", + "handshake2_payload", + ), [ pytest.param( - KlapTransport, lambda c, s, a: c + a, lambda c, s, a: s + a, id="KLAP" + KlapTransport, + lambda c, s, a: c + a, + lambda c, s, a: s + a, + lambda local_seed: local_seed, + lambda hash_bytes: hash_bytes, + id="KLAP", ), pytest.param( KlapTransportV2, lambda c, s, a: c + s + a, lambda c, s, a: s + c + a, + lambda local_seed: local_seed, + lambda hash_bytes: hash_bytes, id="KLAPV2", ), + pytest.param( + KlapTransportV3, + lambda c, s, a: c + s + a, + lambda c, s, a: s + c + a, + lambda local_seed: _to_hex_ascii(local_seed), + lambda hash_bytes: _to_hex_ascii(hash_bytes), + id="KLAPV3", + ), ], ) async def test_handshake( - mocker, transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2 + mocker, + transport_class, + seed_auth_hash_calc1, + seed_auth_hash_calc2, + handshake1_payload, + handshake2_payload, ): client_seed = None server_seed = secrets.token_bytes(16) @@ -392,17 +438,30 @@ async def _return_handshake_response(url: URL, params=None, data=None, *_, **__) nonlocal client_seed, server_seed, device_auth_hash if url == URL("http://127.0.0.1:80/app/handshake1"): + if transport_class is KlapTransportV3: + client_seed_bytes = bytes.fromhex(data.decode("ascii")) + else: + client_seed_bytes = data client_seed = data seed_auth_hash = _sha256( - seed_auth_hash_calc1(client_seed, server_seed, device_auth_hash) + seed_auth_hash_calc1(client_seed_bytes, server_seed, device_auth_hash) ) - return _mock_response(200, server_seed + seed_auth_hash) elif url == URL("http://127.0.0.1:80/app/handshake2"): - seed_auth_hash = _sha256( - seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash) - ) - assert data == seed_auth_hash + if transport_class is KlapTransportV3: + client_seed_bytes = bytes.fromhex(client_seed.decode("ascii")) + expected_hash = _sha256( + seed_auth_hash_calc2( + client_seed_bytes, server_seed, device_auth_hash + ) + ) + expected_payload = handshake2_payload(expected_hash) + assert data == expected_payload + else: + expected_hash = _sha256( + seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash) + ) + assert data == expected_hash return _mock_response(response_status, b"") mocker.patch.object( From 1dea040b02d5843e8c36b5c992b7b792030111f0 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Mon, 29 Sep 2025 14:37:25 -0400 Subject: [PATCH 02/26] Fix test coverages --- tests/test_deviceconfig.py | 36 ++++++++ tests/transports/test_klaptransport.py | 120 ++++++++++++++++++++----- 2 files changed, 133 insertions(+), 23 deletions(-) diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py index b53cd5545..16ef41fac 100644 --- a/tests/test_deviceconfig.py +++ b/tests/test_deviceconfig.py @@ -41,6 +41,42 @@ ) +def test_pre_serialize_omits_new_klap(): + """Test that pre_serialize omits new_klap if None or 0.""" + param_none = DeviceConnectionParameters( + device_family=DeviceFamily.SmartTapoPlug, + encryption_type=DeviceEncryptionType.Klap, + login_version=2, + https=False, + http_port=None, + new_klap=None, + ) + d_none = param_none.to_dict() + assert "new_klap" not in d_none + + param_zero = DeviceConnectionParameters( + device_family=DeviceFamily.SmartTapoPlug, + encryption_type=DeviceEncryptionType.Klap, + login_version=2, + https=False, + http_port=None, + new_klap=0, + ) + d_zero = param_zero.to_dict() + assert "new_klap" not in d_zero + + param_one = DeviceConnectionParameters( + device_family=DeviceFamily.SmartTapoPlug, + encryption_type=DeviceEncryptionType.Klap, + login_version=2, + https=False, + http_port=None, + new_klap=1, + ) + d_one = param_one.to_dict() + assert d_one["new_klap"] == 1 + + async def test_serialization(): """Test device config serialization.""" config = DeviceConfig(host="Foo", http_client=aiohttp.ClientSession()) diff --git a/tests/transports/test_klaptransport.py b/tests/transports/test_klaptransport.py index 0e83f1f82..53b739347 100644 --- a/tests/transports/test_klaptransport.py +++ b/tests/transports/test_klaptransport.py @@ -527,57 +527,104 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): @pytest.mark.parametrize( - ("response_status", "credentials_match", "expectation"), + ("transport_class", "response_status", "credentials_match", "expectation"), [ pytest.param( + KlapTransport, + (403, 403, 403), + True, + pytest.raises(KasaException), + id="klap-handshake1-403-status", + ), + pytest.param( + KlapTransport, + (200, 403, 403), + True, + pytest.raises(KasaException), + id="klap-handshake2-403-status", + ), + pytest.param( + KlapTransport, + (200, 200, 403), + True, + pytest.raises(_RetryableError), + id="klap-request-403-status", + ), + pytest.param( + KlapTransport, + (200, 200, 400), + True, + pytest.raises(KasaException), + id="klap-request-400-status", + ), + pytest.param( + KlapTransport, + (200, 200, 200), + False, + pytest.raises(AuthenticationError), + id="klap-handshake1-wrong-auth", + ), + pytest.param( + KlapTransport, + (200, 200, 200), + secrets.token_bytes(16), + pytest.raises(KasaException), + id="klap-handshake1-bad-auth-length", + ), + pytest.param( + KlapTransportV3, (403, 403, 403), True, pytest.raises(KasaException), - id="handshake1-403-status", + id="v3-handshake1-403-status", ), pytest.param( + KlapTransportV3, (200, 403, 403), True, pytest.raises(KasaException), - id="handshake2-403-status", + id="v3-handshake2-403-status", ), pytest.param( + KlapTransportV3, (200, 200, 403), True, pytest.raises(_RetryableError), - id="request-403-status", + id="v3-request-403-status", ), pytest.param( + KlapTransportV3, (200, 200, 400), True, pytest.raises(KasaException), - id="request-400-status", + id="v3-request-400-status", ), pytest.param( + KlapTransportV3, (200, 200, 200), False, pytest.raises(AuthenticationError), - id="handshake1-wrong-auth", + id="v3-handshake1-wrong-auth", ), pytest.param( + KlapTransportV3, (200, 200, 200), secrets.token_bytes(16), pytest.raises(KasaException), - id="handshake1-bad-auth-length", + id="v3-handshake1-bad-auth-length", ), ], ) async def test_authentication_failures( - mocker, response_status, credentials_match, expectation + mocker, transport_class, response_status, credentials_match, expectation ): client_seed = None - server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") device_credentials = ( - client_credentials if credentials_match else Credentials("bar", "foo") + client_credentials if credentials_match is True else Credentials("bar", "foo") ) - device_auth_hash = KlapTransport.generate_auth_hash(device_credentials) + device_auth_hash = transport_class.generate_auth_hash(device_credentials) async def _return_response(url: URL, params=None, data=None, *_, **__): nonlocal \ @@ -588,26 +635,53 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): credentials_match if url == URL("http://127.0.0.1:80/app/handshake1"): - client_seed = data - client_seed_auth_hash = _sha256(data + device_auth_hash) if credentials_match is not False and credentials_match is not True: - client_seed_auth_hash += credentials_match - return _mock_response( - response_status[0], server_seed + client_seed_auth_hash - ) + if transport_class is KlapTransportV3: + return _mock_response(response_status[0], b"short") + else: + client_seed = data + client_seed_auth_hash = ( + _sha256(data + device_auth_hash) + credentials_match + ) + return _mock_response( + response_status[0], server_seed + client_seed_auth_hash + ) + if transport_class is KlapTransportV3: + client_seed = bytes.fromhex(data.decode("ascii")) + client_seed_auth_hash = _sha256( + client_seed + server_seed + device_auth_hash + ) + return _mock_response( + response_status[0], server_seed + client_seed_auth_hash + ) + else: + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + return _mock_response( + response_status[0], server_seed + client_seed_auth_hash + ) elif url == URL("http://127.0.0.1:80/app/handshake2"): - client_seed = data - client_seed_auth_hash = _sha256(data + device_auth_hash) - return _mock_response( - response_status[1], server_seed + client_seed_auth_hash - ) + if transport_class is KlapTransportV3: + payload_seed = client_seed + client_seed_auth_hash = _sha256( + server_seed + payload_seed + device_auth_hash + ) + return _mock_response( + response_status[1], server_seed + client_seed_auth_hash + ) + else: + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + return _mock_response( + response_status[1], server_seed + client_seed_auth_hash + ) elif url == URL("http://127.0.0.1:80/app/request"): return _mock_response(response_status[2], b"") mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response) config = DeviceConfig("127.0.0.1", credentials=client_credentials) - protocol = IotProtocol(transport=KlapTransport(config=config)) + protocol = IotProtocol(transport=transport_class(config=config)) with expectation: await protocol.query({}) From 047efc4845255ef0f932bf2e7c1645605f22d6e8 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Mon, 29 Sep 2025 15:22:02 -0400 Subject: [PATCH 03/26] Fix deviceconfig test coverage --- kasa/deviceconfig.py | 6 ++---- tests/test_deviceconfig.py | 13 +------------ 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 01f084ad6..bdbf284ec 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -136,10 +136,8 @@ class Config(_DeviceConfigBaseMixin.Config): @classmethod def pre_serialize(cls, data: dict, obj: DeviceConnectionParameters) -> dict: - """Pre-serialization hook to omit new_klap if None or 0.""" - if "new_klap" in data and ( - data["new_klap"] is None or data["new_klap"] == 0 - ): + """Pre-serialization hook to omit new_klap if None.""" + if "new_klap" in data and data["new_klap"] is None: data = dict(data) del data["new_klap"] return data diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py index 16ef41fac..deb9867f6 100644 --- a/tests/test_deviceconfig.py +++ b/tests/test_deviceconfig.py @@ -42,7 +42,7 @@ def test_pre_serialize_omits_new_klap(): - """Test that pre_serialize omits new_klap if None or 0.""" + """Test that pre_serialize omits new_klap if None.""" param_none = DeviceConnectionParameters( device_family=DeviceFamily.SmartTapoPlug, encryption_type=DeviceEncryptionType.Klap, @@ -54,17 +54,6 @@ def test_pre_serialize_omits_new_klap(): d_none = param_none.to_dict() assert "new_klap" not in d_none - param_zero = DeviceConnectionParameters( - device_family=DeviceFamily.SmartTapoPlug, - encryption_type=DeviceEncryptionType.Klap, - login_version=2, - https=False, - http_port=None, - new_klap=0, - ) - d_zero = param_zero.to_dict() - assert "new_klap" not in d_zero - param_one = DeviceConnectionParameters( device_family=DeviceFamily.SmartTapoPlug, encryption_type=DeviceEncryptionType.Klap, From 81885d379eee6817aa6d5e55654283aa652e33f0 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Mon, 29 Sep 2025 16:43:44 -0400 Subject: [PATCH 04/26] Fix new_klap with deviceconfig and try_connect_all --- kasa/deviceconfig.py | 16 ++++------------ kasa/discover.py | 4 ++++ tests/test_deviceconfig.py | 25 ------------------------- 3 files changed, 8 insertions(+), 37 deletions(-) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index bdbf284ec..4f9c569cc 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -101,7 +101,10 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin): login_version: int | None = None https: bool = False http_port: int | None = None - new_klap: int | None = None + new_klap: int | None = field( + default=None, + metadata=field_options(serialize=lambda v: None if v in (None, 0) else v), + ) @staticmethod def from_values( @@ -131,17 +134,6 @@ def from_values( + f"{encryption_type}.{login_version}" ) from ex - class Config(_DeviceConfigBaseMixin.Config): - """Mashumaro config for DeviceConnectionParameters.""" - - @classmethod - def pre_serialize(cls, data: dict, obj: DeviceConnectionParameters) -> dict: - """Pre-serialization hook to omit new_klap if None.""" - if "new_klap" in data and data["new_klap"] is None: - data = dict(data) - del data["new_klap"] - return data - @dataclass class DeviceConfig(_DeviceConfigBaseMixin): diff --git a/kasa/discover.py b/kasa/discover.py index 672741492..869c467f0 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -650,12 +650,16 @@ async def try_connect_all( for device_family in main_device_families for https in (True, False) for login_version in (None, 2) + for new_klap in ( + (1, None) if encrypt == DeviceEncryptionType.Klap else (None,) + ) if ( conn_params := DeviceConnectionParameters( device_family=device_family, encryption_type=encrypt, login_version=login_version, https=https, + new_klap=new_klap, ) ) and ( diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py index deb9867f6..b53cd5545 100644 --- a/tests/test_deviceconfig.py +++ b/tests/test_deviceconfig.py @@ -41,31 +41,6 @@ ) -def test_pre_serialize_omits_new_klap(): - """Test that pre_serialize omits new_klap if None.""" - param_none = DeviceConnectionParameters( - device_family=DeviceFamily.SmartTapoPlug, - encryption_type=DeviceEncryptionType.Klap, - login_version=2, - https=False, - http_port=None, - new_klap=None, - ) - d_none = param_none.to_dict() - assert "new_klap" not in d_none - - param_one = DeviceConnectionParameters( - device_family=DeviceFamily.SmartTapoPlug, - encryption_type=DeviceEncryptionType.Klap, - login_version=2, - https=False, - http_port=None, - new_klap=1, - ) - d_one = param_one.to_dict() - assert d_one["new_klap"] == 1 - - async def test_serialization(): """Test device config serialization.""" config = DeviceConfig(host="Foo", http_client=aiohttp.ClientSession()) From ffa5960a63cf03bdf5c32918aec5d4ea121f159c Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Thu, 2 Oct 2025 11:07:21 -0400 Subject: [PATCH 05/26] Remove KlapTransportV3 and correct new_klap discovery --- kasa/device_factory.py | 3 +- kasa/discover.py | 23 ++ kasa/transports/__init__.py | 3 +- kasa/transports/klaptransport.py | 188 +--------------- .../deviceconfig_plug-new-klap.json | 2 +- tests/protocols/test_iotprotocol.py | 20 +- tests/test_device_factory.py | 3 +- tests/test_deviceconfig.py | 2 +- tests/test_discovery.py | 90 ++++++++ tests/transports/test_klaptransport.py | 209 ++++-------------- 10 files changed, 162 insertions(+), 381 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 4337c15c8..812de1632 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -32,7 +32,6 @@ BaseTransport, KlapTransport, KlapTransportV2, - KlapTransportV3, LinkieTransportV2, SslTransport, XorTransport, @@ -223,7 +222,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol and ctype.new_klap is not None and ctype.new_klap > 0 ): - return IotProtocol(transport=KlapTransportV3(config=config)) + return IotProtocol(transport=XorTransport(config=config)) protocol_transport_key = ( protocol_name diff --git a/kasa/discover.py b/kasa/discover.py index 869c467f0..952424343 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -85,6 +85,7 @@ import asyncio import base64 import binascii +import copy import ipaddress import logging import secrets @@ -375,6 +376,28 @@ def datagram_received( } ) device = device_func(info, config) + + new_klap = ( + info.get("result", {}).get("mgt_encrypt_schm", {}).get("new_klap") + ) + if new_klap is not None: + + async def finalize_discovered(dev: Device) -> None: + await dev.update() + info = copy.deepcopy(dev.sys_info) + if "children" in info and isinstance(info["children"], list): + for idx, child in enumerate(info["children"]): + child["id"] = f"{idx:02d}" + info = {"system": {"get_sysinfo": info}} + device_func = Discover._get_device_instance_legacy + device = device_func(info, config) + self.discovered_devices[ip] = device + if self.on_discovered is not None: + self._run_callback_task(self.on_discovered(device)) + self._handle_discovered_event() + + self._run_callback_task(finalize_discovered(device)) + return except UnsupportedDeviceError as udex: _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) self.unsupported_device_exceptions[ip] = udex diff --git a/kasa/transports/__init__.py b/kasa/transports/__init__.py index 353fd6905..192b4156a 100644 --- a/kasa/transports/__init__.py +++ b/kasa/transports/__init__.py @@ -2,7 +2,7 @@ from .aestransport import AesEncyptionSession, AesTransport from .basetransport import BaseTransport -from .klaptransport import KlapTransport, KlapTransportV2, KlapTransportV3 +from .klaptransport import KlapTransport, KlapTransportV2 from .linkietransport import LinkieTransportV2 from .sslaestransport import SslAesTransport from .ssltransport import SslTransport @@ -16,7 +16,6 @@ "BaseTransport", "KlapTransport", "KlapTransportV2", - "KlapTransportV3", "LinkieTransportV2", "XorTransport", "XorEncryption", diff --git a/kasa/transports/klaptransport.py b/kasa/transports/klaptransport.py index 5e4efdc47..8253e0aef 100644 --- a/kasa/transports/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -85,10 +85,6 @@ def _sha1(payload: bytes) -> bytes: return hashlib.sha1(payload).digest() # noqa: S324 -def _to_hex_ascii(payload: bytes) -> bytes: - return payload.hex().encode("ascii") # noqa: S324 - - class KlapTransport(BaseTransport): """Implementation of the KLAP encryption protocol. @@ -462,11 +458,11 @@ async def _get_ssl_context(self) -> ssl.SSLContext: class KlapTransportV2(KlapTransport): - """Implementation of the KLAP encryption protocol with v2 handshake hashes.""" + """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" @staticmethod def generate_auth_hash(creds: Credentials) -> bytes: - """Generate an sha1 auth hash for the protocol on the supplied credentials.""" + """Generate an md5 auth hash for the protocol on the supplied credentials.""" un = creds.username pw = creds.password @@ -476,192 +472,16 @@ def generate_auth_hash(creds: Credentials) -> bytes: def handshake1_seed_auth_hash( local_seed: bytes, remote_seed: bytes, auth_hash: bytes ) -> bytes: - """Generate an sha1 auth hash for the protocol on the supplied credentials.""" - return _sha256(local_seed + remote_seed + auth_hash) - - @staticmethod - def handshake2_seed_auth_hash( - local_seed: bytes, remote_seed: bytes, auth_hash: bytes - ) -> bytes: - """Generate an sha1 auth hash for the protocol on the supplied credentials.""" - return _sha256(remote_seed + local_seed + auth_hash) - - -class KlapTransportV3(KlapTransport): - """Implementation of the KLAP encryption protocol with new_klap handshake hashes.""" - - @staticmethod - def generate_auth_hash(creds: Credentials) -> bytes: - """Generate an sha1 auth hash for the protocol on the supplied credentials.""" - un = creds.username - pw = creds.password - return _sha256(_sha1(un.encode()) + _sha1(pw.encode())) - - @staticmethod - def handshake1_seed_auth_hash( - local_seed: bytes, remote_seed: bytes, auth_hash: bytes - ) -> bytes: - """Generate an sha1 auth hash for the protocol on the supplied credentials.""" + """Generate an md5 auth hash for the protocol on the supplied credentials.""" return _sha256(local_seed + remote_seed + auth_hash) @staticmethod def handshake2_seed_auth_hash( local_seed: bytes, remote_seed: bytes, auth_hash: bytes ) -> bytes: - """Generate an sha1 auth hash for the protocol on the supplied credentials.""" + """Generate an md5 auth hash for the protocol on the supplied credentials.""" return _sha256(remote_seed + local_seed + auth_hash) - async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: - """Preform handshake1.""" - local_seed: bytes = secrets.token_bytes(16) - - # Handshake 1 has a payload of local_seed in ASCII hex - # and a response of 16 bytes, followed by 32 bytes - # sha256(local_seed | remote_seed | auth_hash) - - payload = _to_hex_ascii(local_seed) - - url = self._app_url / "handshake1" - - response_status, response_data = await self._http_client.post( - url, data=payload, ssl=await self._get_ssl_context() - ) - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - "Handshake1 posted at %s. Host is %s, " - "Response status is %s, Request was %s", - datetime.datetime.now(), - self._host, - response_status, - payload.decode("ascii"), - ) - - if response_status != 200: - raise KasaException( - f"Device {self._host} responded with {response_status} to handshake1" - ) - - response_data = cast(bytes, response_data) - remote_seed: bytes = response_data[0:16] - server_hash = response_data[16:] - - if len(response_data) != 48: - raise KasaException( - f"Device {self._host} responded with unexpected klap response " - + f"{response_data!r} to handshake1" - ) - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - "Handshake1 success at %s. Host is %s, " - "Server remote_seed is: %s, server hash is: %s", - datetime.datetime.now(), - self._host, - remote_seed.hex(), - server_hash.hex(), - ) - - local_seed_auth_hash = self.handshake1_seed_auth_hash( - local_seed, remote_seed, self._local_auth_hash - ) # type: ignore - - # Check the response from the device with local credentials - if local_seed_auth_hash == server_hash: - _LOGGER.debug("handshake1 hashes match with expected credentials") - return local_seed, remote_seed, self._local_auth_hash # type: ignore - - # Now check against the default setup credentials - for key, value in DEFAULT_CREDENTIALS.items(): - if key not in self._default_credentials_auth_hash: - default_credentials = get_default_credentials(value) - self._default_credentials_auth_hash[key] = self.generate_auth_hash( - default_credentials - ) - - default_credentials_seed_auth_hash = self.handshake1_seed_auth_hash( - local_seed, - remote_seed, - self._default_credentials_auth_hash[key], # type: ignore - ) - - if default_credentials_seed_auth_hash == server_hash: - _LOGGER.debug( - "Device response did not match our expected hash on ip %s," - "but an authentication with %s default credentials worked", - self._host, - key, - ) - return local_seed, remote_seed, self._default_credentials_auth_hash[key] # type: ignore - - # Finally check against blank credentials if not already blank - blank_creds = Credentials() - if self._credentials != blank_creds: - if not self._blank_auth_hash: - self._blank_auth_hash = self.generate_auth_hash(blank_creds) - - blank_seed_auth_hash = self.handshake1_seed_auth_hash( - local_seed, - remote_seed, - self._blank_auth_hash, # type: ignore - ) - - if blank_seed_auth_hash == server_hash: - _LOGGER.debug( - "Device response did not match our expected hash on ip %s, " - "but an authentication with blank credentials worked", - self._host, - ) - return local_seed, remote_seed, self._blank_auth_hash # type: ignore - - msg = ( - f"Device response did not match our challenge on ip {self._host}, " - f"check that your e-mail and password (both case-sensitive) are correct. " - ) - _LOGGER.debug(msg) - raise AuthenticationError(msg) - - async def perform_handshake2( - self, local_seed: bytes, remote_seed: bytes, auth_hash: bytes - ) -> KlapEncryptionSession: - """Perform handshake2.""" - # Handshake 2 has the following payload: - # sha256(remote_seed | local_seed | auth_hash) - # which is sent as ASCII hex - - url = self._app_url / "handshake2" - - payload_hash = self.handshake2_seed_auth_hash( - local_seed, remote_seed, auth_hash - ) - payload = _to_hex_ascii(payload_hash) - - response_status, _ = await self._http_client.post( - url, - data=payload, - cookies_dict=self._session_cookie, - ssl=await self._get_ssl_context(), - ) - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - "Handshake2 posted %s. Host is %s, " - "Response status is %s, Request was %s", - datetime.datetime.now(), - self._host, - response_status, - payload.decode("ascii"), - ) - - if response_status != 200: - # This shouldn't be caused by incorrect - # credentials so don't raise AuthenticationError - raise KasaException( - f"Device {self._host} responded with {response_status} to handshake2" - ) - - return KlapEncryptionSession(local_seed, remote_seed, auth_hash) - class KlapEncryptionSession: """Class to represent an encryption session and it's internal state. diff --git a/tests/fixtures/serialization/deviceconfig_plug-new-klap.json b/tests/fixtures/serialization/deviceconfig_plug-new-klap.json index c2c1a3380..be044de73 100644 --- a/tests/fixtures/serialization/deviceconfig_plug-new-klap.json +++ b/tests/fixtures/serialization/deviceconfig_plug-new-klap.json @@ -1,6 +1,6 @@ { "connection_type": { - "device_family": "SMART.TAPOPLUG", + "device_family": "IOT.SMARTPLUGSWITCH", "encryption_type": "KLAP", "https": false, "login_version": 2, diff --git a/tests/protocols/test_iotprotocol.py b/tests/protocols/test_iotprotocol.py index 88eef2f15..fd8facc9e 100644 --- a/tests/protocols/test_iotprotocol.py +++ b/tests/protocols/test_iotprotocol.py @@ -26,11 +26,7 @@ ) from kasa.transports.aestransport import AesTransport from kasa.transports.basetransport import BaseTransport -from kasa.transports.klaptransport import ( - KlapTransport, - KlapTransportV2, - KlapTransportV3, -) +from kasa.transports.klaptransport import KlapTransport, KlapTransportV2 from kasa.transports.xortransport import XorEncryption, XorTransport from ..conftest import device_iot @@ -757,18 +753,6 @@ def test_transport_init_signature(class_name_obj): "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", id="klapv2-lv-2", ), - pytest.param( - KlapTransportV3, - 1, - "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", - id="klapv3-lv-1", - ), - pytest.param( - KlapTransportV3, - 2, - "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", - id="klapv3-lv-2", - ), pytest.param(XorTransport, None, None, id="xor"), ], ) @@ -802,7 +786,7 @@ async def test_transport_credentials_hash( @pytest.mark.parametrize( "transport_class", - [AesTransport, KlapTransport, KlapTransportV2, KlapTransportV3, XorTransport], + [AesTransport, KlapTransport, KlapTransportV2, XorTransport], ) async def test_transport_credentials_hash_from_config(mocker, transport_class): """Test that credentials_hash provided via config sets correctly.""" diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 6c069e217..dec17f38e 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -42,7 +42,6 @@ BaseTransport, KlapTransport, KlapTransportV2, - KlapTransportV3, LinkieTransportV2, SslAesTransport, SslTransport, @@ -263,7 +262,7 @@ async def test_device_class_from_unknown_family(caplog): pytest.param( CP(DF.IotSmartPlugSwitch, ET.Klap, https=False, new_klap=1), IotProtocol, - KlapTransportV3, + XorTransport, id="iot-new-klap", ), pytest.param( diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py index b53cd5545..3a367f566 100644 --- a/tests/test_deviceconfig.py +++ b/tests/test_deviceconfig.py @@ -27,7 +27,7 @@ PLUG_NEW_KLAP_CONFIG = DeviceConfig( host="127.0.0.1", connection_type=DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Klap, login_version=2, new_klap=1, diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 2171a7e6f..dbffb7fdf 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -3,6 +3,7 @@ import asyncio import base64 +import copy import json import logging import re @@ -750,3 +751,92 @@ async def test_discovery_device_repr(discovery_mock, mocker): assert "update() needed" not in repr_ else: assert "update() needed" in repr_ + + +@pytest.mark.parametrize( + ("has_children", "has_callback"), + [(True, True), (False, False)], + ids=["children_callback", "no_children_no_callback"], +) +async def test_discover_protocol_new_klap_finalize_discovered_branches( + mocker, has_children, has_callback +): + """Exercise finalize_discovered.""" + proto = _DiscoverProtocol() + ip = "127.0.0.1" + port = 20002 + + result = { + "mgt_encrypt_schm": {"new_klap": 1}, + "device_type": "IOT.SMARTPLUGSWITCH", + "device_model": "HS100(UK)", + "mac": "00:00:00:00:00:00", + "device_id": "0000000000000000000000000000000000000000", + } + if has_children: + result["children"] = [{"foo": "bar"}, {"foo": "baz"}] + + mocker.patch("kasa.discover.json_loads", return_value={"result": result}) + + def fake_get_device_instance(info_arg, config_arg): + if "system" in info_arg and "get_sysinfo" in info_arg["system"]: + sysinfo = info_arg["system"]["get_sysinfo"] + if "result" in sysinfo: + sysinfo = sysinfo["result"] + elif "result" in info_arg: + sysinfo = info_arg["result"] + else: + sysinfo = info_arg + + dev = mocker.Mock() + dev.sys_info = copy.deepcopy(sysinfo) + + async def fake_update(): + dev.sys_info["updated"] = True + + dev.update = fake_update + return dev + + mocker.patch( + "kasa.discover.Discover._get_device_instance", + side_effect=fake_get_device_instance, + ) + mocker.patch( + "kasa.discover.Discover._get_device_instance_legacy", + side_effect=fake_get_device_instance, + ) + + if has_callback: + + async def _noop_on_discovered(dev): + return None + + proto.on_discovered = _noop_on_discovered + else: + proto.on_discovered = None + + tasks: list[asyncio.Task] = [] + + def run_task(coro): + if asyncio.iscoroutine(coro): + tasks.append(asyncio.create_task(coro)) + + mocker.patch.object(proto, "_run_callback_task", side_effect=run_task) + + mocker.patch.object(proto, "_handle_discovered_event") + + proto.datagram_received(b"", (ip, port)) + + if tasks: + await asyncio.gather(*tasks) + + assert ip in proto.discovered_devices + device = proto.discovered_devices[ip] + assert device.sys_info.get("updated") is True + + if has_children: + children = device.sys_info["children"] + assert children[0]["id"] == "00" + assert children[1]["id"] == "01" + else: + assert "children" not in device.sys_info diff --git a/tests/transports/test_klaptransport.py b/tests/transports/test_klaptransport.py index 53b739347..26d9f57a4 100644 --- a/tests/transports/test_klaptransport.py +++ b/tests/transports/test_klaptransport.py @@ -25,9 +25,7 @@ KlapEncryptionSession, KlapTransport, KlapTransportV2, - KlapTransportV3, _sha256, - _to_hex_ascii, ) DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -325,35 +323,21 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): ids=("client", "blank", "kasa_setup", "shouldfail"), ) @pytest.mark.parametrize( - ("transport_class", "seed_auth_hash_calc", "client_seed_transform"), + ("transport_class", "seed_auth_hash_calc"), [ - pytest.param(KlapTransport, lambda c, s, a: c + a, lambda c: c, id="KLAP"), - pytest.param( - KlapTransportV2, lambda c, s, a: c + s + a, lambda c: c, id="KLAPV2" - ), - pytest.param( - KlapTransportV3, - lambda c, s, a: c + s + a, - lambda c: bytes.fromhex(c.decode("ascii")), - id="KLAPV3", - ), + pytest.param(KlapTransport, lambda c, s, a: c + a, id="KLAP"), + pytest.param(KlapTransportV2, lambda c, s, a: c + s + a, id="KLAPV2"), ], ) async def test_handshake1( - mocker, - device_credentials, - expectation, - transport_class, - seed_auth_hash_calc, - client_seed_transform, + mocker, device_credentials, expectation, transport_class, seed_auth_hash_calc ): async def _return_handshake1_response(url, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash client_seed = data - seed_for_hash = client_seed_transform(client_seed) seed_auth_hash = _sha256( - seed_auth_hash_calc(seed_for_hash, server_seed, device_auth_hash) + seed_auth_hash_calc(client_seed, server_seed, device_auth_hash) ) return _mock_response(200, server_seed + seed_auth_hash) @@ -376,58 +360,28 @@ async def _return_handshake1_response(url, params=None, data=None, *_, **__): auth_hash, ) = await protocol._transport.perform_handshake1() - if transport_class is KlapTransportV3: - assert client_seed == local_seed.hex().encode("ascii") - assert local_seed == bytes.fromhex(client_seed.decode("ascii")) - else: - assert local_seed == client_seed + assert local_seed == client_seed assert device_remote_seed == server_seed assert device_auth_hash == auth_hash await protocol.close() @pytest.mark.parametrize( - ( - "transport_class", - "seed_auth_hash_calc1", - "seed_auth_hash_calc2", - "handshake1_payload", - "handshake2_payload", - ), + ("transport_class", "seed_auth_hash_calc1", "seed_auth_hash_calc2"), [ pytest.param( - KlapTransport, - lambda c, s, a: c + a, - lambda c, s, a: s + a, - lambda local_seed: local_seed, - lambda hash_bytes: hash_bytes, - id="KLAP", + KlapTransport, lambda c, s, a: c + a, lambda c, s, a: s + a, id="KLAP" ), pytest.param( KlapTransportV2, lambda c, s, a: c + s + a, lambda c, s, a: s + c + a, - lambda local_seed: local_seed, - lambda hash_bytes: hash_bytes, id="KLAPV2", ), - pytest.param( - KlapTransportV3, - lambda c, s, a: c + s + a, - lambda c, s, a: s + c + a, - lambda local_seed: _to_hex_ascii(local_seed), - lambda hash_bytes: _to_hex_ascii(hash_bytes), - id="KLAPV3", - ), ], ) async def test_handshake( - mocker, - transport_class, - seed_auth_hash_calc1, - seed_auth_hash_calc2, - handshake1_payload, - handshake2_payload, + mocker, transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2 ): client_seed = None server_seed = secrets.token_bytes(16) @@ -438,30 +392,17 @@ async def _return_handshake_response(url: URL, params=None, data=None, *_, **__) nonlocal client_seed, server_seed, device_auth_hash if url == URL("http://127.0.0.1:80/app/handshake1"): - if transport_class is KlapTransportV3: - client_seed_bytes = bytes.fromhex(data.decode("ascii")) - else: - client_seed_bytes = data client_seed = data seed_auth_hash = _sha256( - seed_auth_hash_calc1(client_seed_bytes, server_seed, device_auth_hash) + seed_auth_hash_calc1(client_seed, server_seed, device_auth_hash) ) + return _mock_response(200, server_seed + seed_auth_hash) elif url == URL("http://127.0.0.1:80/app/handshake2"): - if transport_class is KlapTransportV3: - client_seed_bytes = bytes.fromhex(client_seed.decode("ascii")) - expected_hash = _sha256( - seed_auth_hash_calc2( - client_seed_bytes, server_seed, device_auth_hash - ) - ) - expected_payload = handshake2_payload(expected_hash) - assert data == expected_payload - else: - expected_hash = _sha256( - seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash) - ) - assert data == expected_hash + seed_auth_hash = _sha256( + seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash) + ) + assert data == seed_auth_hash return _mock_response(response_status, b"") mocker.patch.object( @@ -527,104 +468,57 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): @pytest.mark.parametrize( - ("transport_class", "response_status", "credentials_match", "expectation"), + ("response_status", "credentials_match", "expectation"), [ pytest.param( - KlapTransport, - (403, 403, 403), - True, - pytest.raises(KasaException), - id="klap-handshake1-403-status", - ), - pytest.param( - KlapTransport, - (200, 403, 403), - True, - pytest.raises(KasaException), - id="klap-handshake2-403-status", - ), - pytest.param( - KlapTransport, - (200, 200, 403), - True, - pytest.raises(_RetryableError), - id="klap-request-403-status", - ), - pytest.param( - KlapTransport, - (200, 200, 400), - True, - pytest.raises(KasaException), - id="klap-request-400-status", - ), - pytest.param( - KlapTransport, - (200, 200, 200), - False, - pytest.raises(AuthenticationError), - id="klap-handshake1-wrong-auth", - ), - pytest.param( - KlapTransport, - (200, 200, 200), - secrets.token_bytes(16), - pytest.raises(KasaException), - id="klap-handshake1-bad-auth-length", - ), - pytest.param( - KlapTransportV3, (403, 403, 403), True, pytest.raises(KasaException), - id="v3-handshake1-403-status", + id="handshake1-403-status", ), pytest.param( - KlapTransportV3, (200, 403, 403), True, pytest.raises(KasaException), - id="v3-handshake2-403-status", + id="handshake2-403-status", ), pytest.param( - KlapTransportV3, (200, 200, 403), True, pytest.raises(_RetryableError), - id="v3-request-403-status", + id="request-403-status", ), pytest.param( - KlapTransportV3, (200, 200, 400), True, pytest.raises(KasaException), - id="v3-request-400-status", + id="request-400-status", ), pytest.param( - KlapTransportV3, (200, 200, 200), False, pytest.raises(AuthenticationError), - id="v3-handshake1-wrong-auth", + id="handshake1-wrong-auth", ), pytest.param( - KlapTransportV3, (200, 200, 200), secrets.token_bytes(16), pytest.raises(KasaException), - id="v3-handshake1-bad-auth-length", + id="handshake1-bad-auth-length", ), ], ) async def test_authentication_failures( - mocker, transport_class, response_status, credentials_match, expectation + mocker, response_status, credentials_match, expectation ): client_seed = None + server_seed = secrets.token_bytes(16) client_credentials = Credentials("foo", "bar") device_credentials = ( - client_credentials if credentials_match is True else Credentials("bar", "foo") + client_credentials if credentials_match else Credentials("bar", "foo") ) - device_auth_hash = transport_class.generate_auth_hash(device_credentials) + device_auth_hash = KlapTransport.generate_auth_hash(device_credentials) async def _return_response(url: URL, params=None, data=None, *_, **__): nonlocal \ @@ -635,53 +529,26 @@ async def _return_response(url: URL, params=None, data=None, *_, **__): credentials_match if url == URL("http://127.0.0.1:80/app/handshake1"): + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) if credentials_match is not False and credentials_match is not True: - if transport_class is KlapTransportV3: - return _mock_response(response_status[0], b"short") - else: - client_seed = data - client_seed_auth_hash = ( - _sha256(data + device_auth_hash) + credentials_match - ) - return _mock_response( - response_status[0], server_seed + client_seed_auth_hash - ) - if transport_class is KlapTransportV3: - client_seed = bytes.fromhex(data.decode("ascii")) - client_seed_auth_hash = _sha256( - client_seed + server_seed + device_auth_hash - ) - return _mock_response( - response_status[0], server_seed + client_seed_auth_hash - ) - else: - client_seed = data - client_seed_auth_hash = _sha256(data + device_auth_hash) - return _mock_response( - response_status[0], server_seed + client_seed_auth_hash - ) + client_seed_auth_hash += credentials_match + return _mock_response( + response_status[0], server_seed + client_seed_auth_hash + ) elif url == URL("http://127.0.0.1:80/app/handshake2"): - if transport_class is KlapTransportV3: - payload_seed = client_seed - client_seed_auth_hash = _sha256( - server_seed + payload_seed + device_auth_hash - ) - return _mock_response( - response_status[1], server_seed + client_seed_auth_hash - ) - else: - client_seed = data - client_seed_auth_hash = _sha256(data + device_auth_hash) - return _mock_response( - response_status[1], server_seed + client_seed_auth_hash - ) + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + return _mock_response( + response_status[1], server_seed + client_seed_auth_hash + ) elif url == URL("http://127.0.0.1:80/app/request"): return _mock_response(response_status[2], b"") mocker.patch.object(aiohttp.ClientSession, "post", side_effect=_return_response) config = DeviceConfig("127.0.0.1", credentials=client_credentials) - protocol = IotProtocol(transport=transport_class(config=config)) + protocol = IotProtocol(transport=KlapTransport(config=config)) with expectation: await protocol.query({}) From 2eb3977b6fea2aaef6d16a72d2bc86c160105bc9 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Thu, 2 Oct 2025 12:13:25 -0400 Subject: [PATCH 06/26] Fix copilot assertation comments --- tests/test_cli.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9f69d6a5d..1617ef108 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -143,8 +143,7 @@ async def test_discover_raw(discovery_mock, runner, mocker): } assert res.output == json_dumps(expected, indent=True) + "\n" - call_count_before = redact_spy.call_count - + redact_spy.reset_mock() res = await runner.invoke( cli, ["--username", "foo", "--password", "bar", "discover", "raw", "--redact"], @@ -152,7 +151,7 @@ async def test_discover_raw(discovery_mock, runner, mocker): ) assert res.exit_code == 0 - assert redact_spy.call_count > call_count_before + redact_spy.assert_called() @pytest.mark.parametrize( @@ -279,8 +278,7 @@ async def test_raw_command(dev, mocker, runner): res = await runner.invoke(raw_command, params, obj=dev) # Make sure that update was not called for wifi - with pytest.raises(AssertionError): - update.assert_called() + update.assert_not_called() assert res.exit_code == 0 assert dev.model in res.output @@ -369,8 +367,7 @@ async def test_wifi_join(dev, mocker, runner): ) # Make sure that update was not called for wifi - with pytest.raises(AssertionError): - update.assert_called() + update.assert_not_called() assert res.exit_code == 0 assert "Asking the device to connect to FOOBAR" in res.output @@ -514,14 +511,14 @@ async def test_emeter(dev: Device, mocker, runner): res = await runner.invoke(cli, [*base_cmd, "--index", "0"], obj=dev) assert "Voltage: 122.066 V" in res.output - child_status.assert_called() - assert child_status.call_count == 1 + child_status.assert_called_once() + child_status.reset_mock() res = await runner.invoke( cli, [*base_cmd, "--name", dev.children[0].alias], obj=dev ) assert "Voltage: 122.066 V" in res.output - assert child_status.call_count == 2 + child_status.assert_called_once() if isinstance(dev, IotDevice): monthly = mocker.patch.object(energy, "get_monthly_stats") @@ -1367,14 +1364,17 @@ async def test_discover_config(dev: Device, mocker, runner): cparam = dev.config.connection_type expected = f"--device-family {cparam.device_family.value} --encrypt-type {cparam.encryption_type.value} {'--https' if cparam.https else '--no-https'}" assert expected in res.output - assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ failed", - res.output.replace("\n", ""), + normalized = " ".join(res.output.split()) + failed_pat = re.compile( + r"Attempt to connect to 127\.0\.0\.1 with \S+\s*\+\s*\S+\s*\+\s*\S+\s*\+\s*\S+\s+failed", + re.IGNORECASE, ) - assert re.search( - r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ succeeded", - res.output.replace("\n", ""), + succeeded_pat = re.compile( + r"Attempt to connect to 127\.0\.0\.1 with \S+\s*\+\s*\S+\s*\+\s*\S+\s*\+\s*\S+\s+succeeded", + re.IGNORECASE, ) + assert failed_pat.search(normalized) + assert succeeded_pat.search(normalized) async def test_discover_config_invalid(mocker, runner): From 2b6e35b1bc8818ad8be9a7d2f975b17c21e41fed Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Fri, 3 Oct 2025 07:11:59 -0400 Subject: [PATCH 07/26] Reverse janitoring for separate PR --- kasa/cli/hub.py | 10 +++++----- tests/smart/modules/test_childsetup.py | 8 ++++---- tests/smart/modules/test_powerprotection.py | 6 +++--- tests/smartcam/modules/test_childsetup.py | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/kasa/cli/hub.py b/kasa/cli/hub.py index 0bc45bf5a..de4b60715 100644 --- a/kasa/cli/hub.py +++ b/kasa/cli/hub.py @@ -4,7 +4,7 @@ import asyncclick as click -from kasa import Device, DeviceType, Module +from kasa import DeviceType, Module, SmartDevice from kasa.smart import SmartChildDevice from .common import ( @@ -21,7 +21,7 @@ def pretty_category(cat: str): @click.group() @pass_dev -async def hub(dev: Device): +async def hub(dev: SmartDevice): """Commands controlling hub child device pairing.""" if dev.device_type is not DeviceType.Hub: error(f"{dev} is not a hub.") @@ -32,7 +32,7 @@ async def hub(dev: Device): @hub.command(name="list") @pass_dev -async def hub_list(dev: Device): +async def hub_list(dev: SmartDevice): """List hub paired child devices.""" for c in dev.children: echo(f"{c.device_id}: {c}") @@ -40,7 +40,7 @@ async def hub_list(dev: Device): @hub.command(name="supported") @pass_dev -async def hub_supported(dev: Device): +async def hub_supported(dev: SmartDevice): """List supported hub child device categories.""" cs = dev.modules[Module.ChildSetup] @@ -51,7 +51,7 @@ async def hub_supported(dev: Device): @hub.command(name="pair") @click.option("--timeout", default=10) @pass_dev -async def hub_pair(dev: Device, timeout: int): +async def hub_pair(dev: SmartDevice, timeout: int): """Pair all pairable device. This will pair any child devices currently in pairing mode. diff --git a/tests/smart/modules/test_childsetup.py b/tests/smart/modules/test_childsetup.py index afe36b0c0..6f31a9488 100644 --- a/tests/smart/modules/test_childsetup.py +++ b/tests/smart/modules/test_childsetup.py @@ -5,7 +5,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Device, Feature, Module +from kasa import Feature, Module, SmartDevice from ...device_fixtures import parametrize @@ -15,7 +15,7 @@ @childsetup -async def test_childsetup_features(dev: Device): +async def test_childsetup_features(dev: SmartDevice): """Test the exposed features.""" cs = dev.modules.get(Module.ChildSetup) assert cs @@ -27,7 +27,7 @@ async def test_childsetup_features(dev: Device): @childsetup async def test_childsetup_pair( - dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test device pairing.""" caplog.set_level(logging.INFO) @@ -51,7 +51,7 @@ async def test_childsetup_pair( @childsetup async def test_childsetup_unpair( - dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test unpair.""" mock_query_helper = mocker.spy(dev, "_query_helper") diff --git a/tests/smart/modules/test_powerprotection.py b/tests/smart/modules/test_powerprotection.py index 215df2be5..b536ee1d6 100644 --- a/tests/smart/modules/test_powerprotection.py +++ b/tests/smart/modules/test_powerprotection.py @@ -1,7 +1,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Device, Module +from kasa import Module, SmartDevice from ...device_fixtures import get_parent_and_child_modules, parametrize @@ -35,7 +35,7 @@ async def test_features(dev, feature, prop_name, type): @powerprotection -async def test_set_enable(dev: Device, mocker: MockerFixture): +async def test_set_enable(dev: SmartDevice, mocker: MockerFixture): """Test enable.""" powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) assert powerprot @@ -88,7 +88,7 @@ async def test_set_enable(dev: Device, mocker: MockerFixture): @powerprotection -async def test_set_threshold(dev: Device, mocker: MockerFixture): +async def test_set_threshold(dev: SmartDevice, mocker: MockerFixture): """Test enable.""" powerprot = next(get_parent_and_child_modules(dev, Module.PowerProtection)) assert powerprot diff --git a/tests/smartcam/modules/test_childsetup.py b/tests/smartcam/modules/test_childsetup.py index 090ea0338..5b8a7c494 100644 --- a/tests/smartcam/modules/test_childsetup.py +++ b/tests/smartcam/modules/test_childsetup.py @@ -5,7 +5,7 @@ import pytest from pytest_mock import MockerFixture -from kasa import Device, Feature, Module +from kasa import Feature, Module, SmartDevice from ...device_fixtures import parametrize @@ -15,7 +15,7 @@ @childsetup -async def test_childsetup_features(dev: Device): +async def test_childsetup_features(dev: SmartDevice): """Test the exposed features.""" cs = dev.modules[Module.ChildSetup] @@ -26,7 +26,7 @@ async def test_childsetup_features(dev: Device): @childsetup async def test_childsetup_pair( - dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test device pairing.""" caplog.set_level(logging.INFO) @@ -69,7 +69,7 @@ async def test_childsetup_pair( @childsetup async def test_childsetup_unpair( - dev: Device, mocker: MockerFixture, caplog: pytest.LogCaptureFixture + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture ): """Test unpair.""" mock_query_helper = mocker.spy(dev, "_query_helper") From 11c2143f776264f6c3a1b8a36fc7e21fa5dc9596 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Fri, 3 Oct 2025 07:32:32 -0400 Subject: [PATCH 08/26] Add comment for new_klap XOR transport selection --- kasa/device_factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 812de1632..142e6c2a7 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -216,6 +216,8 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol ): return SmartProtocol(transport=SslTransport(config=config)) + # Newer Iot device firmwares report KLAP encryption but still actually + # use Xor transport if ( protocol_name == "IOT" and ctype.encryption_type.value == "KLAP" From 8229dee5084bcede79b42d5272cef67e51134af8 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 7 Oct 2025 12:48:16 -0400 Subject: [PATCH 09/26] Change new_klap transport from XOR to KlapTransportV2 --- kasa/device_factory.py | 12 ++---------- kasa/discover.py | 31 ++++--------------------------- 2 files changed, 6 insertions(+), 37 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 142e6c2a7..d9392fcb5 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -216,21 +216,12 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol ): return SmartProtocol(transport=SslTransport(config=config)) - # Newer Iot device firmwares report KLAP encryption but still actually - # use Xor transport - if ( - protocol_name == "IOT" - and ctype.encryption_type.value == "KLAP" - and ctype.new_klap is not None - and ctype.new_klap > 0 - ): - return IotProtocol(transport=XorTransport(config=config)) - protocol_transport_key = ( protocol_name + "." + ctype.encryption_type.value + (".HTTPS" if ctype.https else "") + + (".NEW_KLAP" if ctype.new_klap not in (None, 0) else "") ) _LOGGER.debug("Finding transport for %s", protocol_transport_key) @@ -239,6 +230,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol ] = { "IOT.XOR": (IotProtocol, XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), + "IOT.KLAP.NEW_KLAP": (IotProtocol, KlapTransportV2), "SMART.AES": (SmartProtocol, AesTransport), "SMART.KLAP": (SmartProtocol, KlapTransportV2), "SMART.KLAP.HTTPS": (SmartProtocol, KlapTransportV2), diff --git a/kasa/discover.py b/kasa/discover.py index 952424343..306635de2 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -85,7 +85,6 @@ import asyncio import base64 import binascii -import copy import ipaddress import logging import secrets @@ -126,7 +125,7 @@ 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 @@ -376,28 +375,6 @@ def datagram_received( } ) device = device_func(info, config) - - new_klap = ( - info.get("result", {}).get("mgt_encrypt_schm", {}).get("new_klap") - ) - if new_klap is not None: - - async def finalize_discovered(dev: Device) -> None: - await dev.update() - info = copy.deepcopy(dev.sys_info) - if "children" in info and isinstance(info["children"], list): - for idx, child in enumerate(info["children"]): - child["id"] = f"{idx:02d}" - info = {"system": {"get_sysinfo": info}} - device_func = Discover._get_device_instance_legacy - device = device_func(info, config) - self.discovered_devices[ip] = device - if self.on_discovered is not None: - self._run_callback_task(self.on_discovered(device)) - self._handle_discovered_event() - - self._run_callback_task(finalize_discovered(device)) - return except UnsupportedDeviceError as udex: _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) self.unsupported_device_exceptions[ip] = udex @@ -766,7 +743,7 @@ def _get_device_instance_legacy(info: dict, config: DeviceConfig) -> Device: 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_class = cast(type[Device], 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")) @@ -874,7 +851,7 @@ def _get_device_instance( info: dict, config: DeviceConfig, ) -> Device: - """Get SmartDevice from the new 20002 response.""" + """Get SmartDevice or IotDevice from the new 20002 response.""" debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) try: @@ -923,7 +900,7 @@ def _get_device_instance( ) from ex if ( - device_class := get_device_class_from_family(type_, https=conn_params.https) + device_class := cast(type[Device], Discover._get_device_class(info)) ) is None: _LOGGER.debug("Got unsupported device type: %s", type_) raise UnsupportedDeviceError( From 5547172ec37f39e3b1e987041e28fc311f4ef2c3 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 7 Oct 2025 12:58:32 -0400 Subject: [PATCH 10/26] Return dump_devinfo to exclude system --- devtools/dump_devinfo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index ae8a721e2..dcc2fa289 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -469,7 +469,7 @@ async def get_legacy_fixture( else: child["id"] = f"SCRUBBED_CHILD_DEVICE_ID_{index + 1}" - if discovery_info: + if discovery_info and not discovery_info.get("system"): final["discovery_result"] = redact_data( discovery_info, _wrap_redactors(NEW_DISCOVERY_REDACTORS) ) From 0b75c261e2acf676b96ca1e670314494e3c6a012 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 7 Oct 2025 15:19:36 -0400 Subject: [PATCH 11/26] Change device creation to async to handle new_klap and tests --- kasa/discover.py | 73 +++++++++++++------ tests/test_device_factory.py | 2 +- tests/test_discovery.py | 131 +++++++++++++++-------------------- 3 files changed, 110 insertions(+), 96 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 306635de2..1e3746e6e 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -350,8 +350,6 @@ def datagram_received( return self.seen_hosts.add(ip) - device: Device | None = None - config = DeviceConfig(host=ip, port_override=self.port) if self.credentials: config.credentials = self.credentials @@ -374,7 +372,10 @@ def datagram_received( "meta": {"ip": ip, "port": port}, } ) - device = device_func(info, config) + self._run_callback_task( + self._resolve_and_process(device_func, info, config, ip) + ) + return except UnsupportedDeviceError as udex: _LOGGER.debug("Unsupported device found at %s << %s", ip, udex) self.unsupported_device_exceptions[ip] = udex @@ -388,12 +389,30 @@ def datagram_received( self._handle_discovered_event() return - self.discovered_devices[ip] = device - - if self.on_discovered is not None: - self._run_callback_task(self.on_discovered(device)) - - self._handle_discovered_event() + async def _resolve_and_process( + self, + device_func: Callable[[dict, DeviceConfig], Coroutine[Any, Any, Device]], + info: dict, + config: DeviceConfig, + ip: str, + ) -> None: + """Await the device factory, then run success/error bookkeeping.""" + try: + device = await 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)) + except KasaException as ex: + _LOGGER.debug("[DISCOVERY] Unable to find device type for %s: %s", ip, ex) + self.invalid_device_exceptions[ip] = ex + else: + self.discovered_devices[ip] = device + if self.on_discovered is not None: + self._run_callback_task(self.on_discovered(device)) + finally: + self._handle_discovered_event() def _handle_discovered_event(self) -> None: """If target is in seen_hosts cancel discover_task.""" @@ -737,7 +756,7 @@ def _get_discovery_json_legacy(data: bytes, ip: str) -> dict: return info @staticmethod - def _get_device_instance_legacy(info: dict, config: DeviceConfig) -> Device: + async 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 @@ -847,7 +866,7 @@ def _get_connection_parameters( ) @staticmethod - def _get_device_instance( + async def _get_device_instance( info: dict, config: DeviceConfig, ) -> Device: @@ -899,16 +918,6 @@ def _get_device_instance( host=config.host, ) from ex - if ( - device_class := cast(type[Device], Discover._get_device_class(info)) - ) 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() @@ -920,6 +929,28 @@ def _get_device_instance( host=config.host, ) + if config.connection_type.new_klap: + sysinfo = await protocol.query({"system": {"get_sysinfo": {}}}) + if ( + device_class := cast(type[Device], Discover._get_device_class(sysinfo)) + ) 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, + ) + else: + if ( + device_class := cast(type[Device], Discover._get_device_class(info)) + ) 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 debug_enabled: data = ( redact_data(info, NEW_DISCOVERY_REDACTORS) diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index dec17f38e..683906b36 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -262,7 +262,7 @@ async def test_device_class_from_unknown_family(caplog): pytest.param( CP(DF.IotSmartPlugSwitch, ET.Klap, https=False, new_klap=1), IotProtocol, - XorTransport, + KlapTransportV2, id="iot-new-klap", ), pytest.param( diff --git a/tests/test_discovery.py b/tests/test_discovery.py index dbffb7fdf..fb8690c10 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -3,7 +3,6 @@ import asyncio import base64 -import copy import json import logging import re @@ -327,6 +326,10 @@ async def test_discover_datagram_received(mocker, discovery_data): mocker.patch("kasa.discover.json_loads", return_value=UNSUPPORTED) proto.datagram_received("", (addr2, 20002)) + # Wait for async processing of the discovery callbacks to finish + if proto.callback_tasks: + await asyncio.gather(*proto.callback_tasks, return_exceptions=True) + # Check that device in discovered_devices is initialized correctly assert len(proto.discovered_devices) == 1 # Check that unsupported device is 1 @@ -753,90 +756,70 @@ async def test_discovery_device_repr(discovery_mock, mocker): assert "update() needed" in repr_ -@pytest.mark.parametrize( - ("has_children", "has_callback"), - [(True, True), (False, False)], - ids=["children_callback", "no_children_no_callback"], -) -async def test_discover_protocol_new_klap_finalize_discovered_branches( - mocker, has_children, has_callback -): - """Exercise finalize_discovered.""" - proto = _DiscoverProtocol() - ip = "127.0.0.1" - port = 20002 - - result = { - "mgt_encrypt_schm": {"new_klap": 1}, - "device_type": "IOT.SMARTPLUGSWITCH", - "device_model": "HS100(UK)", - "mac": "00:00:00:00:00:00", - "device_id": "0000000000000000000000000000000000000000", +@pytest.mark.asyncio +async def test_get_device_instance_new_klap_unsupported_logs(mocker, caplog): + caplog.set_level(logging.DEBUG) + info = { + "result": { + "device_type": "IOT.SMARTPLUGSWITCH", + "device_model": "HS100(UK)", + "device_id": "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": 9999, + "lv": 1, + "new_klap": 1, + }, + # Force decrypt attempt to run and be logged (will fail harmlessly and be caught/logged) + "encrypt_info": {"sym_schm": "AES", "key": "", "data": ""}, + } } - if has_children: - result["children"] = [{"foo": "bar"}, {"foo": "baz"}] - - mocker.patch("kasa.discover.json_loads", return_value={"result": result}) - - def fake_get_device_instance(info_arg, config_arg): - if "system" in info_arg and "get_sysinfo" in info_arg["system"]: - sysinfo = info_arg["system"]["get_sysinfo"] - if "result" in sysinfo: - sysinfo = sysinfo["result"] - elif "result" in info_arg: - sysinfo = info_arg["result"] - else: - sysinfo = info_arg - - dev = mocker.Mock() - dev.sys_info = copy.deepcopy(sysinfo) - async def fake_update(): - dev.sys_info["updated"] = True + class DummyProt: + def __init__(self): + self._transport = MagicMock() - dev.update = fake_update - return dev + async def query(self, req): + # Return legacy-style sysinfo to go through get_device_class(sysinfo) + return {"system": {"get_sysinfo": {"mic_type": "IOT.SMARTPLUGSWITCH"}}} - mocker.patch( - "kasa.discover.Discover._get_device_instance", - side_effect=fake_get_device_instance, - ) - mocker.patch( - "kasa.discover.Discover._get_device_instance_legacy", - side_effect=fake_get_device_instance, - ) - - if has_callback: - - async def _noop_on_discovered(dev): + async def close(self): return None - proto.on_discovered = _noop_on_discovered - else: - proto.on_discovered = None + mocker.patch("kasa.discover.get_protocol", return_value=DummyProt()) + # Force device_class resolution to fail to trigger the debug log and UnsupportedDeviceError + mocker.patch("kasa.discover.Discover._get_device_class", return_value=None) - tasks: list[asyncio.Task] = [] + with pytest.raises(UnsupportedDeviceError): + await Discover._get_device_instance(info, DeviceConfig(host="127.0.0.1")) - def run_task(coro): - if asyncio.iscoroutine(coro): - tasks.append(asyncio.create_task(coro)) + # Validate the debug log was emitted + assert "Got unsupported device type" in caplog.text - mocker.patch.object(proto, "_run_callback_task", side_effect=run_task) - mocker.patch.object(proto, "_handle_discovered_event") +def test_datagram_received_logs_for_exceptions(caplog, mocker): + proto = _DiscoverProtocol() + caplog.set_level(logging.DEBUG) + + ip1 = "127.0.0.10" + ip2 = "127.0.0.11" - proto.datagram_received(b"", (ip, port)) + # First call raises UnsupportedDeviceError -> goes to unsupported_device_exceptions and logs + mocker.patch( + "kasa.discover.Discover._get_discovery_json", + side_effect=[UnsupportedDeviceError("boom"), KasaException("bad")], + ) - if tasks: - await asyncio.gather(*tasks) + proto.datagram_received(b"x", (ip1, Discover.DISCOVERY_PORT_2)) + proto.datagram_received(b"x", (ip2, Discover.DISCOVERY_PORT_2)) - assert ip in proto.discovered_devices - device = proto.discovered_devices[ip] - assert device.sys_info.get("updated") is True + # Bookkeeping recorded correctly + assert ip1 in proto.unsupported_device_exceptions + assert ip2 in proto.invalid_device_exceptions - if has_children: - children = device.sys_info["children"] - assert children[0]["id"] == "00" - assert children[1]["id"] == "01" - else: - assert "children" not in device.sys_info + # Logs were written + assert f"Unsupported device found at {ip1} << boom" in caplog.text + assert f"[DISCOVERY] Unable to find device type for {ip2}: bad" in caplog.text From 12d97cc62bb3e55f978774a31e4e2ecf7aa041f9 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 7 Oct 2025 15:41:38 -0400 Subject: [PATCH 12/26] Correct test_discovery.py to handle all code changes --- tests/test_discovery.py | 186 ++++++++++++++++++++++------------------ 1 file changed, 101 insertions(+), 85 deletions(-) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index fb8690c10..e594db5b1 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -197,8 +197,9 @@ async def test_discover_single_hostname(discovery_mock, mocker): x = await Discover.discover_single(host, credentials=Credentials()) -async def test_discover_credentials(mocker): - """Make sure that discover gives credentials precedence over un and pw.""" +@pytest.mark.parametrize("entrypoint", ["discover", "discover_single"]) +async def test_credentials_precedence(entrypoint, mocker): + """Ensure credentials precedence logic is identical for discover and discover_single.""" host = "127.0.0.1" async def mock_discover(self, *_, **__): @@ -210,46 +211,35 @@ async def mock_discover(self, *_, **__): dp = mocker.spy(_DiscoverProtocol, "__init__") # Only credentials passed - await Discover.discover(credentials=Credentials(), timeout=0) + if entrypoint == "discover": + await Discover.discover(credentials=Credentials(), timeout=0) + else: + await Discover.discover_single(host, credentials=Credentials(), timeout=0) assert dp.mock_calls[0].kwargs["credentials"] == Credentials() - # Credentials and un/pw passed - await Discover.discover( - credentials=Credentials(), username="Foo", password="Bar", timeout=0 - ) - assert dp.mock_calls[1].kwargs["credentials"] == Credentials() - # Only un/pw passed - await Discover.discover(username="Foo", password="Bar", timeout=0) - assert dp.mock_calls[2].kwargs["credentials"] == Credentials("Foo", "Bar") - # Only un passed, credentials should be None - await Discover.discover(username="Foo", timeout=0) - assert dp.mock_calls[3].kwargs["credentials"] is None - - -async def test_discover_single_credentials(mocker): - """Make sure that discover_single gives credentials precedence over un and pw.""" - host = "127.0.0.1" - 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__") - - # Only credentials passed - await Discover.discover_single(host, credentials=Credentials(), timeout=0) - assert dp.mock_calls[0].kwargs["credentials"] == Credentials() # Credentials and un/pw passed - await Discover.discover_single( - host, credentials=Credentials(), username="Foo", password="Bar", timeout=0 - ) + if entrypoint == "discover": + await Discover.discover( + credentials=Credentials(), username="Foo", password="Bar", timeout=0 + ) + else: + await Discover.discover_single( + host, credentials=Credentials(), username="Foo", password="Bar", timeout=0 + ) assert dp.mock_calls[1].kwargs["credentials"] == Credentials() + # Only un/pw passed - await Discover.discover_single(host, username="Foo", password="Bar", timeout=0) + if entrypoint == "discover": + await Discover.discover(username="Foo", password="Bar", timeout=0) + else: + await Discover.discover_single(host, username="Foo", password="Bar", timeout=0) assert dp.mock_calls[2].kwargs["credentials"] == Credentials("Foo", "Bar") + # Only un passed, credentials should be None - await Discover.discover_single(host, username="Foo", timeout=0) + if entrypoint == "discover": + await Discover.discover(username="Foo", timeout=0) + else: + await Discover.discover_single(host, username="Foo", timeout=0) assert dp.mock_calls[3].kwargs["credentials"] is None @@ -422,38 +412,28 @@ async def test_device_update_from_new_discovery_info(discovery_mock): assert device.supported_modules -async def test_discover_single_http_client(discovery_mock, mocker): - """Make sure that discover_single returns an initialized SmartDevice instance.""" - host = "127.0.0.1" - discovery_mock.ip = host - - http_client = aiohttp.ClientSession() - - x: Device = await Discover.discover_single(host) - - assert x.config.uses_http == (discovery_mock.default_port != 9999) - - if discovery_mock.default_port != 9999: - assert x.protocol._transport._http_client.client != http_client - x.config.http_client = http_client - assert x.protocol._transport._http_client.client == http_client - - -async def test_discover_http_client(discovery_mock, mocker): - """Make sure that discover returns an initialized SmartDevice instance.""" +@pytest.mark.parametrize("entrypoint", ["discover_single", "discover"]) +async def test_http_client_passthrough(discovery_mock, mocker, entrypoint): + """Ensure HTTP client handling is consistent for discover and discover_single.""" host = "127.0.0.1" discovery_mock.ip = host http_client = aiohttp.ClientSession() + try: + if entrypoint == "discover_single": + dev: Device = await Discover.discover_single(host) + else: + devices = await Discover.discover(discovery_timeout=0) + dev: Device = devices[host] - devices = await Discover.discover(discovery_timeout=0) - x: Device = devices[host] - assert x.config.uses_http == (discovery_mock.default_port != 9999) + assert dev.config.uses_http == (discovery_mock.default_port != 9999) - if discovery_mock.default_port != 9999: - assert x.protocol._transport._http_client.client != http_client - x.config.http_client = http_client - assert x.protocol._transport._http_client.client == http_client + if discovery_mock.default_port != 9999: + assert dev.protocol._transport._http_client.client != http_client + dev.config.http_client = http_client + assert dev.protocol._transport._http_client.client == http_client + finally: + await http_client.close() LEGACY_DISCOVER_DATA = { @@ -757,44 +737,80 @@ async def test_discovery_device_repr(discovery_mock, mocker): @pytest.mark.asyncio -async def test_get_device_instance_new_klap_unsupported_logs(mocker, caplog): - caplog.set_level(logging.DEBUG) - info = { - "result": { - "device_type": "IOT.SMARTPLUGSWITCH", - "device_model": "HS100(UK)", - "device_id": "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": 9999, - "lv": 1, - "new_klap": 1, +@pytest.mark.parametrize( + "info", + "needs_query", + "host", + [ + ( + { + "result": { + "device_type": "IOT.SMARTPLUGSWITCH", + "device_model": "HS100(UK)", + "device_id": "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": 9999, + "lv": 1, + "new_klap": 1, + }, + # Force decrypt attempt to run and be logged (will fail harmlessly and be caught/logged) + "encrypt_info": {"sym_schm": "AES", "key": "", "data": ""}, + } }, - # Force decrypt attempt to run and be logged (will fail harmlessly and be caught/logged) - "encrypt_info": {"sym_schm": "AES", "key": "", "data": ""}, - } - } + True, + "127.0.0.1", + ), + ( + { + "result": { + "device_type": "SMART.IPCAMERA", + "device_model": "C100(US)", + "device_id": "id", + "ip": "127.0.0.2", + "mac": "00-00-00-00-00-01", + "mgt_encrypt_schm": { + "is_support_https": True, + "encrypt_type": "AES", + "http_port": 443, + "lv": 3, + }, + "encrypt_type": ["3"], + } + }, + False, + "127.0.0.2", + ), + ], + ids=["new_klap_unsupported", "non_iot_unsupported"], +) +async def test_get_device_instance_unsupported_logs( + mocker, caplog, info, needs_query, host +): + caplog.set_level(logging.DEBUG) class DummyProt: def __init__(self): self._transport = MagicMock() - async def query(self, req): - # Return legacy-style sysinfo to go through get_device_class(sysinfo) - return {"system": {"get_sysinfo": {"mic_type": "IOT.SMARTPLUGSWITCH"}}} - async def close(self): return None + # Only needed for the new_klap case to drive get_device_class(sysinfo) + if needs_query: + + async def query(self, req): + return {"system": {"get_sysinfo": {"mic_type": "IOT.SMARTPLUGSWITCH"}}} + mocker.patch("kasa.discover.get_protocol", return_value=DummyProt()) # Force device_class resolution to fail to trigger the debug log and UnsupportedDeviceError mocker.patch("kasa.discover.Discover._get_device_class", return_value=None) with pytest.raises(UnsupportedDeviceError): - await Discover._get_device_instance(info, DeviceConfig(host="127.0.0.1")) + await Discover._get_device_instance(info, DeviceConfig(host=host)) # Validate the debug log was emitted assert "Got unsupported device type" in caplog.text From 03903561057dd0429522d7db5b22c3e9704ebc21 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 7 Oct 2025 15:58:42 -0400 Subject: [PATCH 13/26] Fix test parameters --- tests/test_discovery.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index e594db5b1..dbaa0728b 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -738,9 +738,7 @@ async def test_discovery_device_repr(discovery_mock, mocker): @pytest.mark.asyncio @pytest.mark.parametrize( - "info", - "needs_query", - "host", + ("info", "needs_query", "host"), [ ( { From 3857d65bde3e976d74d5ee2692e07c29b21def22 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Sun, 12 Oct 2025 16:05:09 -0400 Subject: [PATCH 14/26] Change new_klap to boolean --- kasa/device_factory.py | 2 +- kasa/deviceconfig.py | 7 ++-- kasa/discover.py | 39 ++++++++++------------- tests/discovery_fixtures.py | 2 +- tests/test_cli.py | 14 ++++---- tests/test_device_factory.py | 2 +- tests/test_deviceconfig.py | 2 +- tests/test_discovery.py | 62 ++++++++++++++++++++---------------- 8 files changed, 63 insertions(+), 67 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index d9392fcb5..a69b7c2c0 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -221,7 +221,7 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol + "." + ctype.encryption_type.value + (".HTTPS" if ctype.https else "") - + (".NEW_KLAP" if ctype.new_klap not in (None, 0) else "") + + (".NEW_KLAP" if ctype.new_klap else "") ) _LOGGER.debug("Finding transport for %s", protocol_transport_key) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 4f9c569cc..69e67aefc 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -101,10 +101,7 @@ class DeviceConnectionParameters(_DeviceConfigBaseMixin): login_version: int | None = None https: bool = False http_port: int | None = None - new_klap: int | None = field( - default=None, - metadata=field_options(serialize=lambda v: None if v in (None, 0) else v), - ) + new_klap: bool | None = None @staticmethod def from_values( @@ -114,7 +111,7 @@ def from_values( login_version: int | None = None, https: bool | None = None, http_port: int | None = None, - new_klap: int | None = None, + new_klap: bool | None = None, ) -> DeviceConnectionParameters: """Return connection parameters from string values.""" try: diff --git a/kasa/discover.py b/kasa/discover.py index 1e3746e6e..67fa2b7c3 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -669,9 +669,7 @@ async def try_connect_all( for device_family in main_device_families for https in (True, False) for login_version in (None, 2) - for new_klap in ( - (1, None) if encrypt == DeviceEncryptionType.Klap else (None,) - ) + for new_klap in (True, None) if ( conn_params := DeviceConnectionParameters( device_family=device_family, @@ -872,6 +870,7 @@ async def _get_device_instance( ) -> Device: """Get SmartDevice or IotDevice from the new 20002 response.""" debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + sysinfo: dict | None = None try: discovery_result = DiscoveryResult.from_dict(info["result"]) @@ -931,25 +930,19 @@ async def _get_device_instance( if config.connection_type.new_klap: sysinfo = await protocol.query({"system": {"get_sysinfo": {}}}) - if ( - device_class := cast(type[Device], Discover._get_device_class(sysinfo)) - ) 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, - ) - else: - if ( - device_class := cast(type[Device], Discover._get_device_class(info)) - ) 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 ( + device_class := cast( + type[Device], + Discover._get_device_class(sysinfo or info), + ) + ) 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 debug_enabled: data = ( @@ -986,7 +979,7 @@ class EncryptionScheme(_DiscoveryBaseMixin): encrypt_type: str | None = None http_port: int | None = None lv: int | None = None - new_klap: int | None = None + new_klap: bool | None = None @dataclass diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index a256395e8..f25c92e75 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -164,7 +164,7 @@ class _DiscoveryMock: login_version: int | None = None port_override: int | None = None http_port: int | None = None - new_klap: int | None = None + new_klap: bool | None = None @property def model(self) -> str: diff --git a/tests/test_cli.py b/tests/test_cli.py index 1617ef108..7d34cf220 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1365,16 +1365,14 @@ async def test_discover_config(dev: Device, mocker, runner): expected = f"--device-family {cparam.device_family.value} --encrypt-type {cparam.encryption_type.value} {'--https' if cparam.https else '--no-https'}" assert expected in res.output normalized = " ".join(res.output.split()) - failed_pat = re.compile( - r"Attempt to connect to 127\.0\.0\.1 with \S+\s*\+\s*\S+\s*\+\s*\S+\s*\+\s*\S+\s+failed", - re.IGNORECASE, + attempt_segs = normalized.split(f"Attempt to connect to {host} with")[1:] + assert attempt_segs, f"No connection attempt lines found in output:\n{res.output}" + assert any(" succeeded" in seg.lower() for seg in attempt_segs), ( + f"No succeeded attempt line found in:\n{res.output}" ) - succeeded_pat = re.compile( - r"Attempt to connect to 127\.0\.0\.1 with \S+\s*\+\s*\S+\s*\+\s*\S+\s*\+\s*\S+\s+succeeded", - re.IGNORECASE, + assert any(" failed" in seg.lower() for seg in attempt_segs), ( + f"No failed attempt line found in:\n{res.output}" ) - assert failed_pat.search(normalized) - assert succeeded_pat.search(normalized) async def test_discover_config_invalid(mocker, runner): diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 683906b36..73c2e16ec 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -260,7 +260,7 @@ async def test_device_class_from_unknown_family(caplog): id="iot-klap", ), pytest.param( - CP(DF.IotSmartPlugSwitch, ET.Klap, https=False, new_klap=1), + CP(DF.IotSmartPlugSwitch, ET.Klap, https=False, new_klap=True), IotProtocol, KlapTransportV2, id="iot-new-klap", diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py index 3a367f566..cc38e8e85 100644 --- a/tests/test_deviceconfig.py +++ b/tests/test_deviceconfig.py @@ -30,7 +30,7 @@ DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Klap, login_version=2, - new_klap=1, + new_klap=True, ), ) CAMERA_AES_CONFIG = DeviceConfig( diff --git a/tests/test_discovery.py b/tests/test_discovery.py index dbaa0728b..f094e0343 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -197,9 +197,8 @@ async def test_discover_single_hostname(discovery_mock, mocker): x = await Discover.discover_single(host, credentials=Credentials()) -@pytest.mark.parametrize("entrypoint", ["discover", "discover_single"]) -async def test_credentials_precedence(entrypoint, mocker): - """Ensure credentials precedence logic is identical for discover and discover_single.""" +async def test_credentials_precedence_discover(mocker): + """Ensure credentials precedence logic for Discover.discover().""" host = "127.0.0.1" async def mock_discover(self, *_, **__): @@ -210,36 +209,45 @@ async def mock_discover(self, *_, **__): mocker.patch.object(_DiscoverProtocol, "do_discover", new=mock_discover) dp = mocker.spy(_DiscoverProtocol, "__init__") - # Only credentials passed - if entrypoint == "discover": - await Discover.discover(credentials=Credentials(), timeout=0) - else: - await Discover.discover_single(host, credentials=Credentials(), timeout=0) + await Discover.discover(credentials=Credentials(), timeout=0) assert dp.mock_calls[0].kwargs["credentials"] == Credentials() - # Credentials and un/pw passed - if entrypoint == "discover": - await Discover.discover( - credentials=Credentials(), username="Foo", password="Bar", timeout=0 - ) - else: - await Discover.discover_single( - host, credentials=Credentials(), username="Foo", password="Bar", timeout=0 - ) + await Discover.discover( + credentials=Credentials(), username="Foo", password="Bar", timeout=0 + ) assert dp.mock_calls[1].kwargs["credentials"] == Credentials() - # Only un/pw passed - if entrypoint == "discover": - await Discover.discover(username="Foo", password="Bar", timeout=0) - else: - await Discover.discover_single(host, username="Foo", password="Bar", timeout=0) + await Discover.discover(username="Foo", password="Bar", timeout=0) assert dp.mock_calls[2].kwargs["credentials"] == Credentials("Foo", "Bar") - # Only un passed, credentials should be None - if entrypoint == "discover": - await Discover.discover(username="Foo", timeout=0) - else: - await Discover.discover_single(host, username="Foo", timeout=0) + await Discover.discover(username="Foo", timeout=0) + assert dp.mock_calls[3].kwargs["credentials"] is None + + +async def test_credentials_precedence_discover_single(mocker): + """Ensure credentials precedence logic for Discover.discover_single().""" + host = "127.0.0.1" + + 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__") + + await Discover.discover_single(host, credentials=Credentials(), timeout=0) + assert dp.mock_calls[0].kwargs["credentials"] == Credentials() + + await Discover.discover_single( + host, credentials=Credentials(), username="Foo", password="Bar", timeout=0 + ) + assert dp.mock_calls[1].kwargs["credentials"] == Credentials() + + await Discover.discover_single(host, username="Foo", password="Bar", timeout=0) + assert dp.mock_calls[2].kwargs["credentials"] == Credentials("Foo", "Bar") + + await Discover.discover_single(host, username="Foo", timeout=0) assert dp.mock_calls[3].kwargs["credentials"] is None From 496cbb66c9b8af33632d0b131b498e5f39d21b1b Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Fri, 31 Oct 2025 17:50:34 -0400 Subject: [PATCH 15/26] Quick test fixes --- tests/test_cli.py | 5 ++- tests/test_discovery.py | 83 ++++++++++++++++++++--------------------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d34cf220..9594056cf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -143,7 +143,8 @@ async def test_discover_raw(discovery_mock, runner, mocker): } assert res.output == json_dumps(expected, indent=True) + "\n" - redact_spy.reset_mock() + calls_before = redact_spy.call_count + res = await runner.invoke( cli, ["--username", "foo", "--password", "bar", "discover", "raw", "--redact"], @@ -151,7 +152,7 @@ async def test_discover_raw(discovery_mock, runner, mocker): ) assert res.exit_code == 0 - redact_spy.assert_called() + assert redact_spy.call_count > calls_before @pytest.mark.parametrize( diff --git a/tests/test_discovery.py b/tests/test_discovery.py index f094e0343..99152ae2b 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -77,6 +77,42 @@ "error_code": 0, } +NEW_KLAP_INFO = { + "result": { + "device_type": "IOT.SMARTPLUGSWITCH", + "device_model": "HS100(UK)", + "device_id": "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": 9999, + "lv": 1, + "new_klap": 1, + }, + # Force decrypt attempt to run and be logged (will fail harmlessly and be caught/logged) + "encrypt_info": {"sym_schm": "AES", "key": "", "data": ""}, + } +} + +IPCAMERA_INFO = { + "result": { + "device_type": "SMART.IPCAMERA", + "device_model": "C100(US)", + "device_id": "id", + "ip": "127.0.0.2", + "mac": "00-00-00-00-00-01", + "mgt_encrypt_schm": { + "is_support_https": True, + "encrypt_type": "AES", + "http_port": 443, + "lv": 3, + }, + "encrypt_type": ["3"], + } +} + @wallswitch_iot async def test_type_detection_switch(dev: Device): @@ -209,6 +245,7 @@ async def mock_discover(self, *_, **__): mocker.patch.object(_DiscoverProtocol, "do_discover", new=mock_discover) dp = mocker.spy(_DiscoverProtocol, "__init__") + # Only credentials passed await Discover.discover(credentials=Credentials(), timeout=0) assert dp.mock_calls[0].kwargs["credentials"] == Credentials() @@ -236,9 +273,11 @@ async def mock_discover(self, *_, **__): mocker.patch.object(_DiscoverProtocol, "do_discover", new=mock_discover) dp = mocker.spy(_DiscoverProtocol, "__init__") + # Only credentials passed await Discover.discover_single(host, credentials=Credentials(), timeout=0) assert dp.mock_calls[0].kwargs["credentials"] == Credentials() + # Credentials and un/pw passed await Discover.discover_single( host, credentials=Credentials(), username="Foo", password="Bar", timeout=0 ) @@ -748,48 +787,8 @@ async def test_discovery_device_repr(discovery_mock, mocker): @pytest.mark.parametrize( ("info", "needs_query", "host"), [ - ( - { - "result": { - "device_type": "IOT.SMARTPLUGSWITCH", - "device_model": "HS100(UK)", - "device_id": "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": 9999, - "lv": 1, - "new_klap": 1, - }, - # Force decrypt attempt to run and be logged (will fail harmlessly and be caught/logged) - "encrypt_info": {"sym_schm": "AES", "key": "", "data": ""}, - } - }, - True, - "127.0.0.1", - ), - ( - { - "result": { - "device_type": "SMART.IPCAMERA", - "device_model": "C100(US)", - "device_id": "id", - "ip": "127.0.0.2", - "mac": "00-00-00-00-00-01", - "mgt_encrypt_schm": { - "is_support_https": True, - "encrypt_type": "AES", - "http_port": 443, - "lv": 3, - }, - "encrypt_type": ["3"], - } - }, - False, - "127.0.0.2", - ), + (NEW_KLAP_INFO, True, "127.0.0.1"), + (IPCAMERA_INFO, False, "127.0.0.2"), ], ids=["new_klap_unsupported", "non_iot_unsupported"], ) From e9fc5dc683ce10ca15f7f764c1324b7ca2ad6a4f Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Fri, 7 Nov 2025 11:35:06 -0500 Subject: [PATCH 16/26] Update discover and tests for new fixtures --- README.md | 2 +- SUPPORTED.md | 1 + kasa/device_factory.py | 33 +++- kasa/device_models.py | 162 +++++++++++++++++++ kasa/discover.py | 51 ++---- tests/device_fixtures.py | 168 ++++---------------- tests/fixtures/iot/HS300(US)_2.0_1.1.2.json | 146 +++++++++++++++++ tests/test_device_factory.py | 135 ++++++++++++++++ tests/test_discovery.py | 87 +++++++++- 9 files changed, 611 insertions(+), 174 deletions(-) create mode 100644 kasa/device_models.py create mode 100644 tests/fixtures/iot/HS300(US)_2.0_1.1.2.json diff --git a/README.md b/README.md index aca294e22..1aefee0cd 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,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, KL120, KL125, KL130, KL135, KL50, KL60, LB100, LB110 - **Light Strips**: KL400L10, KL400L5, KL420L5, KL430 diff --git a/SUPPORTED.md b/SUPPORTED.md index 8f4c1c102..836038278 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/kasa/device_factory.py b/kasa/device_factory.py index a69b7c2c0..eab4bf851 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -7,6 +7,14 @@ from typing import Any from .device import Device +from .device_models import ( + BULBS_IOT, + BULBS_IOT_LIGHT_STRIP, + DIMMERS_IOT, + PLUGS_IOT, + STRIPS_IOT, + SWITCHES_IOT, +) from .device_type import DeviceType from .deviceconfig import DeviceConfig, DeviceEncryptionType, DeviceFamily from .exceptions import KasaException, UnsupportedDeviceError @@ -118,6 +126,10 @@ def _perf_log(has_params: bool, perf_type: str) -> None: elif device_class := get_device_class_from_family( config.connection_type.device_family.value, https=config.connection_type.https ): + if issubclass(device_class, IotDevice): + info = await protocol.query(GET_SYSINFO_QUERY) + _perf_log(True, "get_sysinfo") + device_class = get_device_class_from_sys_info(info) device = device_class(host=config.host, protocol=protocol) await device.update() _perf_log(True, "update") @@ -146,7 +158,11 @@ def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: def get_device_class_from_family( - device_type: str, *, https: bool, require_exact: bool = False + device_type: str, + *, + https: bool, + require_exact: bool = False, + device_model: str | None = None, ) -> type[Device] | None: """Return the device class from the type name.""" supported_device_types: dict[str, type[Device]] = { @@ -175,6 +191,21 @@ def get_device_class_from_family( _LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type) cls = SmartDevice + if cls is not None and issubclass(cls, IotDevice) and device_model is not None: + device_model = device_model.split("(")[0] + if device_model in BULBS_IOT_LIGHT_STRIP: + cls = IotLightStrip + elif device_model in BULBS_IOT: + cls = IotBulb + elif device_model in PLUGS_IOT: + cls = IotPlug + elif device_model in SWITCHES_IOT: + cls = IotWallSwitch + elif device_model in STRIPS_IOT: + cls = IotStrip + elif device_model in DIMMERS_IOT: + cls = IotDimmer + if cls is not None: _LOGGER.debug("Using %s for %s", cls.__name__, device_type) diff --git a/kasa/device_models.py b/kasa/device_models.py new file mode 100644 index 000000000..fc74b6233 --- /dev/null +++ b/kasa/device_models.py @@ -0,0 +1,162 @@ +"""Device model sets for class resolution and testing.""" + +BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL400L10", "KL430", "KL420L5"} +BULBS_IOT_VARIABLE_TEMP = { + "LB120", + "LB130", + "KL120", + "KL125", + "KL130", + "KL135", + "KL430", +} +BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} +BULBS_IOT = ( + BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) + .union(BULBS_IOT_DIMMABLE) + .union(BULBS_IOT_LIGHT_STRIP) +) + +BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} +BULBS_SMART_VARIABLE_TEMP = {"L530E", "L535E", "L930-5"} +BULBS_SMART_COLOR = {"L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} +BULBS_SMART_DIMMABLE = {"L510B", "L510E"} +BULBS_SMART = ( + BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) + .union(BULBS_SMART_DIMMABLE) + .union(BULBS_SMART_LIGHT_STRIP) +) + +BULBS_COLOR = {*BULBS_IOT_COLOR, *BULBS_SMART_COLOR} +BULBS_VARIABLE_TEMP = {*BULBS_IOT_VARIABLE_TEMP, *BULBS_SMART_VARIABLE_TEMP} + +BULBS = { + *BULBS_IOT, + *BULBS_SMART, +} + +LIGHT_STRIPS = {*BULBS_IOT_LIGHT_STRIP, *BULBS_SMART_LIGHT_STRIP} + + +PLUGS_IOT = { + "HS100", + "HS103", + "HS105", + "HS110", + "EP10", + "EP25", + "KP100", + "KP105", + "KP115", + "KP125", + "KP401", +} + +PLUGS_SMART = { + "P100", + "P110", + "P110M", + "P115", + "KP125M", + "EP25", + "P125M", + "TP10", + "TP15", +} + +PLUGS = { + *PLUGS_IOT, + *PLUGS_SMART, +} + + +SWITCHES_IOT = { + "HS200", + "HS210", + "KS200", + "KS200M", +} + +SWITCHES_SMART = { + "HS200", + "KS205", + "KS225", + "KS240", + "S500", + "S500D", + "S505", + "S505D", +} + +SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} + + +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} + +STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306", "P316M"} + +STRIPS = {*STRIPS_IOT, *STRIPS_SMART} + + +DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} + +DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} + +DIMMERS = { + *DIMMERS_IOT, + *DIMMERS_SMART, +} + + +HUBS_SMART = {"H100", "KH100"} + + +SENSORS_SMART = { + "T310", + "T315", + "T300", + "T100", + "T110", + "S200B", + "S200D", + "S210", + "S220", + "D100C", +} + + +THERMOSTATS_SMART = {"KE100"} + + +VACUUMS_SMART = {"RV20"} + + +WITH_EMETER_IOT = {"EP25", "HS110", "HS300", "KP115", "KP125", *BULBS_IOT} + +WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} + +WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} + + +DIMMABLE = {*BULBS, *DIMMERS} + + +ALL_DEVICES_IOT = ( + BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) +) + + +ALL_DEVICES_SMART = ( + BULBS_SMART.union(PLUGS_SMART) + .union(STRIPS_SMART) + .union(DIMMERS_SMART) + .union(HUBS_SMART) + .union(SENSORS_SMART) + .union(SWITCHES_SMART) + .union(THERMOSTATS_SMART) + .union(VACUUMS_SMART) +) + + +ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) diff --git a/kasa/discover.py b/kasa/discover.py index c077c71d8..b73dbe183 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -352,6 +352,8 @@ def datagram_received( return self.seen_hosts.add(ip) + device: Device | None = None + config = DeviceConfig(host=ip, port_override=self.port) if self.credentials: config.credentials = self.credentials @@ -374,10 +376,7 @@ def datagram_received( "meta": {"ip": ip, "port": port}, } ) - self._run_callback_task( - self._resolve_and_process(device_func, info, config, ip) - ) - return + 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 @@ -391,30 +390,12 @@ def datagram_received( self._handle_discovered_event() return - async def _resolve_and_process( - self, - device_func: Callable[[dict, DeviceConfig], Coroutine[Any, Any, Device]], - info: dict, - config: DeviceConfig, - ip: str, - ) -> None: - """Await the device factory, then run success/error bookkeeping.""" - try: - device = await 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)) - except KasaException as ex: - _LOGGER.debug("[DISCOVERY] Unable to find device type for %s: %s", ip, ex) - self.invalid_device_exceptions[ip] = ex - else: - self.discovered_devices[ip] = device - if self.on_discovered is not None: - self._run_callback_task(self.on_discovered(device)) - finally: - self._handle_discovered_event() + self.discovered_devices[ip] = device + + if self.on_discovered is not None: + self._run_callback_task(self.on_discovered(device)) + + self._handle_discovered_event() def _handle_discovered_event(self) -> None: """If target is in seen_hosts cancel discover_task.""" @@ -734,7 +715,9 @@ def _get_device_class(info: dict) -> type[Device]: else False ) dev_class = get_device_class_from_family( - discovery_result.device_type, https=https + discovery_result.device_type, + https=https, + device_model=discovery_result.device_model, ) if not dev_class: raise UnsupportedDeviceError( @@ -757,7 +740,7 @@ def _get_discovery_json_legacy(data: bytes, ip: str) -> dict: return info @staticmethod - async def _get_device_instance_legacy(info: dict, config: DeviceConfig) -> Device: + 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 @@ -867,13 +850,12 @@ def _get_connection_parameters( ) @staticmethod - async def _get_device_instance( + def _get_device_instance( info: dict, config: DeviceConfig, ) -> Device: """Get SmartDevice or IotDevice from the new 20002 response.""" debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) - sysinfo: dict | None = None try: discovery_result = DiscoveryResult.from_dict(info["result"]) @@ -931,13 +913,10 @@ async def _get_device_instance( host=config.host, ) - if config.connection_type.new_klap: - sysinfo = await protocol.query({"system": {"get_sysinfo": {}}}) - if ( device_class := cast( type[Device], - Discover._get_device_class(sysinfo or info), + Discover._get_device_class(info), ) ) is None: _LOGGER.debug("Got unsupported device type: %s", type_) diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 7c9ab2a2f..0f12a9d6a 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -11,6 +11,39 @@ DeviceType, Discover, ) +from kasa.device_models import ( + ALL_DEVICES, + ALL_DEVICES_IOT, + ALL_DEVICES_SMART, + BULBS, + BULBS_COLOR, + BULBS_IOT, + BULBS_IOT_COLOR, + BULBS_IOT_LIGHT_STRIP, + BULBS_IOT_VARIABLE_TEMP, + BULBS_SMART_VARIABLE_TEMP, + BULBS_VARIABLE_TEMP, + DIMMABLE, + DIMMERS, + DIMMERS_IOT, + DIMMERS_SMART, + HUBS_SMART, + LIGHT_STRIPS, + PLUGS, + PLUGS_IOT, + PLUGS_SMART, + SENSORS_SMART, + STRIPS, + STRIPS_IOT, + STRIPS_SMART, + SWITCHES, + SWITCHES_IOT, + SWITCHES_SMART, + THERMOSTATS_SMART, + WITH_EMETER, + WITH_EMETER_IOT, + WITH_EMETER_SMART, +) from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch from kasa.smart import SmartDevice from kasa.smartcam import SmartCamDevice @@ -26,141 +59,6 @@ idgenerator, ) -# Tapo bulbs -BULBS_SMART_VARIABLE_TEMP = {"L530E", "L535E", "L930-5"} -BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} -BULBS_SMART_COLOR = {"L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} -BULBS_SMART_DIMMABLE = {"L510B", "L510E"} -BULBS_SMART = ( - BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) - .union(BULBS_SMART_DIMMABLE) - .union(BULBS_SMART_LIGHT_STRIP) -) - -# Kasa (IOT-prefixed) bulbs -BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL400L10", "KL430", "KL420L5"} -BULBS_IOT_VARIABLE_TEMP = { - "LB120", - "LB130", - "KL120", - "KL125", - "KL130", - "KL135", - "KL430", -} -BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} -BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} -BULBS_IOT = ( - BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) - .union(BULBS_IOT_DIMMABLE) - .union(BULBS_IOT_LIGHT_STRIP) -) - -BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} -BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} - - -LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} -BULBS = { - *BULBS_IOT, - *BULBS_SMART, -} - - -PLUGS_IOT = { - "HS100", - "HS103", - "HS105", - "HS110", - "EP10", - "EP25", - "KP100", - "KP105", - "KP115", - "KP125", - "KP401", -} -PLUGS_SMART = { - "P100", - "P110", - "P110M", - "P115", - "KP125M", - "EP25", - "P125M", - "TP10", - "TP15", -} -PLUGS = { - *PLUGS_IOT, - *PLUGS_SMART, -} -SWITCHES_IOT = { - "HS200", - "HS210", - "KS200", - "KS200M", -} -SWITCHES_SMART = { - "HS200", - "KS205", - "KS225", - "KS240", - "S500", - "S500D", - "S505", - "S505D", -} -SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} -STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306", "P316M"} -STRIPS = {*STRIPS_IOT, *STRIPS_SMART} - -DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} -DIMMERS = { - *DIMMERS_IOT, - *DIMMERS_SMART, -} - -HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = { - "T310", - "T315", - "T300", - "T100", - "T110", - "S200B", - "S200D", - "S210", - "S220", - "D100C", # needs a home category? -} -THERMOSTATS_SMART = {"KE100"} - -VACUUMS_SMART = {"RV20"} - -WITH_EMETER_IOT = {"EP25", "HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} -WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} - -DIMMABLE = {*BULBS, *DIMMERS} - -ALL_DEVICES_IOT = ( - BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) -) -ALL_DEVICES_SMART = ( - BULBS_SMART.union(PLUGS_SMART) - .union(STRIPS_SMART) - .union(DIMMERS_SMART) - .union(HUBS_SMART) - .union(SENSORS_SMART) - .union(SWITCHES_SMART) - .union(THERMOSTATS_SMART) - .union(VACUUMS_SMART) -) -ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) - IP_FIXTURE_CACHE: dict[str, FixtureInfo] = {} 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..674113d13 --- /dev/null +++ b/tests/fixtures/iot/HS300(US)_2.0_1.1.2.json @@ -0,0 +1,146 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 1, + "err_code": 0, + "fwDlPage": "", + "fwNotifyType": -1, + "illegalType": 0, + "server": "n-devs.tplinkcloud.com", + "stopConnect": 0, + "tcspInfo": "", + "tcspStatus": 1, + "username": "user@example.com" + }, + "get_intl_fw_list": { + "err_code": 0, + "fw_list": [] + } + }, + "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": 0, + "err_code": 0, + "power_mw": 0, + "slot_id": 0, + "total_wh": 785, + "voltage_mv": 122143 + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "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": 735409, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", + "next_action": { + "type": -1 + }, + "on_time": 735408, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", + "next_action": { + "type": -1 + }, + "on_time": 735407, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", + "next_action": { + "type": -1 + }, + "on_time": 735407, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "lnk_on": 1, + "longitude_i": 0, + "mac": "F0:A7:31:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "rssi": -54, + "status": "new", + "sw_ver": "1.1.2 Build 241220 Rel.171333", + "updating": 0 + } + } +} diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 73c2e16ec..1562184d4 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -28,8 +28,17 @@ SmartDevice, connect, get_device_class_from_family, + get_device_class_from_sys_info, get_protocol, ) +from kasa.device_models import ( + BULBS_IOT, + BULBS_IOT_LIGHT_STRIP, + DIMMERS_IOT, + PLUGS_IOT, + STRIPS_IOT, + SWITCHES_IOT, +) from kasa.deviceconfig import ( DeviceConfig, DeviceConnectionParameters, @@ -37,6 +46,15 @@ DeviceFamily, ) from kasa.discover import DiscoveryResult +from kasa.exceptions import UnsupportedDeviceError +from kasa.iot import ( + IotBulb, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, +) from kasa.transports import ( AesTransport, BaseTransport, @@ -301,3 +319,120 @@ async def test_get_protocol( protocol = get_protocol(config) assert isinstance(protocol, expected_protocol) assert isinstance(protocol._transport, expected_transport) + + +async def test_connect_with_host_and_config_raises(): + """Cover branch raising when both host and config are provided.""" + dummy = DeviceConfig(host="127.0.0.1") + with pytest.raises(KasaException): + await connect(host="127.0.0.1", config=dummy) + + +async def test_connect_protocol_none_raises(): + """Cover branch when get_protocol returns None.""" + params = DeviceConnectionParameters( + device_family=DeviceFamily.SmartTapoPlug, + encryption_type=DeviceEncryptionType.Xor, # No SMART.XOR mapping + ) + cfg = DeviceConfig(host="127.0.0.15", connection_type=params) + with pytest.raises(UnsupportedDeviceError): + await connect(config=cfg) + + +async def test__connect_iot_ipcamera_unsupported_family(): + """Cover else branch in _connect raising UnsupportedDeviceError.""" + # Use a valid DeviceFamily that is intentionally unsupported by get_device_class_from_family. + params = DeviceConnectionParameters( + device_family=DeviceFamily.IotIpCamera, + encryption_type=DeviceEncryptionType.Aes, + https=True, + ) + cfg = DeviceConfig(host="127.0.0.16", connection_type=params) + protocol = get_protocol(cfg) + assert protocol is not None + # Ensure first XOR path is not taken. + assert not isinstance(protocol._transport, XorTransport) + with pytest.raises(UnsupportedDeviceError): + await connect(config=cfg) + + +def _sample_model(models): + return next(iter(models)) + + +def test_get_device_class_from_family_lightstrip_model(): + model = _sample_model(BULBS_IOT_LIGHT_STRIP) + cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) + assert cls is IotLightStrip + + +def test_get_device_class_from_family_bulb_model(): + model = next(m for m in BULBS_IOT if m not in BULBS_IOT_LIGHT_STRIP) + cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) + assert cls is IotBulb + + +def test_get_device_class_from_family_plug_model(): + model = _sample_model(PLUGS_IOT) + cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) + assert cls is IotPlug + + +def test_get_device_class_from_family_switch_model(): + model = _sample_model(SWITCHES_IOT) + cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) + assert cls is IotWallSwitch + + +def test_get_device_class_from_family_strip_model(): + model = _sample_model(STRIPS_IOT) + cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) + assert cls is IotStrip + + +def test_get_device_class_from_family_dimmer_model(): + model = _sample_model(DIMMERS_IOT) + cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) + assert cls is IotDimmer + + +def test_get_protocol_returns_none_for_unsupported_combo(): + """Cover protocol_transport_key miss returning None.""" + p = DeviceConnectionParameters( + device_family=DeviceFamily.SmartTapoPlug, + encryption_type=DeviceEncryptionType.Xor, + ) + proto = get_protocol(DeviceConfig("127.0.0.99", connection_type=p)) + assert proto is None + + +def test_get_protocol_strict_encryption_mismatch(): + """Cover strict=True mismatch branch for camera.""" + p = DeviceConnectionParameters( + device_family=DeviceFamily.SmartIpCamera, + encryption_type=DeviceEncryptionType.Klap, + ) + proto = get_protocol(DeviceConfig("127.0.0.55", connection_type=p), strict=True) + assert proto is None + + +def test_get_device_class_from_sys_info_mapping(): + """Cover get_device_class_from_sys_info mapping.""" + info = {"system": {"get_sysinfo": {"type": "IOT.SMARTBULB"}}} + cls = get_device_class_from_sys_info(info) + assert cls is IotBulb + + +async def test_connect_with_host_only_branch_raises_unsupported(mocker): + """Exercise connect(host=..., config=None) to hit 'if host:' branch.""" + mocker.patch("kasa.device_factory.get_protocol", return_value=None) + with pytest.raises(UnsupportedDeviceError): + await connect(host="127.0.0.66", config=None) + + +def test_get_device_class_from_family_unknown_model_defaults_to_iotbulb(): + """Ensure device_model not in any set leaves cls unchanged.""" + cls = get_device_class_from_family( + "IOT.SMARTBULB", https=False, device_model="UNKNOWN_MODEL_123" + ) + assert cls is IotBulb diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 96fcf93c5..a6c22551f 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -457,7 +457,7 @@ async def test_device_update_from_new_discovery_info(discovery_mock): KasaException, match=re.escape("You need to await update() to access the data"), ): - assert device.supported_modules + assert device.modules @pytest.mark.parametrize("entrypoint", ["discover_single", "discover"]) @@ -716,6 +716,9 @@ async def test_discover_try_connect_all(discovery_mock, mocker): protocol = get_protocol( DeviceConfig(discovery_mock.ip, connection_type=cparams) ) + if issubclass(dev_class, IotDevice): + info = await protocol.query({"system": {"get_sysinfo": {}}}) + dev_class = get_device_class_from_sys_info(info) protocol_class = protocol.__class__ transport_class = protocol._transport.__class__ else: @@ -845,3 +848,85 @@ def test_datagram_received_logs_for_exceptions(caplog, mocker): # Logs were written assert f"Unsupported device found at {ip1} << boom" in caplog.text assert f"[DISCOVERY] Unable to find device type for {ip2}: bad" in caplog.text + + +def test_handle_discovered_event_without_discover_task(): + proto = _DiscoverProtocol(target="10.0.0.5") + proto.seen_hosts.add("10.0.0.5") + assert proto.discover_task is None + proto._handle_discovered_event() + assert proto.target_discovered is True + assert proto.discover_task is None + + +def test_error_received_logs(caplog): + caplog.set_level(logging.ERROR) + proto = _DiscoverProtocol() + proto.error_received(RuntimeError("boom")) + assert "Got error: boom" in caplog.text + + +def test_get_device_class_unknown_result_type(): + info = { + "result": { + "device_type": "FOO.BAR", + "device_model": "X100(US)", + "device_id": "id", + "ip": "127.0.0.1", + "mac": "AA-BB-CC-DD-EE-FF", + } + } + with pytest.raises(UnsupportedDeviceError) as ex: + Discover._get_device_class(info) + assert "Unknown device type: FOO.BAR" in str(ex.value) + + +def test_get_discovery_json_legacy_error(monkeypatch): + monkeypatch.setattr( + XorEncryption, "decrypt", lambda _: (_ for _ in ()).throw(ValueError("bad")) + ) + with pytest.raises(KasaException) as ex: + Discover._get_discovery_json_legacy(b"\x00\x01", "127.0.0.2") + assert "Unable to read response from device: 127.0.0.2" in str(ex.value) + + +def test_get_device_instance_legacy_no_debug_logging(caplog): + caplog.set_level(logging.INFO) + info = { + "system": { + "get_sysinfo": { + "alias": "Plug", + "deviceId": "id", + "mac": "00:11:22:33:44:55", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS100(UK)", + } + } + } + config = DeviceConfig(host="127.0.0.3") + dev = Discover._get_device_instance_legacy(info, config) + assert dev.host == "127.0.0.3" + assert "[DISCOVERY]" not in caplog.text + + +def test_get_discovery_json_error(caplog, monkeypatch): + caplog.set_level(logging.DEBUG) + monkeypatch.setattr( + "kasa.discover.json_loads", + lambda *_: (_ for _ in ()).throw(ValueError("bad json")), + ) + with pytest.raises(KasaException) as ex: + Discover._get_discovery_json(b"\x00" * 32, "127.0.0.9") + assert "Unable to read response from device: 127.0.0.9" in str(ex.value) + assert "Got invalid response from device 127.0.0.9" in caplog.text + + +def test_get_discovery_json_legacy_success(monkeypatch): + sample = {"system": {"get_sysinfo": {"type": "IOT.SMARTPLUGSWITCH"}}} + monkeypatch.setattr( + XorEncryption, + "decrypt", + lambda _: b'{"system":{"get_sysinfo":{"type":"IOT.SMARTPLUGSWITCH"}}}', + ) + info = Discover._get_discovery_json_legacy(b"\x00\x01", "127.0.0.4") + assert info == sample From 5a6aa037e9498ef9c5fd289e86cf8a90be5aecbc Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Wed, 12 Nov 2025 16:55:23 -0500 Subject: [PATCH 17/26] Add CLI encrypt_type KLAPV2 --- kasa/cli/main.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 4f1eccda9..217de20f8 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -326,11 +326,16 @@ async def cli( if not encrypt_type: encrypt_type = "KLAP" + new_klap = None + if encrypt_type and encrypt_type == "KLAPV2": + new_klap = True + ctype = DeviceConnectionParameters( DeviceFamily(device_family), DeviceEncryptionType(encrypt_type), login_version, https, + new_klap=new_klap, ) config = DeviceConfig( host=host, From 2e051e4a9a369af13179befedc299a8eba429eec Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Thu, 13 Nov 2025 11:45:09 -0500 Subject: [PATCH 18/26] Update device_models for L430P Bulb --- kasa/device_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/device_models.py b/kasa/device_models.py index fc74b6233..ebb36f0e1 100644 --- a/kasa/device_models.py +++ b/kasa/device_models.py @@ -19,8 +19,8 @@ ) BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} -BULBS_SMART_VARIABLE_TEMP = {"L530E", "L535E", "L930-5"} -BULBS_SMART_COLOR = {"L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} +BULBS_SMART_VARIABLE_TEMP = {"L430P", "L530E", "L535E", "L930-5"} +BULBS_SMART_COLOR = {"L430P", "L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) From b9d96e90ee31593279da8991cfbce66be4c99c4a Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 9 Dec 2025 17:22:37 -0500 Subject: [PATCH 19/26] Fix tests and KLAPV2 encrypt-type call --- kasa/cli/main.py | 2 ++ tests/test_cli.py | 20 +++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/kasa/cli/main.py b/kasa/cli/main.py index 217de20f8..cc1535ee1 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -40,6 +40,7 @@ ] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] +ENCRYPT_TYPES.append("KLAPV2") DEFAULT_TARGET = "255.255.255.255" @@ -328,6 +329,7 @@ async def cli( new_klap = None if encrypt_type and encrypt_type == "KLAPV2": + encrypt_type = "KLAP" new_klap = True ctype = DeviceConnectionParameters( diff --git a/tests/test_cli.py b/tests/test_cli.py index 9594056cf..5424f25cc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -72,6 +72,7 @@ async def test_help(runner): [ pytest.param(None, None, id="No connect params"), pytest.param("SMART.TAPOPLUG", None, id="Only device_family"), + pytest.param("SMART.TAPOPLUG", "KLAPV2", id="KLAPV2 encrypt type"), ], ) async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_type): @@ -83,6 +84,13 @@ async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_ mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) + connect_mock = None + if encrypt_type: + connect_mock = mocker.patch( + "kasa.device.Device.connect", + new_callable=mocker.AsyncMock, + return_value=dev, + ) res = await runner.invoke( cli, @@ -101,7 +109,17 @@ async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_ catch_exceptions=False, ) assert res.exit_code == 0 - update.assert_called() + if encrypt_type: + update.assert_not_called() + else: + update.assert_called() + if connect_mock: + connect_mock.assert_called() + if encrypt_type == "KLAPV2": + called_kwargs = connect_mock.call_args.kwargs + cfg = called_kwargs.get("config") + assert cfg is not None + assert getattr(cfg.connection_type, "new_klap", None) is True async def test_list_devices(discovery_mock, runner): From 9e75565e45816b7715225f3eb4b56dd1bf4b567d Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 27 Jan 2026 12:52:58 -0500 Subject: [PATCH 20/26] Update device_models for new supported devices --- kasa/device_models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/kasa/device_models.py b/kasa/device_models.py index ebb36f0e1..20d55e378 100644 --- a/kasa/device_models.py +++ b/kasa/device_models.py @@ -19,8 +19,8 @@ ) BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} -BULBS_SMART_VARIABLE_TEMP = {"L430P", "L530E", "L535E", "L930-5"} -BULBS_SMART_COLOR = {"L430P", "L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} +BULBS_SMART_VARIABLE_TEMP = {"L430C", "L430P", "L530E", "L535E", "L930-5"} +BULBS_SMART_COLOR = {"L430C", "L430P", "L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) @@ -54,6 +54,7 @@ } PLUGS_SMART = { + "P105", "P100", "P110", "P110M", @@ -87,6 +88,7 @@ "S500D", "S505", "S505D", + "TS15", } SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} From d50176d6efedfecdbc5cde74bd6ae9af28c29137 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Sun, 22 Feb 2026 09:26:15 -0500 Subject: [PATCH 21/26] Rework discovery and device handling and tests --- kasa/cli/main.py | 5 +- kasa/device_factory.py | 45 +---- kasa/device_models.py | 164 ----------------- kasa/deviceconfig.py | 1 + kasa/discover.py | 112 ++++++++++-- tests/device_fixtures.py | 169 ++++++++++++++---- tests/discovery_fixtures.py | 6 + .../deviceconfig_plug-new-klap.json | 2 +- tests/test_device_factory.py | 81 ++------- tests/test_discovery.py | 136 ++++++++++++-- 10 files changed, 386 insertions(+), 335 deletions(-) delete mode 100644 kasa/device_models.py diff --git a/kasa/cli/main.py b/kasa/cli/main.py index cc1535ee1..74f31b170 100755 --- a/kasa/cli/main.py +++ b/kasa/cli/main.py @@ -40,7 +40,6 @@ ] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] -ENCRYPT_TYPES.append("KLAPV2") DEFAULT_TARGET = "255.255.255.255" @@ -328,8 +327,8 @@ async def cli( encrypt_type = "KLAP" new_klap = None - if encrypt_type and encrypt_type == "KLAPV2": - encrypt_type = "KLAP" + if encrypt_type and encrypt_type == DeviceEncryptionType.Klapv2.value: + encrypt_type = DeviceEncryptionType.Klap.value new_klap = True ctype = DeviceConnectionParameters( diff --git a/kasa/device_factory.py b/kasa/device_factory.py index eab4bf851..8170f2aa9 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -7,14 +7,6 @@ from typing import Any from .device import Device -from .device_models import ( - BULBS_IOT, - BULBS_IOT_LIGHT_STRIP, - DIMMERS_IOT, - PLUGS_IOT, - STRIPS_IOT, - SWITCHES_IOT, -) from .device_type import DeviceType from .deviceconfig import DeviceConfig, DeviceEncryptionType, DeviceFamily from .exceptions import KasaException, UnsupportedDeviceError @@ -112,25 +104,20 @@ def _perf_log(has_params: bool, perf_type: str) -> None: device_class: type[Device] | None device: Device | None = None - 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( + if device_class := get_device_class_from_family( config.connection_type.device_family.value, https=config.connection_type.https ): if issubclass(device_class, IotDevice): + # IoT devices report a generic device family (e.g. IOT.SMARTPLUGSWITCH) + # that does not distinguish plug/strip/switch/etc. Query sysinfo for + # the precise subclass regardless of which transport is in use. info = await protocol.query(GET_SYSINFO_QUERY) _perf_log(True, "get_sysinfo") device_class = get_device_class_from_sys_info(info) - device = device_class(host=config.host, protocol=protocol) + device = device_class(config.host, protocol=protocol) + device.update_from_discover_info(info) + else: + device = device_class(host=config.host, protocol=protocol) await device.update() _perf_log(True, "update") return device @@ -162,7 +149,6 @@ def get_device_class_from_family( *, https: bool, require_exact: bool = False, - device_model: str | None = None, ) -> type[Device] | None: """Return the device class from the type name.""" supported_device_types: dict[str, type[Device]] = { @@ -191,21 +177,6 @@ def get_device_class_from_family( _LOGGER.debug("Unknown SMART device with %s, using SmartDevice", device_type) cls = SmartDevice - if cls is not None and issubclass(cls, IotDevice) and device_model is not None: - device_model = device_model.split("(")[0] - if device_model in BULBS_IOT_LIGHT_STRIP: - cls = IotLightStrip - elif device_model in BULBS_IOT: - cls = IotBulb - elif device_model in PLUGS_IOT: - cls = IotPlug - elif device_model in SWITCHES_IOT: - cls = IotWallSwitch - elif device_model in STRIPS_IOT: - cls = IotStrip - elif device_model in DIMMERS_IOT: - cls = IotDimmer - if cls is not None: _LOGGER.debug("Using %s for %s", cls.__name__, device_type) diff --git a/kasa/device_models.py b/kasa/device_models.py deleted file mode 100644 index 20d55e378..000000000 --- a/kasa/device_models.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Device model sets for class resolution and testing.""" - -BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL400L10", "KL430", "KL420L5"} -BULBS_IOT_VARIABLE_TEMP = { - "LB120", - "LB130", - "KL120", - "KL125", - "KL130", - "KL135", - "KL430", -} -BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} -BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} -BULBS_IOT = ( - BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) - .union(BULBS_IOT_DIMMABLE) - .union(BULBS_IOT_LIGHT_STRIP) -) - -BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} -BULBS_SMART_VARIABLE_TEMP = {"L430C", "L430P", "L530E", "L535E", "L930-5"} -BULBS_SMART_COLOR = {"L430C", "L430P", "L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} -BULBS_SMART_DIMMABLE = {"L510B", "L510E"} -BULBS_SMART = ( - BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) - .union(BULBS_SMART_DIMMABLE) - .union(BULBS_SMART_LIGHT_STRIP) -) - -BULBS_COLOR = {*BULBS_IOT_COLOR, *BULBS_SMART_COLOR} -BULBS_VARIABLE_TEMP = {*BULBS_IOT_VARIABLE_TEMP, *BULBS_SMART_VARIABLE_TEMP} - -BULBS = { - *BULBS_IOT, - *BULBS_SMART, -} - -LIGHT_STRIPS = {*BULBS_IOT_LIGHT_STRIP, *BULBS_SMART_LIGHT_STRIP} - - -PLUGS_IOT = { - "HS100", - "HS103", - "HS105", - "HS110", - "EP10", - "EP25", - "KP100", - "KP105", - "KP115", - "KP125", - "KP401", -} - -PLUGS_SMART = { - "P105", - "P100", - "P110", - "P110M", - "P115", - "KP125M", - "EP25", - "P125M", - "TP10", - "TP15", -} - -PLUGS = { - *PLUGS_IOT, - *PLUGS_SMART, -} - - -SWITCHES_IOT = { - "HS200", - "HS210", - "KS200", - "KS200M", -} - -SWITCHES_SMART = { - "HS200", - "KS205", - "KS225", - "KS240", - "S500", - "S500D", - "S505", - "S505D", - "TS15", -} - -SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} - - -STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} - -STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306", "P316M"} - -STRIPS = {*STRIPS_IOT, *STRIPS_SMART} - - -DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} - -DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} - -DIMMERS = { - *DIMMERS_IOT, - *DIMMERS_SMART, -} - - -HUBS_SMART = {"H100", "KH100"} - - -SENSORS_SMART = { - "T310", - "T315", - "T300", - "T100", - "T110", - "S200B", - "S200D", - "S210", - "S220", - "D100C", -} - - -THERMOSTATS_SMART = {"KE100"} - - -VACUUMS_SMART = {"RV20"} - - -WITH_EMETER_IOT = {"EP25", "HS110", "HS300", "KP115", "KP125", *BULBS_IOT} - -WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} - -WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} - - -DIMMABLE = {*BULBS, *DIMMERS} - - -ALL_DEVICES_IOT = ( - BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) -) - - -ALL_DEVICES_SMART = ( - BULBS_SMART.union(PLUGS_SMART) - .union(STRIPS_SMART) - .union(DIMMERS_SMART) - .union(HUBS_SMART) - .union(SENSORS_SMART) - .union(SWITCHES_SMART) - .union(THERMOSTATS_SMART) - .union(VACUUMS_SMART) -) - - -ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 69e67aefc..91258308c 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -62,6 +62,7 @@ class DeviceEncryptionType(Enum): Klap = "KLAP" Aes = "AES" Xor = "XOR" + Klapv2 = "KLAPV2" # CLI alias for KLAP + new_klap=True; maps to KlapTransportV2 class DeviceFamily(Enum): diff --git a/kasa/discover.py b/kasa/discover.py index b73dbe183..a821ac9aa 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -111,6 +111,7 @@ from kasa import Device from kasa.credentials import Credentials from kasa.device_factory import ( + GET_SYSINFO_QUERY, get_device_class_from_family, get_device_class_from_sys_info, get_protocol, @@ -361,22 +362,33 @@ def datagram_received( config.timeout = self.timeout try: if port == self.discovery_port: - json_func = Discover._get_discovery_json_legacy - device_func = Discover._get_device_instance_legacy + info = Discover._get_discovery_json_legacy(data, ip) + if self.on_discovered_raw is not None: + self.on_discovered_raw( + { + "discovery_response": info, + "meta": {"ip": ip, "port": port}, + } + ) + device = Discover._get_device_instance_legacy(info, config) elif port in (Discover.DISCOVERY_PORT_2, Discover.DISCOVERY_PORT_3): - json_func = Discover._get_discovery_json - device_func = Discover._get_device_instance + info = Discover._get_discovery_json(data, ip) + if self.on_discovered_raw is not None: + self.on_discovered_raw( + { + "discovery_response": info, + "meta": {"ip": ip, "port": port}, + } + ) + # IoT devices with new_klap firmware use KlapTransportV2 and need + # an async sysinfo query to determine the correct device subclass; + # spawn a callback task so the datagram handler is not blocked. + if self._is_new_klap_iot(info): + self._run_callback_task(self._process_new_klap_device(info, config)) + return + device = Discover._get_device_instance(info, config) 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}, - } - ) - 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 @@ -404,6 +416,77 @@ def _handle_discovered_event(self) -> None: if self.discover_task: self.discover_task.cancel() + @staticmethod + def _is_new_klap_iot(info: dict) -> bool: + """Return True if the port 20002 response is from an IoT new_klap device. + + IoT devices with new_klap firmware advertise KLAP encryption but use + KlapTransportV2. The discovery response alone does not carry enough + structural information to determine the precise IoT subclass, so an + async sysinfo query is required after connection. + """ + result = info.get("result", {}) + schm = result.get("mgt_encrypt_schm", {}) + return bool(schm.get("new_klap")) and result.get("device_type", "").startswith( + "IOT." + ) + + async def _process_new_klap_device(self, info: dict, config: DeviceConfig) -> None: + """Process an IoT new_klap device discovered via port 20002. + + These devices use KlapTransportV2 but require a sysinfo query to + determine the correct IoT subclass (IotStrip, IotBulb, etc.) because + the 20002 discovery response only carries the generic device family. + """ + ip = config.host + try: + discovery_result = DiscoveryResult.from_dict(info["result"]) + conn_params = Discover._get_connection_parameters(discovery_result) + config.connection_type = conn_params + + if (protocol := get_protocol(config)) is None: + raise UnsupportedDeviceError( + f"Unsupported encryption scheme {ip}: " + + f"{config.connection_type.to_dict()}: {info}", + discovery_result=discovery_result.to_dict(), + host=ip, + ) + + sysinfo = await protocol.query(GET_SYSINFO_QUERY) + device_class = get_device_class_from_sys_info(sysinfo) + device: Device = device_class(ip, protocol=protocol) + + di = discovery_result.to_dict() + di["model"], _, _ = discovery_result.device_model.partition("(") + device.update_from_discover_info(di) + + if _LOGGER.isEnabledFor(logging.DEBUG): + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug("[DISCOVERY] %s << %s", ip, pf(data)) + 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: + await self.on_unsupported(udex) + self._handle_discovered_event() + 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() + return + + self.discovered_devices[ip] = device + + if self.on_discovered is not None: + await self.on_discovered(device) + + self._handle_discovered_event() + def error_received(self, ex: Exception) -> None: """Handle asyncio.Protocol errors.""" _LOGGER.error("Got error: %s", ex) @@ -717,7 +800,6 @@ def _get_device_class(info: dict) -> type[Device]: dev_class = get_device_class_from_family( discovery_result.device_type, https=https, - device_model=discovery_result.device_model, ) if not dev_class: raise UnsupportedDeviceError( @@ -854,7 +936,7 @@ def _get_device_instance( info: dict, config: DeviceConfig, ) -> Device: - """Get SmartDevice or IotDevice from the new 20002 response.""" + """Get Device for the port 20002 response.""" debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) try: diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py index 0f12a9d6a..1a6ef75db 100644 --- a/tests/device_fixtures.py +++ b/tests/device_fixtures.py @@ -11,39 +11,6 @@ DeviceType, Discover, ) -from kasa.device_models import ( - ALL_DEVICES, - ALL_DEVICES_IOT, - ALL_DEVICES_SMART, - BULBS, - BULBS_COLOR, - BULBS_IOT, - BULBS_IOT_COLOR, - BULBS_IOT_LIGHT_STRIP, - BULBS_IOT_VARIABLE_TEMP, - BULBS_SMART_VARIABLE_TEMP, - BULBS_VARIABLE_TEMP, - DIMMABLE, - DIMMERS, - DIMMERS_IOT, - DIMMERS_SMART, - HUBS_SMART, - LIGHT_STRIPS, - PLUGS, - PLUGS_IOT, - PLUGS_SMART, - SENSORS_SMART, - STRIPS, - STRIPS_IOT, - STRIPS_SMART, - SWITCHES, - SWITCHES_IOT, - SWITCHES_SMART, - THERMOSTATS_SMART, - WITH_EMETER, - WITH_EMETER_IOT, - WITH_EMETER_SMART, -) from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch from kasa.smart import SmartDevice from kasa.smartcam import SmartCamDevice @@ -59,6 +26,142 @@ idgenerator, ) +# Tapo bulbs +BULBS_SMART_VARIABLE_TEMP = {"L430C", "L430P", "L530E", "L535E", "L930-5"} +BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} +BULBS_SMART_COLOR = {"L430C", "L430P", "L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} +BULBS_SMART_DIMMABLE = {"L510B", "L510E"} +BULBS_SMART = ( + BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) + .union(BULBS_SMART_DIMMABLE) + .union(BULBS_SMART_LIGHT_STRIP) +) + +# Kasa (IOT-prefixed) bulbs +BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL400L10", "KL430", "KL420L5"} +BULBS_IOT_VARIABLE_TEMP = { + "LB120", + "LB130", + "KL120", + "KL125", + "KL130", + "KL135", + "KL430", +} +BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110"} +BULBS_IOT = ( + BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) + .union(BULBS_IOT_DIMMABLE) + .union(BULBS_IOT_LIGHT_STRIP) +) + +BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} +BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} + +LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} +BULBS = { + *BULBS_IOT, + *BULBS_SMART, +} + + +PLUGS_IOT = { + "HS100", + "HS103", + "HS105", + "HS110", + "EP10", + "EP25", + "KP100", + "KP105", + "KP115", + "KP125", + "KP401", +} +PLUGS_SMART = { + "P105", + "P100", + "P110", + "P110M", + "P115", + "KP125M", + "EP25", + "P125M", + "TP10", + "TP15", +} +PLUGS = { + *PLUGS_IOT, + *PLUGS_SMART, +} +SWITCHES_IOT = { + "HS200", + "HS210", + "KS200", + "KS200M", +} +SWITCHES_SMART = { + "HS200", + "KS205", + "KS225", + "KS240", + "S500", + "S500D", + "S505", + "S505D", + "TS15", +} +SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} +STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306", "P316M"} +STRIPS = {*STRIPS_IOT, *STRIPS_SMART} + +DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"} +DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"} +DIMMERS = { + *DIMMERS_IOT, + *DIMMERS_SMART, +} + +HUBS_SMART = {"H100", "KH100"} +SENSORS_SMART = { + "T310", + "T315", + "T300", + "T100", + "T110", + "S200B", + "S200D", + "S210", + "S220", + "D100C", # needs a home category? +} +THERMOSTATS_SMART = {"KE100"} + +VACUUMS_SMART = {"RV20"} + +WITH_EMETER_IOT = {"EP25", "HS110", "HS300", "KP115", "KP125", *BULBS_IOT} +WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} +WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} + +DIMMABLE = {*BULBS, *DIMMERS} + +ALL_DEVICES_IOT = ( + BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) +) +ALL_DEVICES_SMART = ( + BULBS_SMART.union(PLUGS_SMART) + .union(STRIPS_SMART) + .union(DIMMERS_SMART) + .union(HUBS_SMART) + .union(SENSORS_SMART) + .union(SWITCHES_SMART) + .union(THERMOSTATS_SMART) + .union(VACUUMS_SMART) +) +ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) + IP_FIXTURE_CACHE: dict[str, FixtureInfo] = {} diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index f25c92e75..15f3a14c6 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -316,6 +316,12 @@ async def mock_discover(self): if fixture_info.protocol in {"SMARTCAM", "SMARTCAM.CHILD"} else FakeIotProtocol(fixture_info.data, fixture_info.name) ) + # Also register under the device IP when the host key is a hostname + # (e.g. discover_single with a hostname). _process_new_klap_device + # creates a protocol with config.host set to the resolved IP, so + # the _query mock must be able to look it up by IP as well. + if host != dm.ip: + protos[dm.ip] = protos[host] port = ( dm.port_override if dm.port_override and dm.discovery_port != 20002 diff --git a/tests/fixtures/serialization/deviceconfig_plug-new-klap.json b/tests/fixtures/serialization/deviceconfig_plug-new-klap.json index be044de73..b7a9a596f 100644 --- a/tests/fixtures/serialization/deviceconfig_plug-new-klap.json +++ b/tests/fixtures/serialization/deviceconfig_plug-new-klap.json @@ -4,7 +4,7 @@ "encryption_type": "KLAP", "https": false, "login_version": 2, - "new_klap": 1 + "new_klap": true }, "host": "127.0.0.1", "timeout": 5 diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 1562184d4..284a7f602 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -31,14 +31,6 @@ get_device_class_from_sys_info, get_protocol, ) -from kasa.device_models import ( - BULBS_IOT, - BULBS_IOT_LIGHT_STRIP, - DIMMERS_IOT, - PLUGS_IOT, - STRIPS_IOT, - SWITCHES_IOT, -) from kasa.deviceconfig import ( DeviceConfig, DeviceConnectionParameters, @@ -47,14 +39,7 @@ ) from kasa.discover import DiscoveryResult from kasa.exceptions import UnsupportedDeviceError -from kasa.iot import ( - IotBulb, - IotDimmer, - IotLightStrip, - IotPlug, - IotStrip, - IotWallSwitch, -) +from kasa.iot import IotBulb from kasa.transports import ( AesTransport, BaseTransport, @@ -75,10 +60,18 @@ def _get_connection_type_device_class(discovery_info): if "result" in discovery_info: - device_class = Discover._get_device_class(discovery_info) dr = DiscoveryResult.from_dict(discovery_info["result"]) - connection_type = Discover._get_connection_parameters(dr) + # For IoT device families, the precise subclass is determined by sysinfo + # at connect time (not from the discovery response alone); use the base + # IotDevice class for isinstance checks in tests. + if connection_type.device_family in ( + DeviceFamily.IotSmartPlugSwitch, + DeviceFamily.IotSmartBulb, + ): + device_class = IotDevice + else: + device_class = Discover._get_device_class(discovery_info) else: connection_type = DeviceConnectionParameters.from_values( DeviceFamily.IotSmartPlugSwitch.value, DeviceEncryptionType.Xor.value @@ -341,7 +334,8 @@ async def test_connect_protocol_none_raises(): async def test__connect_iot_ipcamera_unsupported_family(): """Cover else branch in _connect raising UnsupportedDeviceError.""" - # Use a valid DeviceFamily that is intentionally unsupported by get_device_class_from_family. + # IotIpCamera is disabled in the supported device types table; it should + # raise UnsupportedDeviceError even though a valid protocol was created. params = DeviceConnectionParameters( device_family=DeviceFamily.IotIpCamera, encryption_type=DeviceEncryptionType.Aes, @@ -350,52 +344,11 @@ async def test__connect_iot_ipcamera_unsupported_family(): cfg = DeviceConfig(host="127.0.0.16", connection_type=params) protocol = get_protocol(cfg) assert protocol is not None - # Ensure first XOR path is not taken. assert not isinstance(protocol._transport, XorTransport) with pytest.raises(UnsupportedDeviceError): await connect(config=cfg) -def _sample_model(models): - return next(iter(models)) - - -def test_get_device_class_from_family_lightstrip_model(): - model = _sample_model(BULBS_IOT_LIGHT_STRIP) - cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) - assert cls is IotLightStrip - - -def test_get_device_class_from_family_bulb_model(): - model = next(m for m in BULBS_IOT if m not in BULBS_IOT_LIGHT_STRIP) - cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) - assert cls is IotBulb - - -def test_get_device_class_from_family_plug_model(): - model = _sample_model(PLUGS_IOT) - cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) - assert cls is IotPlug - - -def test_get_device_class_from_family_switch_model(): - model = _sample_model(SWITCHES_IOT) - cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) - assert cls is IotWallSwitch - - -def test_get_device_class_from_family_strip_model(): - model = _sample_model(STRIPS_IOT) - cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) - assert cls is IotStrip - - -def test_get_device_class_from_family_dimmer_model(): - model = _sample_model(DIMMERS_IOT) - cls = get_device_class_from_family("IOT.SMARTBULB", https=False, device_model=model) - assert cls is IotDimmer - - def test_get_protocol_returns_none_for_unsupported_combo(): """Cover protocol_transport_key miss returning None.""" p = DeviceConnectionParameters( @@ -428,11 +381,3 @@ async def test_connect_with_host_only_branch_raises_unsupported(mocker): mocker.patch("kasa.device_factory.get_protocol", return_value=None) with pytest.raises(UnsupportedDeviceError): await connect(host="127.0.0.66", config=None) - - -def test_get_device_class_from_family_unknown_model_defaults_to_iotbulb(): - """Ensure device_model not in any set leaves cls unchanged.""" - cls = get_device_class_from_family( - "IOT.SMARTBULB", https=False, device_model="UNKNOWN_MODEL_123" - ) - assert cls is IotBulb diff --git a/tests/test_discovery.py b/tests/test_discovery.py index a6c22551f..16375b439 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -416,6 +416,10 @@ async def test_discover_single_authentication(discovery_mock, mocker): host = "127.0.0.1" discovery_mock.ip = host device_class = Discover._get_device_class(discovery_mock.discovery_data) + # For new_klap IoT fixtures the exact subclass comes from sysinfo, not the + # family-level class returned by _get_device_class. + if discovery_mock.new_klap and discovery_mock.device_type.startswith("IOT."): + device_class = get_device_class_from_sys_info(discovery_mock.query_data) mocker.patch.object( device_class, "update", @@ -789,16 +793,16 @@ async def test_discovery_device_repr(discovery_mock, mocker): @pytest.mark.asyncio @pytest.mark.parametrize( - ("info", "needs_query", "host"), + ("info", "host"), [ - (NEW_KLAP_INFO, True, "127.0.0.1"), - (IPCAMERA_INFO, False, "127.0.0.2"), + (NEW_KLAP_INFO, "127.0.0.1"), + (IPCAMERA_INFO, "127.0.0.2"), ], ids=["new_klap_unsupported", "non_iot_unsupported"], ) -async def test_get_device_instance_unsupported_logs( - mocker, caplog, info, needs_query, host -): +async def test_get_device_instance_unsupported_logs(mocker, caplog, info, host): + """_get_device_instance raises UnsupportedDeviceError and logs when device class cannot be resolved.""" + # Also validates the debug log is emitted. caplog.set_level(logging.DEBUG) class DummyProt: @@ -808,20 +812,13 @@ def __init__(self): async def close(self): return None - # Only needed for the new_klap case to drive get_device_class(sysinfo) - if needs_query: - - async def query(self, req): - return {"system": {"get_sysinfo": {"mic_type": "IOT.SMARTPLUGSWITCH"}}} - mocker.patch("kasa.discover.get_protocol", return_value=DummyProt()) - # Force device_class resolution to fail to trigger the debug log and UnsupportedDeviceError + # Force device_class resolution to fail to trigger the debug log mocker.patch("kasa.discover.Discover._get_device_class", return_value=None) with pytest.raises(UnsupportedDeviceError): await Discover._get_device_instance(info, DeviceConfig(host=host)) - # Validate the debug log was emitted assert "Got unsupported device type" in caplog.text @@ -930,3 +927,114 @@ def test_get_discovery_json_legacy_success(monkeypatch): ) info = Discover._get_discovery_json_legacy(b"\x00\x01", "127.0.0.4") assert info == sample + + +# --------------------------------------------------------------------------- +# _is_new_klap_iot +# --------------------------------------------------------------------------- + + +def test_is_new_klap_iot_true(): + """IOT device with new_klap=1 should return True.""" + info = { + "result": { + "device_type": "IOT.SMARTPLUGSWITCH", + "mgt_encrypt_schm": {"new_klap": 1}, + } + } + assert _DiscoverProtocol._is_new_klap_iot(info) is True + + +def test_is_new_klap_iot_false_not_iot(): + """Non-IOT device with new_klap should return False.""" + info = { + "result": { + "device_type": "SMART.TAPOPLUG", + "mgt_encrypt_schm": {"new_klap": 1}, + } + } + assert _DiscoverProtocol._is_new_klap_iot(info) is False + + +def test_is_new_klap_iot_false_no_flag(): + """IOT device without the new_klap flag should return False.""" + info = { + "result": { + "device_type": "IOT.SMARTPLUGSWITCH", + "mgt_encrypt_schm": {"encrypt_type": "KLAP"}, + } + } + assert _DiscoverProtocol._is_new_klap_iot(info) is False + + +# --------------------------------------------------------------------------- +# _process_new_klap_device +# --------------------------------------------------------------------------- + + +async def test_process_new_klap_device_protocol_none(mocker): + """UnsupportedDeviceError is stored when get_protocol returns None.""" + proto = _DiscoverProtocol() + mocker.patch("kasa.discover.get_protocol", return_value=None) + + await proto._process_new_klap_device(NEW_KLAP_INFO, DeviceConfig(host="127.0.0.1")) + + assert "127.0.0.1" in proto.unsupported_device_exceptions + assert "127.0.0.1" not in proto.discovered_devices + + +async def test_process_new_klap_device_on_unsupported_callback(mocker): + """on_unsupported callback is awaited in the UnsupportedDeviceError path.""" + on_unsupported = mocker.AsyncMock() + proto = _DiscoverProtocol(on_unsupported=on_unsupported) + mocker.patch("kasa.discover.get_protocol", return_value=None) + + await proto._process_new_klap_device(NEW_KLAP_INFO, DeviceConfig(host="127.0.0.1")) + + assert "127.0.0.1" in proto.unsupported_device_exceptions + on_unsupported.assert_awaited_once() + + +async def test_process_new_klap_device_kasa_exception(mocker): + """KasaException from sysinfo query is stored in invalid_device_exceptions.""" + proto = _DiscoverProtocol() + mock_protocol = MagicMock() + mock_protocol.query = mocker.AsyncMock(side_effect=KasaException("query failed")) + mocker.patch("kasa.discover.get_protocol", return_value=mock_protocol) + + await proto._process_new_klap_device(NEW_KLAP_INFO, DeviceConfig(host="127.0.0.1")) + + assert "127.0.0.1" in proto.invalid_device_exceptions + assert "127.0.0.1" not in proto.discovered_devices + + +async def test_process_new_klap_device_success_with_on_discovered(mocker): + """Success path fires on_discovered and adds device to discovered_devices.""" + on_discovered = mocker.AsyncMock() + proto = _DiscoverProtocol(on_discovered=on_discovered) + mock_protocol = MagicMock() + mock_protocol.query = mocker.AsyncMock( + return_value={"system": {"get_sysinfo": {"type": "IOT.SMARTPLUGSWITCH"}}} + ) + mocker.patch("kasa.discover.get_protocol", return_value=mock_protocol) + + await proto._process_new_klap_device(NEW_KLAP_INFO, DeviceConfig(host="127.0.0.1")) + + assert "127.0.0.1" in proto.discovered_devices + on_discovered.assert_awaited_once() + + +@pytest.mark.xdist_group(name="caplog") +async def test_process_new_klap_device_debug_logging(mocker, caplog): + """Debug logging branch in _process_new_klap_device emits [DISCOVERY].""" + caplog.set_level(logging.DEBUG) + proto = _DiscoverProtocol() + mock_protocol = MagicMock() + mock_protocol.query = mocker.AsyncMock( + return_value={"system": {"get_sysinfo": {"type": "IOT.SMARTPLUGSWITCH"}}} + ) + mocker.patch("kasa.discover.get_protocol", return_value=mock_protocol) + + await proto._process_new_klap_device(NEW_KLAP_INFO, DeviceConfig(host="127.0.0.1")) + + assert "[DISCOVERY]" in caplog.text From 0ae3bd0bb324d4d40a2201606fd8b1fbd8c44013 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Sun, 22 Feb 2026 09:35:36 -0500 Subject: [PATCH 22/26] Fix tests --- tests/test_cli.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index a367e8a56..1610885fc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -42,6 +42,7 @@ from kasa.cli.time import time from kasa.cli.usage import energy from kasa.cli.wifi import wifi +from kasa.device_factory import get_device_class_from_sys_info from kasa.discover import Discover, DiscoveryResult, redact_data from kasa.iot import IotDevice from kasa.json import dumps as json_dumps @@ -192,6 +193,9 @@ async def test_discover_raw(discovery_mock, runner, mocker): 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) + # For new_klap IoT fixtures the precise subclass comes from sysinfo. + if discovery_mock.new_klap and discovery_mock.device_type.startswith("IOT."): + device_class = get_device_class_from_sys_info(discovery_mock.query_data) mocker.patch.object( device_class, "update", @@ -1006,6 +1010,9 @@ async def test_host_auth_failed(discovery_mock, mocker, runner): host = "127.0.0.1" discovery_mock.ip = host device_class = Discover._get_device_class(discovery_mock.discovery_data) + # For new_klap IoT fixtures the precise subclass comes from sysinfo. + if discovery_mock.new_klap and discovery_mock.device_type.startswith("IOT."): + device_class = get_device_class_from_sys_info(discovery_mock.query_data) mocker.patch.object( device_class, "update", From de6e02571d645719d32dfaee5460f103e90c396b Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Sun, 22 Feb 2026 09:41:12 -0500 Subject: [PATCH 23/26] Clean up comments --- kasa/deviceconfig.py | 2 +- tests/test_cli.py | 2 -- tests/test_device_factory.py | 4 ++-- tests/test_discovery.py | 10 ---------- 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 91258308c..b224a3e51 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -62,7 +62,7 @@ class DeviceEncryptionType(Enum): Klap = "KLAP" Aes = "AES" Xor = "XOR" - Klapv2 = "KLAPV2" # CLI alias for KLAP + new_klap=True; maps to KlapTransportV2 + Klapv2 = "KLAPV2" class DeviceFamily(Enum): diff --git a/tests/test_cli.py b/tests/test_cli.py index 1610885fc..b51c9b1ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -193,7 +193,6 @@ async def test_discover_raw(discovery_mock, runner, mocker): 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) - # For new_klap IoT fixtures the precise subclass comes from sysinfo. if discovery_mock.new_klap and discovery_mock.device_type.startswith("IOT."): device_class = get_device_class_from_sys_info(discovery_mock.query_data) mocker.patch.object( @@ -1010,7 +1009,6 @@ async def test_host_auth_failed(discovery_mock, mocker, runner): host = "127.0.0.1" discovery_mock.ip = host device_class = Discover._get_device_class(discovery_mock.discovery_data) - # For new_klap IoT fixtures the precise subclass comes from sysinfo. if discovery_mock.new_klap and discovery_mock.device_type.startswith("IOT."): device_class = get_device_class_from_sys_info(discovery_mock.query_data) mocker.patch.object( diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 284a7f602..d7b608116 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -334,8 +334,7 @@ async def test_connect_protocol_none_raises(): async def test__connect_iot_ipcamera_unsupported_family(): """Cover else branch in _connect raising UnsupportedDeviceError.""" - # IotIpCamera is disabled in the supported device types table; it should - # raise UnsupportedDeviceError even though a valid protocol was created. + # Use a valid DeviceFamily that is intentionally unsupported by get_device_class_from_family. params = DeviceConnectionParameters( device_family=DeviceFamily.IotIpCamera, encryption_type=DeviceEncryptionType.Aes, @@ -344,6 +343,7 @@ async def test__connect_iot_ipcamera_unsupported_family(): cfg = DeviceConfig(host="127.0.0.16", connection_type=params) protocol = get_protocol(cfg) assert protocol is not None + # Ensure first XOR path is not taken. assert not isinstance(protocol._transport, XorTransport) with pytest.raises(UnsupportedDeviceError): await connect(config=cfg) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 16375b439..213288963 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -929,11 +929,6 @@ def test_get_discovery_json_legacy_success(monkeypatch): assert info == sample -# --------------------------------------------------------------------------- -# _is_new_klap_iot -# --------------------------------------------------------------------------- - - def test_is_new_klap_iot_true(): """IOT device with new_klap=1 should return True.""" info = { @@ -967,11 +962,6 @@ def test_is_new_klap_iot_false_no_flag(): assert _DiscoverProtocol._is_new_klap_iot(info) is False -# --------------------------------------------------------------------------- -# _process_new_klap_device -# --------------------------------------------------------------------------- - - async def test_process_new_klap_device_protocol_none(mocker): """UnsupportedDeviceError is stored when get_protocol returns None.""" proto = _DiscoverProtocol() From 3de1d57282518cf8623c416b70e43d242a17579c Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Sun, 22 Feb 2026 11:45:28 -0500 Subject: [PATCH 24/26] Fix tests --- tests/test_cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index b51c9b1ee..fc7b797c2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -979,6 +979,9 @@ async def test_discover_auth_failed(discovery_mock, mocker, runner): host = "127.0.0.1" discovery_mock.ip = host device_class = Discover._get_device_class(discovery_mock.discovery_data) + # For new_klap IoT fixtures the precise subclass comes from sysinfo. + if discovery_mock.new_klap and discovery_mock.device_type.startswith("IOT."): + device_class = get_device_class_from_sys_info(discovery_mock.query_data) mocker.patch.object( device_class, "update", From aa8b2aa7580563f704674d974e0593e25063da80 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Sun, 22 Feb 2026 13:00:07 -0500 Subject: [PATCH 25/26] Update dump_devinfo to handle KLAPV2 flag if used. --- devtools/dump_devinfo.py | 5 ++++ tests/fixtures/iot/HS300(US)_2.0_1.1.2.json | 32 ++++++++++----------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 2a38c1467..27a613a16 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -321,11 +321,16 @@ def capture_raw(discovered: DiscoveredRaw): batch_size=batch_size, ) elif device_family and encrypt_type: + new_klap = None + if encrypt_type == DeviceEncryptionType.Klapv2.value: + encrypt_type = DeviceEncryptionType.Klap.value + new_klap = True ctype = DeviceConnectionParameters( DeviceFamily(device_family), DeviceEncryptionType(encrypt_type), login_version, https, + new_klap=new_klap, ) config = DeviceConfig( host=host, 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 index 674113d13..0d1c68df2 100644 --- a/tests/fixtures/iot/HS300(US)_2.0_1.1.2.json +++ b/tests/fixtures/iot/HS300(US)_2.0_1.1.2.json @@ -43,12 +43,12 @@ }, "emeter": { "get_realtime": { - "current_ma": 0, + "current_ma": 119, "err_code": 0, - "power_mw": 0, + "power_mw": 9021, "slot_id": 0, - "total_wh": 785, - "voltage_mv": 122143 + "total_wh": 4013, + "voltage_mv": 121937 } }, "schedule": { @@ -74,7 +74,7 @@ "next_action": { "type": -1 }, - "on_time": 735409, + "on_time": 1760, "state": 1 }, { @@ -83,8 +83,8 @@ "next_action": { "type": -1 }, - "on_time": 0, - "state": 0 + "on_time": 1759, + "state": 1 }, { "alias": "#MASKED_NAME# 3", @@ -92,7 +92,7 @@ "next_action": { "type": -1 }, - "on_time": 735408, + "on_time": 1758, "state": 1 }, { @@ -101,8 +101,8 @@ "next_action": { "type": -1 }, - "on_time": 0, - "state": 0 + "on_time": 1758, + "state": 1 }, { "alias": "#MASKED_NAME# 5", @@ -110,8 +110,8 @@ "next_action": { "type": -1 }, - "on_time": 735407, - "state": 1 + "on_time": 0, + "state": 0 }, { "alias": "#MASKED_NAME# 6", @@ -119,8 +119,8 @@ "next_action": { "type": -1 }, - "on_time": 735407, - "state": 1 + "on_time": 0, + "state": 0 } ], "deviceId": "0000000000000000000000000000000000000000", @@ -130,14 +130,14 @@ "hw_ver": "2.0", "latitude_i": 0, "led_off": 0, - "lnk_on": 1, + "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": -54, + "rssi": -45, "status": "new", "sw_ver": "1.1.2 Build 241220 Rel.171333", "updating": 0 From 71a4147984aedc7cfa9ce4613204ecf28b15dac7 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Wed, 8 Jul 2026 14:17:13 -0400 Subject: [PATCH 26/26] Address new_klap review follow-ups --- kasa/cli/discover.py | 11 ++- tests/discovery_fixtures.py | 41 ++++++--- tests/test_cli.py | 13 ++- tests/test_device_factory.py | 53 +++++++++--- tests/test_discovery.py | 163 +++++++++++++++++++++++++++-------- 5 files changed, 220 insertions(+), 61 deletions(-) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py index af367e32b..82eee7eab 100644 --- a/kasa/cli/discover.py +++ b/kasa/cli/discover.py @@ -15,6 +15,7 @@ Discover, UnsupportedDeviceError, ) +from kasa.deviceconfig import DeviceConnectionParameters, DeviceEncryptionType from kasa.discover import ( NEW_DISCOVERY_REDACTORS, ConnectAttempt, @@ -59,6 +60,13 @@ def _discover_is_root_cmd(ctx: click.Context) -> bool: ) +def _get_cli_encrypt_type(cparams: DeviceConnectionParameters) -> str: + """Return the encryption type value needed to recreate the connection.""" + if cparams.new_klap: + return DeviceEncryptionType.Klapv2.value + return cparams.encryption_type.value + + @discover.command() @click.pass_context async def detail(ctx: click.Context) -> DeviceDict: @@ -275,10 +283,11 @@ def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: ) if dev: cparams = dev.config.connection_type + encrypt_type = _get_cli_encrypt_type(cparams) 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"--encrypt-type {encrypt_type} " f"{'--https' if cparams.https else '--no-https'}" ) return {host: dev} diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py index 15f3a14c6..be0ff6d49 100644 --- a/tests/discovery_fixtures.py +++ b/tests/discovery_fixtures.py @@ -8,6 +8,7 @@ from typing import Any, TypedDict import pytest +from pytest_mock import MockerFixture from kasa.transports.xortransport import XorEncryption @@ -48,8 +49,8 @@ class DiscoveryResponse(TypedDict): def _make_unsupported( - device_family, - encrypt_type, + device_family: str, + encrypt_type: str, *, https: bool = False, omit_keys: dict[str, Any] | None = None, @@ -112,8 +113,12 @@ def _make_unsupported( def parametrize_discovery( - desc, *, data_root_filter=None, protocol_filter=None, model_filter=None -): + desc: str, + *, + data_root_filter: str | None = None, + protocol_filter: set[str] | None = None, + model_filter: set[str] | None = None, +) -> pytest.MarkDecorator: filtered_fixtures = filter_fixtures( desc, data_root_filter=data_root_filter, @@ -141,14 +146,14 @@ def parametrize_discovery( ), ids=idgenerator, ) -async def discovery_mock(request, mocker): +async def discovery_mock(request: pytest.FixtureRequest, mocker: MockerFixture) -> Any: """Mock discovery and patch protocol queries to use Fake protocols.""" fi: FixtureInfo = request.param fixture_info = FixtureInfo(fi.name, fi.protocol, copy.deepcopy(fi.data)) return patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker) -def create_discovery_mock(ip: str, fixture_data: dict): +def create_discovery_mock(ip: str, fixture_data: dict[str, Any]) -> Any: """Mock discovery and patch protocol queries to use Fake protocols.""" @dataclass @@ -242,7 +247,9 @@ def _datagram(self) -> bytes: return dm -def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker): +def patch_discovery( + fixture_infos: dict[str, FixtureInfo], mocker: MockerFixture +) -> Any: """Mock discovery and patch protocol queries to use Fake protocols.""" discovery_mocks = { ip: create_discovery_mock(ip, fixture_info.data) @@ -275,7 +282,7 @@ async def process_callback_queue(finished_event: asyncio.Event) -> None: await exception_queue.put(None) callback_queue.task_done() - async def wait_for_coro(): + async def wait_for_coro() -> None: await callback_queue.join() if ex := exception_queue.get_nowait(): raise ex @@ -290,7 +297,7 @@ def _run_callback_task(self, coro: Coroutine) -> None: ) # do_discover_mock - async def mock_discover(self): + async def mock_discover(self: Any) -> None: """Call datagram_received for all mock fixtures. Handles test cases modifying the ip and hostname of the first fixture @@ -337,13 +344,15 @@ async def mock_discover(self): mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) # query_mock - async def _query(self, request, retry_count: int = 3): + async def _query( + self: Any, request: dict[str, Any], retry_count: int = 3 + ) -> dict[str, Any]: return await protos[self._host].query(request) mocker.patch("kasa.IotProtocol.query", _query) mocker.patch("kasa.SmartProtocol.query", _query) - def _getaddrinfo(host, *_, **__): + def _getaddrinfo(host: str, *_: Any, **__: Any) -> list[tuple[Any, ...]]: nonlocal first_host, first_ip first_host = host # Store the hostname used by discover single first_ip = list(discovery_mocks.values())[ @@ -368,7 +377,9 @@ def _getaddrinfo(host, *_, **__): ), ids=idgenerator, ) -def discovery_data(request, mocker): +def discovery_data( + request: pytest.FixtureRequest, mocker: MockerFixture +) -> dict[str, Any] | DiscoveryResponse: """Return raw discovery file contents as JSON. Used for discovery tests.""" fixture_info = request.param fixture_data = copy.deepcopy(fixture_info.data) @@ -393,12 +404,14 @@ def discovery_data(request, mocker): @pytest.fixture( params=UNSUPPORTED_DEVICES.values(), ids=list(UNSUPPORTED_DEVICES.keys()) ) -def unsupported_device_info(request, mocker): +def unsupported_device_info( + request: pytest.FixtureRequest, mocker: MockerFixture +) -> DiscoveryResponse: """Return unsupported devices for cli and discovery tests.""" discovery_data = request.param host = "127.0.0.1" - async def mock_discover(self): + async def mock_discover(self: Any) -> None: if discovery_data: data = ( b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" diff --git a/tests/test_cli.py b/tests/test_cli.py index e83119285..92eba7c92 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,6 +43,7 @@ from kasa.cli.usage import energy from kasa.cli.wifi import wifi from kasa.device_factory import get_device_class_from_sys_info +from kasa.deviceconfig import DeviceEncryptionType from kasa.discover import Discover, DiscoveryResult, redact_data from kasa.iot import IotDevice from kasa.json import dumps as json_dumps @@ -794,6 +795,9 @@ async def _state(dev: Device): mocker.patch("kasa.cli.device.state", new=_state) dr = DiscoveryResult.from_dict(discovery_mock.discovery_data["result"]) + encrypt_type = dr.mgt_encrypt_schm.encrypt_type + if dr.mgt_encrypt_schm.new_klap: + encrypt_type = DeviceEncryptionType.Klapv2.value res = await runner.invoke( cli, [ @@ -806,7 +810,7 @@ async def _state(dev: Device): "--device-family", dr.device_type, "--encrypt-type", - dr.mgt_encrypt_schm.encrypt_type, + encrypt_type, "--login-version", dr.mgt_encrypt_schm.lv or 1, ], @@ -1462,7 +1466,12 @@ 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'}" + encrypt_type = ( + DeviceEncryptionType.Klapv2.value + if cparam.new_klap + else cparam.encryption_type.value + ) + expected = f"--device-family {cparam.device_family.value} --encrypt-type {encrypt_type} {'--https' if cparam.https else '--no-https'}" assert expected in res.output normalized = " ".join(res.output.split()) attempt_segs = normalized.split(f"Attempt to connect to {host} with")[1:] diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index d7b608116..4ee33e548 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -7,10 +7,11 @@ """ import logging -from typing import cast +from typing import Any, cast import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 +from pytest_mock import MockerFixture from kasa import ( BaseProtocol, @@ -22,6 +23,7 @@ SmartProtocol, ) from kasa.device_factory import ( + GET_SYSINFO_QUERY, Device, IotDevice, SmartCamDevice, @@ -58,7 +60,10 @@ pytestmark = [pytest.mark.requires_dummy] -def _get_connection_type_device_class(discovery_info): +def _get_connection_type_device_class( + discovery_info: dict[str, Any], +) -> tuple[DeviceConnectionParameters, type[Device]]: + device_class: type[Device] if "result" in discovery_info: dr = DiscoveryResult.from_dict(discovery_info["result"]) connection_type = Discover._get_connection_parameters(dr) @@ -83,8 +88,8 @@ def _get_connection_type_device_class(discovery_info): async def test_connect( discovery_mock, - mocker, -): + mocker: MockerFixture, +) -> None: """Test that if the protocol is passed in it gets set correctly.""" host = DISCOVERY_MOCK_IP ctype, device_class = _get_connection_type_device_class( @@ -306,7 +311,7 @@ async def test_get_protocol( conn_params: DeviceConnectionParameters, expected_protocol: type[BaseProtocol], expected_transport: type[BaseTransport], -): +) -> None: """Test get_protocol returns the right protocol.""" config = DeviceConfig("127.0.0.1", connection_type=conn_params) protocol = get_protocol(config) @@ -349,7 +354,7 @@ async def test__connect_iot_ipcamera_unsupported_family(): await connect(config=cfg) -def test_get_protocol_returns_none_for_unsupported_combo(): +def test_get_protocol_returns_none_for_unsupported_combo() -> None: """Cover protocol_transport_key miss returning None.""" p = DeviceConnectionParameters( device_family=DeviceFamily.SmartTapoPlug, @@ -359,7 +364,7 @@ def test_get_protocol_returns_none_for_unsupported_combo(): assert proto is None -def test_get_protocol_strict_encryption_mismatch(): +def test_get_protocol_strict_encryption_mismatch() -> None: """Cover strict=True mismatch branch for camera.""" p = DeviceConnectionParameters( device_family=DeviceFamily.SmartIpCamera, @@ -369,15 +374,43 @@ def test_get_protocol_strict_encryption_mismatch(): assert proto is None -def test_get_device_class_from_sys_info_mapping(): +def test_get_device_class_from_sys_info_mapping() -> None: """Cover get_device_class_from_sys_info mapping.""" info = {"system": {"get_sysinfo": {"type": "IOT.SMARTBULB"}}} cls = get_device_class_from_sys_info(info) assert cls is IotBulb -async def test_connect_with_host_only_branch_raises_unsupported(mocker): +async def test_connect_with_host_only_branch_raises_unsupported( + mocker: MockerFixture, +) -> None: """Exercise connect(host=..., config=None) to hit 'if host:' branch.""" mocker.patch("kasa.device_factory.get_protocol", return_value=None) with pytest.raises(UnsupportedDeviceError): - await connect(host="127.0.0.66", config=None) + await connect(host="127.0.0.66", config=None) # type: ignore[arg-type] + + +async def test_connect_iot_family_resolves_subclass_from_sysinfo( + mocker: MockerFixture, +) -> None: + """IoT discovery families are narrowed to the concrete subclass via sysinfo.""" + query_mock = mocker.patch( + "kasa.IotProtocol.query", + new_callable=mocker.AsyncMock, + return_value={"system": {"get_sysinfo": {"type": "IOT.SMARTBULB"}}}, + ) + update_mock = mocker.patch.object(IotBulb, "update", new_callable=mocker.AsyncMock) + config = DeviceConfig( + host="127.0.0.1", + connection_type=DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, + DeviceEncryptionType.Klap, + new_klap=True, + ), + ) + + dev = await connect(config=config) + + assert isinstance(dev, IotBulb) + query_mock.assert_awaited_once_with(GET_SYSINFO_QUERY) + update_mock.assert_awaited_once() diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 213288963..39521c2ee 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -8,12 +8,14 @@ import re import socket from asyncio import timeout as asyncio_timeout +from typing import NotRequired, TypedDict from unittest.mock import MagicMock import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding +from pytest_mock import MockerFixture from kasa import ( Credentials, @@ -56,7 +58,74 @@ # A physical device has to respond to discovery for the tests to work. pytestmark = [pytest.mark.requires_dummy] -UNSUPPORTED = { + +class DiscoveryEncryptionScheme(TypedDict, total=False): + is_support_https: bool + encrypt_type: str + http_port: int + lv: int + new_klap: int + + +class DiscoveryEncryptInfo(TypedDict): + sym_schm: str + key: str + data: str + + +class DiscoveryResponseResult(TypedDict, total=False): + device_id: str + owner: str + device_type: str + device_model: str + ip: str + mac: str + is_support_iot_cloud: bool + obd_src: str + factory_default: bool + mgt_encrypt_schm: DiscoveryEncryptionScheme + encrypt_info: DiscoveryEncryptInfo + encrypt_type: list[str] + + +class DiscoveryResponseInfo(TypedDict): + result: DiscoveryResponseResult + error_code: NotRequired[int] + + +class LegacySysInfo(TypedDict, total=False): + alias: str + dev_name: str + deviceId: str + err_code: int + hwId: str + hw_ver: str + mac: str + mic_type: str + model: str + sw_ver: str + type: str + updating: int + + +class LegacySystem(TypedDict): + get_sysinfo: LegacySysInfo + + +class LegacyDiscoveryInfo(TypedDict): + system: LegacySystem + + +class NewKlapCheckResult(TypedDict): + device_type: str + mgt_encrypt_schm: DiscoveryEncryptionScheme + + +class NewKlapCheckInfo(TypedDict): + result: NewKlapCheckResult + + +UNSUPPORTED: DiscoveryResponseInfo = { "result": { "device_id": "xx", "owner": "xx", @@ -77,7 +146,7 @@ "error_code": 0, } -NEW_KLAP_INFO = { +NEW_KLAP_INFO: DiscoveryResponseInfo = { "result": { "device_type": "IOT.SMARTPLUGSWITCH", "device_model": "HS100(UK)", @@ -96,7 +165,7 @@ } } -IPCAMERA_INFO = { +IPCAMERA_INFO: DiscoveryResponseInfo = { "result": { "device_type": "SMART.IPCAMERA", "device_model": "C100(US)", @@ -389,7 +458,7 @@ async def test_discover_invalid_responses(msg, data, mocker): assert len(proto.discovered_devices) == 0 -AUTHENTICATION_DATA_KLAP = { +AUTHENTICATION_DATA_KLAP: DiscoveryResponseInfo = { "result": { "device_id": "xx", "owner": "xx", @@ -488,7 +557,7 @@ async def test_http_client_passthrough(discovery_mock, mocker, entrypoint): await http_client.close() -LEGACY_DISCOVER_DATA = { +LEGACY_DISCOVER_DATA: LegacyDiscoveryInfo = { "system": { "get_sysinfo": { "alias": "#MASKED_NAME#", @@ -692,12 +761,15 @@ async def test_discovery_decryption(): data = json.dumps(data_dict) encypted_data = encryption_session.encrypt(data.encode()) - encrypt_info = { + encrypt_info: DiscoveryEncryptInfo = { "data": encypted_data.decode(), "key": encrypted_key_iv_b4.decode(), "sym_schm": "AES", } - info = {**UNSUPPORTED["result"], "encrypt_info": encrypt_info} + info: DiscoveryResponseResult = { + **UNSUPPORTED["result"], + "encrypt_info": encrypt_info, + } dr = DiscoveryResult.from_dict(info) Discover._decrypt_discovery_data(dr) assert dr.decrypted_data == data_dict @@ -800,16 +872,21 @@ async def test_discovery_device_repr(discovery_mock, mocker): ], ids=["new_klap_unsupported", "non_iot_unsupported"], ) -async def test_get_device_instance_unsupported_logs(mocker, caplog, info, host): +async def test_get_device_instance_unsupported_logs( + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + info: DiscoveryResponseInfo, + host: str, +) -> None: """_get_device_instance raises UnsupportedDeviceError and logs when device class cannot be resolved.""" # Also validates the debug log is emitted. caplog.set_level(logging.DEBUG) class DummyProt: - def __init__(self): + def __init__(self) -> None: self._transport = MagicMock() - async def close(self): + async def close(self) -> None: return None mocker.patch("kasa.discover.get_protocol", return_value=DummyProt()) @@ -822,7 +899,9 @@ async def close(self): assert "Got unsupported device type" in caplog.text -def test_datagram_received_logs_for_exceptions(caplog, mocker): +def test_datagram_received_logs_for_exceptions( + caplog: pytest.LogCaptureFixture, mocker: MockerFixture +) -> None: proto = _DiscoverProtocol() caplog.set_level(logging.DEBUG) @@ -847,7 +926,7 @@ def test_datagram_received_logs_for_exceptions(caplog, mocker): assert f"[DISCOVERY] Unable to find device type for {ip2}: bad" in caplog.text -def test_handle_discovered_event_without_discover_task(): +def test_handle_discovered_event_without_discover_task() -> None: proto = _DiscoverProtocol(target="10.0.0.5") proto.seen_hosts.add("10.0.0.5") assert proto.discover_task is None @@ -856,15 +935,15 @@ def test_handle_discovered_event_without_discover_task(): assert proto.discover_task is None -def test_error_received_logs(caplog): +def test_error_received_logs(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.ERROR) proto = _DiscoverProtocol() proto.error_received(RuntimeError("boom")) assert "Got error: boom" in caplog.text -def test_get_device_class_unknown_result_type(): - info = { +def test_get_device_class_unknown_result_type() -> None: + info: DiscoveryResponseInfo = { "result": { "device_type": "FOO.BAR", "device_model": "X100(US)", @@ -878,7 +957,7 @@ def test_get_device_class_unknown_result_type(): assert "Unknown device type: FOO.BAR" in str(ex.value) -def test_get_discovery_json_legacy_error(monkeypatch): +def test_get_discovery_json_legacy_error(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( XorEncryption, "decrypt", lambda _: (_ for _ in ()).throw(ValueError("bad")) ) @@ -887,9 +966,11 @@ def test_get_discovery_json_legacy_error(monkeypatch): assert "Unable to read response from device: 127.0.0.2" in str(ex.value) -def test_get_device_instance_legacy_no_debug_logging(caplog): +def test_get_device_instance_legacy_no_debug_logging( + caplog: pytest.LogCaptureFixture, +) -> None: caplog.set_level(logging.INFO) - info = { + info: LegacyDiscoveryInfo = { "system": { "get_sysinfo": { "alias": "Plug", @@ -906,7 +987,9 @@ def test_get_device_instance_legacy_no_debug_logging(caplog): assert "[DISCOVERY]" not in caplog.text -def test_get_discovery_json_error(caplog, monkeypatch): +def test_get_discovery_json_error( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: caplog.set_level(logging.DEBUG) monkeypatch.setattr( "kasa.discover.json_loads", @@ -918,8 +1001,10 @@ def test_get_discovery_json_error(caplog, monkeypatch): assert "Got invalid response from device 127.0.0.9" in caplog.text -def test_get_discovery_json_legacy_success(monkeypatch): - sample = {"system": {"get_sysinfo": {"type": "IOT.SMARTPLUGSWITCH"}}} +def test_get_discovery_json_legacy_success(monkeypatch: pytest.MonkeyPatch) -> None: + sample: LegacyDiscoveryInfo = { + "system": {"get_sysinfo": {"type": "IOT.SMARTPLUGSWITCH"}} + } monkeypatch.setattr( XorEncryption, "decrypt", @@ -929,9 +1014,9 @@ def test_get_discovery_json_legacy_success(monkeypatch): assert info == sample -def test_is_new_klap_iot_true(): +def test_is_new_klap_iot_true() -> None: """IOT device with new_klap=1 should return True.""" - info = { + info: NewKlapCheckInfo = { "result": { "device_type": "IOT.SMARTPLUGSWITCH", "mgt_encrypt_schm": {"new_klap": 1}, @@ -940,9 +1025,9 @@ def test_is_new_klap_iot_true(): assert _DiscoverProtocol._is_new_klap_iot(info) is True -def test_is_new_klap_iot_false_not_iot(): +def test_is_new_klap_iot_false_not_iot() -> None: """Non-IOT device with new_klap should return False.""" - info = { + info: NewKlapCheckInfo = { "result": { "device_type": "SMART.TAPOPLUG", "mgt_encrypt_schm": {"new_klap": 1}, @@ -951,9 +1036,9 @@ def test_is_new_klap_iot_false_not_iot(): assert _DiscoverProtocol._is_new_klap_iot(info) is False -def test_is_new_klap_iot_false_no_flag(): +def test_is_new_klap_iot_false_no_flag() -> None: """IOT device without the new_klap flag should return False.""" - info = { + info: NewKlapCheckInfo = { "result": { "device_type": "IOT.SMARTPLUGSWITCH", "mgt_encrypt_schm": {"encrypt_type": "KLAP"}, @@ -962,7 +1047,9 @@ def test_is_new_klap_iot_false_no_flag(): assert _DiscoverProtocol._is_new_klap_iot(info) is False -async def test_process_new_klap_device_protocol_none(mocker): +async def test_process_new_klap_device_protocol_none( + mocker: MockerFixture, +) -> None: """UnsupportedDeviceError is stored when get_protocol returns None.""" proto = _DiscoverProtocol() mocker.patch("kasa.discover.get_protocol", return_value=None) @@ -973,7 +1060,9 @@ async def test_process_new_klap_device_protocol_none(mocker): assert "127.0.0.1" not in proto.discovered_devices -async def test_process_new_klap_device_on_unsupported_callback(mocker): +async def test_process_new_klap_device_on_unsupported_callback( + mocker: MockerFixture, +) -> None: """on_unsupported callback is awaited in the UnsupportedDeviceError path.""" on_unsupported = mocker.AsyncMock() proto = _DiscoverProtocol(on_unsupported=on_unsupported) @@ -985,7 +1074,9 @@ async def test_process_new_klap_device_on_unsupported_callback(mocker): on_unsupported.assert_awaited_once() -async def test_process_new_klap_device_kasa_exception(mocker): +async def test_process_new_klap_device_kasa_exception( + mocker: MockerFixture, +) -> None: """KasaException from sysinfo query is stored in invalid_device_exceptions.""" proto = _DiscoverProtocol() mock_protocol = MagicMock() @@ -998,13 +1089,15 @@ async def test_process_new_klap_device_kasa_exception(mocker): assert "127.0.0.1" not in proto.discovered_devices -async def test_process_new_klap_device_success_with_on_discovered(mocker): +async def test_process_new_klap_device_success_with_on_discovered( + mocker: MockerFixture, +) -> None: """Success path fires on_discovered and adds device to discovered_devices.""" on_discovered = mocker.AsyncMock() proto = _DiscoverProtocol(on_discovered=on_discovered) mock_protocol = MagicMock() mock_protocol.query = mocker.AsyncMock( - return_value={"system": {"get_sysinfo": {"type": "IOT.SMARTPLUGSWITCH"}}} + return_value={"system": {"get_sysinfo": {"type": "IOT.SMARTPLUGSWITCH"}}}, ) mocker.patch("kasa.discover.get_protocol", return_value=mock_protocol) @@ -1015,13 +1108,15 @@ async def test_process_new_klap_device_success_with_on_discovered(mocker): @pytest.mark.xdist_group(name="caplog") -async def test_process_new_klap_device_debug_logging(mocker, caplog): +async def test_process_new_klap_device_debug_logging( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: """Debug logging branch in _process_new_klap_device emits [DISCOVERY].""" caplog.set_level(logging.DEBUG) proto = _DiscoverProtocol() mock_protocol = MagicMock() mock_protocol.query = mocker.AsyncMock( - return_value={"system": {"get_sysinfo": {"type": "IOT.SMARTPLUGSWITCH"}}} + return_value={"system": {"get_sysinfo": {"type": "IOT.SMARTPLUGSWITCH"}}}, ) mocker.patch("kasa.discover.get_protocol", return_value=mock_protocol)