From 6e9f70e7bb8ce010a483f16074f6207aebfbad70 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 6 Jan 2026 13:03:19 -0500 Subject: [PATCH 01/10] Update SSL AES Transport for correct default credentials --- kasa/credentials.py | 17 +++++++++++------ kasa/transports/sslaestransport.py | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/kasa/credentials.py b/kasa/credentials.py index 66dd11742..6602865db 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -16,16 +16,21 @@ class Credentials: password: str = field(default="", repr=False) -def get_default_credentials(tuple: tuple[str, str]) -> Credentials: +def get_default_credentials( + tuple: tuple[str, str, str | None], login_version: int | None = None +) -> Credentials: """Return decoded default credentials.""" un = base64.b64decode(tuple[0].encode()).decode() - pw = base64.b64decode(tuple[1].encode()).decode() + if login_version == 3 and tuple[2] is not None: + pw = base64.b64decode(tuple[2].encode()).decode() + else: + pw = base64.b64decode(tuple[1].encode()).decode() return Credentials(un, pw) DEFAULT_CREDENTIALS = { - "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), - "KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="), - "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), - "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), + "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw", None), + "KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM=", None), + "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA==", None), + "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4=", "VFBMMDc1NTI2NDYwNjAz"), } diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index eeb298099..b7a80dd7f 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -95,7 +95,7 @@ def __init__( ) and not self._credentials_hash: self._credentials = Credentials() self._default_credentials: Credentials = get_default_credentials( - DEFAULT_CREDENTIALS["TAPOCAMERA"] + DEFAULT_CREDENTIALS["TAPOCAMERA"], self._login_version ) self._http_client: HttpClient = HttpClient(config) From 6c3f4e1215b8793cd7e2a06457f0d989c7a31c9d Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 6 Jan 2026 14:00:27 -0500 Subject: [PATCH 02/10] Add test coverage for get_default_credentials changes --- tests/transports/test_sslaestransport.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py index 2974a9148..8fe499cad 100644 --- a/tests/transports/test_sslaestransport.py +++ b/tests/transports/test_sslaestransport.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import logging import secrets from contextlib import nullcontext as does_not_raise @@ -393,6 +394,18 @@ async def test_port_override(): assert str(transport._app_url) == f"https://127.0.0.1:{port_override}" +async def test_login_version_get_credentials(): + """Test that login_version is passed to get_default_credentials.""" + creds = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"], login_version=3) + assert creds.username == "admin" + password_b64 = base64.b64encode(creds.password.encode()).decode() + assert password_b64 == "VFBMMDc1NTI2NDYwNjAz" # noqa: S105 + creds = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"], login_version=2) + assert creds.username == "admin" + password_b64 = base64.b64encode(creds.password.encode()).decode() + assert password_b64 == "YWRtaW4=" # noqa: S105 + + class MockSslAesDevice: BAD_USER_RESP = { "error_code": SmartErrorCode.SESSION_EXPIRED.value, From 67ee802ab589bd3cce73e3f2eed31f246db6c4f5 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Wed, 7 Jan 2026 22:17:21 -0500 Subject: [PATCH 03/10] Add new wifi scan method --- kasa/smart/smartdevice.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 87aa628d8..63b2c297e 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -749,7 +749,14 @@ def _net_for_scan_info(res: dict) -> WifiNetwork: _LOGGER.debug("Querying networks") - resp = await self.protocol.query({"get_wireless_scan_info": {"start_index": 0}}) + if self.config.connection_type.login_version == 3: + resp = await self.protocol.query( + {"scanApList": {"onboarding": {"scan": {}}}} + ) + else: + resp = await self.protocol.query( + {"get_wireless_scan_info": {"start_index": 0}} + ) networks = [ _net_for_scan_info(net) for net in resp["get_wireless_scan_info"]["ap_list"] ] From d8580ab5661915fcf5d70feb7d3835b6bf0f7404 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Thu, 8 Jan 2026 10:37:20 -0500 Subject: [PATCH 04/10] Update new scan and tests --- kasa/smart/smartdevice.py | 11 ++++++--- tests/smart/test_smartdevice.py | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 63b2c297e..a83b47980 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -753,13 +753,18 @@ def _net_for_scan_info(res: dict) -> WifiNetwork: resp = await self.protocol.query( {"scanApList": {"onboarding": {"scan": {}}}} ) + networks = [ + _net_for_scan_info(net) + for net in resp["scanApList"]["onboarding"]["scan"]["ap_list"] + ] else: resp = await self.protocol.query( {"get_wireless_scan_info": {"start_index": 0}} ) - networks = [ - _net_for_scan_info(net) for net in resp["get_wireless_scan_info"]["ap_list"] - ] + networks = [ + _net_for_scan_info(net) + for net in resp["get_wireless_scan_info"]["ap_list"] + ] return networks async def wifi_join( diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index 155c2bdf7..d94cba604 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -1010,3 +1010,44 @@ async def test_unpair(dev: SmartDevice, mocker: MockerFixture): await unpair_feat.set_value(None) unpair_call.assert_called_with(child.device_id) + + +@device_smart +async def test_wifi_scan_login_version_3(dev: SmartDevice, mocker: MockerFixture): + """Test wifi_scan for login_version 3 uses scanApList onboarding scan query.""" + mocker.patch.object(dev.config.connection_type, "login_version", 3) + dev._discovery_info = {"region": "US"} + + mock_response = { + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "ssid": "dGVzdF9zc2lk", + "cipher_type": "WPA2", + "key_type": "wpa2_psk", + "channel": 6, + "signal_level": -40, + "bssid": "00:11:22:33:44:55", + } + ] + } + } + }, + "get_wireless_scan_info": {"ap_list": []}, + } + + query_spy = mocker.patch.object(dev.protocol, "query", return_value=mock_response) + + networks = await dev.wifi_scan() + + query_spy.assert_called_once_with({"scanApList": {"onboarding": {"scan": {}}}}) + assert len(networks) == 1 + net = networks[0] + assert net.ssid == "test_ssid" + assert net.bssid == "00:11:22:33:44:55" + assert net.cipher_type == "WPA2" + assert net.key_type == "wpa2_psk" + assert net.channel == 6 + assert net.signal_level == -40 From 0541459b3ab5e3ff081c1f07af686484473305b7 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Fri, 9 Jan 2026 08:20:02 -0500 Subject: [PATCH 05/10] Revert "Add new wifi scan method" This reverts commit 67ee802ab589bd3cce73e3f2eed31f246db6c4f5. --- kasa/smart/smartdevice.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index a83b47980..87aa628d8 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -749,22 +749,10 @@ def _net_for_scan_info(res: dict) -> WifiNetwork: _LOGGER.debug("Querying networks") - if self.config.connection_type.login_version == 3: - resp = await self.protocol.query( - {"scanApList": {"onboarding": {"scan": {}}}} - ) - networks = [ - _net_for_scan_info(net) - for net in resp["scanApList"]["onboarding"]["scan"]["ap_list"] - ] - else: - resp = await self.protocol.query( - {"get_wireless_scan_info": {"start_index": 0}} - ) - networks = [ - _net_for_scan_info(net) - for net in resp["get_wireless_scan_info"]["ap_list"] - ] + resp = await self.protocol.query({"get_wireless_scan_info": {"start_index": 0}}) + networks = [ + _net_for_scan_info(net) for net in resp["get_wireless_scan_info"]["ap_list"] + ] return networks async def wifi_join( From b3932783b55191400ef1bd4a238c81cf20afbce0 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Fri, 9 Jan 2026 08:23:14 -0500 Subject: [PATCH 06/10] Revert "Update new scan and tests" This reverts commit d8580ab5661915fcf5d70feb7d3835b6bf0f7404. --- tests/smart/test_smartdevice.py | 41 --------------------------------- 1 file changed, 41 deletions(-) diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py index d94cba604..155c2bdf7 100644 --- a/tests/smart/test_smartdevice.py +++ b/tests/smart/test_smartdevice.py @@ -1010,44 +1010,3 @@ async def test_unpair(dev: SmartDevice, mocker: MockerFixture): await unpair_feat.set_value(None) unpair_call.assert_called_with(child.device_id) - - -@device_smart -async def test_wifi_scan_login_version_3(dev: SmartDevice, mocker: MockerFixture): - """Test wifi_scan for login_version 3 uses scanApList onboarding scan query.""" - mocker.patch.object(dev.config.connection_type, "login_version", 3) - dev._discovery_info = {"region": "US"} - - mock_response = { - "scanApList": { - "onboarding": { - "scan": { - "ap_list": [ - { - "ssid": "dGVzdF9zc2lk", - "cipher_type": "WPA2", - "key_type": "wpa2_psk", - "channel": 6, - "signal_level": -40, - "bssid": "00:11:22:33:44:55", - } - ] - } - } - }, - "get_wireless_scan_info": {"ap_list": []}, - } - - query_spy = mocker.patch.object(dev.protocol, "query", return_value=mock_response) - - networks = await dev.wifi_scan() - - query_spy.assert_called_once_with({"scanApList": {"onboarding": {"scan": {}}}}) - assert len(networks) == 1 - net = networks[0] - assert net.ssid == "test_ssid" - assert net.bssid == "00:11:22:33:44:55" - assert net.cipher_type == "WPA2" - assert net.key_type == "wpa2_psk" - assert net.channel == 6 - assert net.signal_level == -40 From cec38004664dce1c2d4145c23f30bff597069004 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Fri, 9 Jan 2026 08:42:19 -0500 Subject: [PATCH 07/10] Add TC40 fixture --- README.md | 2 +- SUPPORTED.md | 2 + .../fixtures/smartcam/TC40(EU)_2.0_1.0.4.json | 1037 +++++++++++++++++ 3 files changed, 1040 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json diff --git a/README.md b/README.md index 2f3834d64..af0dbf420 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ The following devices have been tested and confirmed as working. If your device - **Wall Switches**: S210, S220, S500, S500D, S505, S505D - **Bulbs**: L430P, L510B, L510E, L530B, L530E, L535E, L630 - **Light Strips**: L900-10, L900-5, L920-5, L930-5 -- **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC65, TC70 +- **Cameras**: C100, C110, C210, C220, C225, C325WB, C520WS, C720, TC40, TC65, TC70 - **Doorbells and chimes**: D100C, D130, D230 - **Vacuums**: RV20 Max Plus, RV30 Max - **Hubs**: H100, H200 diff --git a/SUPPORTED.md b/SUPPORTED.md index f081b1945..b0f6b21bb 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -309,6 +309,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - Hardware: 1.0 (US) / Firmware: 1.2.8 - **C720** - Hardware: 1.0 (US) / Firmware: 1.2.3 +- **TC40** + - Hardware: 2.0 (EU) / Firmware: 1.0.4 - **TC65** - Hardware: 1.0 / Firmware: 1.3.9 - **TC70** diff --git a/tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json b/tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json new file mode 100644 index 000000000..9d625f735 --- /dev/null +++ b/tests/fixtures/smartcam/TC40(EU)_2.0_1.0.4.json @@ -0,0 +1,1037 @@ +{ + "discovery_result": { + "error_code": 0, + "result": { + "decrypted_data": { + "connect_ssid": "", + "connect_type": "wireless", + "device_id": "0000000000000000000000000000000000000000", + "http_port": 443, + "last_alarm_time": "0", + "last_alarm_type": "", + "owner": "", + "sd_status": "offline" + }, + "device_id": "00000000000000000000000000000000", + "device_model": "TC40", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "encrypt_info": { + "data": "", + "key": "", + "sym_schm": "AES" + }, + "encrypt_type": [ + "3" + ], + "factory_default": true, + "firmware_version": "1.0.4 Build 240902 Rel.38194n", + "hardware_version": "2.0", + "ip": "127.0.0.123", + "isResetWiFi": false, + "is_support_iot_cloud": true, + "mac": "3C-64-CF-00-00-00", + "mgt_encrypt_schm": { + "is_support_https": true + }, + "protocol_version": 1 + } + }, + "getAlertConfig": { + "msg_alarm": { + "capability": { + "alarm_duration_support": "1", + "alarm_volume_support": "1", + "alert_event_type_support": "1", + "usr_def_audio_alarm_max_num": "2", + "usr_def_audio_alarm_support": "1", + "usr_def_audio_max_duration": "15", + "usr_def_audio_type": "0", + "usr_def_start_file_id": "8195" + }, + "chn1_msg_alarm_info": { + "alarm_duration": "0", + "alarm_mode": [ + "sound", + "light" + ], + "alarm_type": "0", + "alarm_volume": "high", + "enabled": "off", + "light_alarm_enabled": "on", + "light_type": "1", + "sound_alarm_enabled": "on" + }, + "usr_def_audio": [] + } + }, + "getAlertPlan": { + "msg_alarm_plan": { + "chn1_msg_alarm_plan": { + "alarm_plan_1": "0000-0000,127", + "enabled": "off" + } + } + }, + "getAlertTypeList": { + "msg_alarm": { + "alert_type": { + "alert_type_list": [ + "Siren", + "Emergency", + "Red Alert" + ] + } + } + }, + "getAppComponentList": { + "app_component": { + "app_component_list": [ + { + "name": "sdCard", + "version": 1 + }, + { + "name": "timezone", + "version": 1 + }, + { + "name": "system", + "version": 3 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 6 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 2 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 2 + }, + { + "name": "quickSetup", + "version": 1 + }, + { + "name": "ptz", + "version": 1 + }, + { + "name": "video", + "version": 3 + }, + { + "name": "lensMask", + "version": 2 + }, + { + "name": "lightFrequency", + "version": 1 + }, + { + "name": "dayNightMode", + "version": 1 + }, + { + "name": "osd", + "version": 2 + }, + { + "name": "record", + "version": 1 + }, + { + "name": "videoRotation", + "version": 1 + }, + { + "name": "audio", + "version": 3 + }, + { + "name": "nightVisionMode", + "version": 3 + }, + { + "name": "diagnose", + "version": 1 + }, + { + "name": "msgPush", + "version": 3 + }, + { + "name": "deviceShare", + "version": 1 + }, + { + "name": "tamperDetection", + "version": 1 + }, + { + "name": "patrol", + "version": 1 + }, + { + "name": "tapoCare", + "version": 1 + }, + { + "name": "targetTrack", + "version": 1 + }, + { + "name": "blockZone", + "version": 1 + }, + { + "name": "personDetection", + "version": 2 + }, + { + "name": "needSubscriptionServiceList", + "version": 1 + }, + { + "name": "whiteLamp", + "version": 1 + }, + { + "name": "nvmp", + "version": 1 + }, + { + "name": "detectionRegion", + "version": 2 + }, + { + "name": "recordDownload", + "version": 1 + }, + { + "name": "upnpc", + "version": 2 + }, + { + "name": "iotCloud", + "version": 1 + }, + { + "name": "staticIp", + "version": 2 + } + ] + } + }, + "getAudioConfig": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "90" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-09-02 03:14:19", + "seconds_from_1970": 1725246859 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "-1", + "rssiValue": 0, + "ssid": "" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "60", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "room", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "TC40 2.0 IPC", + "device_model": "TC40", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": 3, + "ffs": false, + "has_set_location_info": 0, + "hw_desc": "00000000000000000000000000000000", + "hw_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "3C-64-CF-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.0.4 Build 240902 Rel.38194n" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "0", + "last_alarm_type": "" + } + } + }, + "getLdc": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + }, + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getLedStatus": { + "led": { + "config": { + "enabled": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "off" + } + } + }, + "getLightFrequencyInfo": { + "image": { + "common": { + "area_compensation": "default", + "auto_exp_antiflicker": "off", + "auto_exp_gain_max": "0", + "backlight": "off", + "chroma": "50", + "contrast": "50", + "dehaze": "off", + "eis": "off", + "exp_gain": "0", + "exp_level": "0", + "exp_type": "auto", + "focus_limited": "10", + "focus_type": "manual", + "high_light_compensation": "off", + "inf_delay": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "inf_sensitivity_day2night": "1400", + "inf_sensitivity_night2day": "9100", + "inf_start_time": "64800", + "inf_type": "auto", + "iris_level": "160", + "light_freq_mode": "auto", + "lock_blue_colton": "0", + "lock_blue_gain": "0", + "lock_gb_gain": "0", + "lock_gr_gain": "0", + "lock_green_colton": "0", + "lock_red_colton": "0", + "lock_red_gain": "0", + "lock_source": "local", + "luma": "50", + "saturation": "50", + "sharpness": "50", + "shutter": "1/25", + "smartir": "auto_ir", + "smartir_level": "0", + "smartwtl": "auto_wtl", + "smartwtl_digital_level": "100", + "smartwtl_level": "5", + "style": "standard", + "wb_B_gain": "50", + "wb_G_gain": "50", + "wb_R_gain": "50", + "wb_type": "auto", + "wd_gain": "50", + "wide_dynamic": "off", + "wtl_delay": "5", + "wtl_end_time": "21600", + "wtl_sensitivity": "4", + "wtl_sensitivity_day2night": "1400", + "wtl_sensitivity_night2day": "9100", + "wtl_start_time": "64800", + "wtl_type": "auto" + } + } + }, + "getMediaEncrypt": { + "cet": { + "media_encrypt": { + "enabled": "off" + } + } + }, + "getMsgPushConfig": { + "msg_push": { + "chn1_msg_push_info": { + "notification_enabled": "on", + "rich_notification_enabled": "off" + } + } + }, + "getNightVisionCapability": { + "image_capability": { + "supplement_lamp": { + "night_vision_mode_range": [ + "inf_night_vision", + "wtl_night_vision", + "md_night_vision" + ], + "supplement_lamp_type": [ + "infrared_lamp", + "white_lamp" + ] + } + } + }, + "getNightVisionModeConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getPersonDetectionConfig": { + "people_detection": { + "detection": { + "enabled": "on", + "sensitivity": "60" + } + } + }, + "getPresetConfig": { + "preset": { + "preset": { + "id": [], + "name": [], + "position_pan": [], + "position_tilt": [], + "position_zoom": [], + "read_only": [] + } + } + }, + "getRecordPlan": { + "record_plan": { + "chn1_channel": { + "enabled": "off", + "friday": "[\"0000-2400:2\"]", + "monday": "[\"0000-2400:2\"]", + "saturday": "[\"0000-2400:2\"]", + "sunday": "[\"0000-2400:2\"]", + "thursday": "[\"0000-2400:2\"]", + "tuesday": "[\"0000-2400:2\"]", + "wednesday": "[\"0000-2400:2\"]" + } + } + }, + "getRotationStatus": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getSdCardStatus": { + "harddisk_manage": { + "hd_info": [ + { + "hd_info_1": { + "crossline_free_space": "0B", + "crossline_free_space_accurate": "0B", + "crossline_total_space": "0B", + "crossline_total_space_accurate": "0B", + "detect_status": "offline", + "disk_name": "1", + "free_space": "0B", + "free_space_accurate": "0B", + "loop_record_status": "0", + "msg_push_free_space": "0B", + "msg_push_free_space_accurate": "0B", + "msg_push_total_space": "0B", + "msg_push_total_space_accurate": "0B", + "percent": "0", + "picture_free_space": "0B", + "picture_free_space_accurate": "0B", + "picture_total_space": "0B", + "picture_total_space_accurate": "0B", + "record_duration": "0", + "record_free_duration": "0", + "record_start_time": "0", + "rw_attr": "r", + "status": "offline", + "total_space": "0B", + "total_space_accurate": "0B", + "type": "local", + "video_free_space": "0B", + "video_free_space_accurate": "0B", + "video_total_space": "0B", + "video_total_space_accurate": "0B", + "write_protect": "0" + } + } + ] + } + }, + "getTamperDetectionConfig": { + "tamper_detection": { + "tamper_det": { + "digital_sensitivity": "50", + "enabled": "off", + "sensitivity": "medium" + } + } + }, + "getTargetTrackConfig": { + "target_track": { + "target_track_info": { + "back_time": "30", + "enabled": "off", + "track_mode": "pantilt", + "track_time": "0" + } + } + }, + "getTimezone": { + "system": { + "basic": { + "timezone": "UTC-00:00", + "timing_mode": "ntp", + "zone_id": "Europe/London" + } + } + }, + "getVideoCapability": { + "video_capability": { + "main": { + "bitrate_types": [ + "cbr", + "vbr" + ], + "bitrates": [ + "256", + "512", + "1024", + "1228", + "2048" + ], + "change_fps_support": "1", + "encode_types": [ + "H264", + "H265" + ], + "frame_rates": [ + "65551", + "65556", + "65561" + ], + "minor_stream_support": "1", + "qualitys": [ + "1", + "3", + "5" + ], + "resolutions": [ + "1920*1080", + "1280*720", + "640*360" + ] + } + } + }, + "getVideoQualities": { + "video": { + "main": { + "bitrate": "1228", + "bitrate_type": "vbr", + "default_bitrate": "1228", + "encode_type": "H264", + "frame_rate": "65551", + "name": "VideoEncoder_1", + "quality": "5", + "resolution": "1920*1080", + "smart_codec": "off" + } + } + }, + "getWhitelampConfig": { + "image": { + "switch": { + "best_view_distance": "0", + "clear_licence_plate_mode": "off", + "flip_type": "off", + "full_color_min_keep_time": "5", + "full_color_people_enhance": "off", + "image_scene_mode": "normal", + "image_scene_mode_autoday": "normal", + "image_scene_mode_autonight": "normal", + "image_scene_mode_common": "normal", + "image_scene_mode_shedday": "normal", + "image_scene_mode_shednight": "normal", + "ldc": "off", + "night_vision_mode": "inf_night_vision", + "overexposure_people_suppression": "off", + "rotate_type": "off", + "schedule_end_time": "64800", + "schedule_start_time": "21600", + "switch_mode": "common", + "wtl_force_time": "300", + "wtl_intensity_level": "3", + "wtl_manual_start_flag": "off" + } + } + }, + "getWhitelampStatus": { + "rest_time": 0, + "status": 0 + }, + "get_audio_capability": { + "get": { + "audio_capability": { + "device_microphone": { + "aec": "1", + "channels": "1", + "echo_cancelling": "0", + "encode_type": [ + "G711alaw" + ], + "half_duplex": "1", + "mute": "1", + "noise_cancelling": "1", + "sampling_rate": [ + "8" + ], + "volume": "1" + }, + "device_speaker": { + "channels": "1", + "decode_type": [ + "G711alaw", + "G711ulaw" + ], + "mute": "0", + "output_device_type": "0", + "sampling_rate": [ + "8", + "16" + ], + "system_volume": "100", + "volume": "1" + } + } + } + }, + "get_audio_config": { + "get": { + "audio_config": { + "microphone": { + "bitrate": "64", + "channels": "1", + "echo_cancelling": "off", + "encode_type": "G711alaw", + "input_device_type": "MicIn", + "mute": "off", + "noise_cancelling": "on", + "sampling_rate": "8", + "volume": "90" + }, + "speaker": { + "mute": "off", + "output_device_type": "SpeakerOut", + "volume": "100" + } + } + } + }, + "get_cet": { + "get": { + "cet": { + "vhttpd": { + "port": "8800" + } + } + } + }, + "get_function": { + "get": { + "function": { + "module_spec": { + "ae_weighting_table_resolution": "5*5", + "ai_enhance_capability": "1", + "ai_enhance_range": [ + "traditional_enhance" + ], + "alarm_out_num": "0", + "app_version": "1.0.0", + "audio": [ + "speaker", + "microphone" + ], + "audio_alarm_clock": "1", + "audio_lib": "1", + "auth_encrypt": "1", + "auto_ip_configurable": "1", + "backlight_coexistence": "1", + "change_password": "1", + "client_info": "1", + "cloud_storage_version": "1.0", + "config_recovery": [ + "audio_config", + "image", + "OSD", + "video" + ], + "custom_area_compensation": "1", + "daynight_subdivision": "1", + "device_share": [ + "preview", + "playback", + "voice", + "cloud_storage", + "motor" + ], + "diagnose": "1", + "diagnose_capability": "1", + "download": [ + "video" + ], + "events": [ + "motion", + "tamper" + ], + "gb28181": "1", + "greeter": "1.0", + "http_system_state_audio_support": "1", + "image_capability": "1", + "image_list": [ + "supplement_lamp", + "expose" + ], + "led": "1", + "lens_mask": "1", + "linkage_capability": "1", + "local_storage": "1", + "media_encrypt": "1", + "msg_alarm_list": [ + "sound", + "light" + ], + "msg_push": "1", + "multi_user": "0", + "network": [ + "wifi", + "ethernet" + ], + "osd_capability": "1", + "ota_upgrade": "1", + "p2p_support_versions": [ + "2.0" + ], + "playback": [ + "local", + "p2p", + "relay" + ], + "playback_scale": "1", + "playback_version": "1.1", + "preview": [ + "local", + "p2p", + "relay" + ], + "privacy_mask_api_version": "1.0", + "ptz": "1", + "record_max_slot_cnt": "10", + "record_type": [ + "timing", + "motion" + ], + "relay_support_versions": [ + "2.0" + ], + "remote_upgrade": "1", + "reonboarding": "1", + "smart_codec": "0", + "smart_detection": "1", + "smart_msg_push_capability": "1", + "ssl_cer_version": "1.0", + "storage_api_version": "2.2", + "stream_max_sessions": "10", + "streaming_support_versions": [ + "2.0" + ], + "tapo_care_version": "1.0.0", + "target_track": "1", + "timing_reboot": "1", + "tums": "1", + "tums_config": "1", + "tums_msg_push": "1", + "verification_change_password": "1", + "video_codec": [ + "h264", + "h265" + ], + "video_detection_digital_sensitivity": "1", + "video_message": "1", + "wide_range_inf_sensitivity": "1", + "wifi_cascade_connection": "0", + "wifi_connection_info": "1", + "wireless_hotspot": "0" + } + } + } + }, + "scanApList": { + "onboarding": { + "scan": { + "ap_list": [ + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "auth": 4, + "bssid": "000000000000", + "encryption": 3, + "rssi": 0, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wpa3_supported": "false" + } + } + } +} From 4ca973cafb63a900a4bffb5fea51e404be882aba Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Tue, 27 Jan 2026 12:38:58 -0500 Subject: [PATCH 08/10] Fix Copilot suggestions --- kasa/credentials.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/kasa/credentials.py b/kasa/credentials.py index 6602865db..34e237241 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -17,14 +17,14 @@ class Credentials: def get_default_credentials( - tuple: tuple[str, str, str | None], login_version: int | None = None + crdentials: tuple[str, str, str | None], login_version: int | None = None ) -> Credentials: """Return decoded default credentials.""" - un = base64.b64decode(tuple[0].encode()).decode() - if login_version == 3 and tuple[2] is not None: - pw = base64.b64decode(tuple[2].encode()).decode() + un = base64.b64decode(crdentials[0].encode()).decode() + if login_version == 3 and crdentials[2] is not None: + pw = base64.b64decode(crdentials[2].encode()).decode() else: - pw = base64.b64decode(tuple[1].encode()).decode() + pw = base64.b64decode(crdentials[1].encode()).decode() return Credentials(un, pw) From a0c3749ac565a69eb01d8cd2b90fe76657d8af86 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Wed, 28 Jan 2026 19:43:20 -0500 Subject: [PATCH 09/10] Revert login_version handling and put it back on the transport --- kasa/credentials.py | 18 +++----- kasa/transports/sslaestransport.py | 6 ++- tests/transports/test_sslaestransport.py | 54 +++++++++++++++++++----- 3 files changed, 55 insertions(+), 23 deletions(-) diff --git a/kasa/credentials.py b/kasa/credentials.py index 34e237241..3497b76aa 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -16,21 +16,17 @@ class Credentials: password: str = field(default="", repr=False) -def get_default_credentials( - crdentials: tuple[str, str, str | None], login_version: int | None = None -) -> Credentials: +def get_default_credentials(crdentials: tuple[str, str]) -> Credentials: """Return decoded default credentials.""" un = base64.b64decode(crdentials[0].encode()).decode() - if login_version == 3 and crdentials[2] is not None: - pw = base64.b64decode(crdentials[2].encode()).decode() - else: - pw = base64.b64decode(crdentials[1].encode()).decode() + pw = base64.b64decode(crdentials[1].encode()).decode() return Credentials(un, pw) DEFAULT_CREDENTIALS = { - "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw", None), - "KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM=", None), - "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA==", None), - "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4=", "VFBMMDc1NTI2NDYwNjAz"), + "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), + "KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="), + "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), + "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), + "TAPOCAMERA_LV3": ("YWRtaW4=", "VFBMMDc1NTI2NDYwNjAz"), } diff --git a/kasa/transports/sslaestransport.py b/kasa/transports/sslaestransport.py index b7a80dd7f..729018e4d 100644 --- a/kasa/transports/sslaestransport.py +++ b/kasa/transports/sslaestransport.py @@ -94,8 +94,12 @@ def __init__( not self._credentials or self._credentials.username is None ) and not self._credentials_hash: self._credentials = Credentials() + if self._login_version == 3: + _default_credentials = DEFAULT_CREDENTIALS["TAPOCAMERA_LV3"] + else: + _default_credentials = DEFAULT_CREDENTIALS["TAPOCAMERA"] self._default_credentials: Credentials = get_default_credentials( - DEFAULT_CREDENTIALS["TAPOCAMERA"], self._login_version + _default_credentials ) self._http_client: HttpClient = HttpClient(config) diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py index 8fe499cad..03a0b3318 100644 --- a/tests/transports/test_sslaestransport.py +++ b/tests/transports/test_sslaestransport.py @@ -13,7 +13,12 @@ from yarl import URL from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials -from kasa.deviceconfig import DeviceConfig +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) from kasa.exceptions import ( AuthenticationError, DeviceError, @@ -394,16 +399,43 @@ async def test_port_override(): assert str(transport._app_url) == f"https://127.0.0.1:{port_override}" -async def test_login_version_get_credentials(): - """Test that login_version is passed to get_default_credentials.""" - creds = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"], login_version=3) - assert creds.username == "admin" - password_b64 = base64.b64encode(creds.password.encode()).decode() - assert password_b64 == "VFBMMDc1NTI2NDYwNjAz" # noqa: S105 - creds = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"], login_version=2) - assert creds.username == "admin" - password_b64 = base64.b64encode(creds.password.encode()).decode() - assert password_b64 == "YWRtaW4=" # noqa: S105 +async def test_login_version_get_credentials(mocker): + """Test that login_version selects default credentials in transport.""" + host = "127.0.0.1" + tapo_family = DeviceFamily.SmartIpCamera + aes_type = DeviceEncryptionType.Aes + mock_ssl_aes_device = MockSslAesDevice(host) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + config_lv3 = DeviceConfig( + host, + credentials=Credentials("foo", "bar"), + connection_type=DeviceConnectionParameters( + tapo_family, aes_type, login_version=3 + ), + ) + transport_lv3 = SslAesTransport(config=config_lv3) + assert transport_lv3._default_credentials.username == "admin" + password_b64_lv3 = base64.b64encode( + transport_lv3._default_credentials.password.encode() + ).decode() + assert password_b64_lv3 == "VFBMMDc1NTI2NDYwNjAz" # noqa: S105 + + config_lv2 = DeviceConfig( + host, + credentials=Credentials("foo", "bar"), + connection_type=DeviceConnectionParameters( + tapo_family, aes_type, login_version=2 + ), + ) + transport_lv2 = SslAesTransport(config=config_lv2) + assert transport_lv2._default_credentials.username == "admin" + password_b64_lv2 = base64.b64encode( + transport_lv2._default_credentials.password.encode() + ).decode() + assert password_b64_lv2 == "YWRtaW4=" # noqa: S105 class MockSslAesDevice: From f034bc678d46c591e7287eed9c62147d5c4d3879 Mon Sep 17 00:00:00 2001 From: ZeliardM Date: Sun, 22 Feb 2026 07:30:16 -0500 Subject: [PATCH 10/10] Update tests --- tests/transports/test_sslaestransport.py | 54 ++++++++++++++---------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/tests/transports/test_sslaestransport.py b/tests/transports/test_sslaestransport.py index 03a0b3318..0105205d8 100644 --- a/tests/transports/test_sslaestransport.py +++ b/tests/transports/test_sslaestransport.py @@ -399,8 +399,30 @@ async def test_port_override(): assert str(transport._app_url) == f"https://127.0.0.1:{port_override}" -async def test_login_version_get_credentials(mocker): - """Test that login_version selects default credentials in transport.""" +@pytest.mark.parametrize( + ("login_version", "expected_password_b64"), + [ + pytest.param( + 3, + "VFBMMDc1NTI2NDYwNjAz", # noqa: S105 + id="version-3-uses-lv3-credentials", + ), + pytest.param( + 2, + "YWRtaW4=", # noqa: S105 + id="version-2-uses-tapocamera-credentials", + ), + pytest.param( + None, + "YWRtaW4=", # noqa: S105 + id="no-version-uses-tapocamera-credentials", + ), + ], +) +async def test_login_version_default_credentials( + mocker, login_version, expected_password_b64 +): + """Test that login_version=3 uses TAPOCAMERA_LV3 credentials while other versions use TAPOCAMERA.""" host = "127.0.0.1" tapo_family = DeviceFamily.SmartIpCamera aes_type = DeviceEncryptionType.Aes @@ -409,33 +431,19 @@ async def test_login_version_get_credentials(mocker): aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post ) - config_lv3 = DeviceConfig( - host, - credentials=Credentials("foo", "bar"), - connection_type=DeviceConnectionParameters( - tapo_family, aes_type, login_version=3 - ), - ) - transport_lv3 = SslAesTransport(config=config_lv3) - assert transport_lv3._default_credentials.username == "admin" - password_b64_lv3 = base64.b64encode( - transport_lv3._default_credentials.password.encode() - ).decode() - assert password_b64_lv3 == "VFBMMDc1NTI2NDYwNjAz" # noqa: S105 - - config_lv2 = DeviceConfig( + config = DeviceConfig( host, credentials=Credentials("foo", "bar"), connection_type=DeviceConnectionParameters( - tapo_family, aes_type, login_version=2 + tapo_family, aes_type, login_version=login_version ), ) - transport_lv2 = SslAesTransport(config=config_lv2) - assert transport_lv2._default_credentials.username == "admin" - password_b64_lv2 = base64.b64encode( - transport_lv2._default_credentials.password.encode() + transport = SslAesTransport(config=config) + assert transport._default_credentials.username == "admin" + password_b64 = base64.b64encode( + transport._default_credentials.password.encode() ).decode() - assert password_b64_lv2 == "YWRtaW4=" # noqa: S105 + assert password_b64 == expected_password_b64 class MockSslAesDevice: