Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# tplink-cloud-api

Python library for controlling TP-Link Kasa smart home devices remotely via the TP-Link Cloud API. Unlike local-network libraries, this works from anywhere with an internet connection.
Python library for controlling TP-Link Kasa and Tapo smart home devices remotely via the TP-Link Cloud API. Unlike local-network libraries, this works from anywhere with an internet connection.

## Quick start

Expand All @@ -21,16 +21,32 @@ pytest --verbose
2. **Login** (`/api/v2/account/login`) — authenticates with TP-Link credentials, returns token + refresh token
3. **Device operations** — POST to `/` on the device's `appServerUrl` with V2 signing headers

All V2 requests use HMAC-SHA1 signing (see `tplinkcloud/signing.py`). The signing keys are app-level constants extracted from the Kasa Android APK — they are not secrets.
All V2 requests use HMAC-SHA1 signing (see `tplinkcloud/signing.py`). The signing keys are app-level constants extracted from the Kasa and Tapo Android APKs — they are not secrets.

### Dual-cloud architecture (Kasa + Tapo)

The library authenticates against both Kasa and Tapo clouds in parallel using the same TP-Link credentials. Each cloud has its own host, signing keys, and app identifiers:

| Aspect | Kasa | Tapo |
|--------|------|------|
| Cloud host | `n-wap.tplinkcloud.com` | `n-wap.i.tplinkcloud.com` |
| App type | `Kasa_Android_Mix` | `TP-Link_Tapo_Android` |
| Passthrough URL | POST `/` with `{"method":"passthrough","params":{...}}` | POST `/api/v2/common/passthrough` with flat `{deviceId, requestData}` |
| Signing algorithm | HMAC-SHA1 (same) | HMAC-SHA1 (same) |

`TPLinkDeviceManager` creates two `TPLinkApi` instances, logs into both, and merges device lists with deduplication by device ID. Each device has a `cloud_type` attribute ("kasa" or "tapo"). Tapo login failure is silently caught — the library falls back to Kasa-only.

Set `include_tapo=False` on `TPLinkDeviceManager` to disable Tapo cloud support.

### Key modules

| Module | Purpose |
|---|---|
| `client.py` | Synchronous HTTP client for auth (login, MFA, refresh, device list) |
| `device_client.py` | Async HTTP client (aiohttp) for device operations with V2 signing |
| `device_manager.py` | Main entry point — `TPLinkDeviceManager` handles auth + device construction |
| `signing.py` | HMAC-SHA1 request signing for V2 API |
| `client.py` | Synchronous HTTP client for auth (login, MFA, refresh, device list); supports both Kasa and Tapo cloud types |
| `device_client.py` | Async HTTP client (aiohttp) for device operations with V2 signing; handles Kasa and Tapo passthrough formats |
| `device_manager.py` | Main entry point — `TPLinkDeviceManager` handles dual-cloud auth + unified device construction |
| `device_info.py` | Device metadata including `cloud_type` ("kasa" or "tapo") |
| `signing.py` | HMAC-SHA1 request signing for V2 API with configurable key pairs |
| `exceptions.py` | `TPLinkAuthError`, `TPLinkMFARequiredError`, `TPLinkTokenExpiredError`, `TPLinkCloudError`, `TPLinkDeviceOfflineError` |
| `certs/` | TP-Link private CA cert chain (V2 API servers use their own CA) |

Expand Down
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# tplink-cloud-api

Control TP-Link Kasa smart home devices from anywhere over the internet using TP-Link's cloud API — no local network access required.
Control TP-Link Kasa and Tapo smart home devices from anywhere over the internet using TP-Link's cloud API — no local network access required.

## Why cloud control?

Expand All @@ -17,14 +17,16 @@ If you just need local control on the same network as your devices, [python-kasa

## How it works

The library authenticates with your TP-Link / Kasa account credentials using the **V2 TP-Link Cloud API** with HMAC-SHA1 request signing. It supports MFA (two-factor authentication) and automatic refresh token management.
The library authenticates with your TP-Link / Kasa account credentials using the **V2 TP-Link Cloud API** with HMAC-SHA1 request signing. It automatically connects to both the Kasa and Tapo clouds in parallel, returning a unified device list. It supports MFA (two-factor authentication) and automatic refresh token management.

Originally a Python port of [Adumont's Node.js module](https://github.com/adumont/tplink-cloud-api).

## Device Compatibility

The following devices are _officially_ supported by the library at this time:

### Kasa Devices

**Smart Plugs**
* HS100 (Smart Plug - Blocks two outlets as a single outlet)
* HS103 (Smart Plug Lite - 12 Amp)
Expand All @@ -49,6 +51,17 @@ The following devices are _officially_ supported by the library at this time:
* KL420L5 (Smart LED Light Strip)
* KL430 (Smart Light Strip, Multicolor)

### Tapo Devices

Tapo devices are supported through the Tapo cloud API. The library automatically authenticates against both clouds and returns a unified device list. All Tapo devices support basic on/off control through the generic `TPLinkDevice` class:

* P100 (Mini Smart Wi-Fi Plug)
* P110 (Mini Smart Wi-Fi Plug with Energy Monitoring)
* L530 (Smart Wi-Fi Light Bulb, Multicolor)
* Any other Tapo device registered to your TP-Link account

Each device has a `cloud_type` attribute ("kasa" or "tapo") so you can identify which cloud it belongs to.

Devices not explicitly listed above will still work with basic on/off functionality through the generic `TPLinkDevice` class.

## Requirements
Expand Down Expand Up @@ -194,7 +207,13 @@ devices = await device_manager.get_devices()
if devices:
print(f'Found {len(devices)} devices')
for device in devices:
print(f'{device.model_type.name} device called {device.get_alias()}')
print(f'[{device.cloud_type}] {device.model_type.name} device called {device.get_alias()}')
```

By default, `get_devices()` returns devices from both the Kasa and Tapo clouds. To disable Tapo device discovery:

```python
device_manager = TPLinkDeviceManager(username, password, include_tapo=False)
```

### Control your devices
Expand All @@ -213,7 +232,7 @@ else:
print(f'Could not find {device_name}')
```

Replace `My Smart Plug` with the alias you gave to your plug in the Kasa app (be sure to give a different alias to each device). Instead of `toggle()`, you can also use `power_on()` or `power_off()`.
Replace `My Smart Plug` with the alias you gave to your plug in the Kasa or Tapo app (be sure to give a different alias to each device). Instead of `toggle()`, you can also use `power_on()` or `power_off()`.

To retrieve power consumption data for one of the individual plugs on an HS300 power strip (KP303 does not support power usage data):

Expand Down
16 changes: 9 additions & 7 deletions tests/test_device_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ class TestGetDevices(object):
async def test_gets_devices(self, client):
device_list = await client.get_devices()
assert device_list is not None
# 9 devices: HS103, HS105, HS110, HS300 (6 children), KL430,
# HS200, KP200 (2 children), KP400 (2 children), KL420L5
# Total: 9 parents + 6 + 2 + 2 children = 19
assert len(device_list) == 19
# Kasa: 9 parents + 10 children = 19
# HS103, HS105, HS110, HS300 (6 children), KL430,
# HS200, KP200 (2 children), KP400 (2 children), KL420L5
# Tapo: 3 devices (P100, P110, L530)
# Total: 19 + 3 = 22
assert len(device_list) == 22

@pytest.mark.usefixtures('client')
class TestFindDevice(object):
Expand Down Expand Up @@ -295,11 +297,11 @@ async def test_auth_token_initialized_without_credentials(self, client):
verbose=False,
term_id=os.environ.get('TPLINK_KASA_TERM_ID')
)
# Should be able to access _auth_token without AttributeError
assert device_manager._auth_token is None
# Should be able to access token without AttributeError
assert device_manager.get_token() is None
# Should be able to set auth token manually
device_manager.set_auth_token('test_token')
assert device_manager._auth_token == 'test_token'
assert device_manager.get_token() == 'test_token'

@pytest.mark.asyncio
async def test_auth_no_username(self, client):
Expand Down
159 changes: 159 additions & 0 deletions tests/test_tapo_devices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""Tests for Tapo device support via unified cloud listing."""

import os
import pytest
from tplinkcloud import TPLinkDeviceManager


class TestTapoDeviceListing:

@pytest.fixture(scope="class")
async def device_manager(self):
return await TPLinkDeviceManager(
username=os.environ.get('TPLINK_KASA_USERNAME'),
password=os.environ.get('TPLINK_KASA_PASSWORD'),
prefetch=False,
cache_devices=False,
tplink_cloud_api_host=os.environ.get('TPLINK_KASA_API_URL'),
verbose=False,
term_id=os.environ.get('TPLINK_KASA_TERM_ID'),
include_tapo=True,
)

@pytest.mark.asyncio
async def test_gets_both_kasa_and_tapo_devices(self, device_manager):
devices = await device_manager.get_devices()
assert devices is not None
assert len(devices) > 0

cloud_types = {d.cloud_type for d in devices}
assert "kasa" in cloud_types
assert "tapo" in cloud_types

@pytest.mark.asyncio
async def test_tapo_devices_have_tapo_cloud_type(self, device_manager):
devices = await device_manager.get_devices()
tapo_devices = [d for d in devices if d.cloud_type == "tapo"]
assert len(tapo_devices) > 0
# Only check device_info.cloud_type on non-child devices
# (child devices use HS300ChildSysInfo, not TPLinkDeviceInfo)
for device in tapo_devices:
if hasattr(device.device_info, 'cloud_type'):
assert device.device_info.cloud_type == "tapo"

@pytest.mark.asyncio
async def test_kasa_devices_have_kasa_cloud_type(self, device_manager):
devices = await device_manager.get_devices()
kasa_devices = [d for d in devices if d.cloud_type == "kasa"]
assert len(kasa_devices) > 0
for device in kasa_devices:
if hasattr(device.device_info, 'cloud_type'):
assert device.device_info.cloud_type == "kasa"

@pytest.mark.asyncio
async def test_tapo_device_models_present(self, device_manager):
devices = await device_manager.get_devices()
tapo_models = {d.device_info.device_model for d in devices if d.cloud_type == "tapo"}
assert "P100" in tapo_models
assert "P110" in tapo_models
assert "L530" in tapo_models

@pytest.mark.asyncio
async def test_find_tapo_device_by_name(self, device_manager):
device = await device_manager.find_device("Kitchen Tapo Plug")
assert device is not None
assert device.cloud_type == "tapo"
assert device.device_info.device_model == "P100"

@pytest.mark.asyncio
async def test_find_devices_returns_both_types(self, device_manager):
# "Light" and "Lamp" appear in both Kasa and Tapo device names
devices = await device_manager.find_devices("Tapo")
assert len(devices) > 0
assert all(d.cloud_type == "tapo" for d in devices)

@pytest.mark.asyncio
async def test_tapo_device_get_sys_info(self, device_manager):
device = await device_manager.find_device("Kitchen Tapo Plug")
assert device is not None
sys_info = await device.get_sys_info()
assert sys_info is not None
assert sys_info.get('model') == 'P100'


class TestTapoAuth:

@pytest.mark.asyncio
async def test_tapo_token_set_on_login(self):
device_manager = TPLinkDeviceManager(
username=os.environ.get('TPLINK_KASA_USERNAME'),
password=os.environ.get('TPLINK_KASA_PASSWORD'),
prefetch=False,
tplink_cloud_api_host=os.environ.get('TPLINK_KASA_API_URL'),
term_id=os.environ.get('TPLINK_KASA_TERM_ID'),
include_tapo=True,
)
assert device_manager.get_token() is not None
assert device_manager.get_tapo_token() is not None

@pytest.mark.asyncio
async def test_tapo_refresh_token_set_on_login(self):
device_manager = TPLinkDeviceManager(
username=os.environ.get('TPLINK_KASA_USERNAME'),
password=os.environ.get('TPLINK_KASA_PASSWORD'),
prefetch=False,
tplink_cloud_api_host=os.environ.get('TPLINK_KASA_API_URL'),
term_id=os.environ.get('TPLINK_KASA_TERM_ID'),
include_tapo=True,
)
assert device_manager.get_refresh_token() is not None
assert device_manager.get_tapo_refresh_token() is not None


class TestIncludeTapoFalse:

@pytest.mark.asyncio
async def test_no_tapo_devices_when_disabled(self):
device_manager = await TPLinkDeviceManager(
username=os.environ.get('TPLINK_KASA_USERNAME'),
password=os.environ.get('TPLINK_KASA_PASSWORD'),
prefetch=False,
cache_devices=False,
tplink_cloud_api_host=os.environ.get('TPLINK_KASA_API_URL'),
term_id=os.environ.get('TPLINK_KASA_TERM_ID'),
include_tapo=False,
)
devices = await device_manager.get_devices()
assert all(d.cloud_type == "kasa" for d in devices)

@pytest.mark.asyncio
async def test_tapo_token_none_when_disabled(self):
device_manager = TPLinkDeviceManager(
username=os.environ.get('TPLINK_KASA_USERNAME'),
password=os.environ.get('TPLINK_KASA_PASSWORD'),
prefetch=False,
tplink_cloud_api_host=os.environ.get('TPLINK_KASA_API_URL'),
term_id=os.environ.get('TPLINK_KASA_TERM_ID'),
include_tapo=False,
)
assert device_manager.get_token() is not None
assert device_manager.get_tapo_token() is None


class TestDeviceDeduplication:

@pytest.mark.asyncio
async def test_duplicate_devices_not_listed_twice(self):
"""If a device appears in both clouds, it should only appear once."""
device_manager = await TPLinkDeviceManager(
username=os.environ.get('TPLINK_KASA_USERNAME'),
password=os.environ.get('TPLINK_KASA_PASSWORD'),
prefetch=False,
cache_devices=False,
tplink_cloud_api_host=os.environ.get('TPLINK_KASA_API_URL'),
term_id=os.environ.get('TPLINK_KASA_TERM_ID'),
include_tapo=True,
)
devices = await device_manager.get_devices()
device_keys = [(d.device_id, getattr(d, 'child_id', None)) for d in devices]
assert len(device_keys) == len(set(device_keys))
61 changes: 61 additions & 0 deletions tests/wiremock/__files/tapo_get_device_list_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"error_code": 0,
"result": {
"deviceList": [
{
"deviceType": "SMART.TAPOPLUG",
"role": 0,
"fwVer": "1.3.0 Build 230425 Rel.163532",
"appServerUrl": "http://127.0.0.1:8080",
"deviceRegion": "us-east-1",
"deviceId": "TAPO_P100_ABCDEF1234567890ABCDEF1234567890",
"deviceName": "Tapo Mini Smart Wi-Fi Plug",
"deviceHwVer": "1.0",
"alias": "Kitchen Tapo Plug",
"deviceMac": "TAPO11223344",
"oemId": "TAPO1234EFGH5678ABCD1234EFGH5678",
"deviceModel": "P100",
"hwId": "TAPOABCD5678EFGH1234ABCD5678EFGH",
"fwId": "00000000000000000000000000000000",
"isSameRegion": true,
"status": 1
},
{
"deviceType": "SMART.TAPOPLUG",
"role": 0,
"fwVer": "1.2.1 Build 230425 Rel.163532",
"appServerUrl": "http://127.0.0.1:8080",
"deviceRegion": "us-east-1",
"deviceId": "TAPO_P110_BCDEF1234567890ABCDEF1234567890A",
"deviceName": "Tapo Mini Smart Wi-Fi Plug with Energy Monitoring",
"deviceHwVer": "1.0",
"alias": "Office Tapo Energy Plug",
"deviceMac": "TAPO55667788",
"oemId": "TAPO5678ABCD1234EFGH5678ABCD1234",
"deviceModel": "P110",
"hwId": "TAPOEFGH1234ABCD5678EFGH1234ABCD",
"fwId": "00000000000000000000000000000000",
"isSameRegion": true,
"status": 1
},
{
"deviceType": "SMART.TAPOBULB",
"role": 0,
"fwVer": "1.1.0 Build 230602 Rel.145203",
"appServerUrl": "http://127.0.0.1:8080",
"deviceRegion": "us-east-1",
"deviceId": "TAPO_L530_CDEF1234567890ABCDEF1234567890AB",
"deviceName": "Tapo Smart Wi-Fi Light Bulb, Multicolor",
"deviceHwVer": "2.0",
"alias": "Living Room Tapo Bulb",
"deviceMac": "TAPO99AABBCC",
"oemId": "TAPOBULB1234EFGH5678ABCD1234EFGH",
"deviceModel": "L530",
"hwId": "TAPOBULBABCD5678EFGH1234ABCD5678",
"fwId": "00000000000000000000000000000000",
"isSameRegion": true,
"status": 1
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"error_code": 0,
"result": {
"responseData": "{\"system\":{\"get_sysinfo\":{\"sw_ver\":\"1.3.0 Build 230425 Rel.163532\",\"hw_ver\":\"1.0\",\"model\":\"P100\",\"deviceId\":\"TAPO_P100_ABCDEF1234567890ABCDEF1234567890\",\"alias\":\"Kitchen Tapo Plug\",\"relay_state\":1,\"on_time\":12345,\"feature\":\"TIM\",\"rssi\":-45,\"err_code\":0}}}"
}
}
7 changes: 7 additions & 0 deletions tests/wiremock/__files/tapo_v2_account_status_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"error_code": 0,
"result": {
"accountStatus": 0,
"appServerUrl": "http://127.0.0.1:8080"
}
}
11 changes: 11 additions & 0 deletions tests/wiremock/__files/tapo_v2_login_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"error_code": 0,
"result": {
"accountId": "123456",
"regTime": "2017-11-27 23:21:40",
"countryCode": "US",
"email": "kasa_docker",
"token": "tapo-token-mock-xyz789",
"refreshToken": "tapo-refresh-token-mock-xyz789"
}
}
Loading