From b2a15afa7ee2ab153e470dc16eb044046ae1b21c Mon Sep 17 00:00:00 2001 From: bluntman001 <157980851+bluntman001@users.noreply.github.com> Date: Sun, 4 Oct 2026 07:48:22 -0600 Subject: [PATCH] Classify IOT devices via sysinfo regardless of transport (fixes #1740) (#1769) Drop the `XorTransport` guard and always query sysinfo for `IotProtocol` connections, so `get_device_class_from_sys_info` sees `dev_name` (\"Wi-Fi Smart Dimmer\", etc.) and picks the right class to initialize. This is necessary, as some IOT-speaking devices have moved to use KLAP instead of XOR as their transport, which in turn caused devices like dimmers to be detected incorrectly as plugs. --------- Co-authored-by: bluntman001 Co-authored-by: Teemu Rytilahti --- kasa/device_factory.py | 6 ++-- tests/test_device_factory.py | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 8f7c2571..6b4b8c96 100644 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -104,9 +104,9 @@ async def _connect(config: DeviceConfig, protocol: BaseProtocol) -> Device: device_class: type[Device] | None device: Device | None = None - if isinstance(protocol, IotProtocol) and isinstance( - protocol._transport, XorTransport - ): + # For IOT devices, we need to obtain the initial sysinfo in order decide + # on the device class to initialize. + if isinstance(protocol, IotProtocol): info = await protocol.query(GET_SYSINFO_QUERY) _perf_log(True, "get_sysinfo") device_class = get_device_class_from_sys_info(info) diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py index 0f4b31e3..8d92114d 100644 --- a/tests/test_device_factory.py +++ b/tests/test_device_factory.py @@ -24,6 +24,8 @@ from kasa import ( from kasa.device_factory import ( Device, IotDevice, + IotDimmer, + IotPlug, SmartCamDevice, SmartDevice, connect, @@ -307,3 +309,63 @@ async def test_get_protocol( protocol = get_protocol(config) assert isinstance(protocol, expected_protocol) assert isinstance(protocol._transport, expected_transport) + + +async def test_connect_iot_klap_dimmer_classification(mocker): + """Test that IOT dimmers reached via KLAP initialize IotDimmer. + + Regression test for the bug where `_connect` only queried sysinfo for + IOT devices reached via XorTransport, so KLAP-authenticated + IOT.SMARTPLUGSWITCH devices were always classified incorrectly as IotPlugs. + """ + dimmer_sysinfo = { + "system": { + "get_sysinfo": { + "sw_ver": "1.1.4 Build 241220 Rel.171050", + "hw_ver": "3.0", + "model": "HS220(US)", + "deviceId": "0" * 40, + "hwId": "0" * 32, + "fwId": "0" * 32, + "oemId": "0" * 32, + "alias": "Dimmer under test", + "dev_name": "Wi-Fi Smart Dimmer", + "mic_type": "IOT.SMARTPLUGSWITCH", + "relay_state": 0, + "brightness": 25, + "on_time": 0, + "active_mode": "none", + "feature": "TIM", + "updating": 0, + "rssi": -60, + "led_off": 0, + "latitude_i": 0, + "longitude_i": 0, + "err_code": 0, + } + } + } + + mocker.patch("kasa.IotProtocol.query", return_value=dimmer_sysinfo) + # Avoid touching the wire on the update() that runs after class selection. + mocker.patch.object(IotDevice, "update", return_value=None) + + ctype = DeviceConnectionParameters( + device_family=DeviceFamily.IotSmartPlugSwitch, + encryption_type=DeviceEncryptionType.Klap, + login_version=2, + https=False, + ) + config = DeviceConfig( + host=DISCOVERY_MOCK_IP, + credentials=Credentials("user", "pass"), + connection_type=ctype, + ) + dev = await connect(config=config) + try: + assert isinstance(dev, IotDimmer), ( + f"HS220 via KLAP must be IotDimmer, got {type(dev).__name__}" + ) + assert not isinstance(dev, IotPlug) or isinstance(dev, IotDimmer) + finally: + await dev.disconnect()