Skip to content
Open
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,9 @@ dmypy.json

# Pyre type checker
.pyre/

# Local exploration / probe scripts that may contain real credentials
tplink.py

# Decompiled Tether source — license + size; do not commit
tether-src/
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,29 @@ Set `include_tapo=False` on `TPLinkDeviceManager` to disable Tapo cloud support.
- **Power strips**: `HS300`, `KP303` — parent + child devices per outlet
- **Outdoor plugs**: `KP200`, `KP400` — parent + child devices per outlet, `EP40` — single outlet
- **Light strips**: `KL420L5`, `KL430` — color, brightness, color temp via `smartlife.iot.smartbulb.lightingservice`
- **Routers** (Archer family, both wireless and xDSL modem-router): `Router` — metadata-only class. Cloud control of router *operations* is not possible via this library; the cloud relay rejects every JSON method (`-20103`) for router-typed devices because routers speak the proprietary TMP binary protocol. The class surfaces alias/model/MAC/firmware/hardware/region/online-status/V2-URL and raises `NotImplementedError` on plug-style action methods. See `docs/superpowers/router-api/discovery-findings.md` for the empirical evidence and reasoning.

Devices with multiple outlets (`HS300`, `KP303`, `KP200`, `KP400`) have `has_children() -> True`. Child devices are separate class instances (e.g., `HS300Child`) with a `child_id`.

### Device dispatch in `_construct_device`

Two-tier:

1. **`device_type`-first** (router shortcut): if the cloud returns `deviceType` `WIRELESSROUTER` or `XDSLMODEMROUTER`, instantiate `Router` directly. Used for devices where model-name disambiguation is unnecessary because every router goes to the same metadata-only class.
2. **Model-prefix dispatch** (everything else): walk `DEVICE_MODEL_MAP` looking for a model-name prefix match (`HS103`, `KL430`, etc.). Fall back to base `TPLinkDevice` if no prefix matches.

### Adding a new device

For a plug/switch/light/strip:

1. Create `tplinkcloud/<model>.py` extending `TPLinkDevice` (or `EmeterDevice` for energy monitoring)
2. Define the `DeviceType` enum value in `device_type.py`
3. Add the model mapping in `device_manager.py` `_construct_device()`
3. Add the model mapping in `device_manager.py` `DEVICE_MODEL_MAP`
4. Add wiremock stubs in `tests/wiremock/mappings/` and `tests/wiremock/__files/`
5. Add tests in `tests/test_device_manager.py`

For a new router-family `deviceType` (e.g., a mesh node) that should also use the metadata-only `Router` class: add the new `deviceType` string to the tuple in `_construct_device`'s router check and add a wiremock fixture entry; no new file needed.

## Controlling devices

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

@pytest.mark.usefixtures('client')
class TestFindDevice(object):
Expand Down Expand Up @@ -330,3 +331,29 @@ async def test_auth_no_password(self, client):
term_id=os.environ.get('TPLINK_KASA_TERM_ID')
)
device_manager.login(os.environ.get('TPLINK_KASA_USERNAME'), None)


@pytest.mark.usefixtures('client')
class TestRouterDispatch(object):

@pytest.mark.asyncio
async def test_finds_archer_ax53_router(self, client):
from tplinkcloud.router import Router
device_name = 'Archer AX53'
device = await client.find_device(device_name)
assert device is not None
assert isinstance(device, Router)
assert device.device_info.device_type == 'WIRELESSROUTER'
assert device.device_info.device_model == 'Archer AX53(EU)'
assert device.app_server_url_v2 == 'http://127.0.0.1:8080'
assert device.is_online() is True

@pytest.mark.asyncio
async def test_finds_archer_vr400_xdsl_router(self, client):
from tplinkcloud.router import Router
device_name = 'Archer VR400'
device = await client.find_device(device_name)
assert device is not None
assert isinstance(device, Router)
assert device.device_info.device_type == 'XDSLMODEMROUTER'
assert device.is_online() is False # status 0 in fixture
141 changes: 141 additions & 0 deletions tests/test_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Tests for the Router device class and supporting metadata."""
import pytest

from tplinkcloud.device_info import TPLinkDeviceInfo
from tplinkcloud.device_type import TPLinkDeviceType


class TestDeviceInfoAppServerUrlV2:

def test_reads_app_server_url_v2_when_present(self):
info = TPLinkDeviceInfo({
"deviceType": "WIRELESSROUTER",
"appServerUrl": "https://primary.example",
"appServerUrlV2": "https://v2.example",
})
assert info.app_server_url_v2 == "https://v2.example"

def test_app_server_url_v2_is_none_when_missing(self):
info = TPLinkDeviceInfo({"deviceType": "IOT.SMARTPLUGSWITCH"})
assert info.app_server_url_v2 is None


class TestRouterEnum:

def test_router_enum_value_exists(self):
assert TPLinkDeviceType.ROUTER.value == 1000

def test_router_does_not_collide_with_other_values(self):
values = [m.value for m in TPLinkDeviceType]
assert len(values) == len(set(values)), "duplicate enum value"


def _make_router_info(status=1, **overrides):
base = {
"deviceType": "WIRELESSROUTER",
"fwVer": "1.6.2 Build 20260119 rel.87654",
"appServerUrl": "https://v1.example",
"appServerUrlV2": "https://v2.example",
"deviceRegion": "ap-southeast-1",
"deviceId": "ROUTER-DEVICE-ID",
"deviceName": "Archer AX53",
"deviceHwVer": "1.0",
"alias": "My Router",
"deviceMac": "AABBCCDDEEFF",
"deviceModel": "Archer AX53(EU)",
"status": status,
}
base.update(overrides)
return TPLinkDeviceInfo(base)


def _make_router(**overrides):
from tplinkcloud.router import Router
info = _make_router_info(**overrides)
# Router doesn't talk to the network; pass None for the client.
return Router(client=None, device_id=info.device_id, device_info=info)


class TestRouterMetadata:

def test_alias(self):
r = _make_router()
assert r.get_alias() == "My Router"

def test_mac(self):
r = _make_router()
assert r.mac == "AABBCCDDEEFF"

def test_firmware_version(self):
r = _make_router()
assert r.firmware_version == "1.6.2 Build 20260119 rel.87654"

def test_hardware_version(self):
r = _make_router()
assert r.hardware_version == "1.0"

def test_region(self):
r = _make_router()
assert r.region == "ap-southeast-1"

def test_app_server_url_v2(self):
r = _make_router()
assert r.app_server_url_v2 == "https://v2.example"

def test_is_online_true_when_status_1(self):
r = _make_router(status=1)
assert r.is_online() is True

def test_is_online_false_when_status_0(self):
r = _make_router(status=0)
assert r.is_online() is False

def test_model_type_is_router(self):
r = _make_router()
assert r.model_type == TPLinkDeviceType.ROUTER


class TestRouterUnsupportedActions:

_METHOD_ARGS = {
"set_led_state": (True,),
"get_schedule_rule": ("rule-id",),
"edit_schedule_rule": ({"id": "rule-id"},),
"add_schedule_rule": ({"enable": 1},),
"delete_schedule_rule": ("rule-id",),
"get_runtime_day": (2026, 4),
"get_runtime_month": (2026,),
}

@pytest.mark.asyncio
@pytest.mark.parametrize("method_name", [
"power_on", "power_off", "toggle", "is_on", "is_off",
"set_led_state",
"get_sys_info",
"get_schedule_rules", "get_schedule_rule",
"edit_schedule_rule", "add_schedule_rule",
"delete_all_scheduled_rules", "delete_schedule_rule",
"get_runtime_day", "get_runtime_month",
"get_net_info", "get_time", "get_timezone",
])
async def test_unsupported_methods_raise(self, method_name):
r = _make_router()
method = getattr(r, method_name)
args = self._METHOD_ARGS.get(method_name, ())
with pytest.raises(NotImplementedError) as exc_info:
await method(*args)
msg = str(exc_info.value)
assert method_name in msg
assert "discovery-findings" in msg


class TestPackageReExport:

def test_router_importable_from_package_root(self):
from tplinkcloud import Router
from tplinkcloud.router import Router as RouterDirect
assert Router is RouterDirect

def test_router_in_dunder_all(self):
import tplinkcloud
assert "Router" in tplinkcloud.__all__
38 changes: 38 additions & 0 deletions tests/wiremock/__files/get_device_list_response.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,44 @@
"fwId": "00000000000000000000000000000000",
"isSameRegion": true,
"status": 1
},
{
"deviceType": "WIRELESSROUTER",
"role": 0,
"fwVer": "1.6.2 Build 20260119 rel.87654",
"appServerUrl": "http://127.0.0.1:8080",
"appServerUrlV2": "http://127.0.0.1:8080",
"deviceRegion": "us-east-1",
"deviceId": "E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2E5F60005",
"deviceName": "Archer AX53",
"deviceHwVer": "1.0",
"alias": "Archer AX53",
"deviceMac": "AABBCCDDEEE5",
"oemId": "ARCHER_AX53_OEM_ID_PLACEHOLDER0001",
"deviceModel": "Archer AX53(EU)",
"hwId": "ARCHER_AX53_HW_ID_PLACEHOLDER00001",
"fwId": "00000000000000000000000000000000",
"isSameRegion": true,
"status": 1
},
{
"deviceType": "XDSLMODEMROUTER",
"role": 0,
"fwVer": "1.5.0 0.9.1 v00a1.0 Build 220321 Rel.60385n",
"appServerUrl": "http://127.0.0.1:8080",
"appServerUrlV2": "http://127.0.0.1:8080",
"deviceRegion": "us-east-1",
"deviceId": "F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3F6A10006",
"deviceName": "Archer VR400",
"deviceHwVer": "3.0",
"alias": "Archer VR400",
"deviceMac": "AABBCCDDEEE6",
"oemId": "ARCHER_VR400_OEM_ID_PLACEHOLDER0002",
"deviceModel": "Archer VR400(EU)",
"hwId": "ARCHER_VR400_HW_ID_PLACEHOLDER0002",
"fwId": "00000000000000000000000000000000",
"isSameRegion": true,
"status": 0
}
]
}
Expand Down
2 changes: 2 additions & 0 deletions tplinkcloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
TPLinkMFARequiredError,
TPLinkTokenExpiredError,
)
from .router import Router

__all__ = [
'TPLinkDeviceManager',
'TPLinkDeviceManagerPowerTools',
'TPLinkDeviceScheduleRuleBuilder',
'Router',
'TPLinkAuthError',
'TPLinkCloudError',
'TPLinkDeviceOfflineError',
Expand Down
1 change: 1 addition & 0 deletions tplinkcloud/device_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ def __init__(self, device_info, cloud_type="kasa"):
self.fw_id = device_info.get('fwId')
self.is_same_region = device_info.get('isSameRegion')
self.status = device_info.get('status')
self.app_server_url_v2 = device_info.get('appServerUrlV2')
self.cloud_type = cloud_type
16 changes: 10 additions & 6 deletions tplinkcloud/device_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .kp303 import KP303
from .kp400 import KP400
from .ep40 import EP40
from .router import Router
from .device import TPLinkDevice

DEVICE_MODEL_MAP: dict[str, type[TPLinkDevice]] = {
Expand Down Expand Up @@ -195,12 +196,15 @@ def _construct_device(self, device_info, api, token, cloud_type):
app_name=api._app_name,
cloud_type=cloud_type,
)
model = tplink_device_info.device_model
device_cls = next(
(cls for prefix, cls in DEVICE_MODEL_MAP.items() if model.startswith(prefix)),
TPLinkDevice,
)
device = device_cls(client, tplink_device_info.device_id, tplink_device_info)
if tplink_device_info.device_type in ('WIRELESSROUTER', 'XDSLMODEMROUTER'):
device = Router(client, tplink_device_info.device_id, tplink_device_info)
else:
model = tplink_device_info.device_model
device_cls = next(
(cls for prefix, cls in DEVICE_MODEL_MAP.items() if model.startswith(prefix)),
TPLinkDevice,
)
device = device_cls(client, tplink_device_info.device_id, tplink_device_info)
device.cloud_type = cloud_type
return device

Expand Down
3 changes: 2 additions & 1 deletion tplinkcloud/device_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class TPLinkDeviceType(Enum):
KP400CHILD = 4002
KL420L5 = 420
KL430 = 430
UNKNOWN = 9999
EP40 = 400
EP40CHILD = 4000
ROUTER = 1000
UNKNOWN = 9999
Loading