tplink-deco-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ from .client import DecoClient
2
+ from .exceptions import ApiError, AuthenticationError, CryptoError, DecoError, TransportError
3
+ from .models import (
4
+ ClientDevice,
5
+ Device,
6
+ DeviceMode,
7
+ IotHost,
8
+ LoginResult,
9
+ MloHost,
10
+ Performance,
11
+ SignalLevel,
12
+ WlanBackhaul,
13
+ WlanBand,
14
+ WlanConfig,
15
+ WlanGuest,
16
+ WlanHost,
17
+ )
18
+
19
+ __all__ = [
20
+ "DecoClient",
21
+ "DecoError",
22
+ "AuthenticationError",
23
+ "ApiError",
24
+ "CryptoError",
25
+ "TransportError",
26
+ "LoginResult",
27
+ "Device",
28
+ "SignalLevel",
29
+ "DeviceMode",
30
+ "WlanConfig",
31
+ "WlanBand",
32
+ "WlanHost",
33
+ "WlanGuest",
34
+ "WlanBackhaul",
35
+ "IotHost",
36
+ "MloHost",
37
+ "Performance",
38
+ "ClientDevice",
39
+ ]
@@ -0,0 +1,5 @@
1
+ from .protocol import build_payload, parse_plain_response, parse_response
2
+ from .session import SessionContext
3
+ from .transport import HttpTransport
4
+
5
+ __all__ = ["SessionContext", "HttpTransport", "build_payload", "parse_response", "parse_plain_response"]
@@ -0,0 +1,45 @@
1
+ import json
2
+ from typing import Any
3
+ from urllib.parse import quote_plus
4
+
5
+ from ..crypto.aes import aes_decrypt, aes_encrypt
6
+ from ..crypto.rsa import rsa_encrypt
7
+ from ..exceptions.api import ApiError
8
+ from ..models.rsa_key import RsaKey
9
+ from ..models.session_keys import SessionKeys
10
+
11
+
12
+ def build_payload(keys: SessionKeys, sign_key: RsaKey, data: dict[str, Any]) -> str:
13
+ data_b64 = _encode_data(keys, data)
14
+ sign = _encode_sign(keys, sign_key, len(data_b64))
15
+ return f"sign={sign}&data={quote_plus(data_b64)}"
16
+
17
+
18
+ def parse_response(raw: dict[str, Any], keys: SessionKeys) -> dict[str, Any]:
19
+ data_b64 = raw.get("data", "")
20
+ if not data_b64:
21
+ raise ApiError(-1)
22
+ decrypted = json.loads(aes_decrypt(keys.aes_key, keys.aes_iv, data_b64))
23
+ _check_error(decrypted)
24
+ return decrypted.get("result", {})
25
+
26
+
27
+ def parse_plain_response(raw: dict[str, Any]) -> dict[str, Any]:
28
+ _check_error(raw)
29
+ return raw.get("result", {})
30
+
31
+
32
+ def _encode_data(keys: SessionKeys, data: dict[str, Any]) -> str:
33
+ return aes_encrypt(keys.aes_key, keys.aes_iv, json.dumps(data, separators=(",", ":")))
34
+
35
+
36
+ def _encode_sign(keys: SessionKeys, sign_key: RsaKey, data_len: int) -> str:
37
+ seq_with_len = keys.seq + data_len
38
+ sig_str = f"k={keys.aes_key}&i={keys.aes_iv}&h={keys.session_hash}&s={seq_with_len}"
39
+ return rsa_encrypt(sign_key.n, sign_key.e, sig_str.encode())
40
+
41
+
42
+ def _check_error(response: dict[str, Any]) -> None:
43
+ code = response.get("error_code") or response.get("errorcode")
44
+ if code and code != 0:
45
+ raise ApiError(int(code))
@@ -0,0 +1,26 @@
1
+ from ..models.rsa_key import RsaKey
2
+ from ..models.session_keys import SessionKeys
3
+
4
+
5
+ class SessionContext:
6
+ def __init__(
7
+ self,
8
+ sign_key: RsaKey,
9
+ pwd_key: RsaKey,
10
+ keys: SessionKeys,
11
+ stok: str = "",
12
+ ):
13
+ self.sign_key = sign_key
14
+ self.pwd_key = pwd_key
15
+ self.keys = keys
16
+ self.stok = stok
17
+
18
+ def is_authenticated(self) -> bool:
19
+ return bool(self.stok)
20
+
21
+ def increment_seq(self) -> None:
22
+ self.keys.seq += 1
23
+
24
+ def invalidate(self) -> None:
25
+ self.stok = ""
26
+ self.keys.seq = 0
@@ -0,0 +1,43 @@
1
+ import json
2
+ import re
3
+ import urllib.error
4
+ import urllib.request
5
+ from typing import Any
6
+
7
+ from ..exceptions.transport import TransportError
8
+
9
+ _COOKIE_RE = re.compile(r"(sysauth=[a-f0-9]+)")
10
+
11
+
12
+ class HttpTransport:
13
+ def __init__(self, timeout: float = 10.0):
14
+ self.timeout = timeout
15
+ self._cookie: str | None = None
16
+
17
+ def post_json(self, url: str, body: dict[str, Any]) -> dict[str, Any]:
18
+ return self._post(url, json.dumps(body).encode())
19
+
20
+ def post_form(self, url: str, body: str) -> dict[str, Any]:
21
+ return self._post(url, body.encode())
22
+
23
+ def _post(self, url: str, data: bytes) -> dict[str, Any]:
24
+ headers = {"Content-Type": "application/json"}
25
+ if self._cookie:
26
+ headers["Cookie"] = self._cookie
27
+ req = urllib.request.Request(url, data=data, headers=headers)
28
+ try:
29
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
30
+ self._capture_cookie(resp)
31
+ return json.loads(resp.read())
32
+ except urllib.error.HTTPError as exc:
33
+ raise TransportError(str(exc), status_code=exc.code) from exc
34
+ except urllib.error.URLError as exc:
35
+ raise TransportError(str(exc)) from exc
36
+
37
+ def _capture_cookie(self, resp) -> None:
38
+ for header, value in resp.headers.items():
39
+ if header.lower() == "set-cookie":
40
+ match = _COOKIE_RE.search(value)
41
+ if match:
42
+ self._cookie = match.group(1)
43
+ break
tplink_deco/client.py ADDED
@@ -0,0 +1,120 @@
1
+ from typing import Any
2
+
3
+ from . import endpoints
4
+ from .auth.protocol import build_payload, parse_response
5
+ from .auth.session import SessionContext
6
+ from .auth.transport import HttpTransport
7
+ from .crypto import generate_aes_pair, md5_session_hash, rsa_encrypt
8
+ from .exceptions import AuthenticationError
9
+ from .models import (
10
+ ClientDevice,
11
+ Device,
12
+ DeviceMode,
13
+ LoginResult,
14
+ Performance,
15
+ RsaKey,
16
+ SessionKeys,
17
+ WlanConfig,
18
+ )
19
+
20
+
21
+ class DecoClient:
22
+ def __init__(
23
+ self,
24
+ host: str,
25
+ username: str,
26
+ password: str,
27
+ timeout: float = 10.0,
28
+ ):
29
+ self._host = host
30
+ self._username = username
31
+ self._password = password
32
+ self._transport = HttpTransport(timeout=timeout)
33
+ self._session: SessionContext | None = None
34
+
35
+ def __enter__(self) -> "DecoClient":
36
+ self.login()
37
+ return self
38
+
39
+ def __exit__(self, *_) -> None:
40
+ self.logout()
41
+
42
+ def login(self) -> LoginResult:
43
+ auth_raw = self._transport.post_json(
44
+ endpoints.login_url(self._host, "auth"),
45
+ {"operation": "read"},
46
+ )
47
+ keys_raw = self._transport.post_json(
48
+ endpoints.login_url(self._host, "keys"),
49
+ {"operation": "read"},
50
+ )
51
+
52
+ sign_key = RsaKey.from_hex(*auth_raw["result"]["key"])
53
+ pwd_key = RsaKey.from_hex(*keys_raw["result"]["password"])
54
+ seq = auth_raw["result"]["seq"]
55
+
56
+ aes_key, aes_iv = generate_aes_pair()
57
+ session_hash = md5_session_hash(self._username, self._password)
58
+ keys = SessionKeys(aes_key=aes_key, aes_iv=aes_iv,
59
+ session_hash=session_hash, seq=seq)
60
+
61
+ self._session = SessionContext(sign_key=sign_key, pwd_key=pwd_key, keys=keys)
62
+
63
+ password_encrypted = rsa_encrypt(
64
+ pwd_key.n, pwd_key.e, self._password.encode()
65
+ )
66
+ login_body = build_payload(
67
+ keys, sign_key,
68
+ {"operation": "login", "params": {"password": password_encrypted}},
69
+ )
70
+ raw = self._transport.post_form(
71
+ endpoints.login_url(self._host, "login"), login_body
72
+ )
73
+
74
+ result = parse_response(raw, keys)
75
+ if "stok" not in result:
76
+ raise AuthenticationError("Login falhou — stok ausente na resposta")
77
+
78
+ self._session.stok = result["stok"]
79
+ return LoginResult(stok=result["stok"], usr_lvl=result.get("usrLvl", 1))
80
+
81
+ def logout(self) -> None:
82
+ if self._session:
83
+ self._session.invalidate()
84
+
85
+ def is_authenticated(self) -> bool:
86
+ return self._session is not None and self._session.is_authenticated()
87
+
88
+ def request(self, path: str, form: str, data: dict[str, Any]) -> dict[str, Any]:
89
+ self._require_auth()
90
+ url = endpoints.admin_url(self._host, self._session.stok, path, form)
91
+ body = build_payload(self._session.keys, self._session.sign_key, data)
92
+ raw = self._transport.post_form(url, body)
93
+ return parse_response(raw, self._session.keys)
94
+
95
+ def get_device_list(self) -> list[Device]:
96
+ result = self.request("admin/device", "device_list", {"operation": "read"})
97
+ return [Device.from_api(d) for d in result.get("device_list", [])]
98
+
99
+ def get_device_mode(self) -> DeviceMode:
100
+ result = self.request("admin/device", "mode", {"operation": "read"})
101
+ return DeviceMode.from_api(result)
102
+
103
+ def get_wlan_config(self) -> WlanConfig:
104
+ result = self.request("admin/wireless", "wlan", {"operation": "read"})
105
+ return WlanConfig.from_api(result)
106
+
107
+ def get_performance(self) -> Performance:
108
+ result = self.request("admin/network", "performance", {"operation": "read"})
109
+ return Performance.from_api(result)
110
+
111
+ def get_client_list(self, deco_mac: str = "default") -> list[ClientDevice]:
112
+ result = self.request(
113
+ "admin/client", "client_list",
114
+ {"operation": "read", "params": {"device_mac": deco_mac}},
115
+ )
116
+ return [ClientDevice.from_api(c) for c in result.get("client_list", [])]
117
+
118
+ def _require_auth(self) -> None:
119
+ if not self.is_authenticated():
120
+ raise AuthenticationError("Não autenticado — chame login() primeiro")
@@ -0,0 +1,5 @@
1
+ from .aes import aes_decrypt, aes_encrypt, generate_aes_pair
2
+ from .hash import md5_session_hash
3
+ from .rsa import rsa_encrypt
4
+
5
+ __all__ = ["generate_aes_pair", "aes_encrypt", "aes_decrypt", "rsa_encrypt", "md5_session_hash"]
@@ -0,0 +1,37 @@
1
+ import secrets
2
+ from base64 import b64decode, b64encode
3
+
4
+ from cryptography.hazmat.primitives import padding as sym_padding
5
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
6
+
7
+ from ..exceptions.crypto import CryptoError
8
+
9
+ _KEY_DIGITS = 16
10
+ _KEY_MIN = 10 ** (_KEY_DIGITS - 1)
11
+ _KEY_MAX = (10 ** _KEY_DIGITS) - 1
12
+
13
+
14
+ def generate_aes_pair() -> tuple[str, str]:
15
+ key = str(secrets.randbelow(_KEY_MAX - _KEY_MIN) + _KEY_MIN)
16
+ iv = str(secrets.randbelow(_KEY_MAX - _KEY_MIN) + _KEY_MIN)
17
+ return key, iv
18
+
19
+
20
+ def aes_encrypt(key: str, iv: str, plaintext: str) -> str:
21
+ padder = sym_padding.PKCS7(128).padder()
22
+ padded = padder.update(plaintext.encode()) + padder.finalize()
23
+ cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(iv.encode()))
24
+ enc = cipher.encryptor()
25
+ return b64encode(enc.update(padded) + enc.finalize()).decode()
26
+
27
+
28
+ def aes_decrypt(key: str, iv: str, ciphertext_b64: str) -> str:
29
+ try:
30
+ ct = b64decode(ciphertext_b64)
31
+ cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(iv.encode()))
32
+ dec = cipher.decryptor()
33
+ raw = dec.update(ct) + dec.finalize()
34
+ n_pad = raw[-1]
35
+ return raw[:-n_pad].decode()
36
+ except Exception as exc:
37
+ raise CryptoError(f"Falha ao decifrar AES: {exc}") from exc
@@ -0,0 +1,5 @@
1
+ import hashlib
2
+
3
+
4
+ def md5_session_hash(username: str, password: str) -> str:
5
+ return hashlib.md5((username + password).encode()).hexdigest()
@@ -0,0 +1,27 @@
1
+ import math
2
+ import secrets
3
+
4
+ from ..exceptions.crypto import CryptoError
5
+
6
+
7
+ def rsa_encrypt(n: int, e: int, plaintext: bytes) -> str:
8
+ block_size = (int(math.log2(n)) + 8) >> 3
9
+ bytes_per_block = block_size - 11
10
+ result = ""
11
+ for i in range(0, len(plaintext), bytes_per_block):
12
+ result += _encrypt_block(n, e, block_size, plaintext[i : i + bytes_per_block])
13
+ return result
14
+
15
+
16
+ def _encrypt_block(n: int, e: int, k: int, block: bytes) -> str:
17
+ if len(block) > k - 11:
18
+ raise CryptoError(f"Bloco RSA muito longo: {len(block)} > {k - 11}")
19
+ pad_len = k - len(block) - 3
20
+ pad = b""
21
+ while len(pad) < pad_len:
22
+ byte = secrets.token_bytes(1)
23
+ if byte != b"\x00":
24
+ pad += byte
25
+ em = b"\x00\x02" + pad + b"\x00" + block
26
+ ct = pow(int.from_bytes(em, "big"), e, n)
27
+ return format(ct, f"0{k * 2}x")
@@ -0,0 +1,24 @@
1
+ _PLAIN_ENDPOINTS: frozenset[str] = frozenset({
2
+ "/login?form=auth",
3
+ "/login?form=keys",
4
+ "/login?form=check_factory_default",
5
+ "/login?form=default_info",
6
+ "/admin/system?form=envar",
7
+ "/admin/system?form=sysmode",
8
+ "/admin/cloud?form=firmware",
9
+ "/admin/isp?form=isp_upgrade",
10
+ "/admin/firmware?form=config_multipart",
11
+ "/admin/log_export?form=save_log",
12
+ })
13
+
14
+
15
+ def is_plain(path_with_form: str) -> bool:
16
+ return path_with_form in _PLAIN_ENDPOINTS
17
+
18
+
19
+ def login_url(host: str, form: str) -> str:
20
+ return f"http://{host}/cgi-bin/luci/;stok=/login?form={form}"
21
+
22
+
23
+ def admin_url(host: str, stok: str, path: str, form: str) -> str:
24
+ return f"http://{host}/cgi-bin/luci/;stok={stok}/{path}?form={form}"
@@ -0,0 +1,7 @@
1
+ from .api import ApiError
2
+ from .auth import AuthenticationError
3
+ from .base import DecoError
4
+ from .crypto import CryptoError
5
+ from .transport import TransportError
6
+
7
+ __all__ = ["DecoError", "AuthenticationError", "ApiError", "CryptoError", "TransportError"]
@@ -0,0 +1,7 @@
1
+ from .base import DecoError
2
+
3
+
4
+ class ApiError(DecoError):
5
+ def __init__(self, error_code: int):
6
+ super().__init__(f"API returned error_code={error_code}")
7
+ self.error_code = error_code
@@ -0,0 +1,5 @@
1
+ from .base import DecoError
2
+
3
+
4
+ class AuthenticationError(DecoError):
5
+ pass
@@ -0,0 +1,2 @@
1
+ class DecoError(Exception):
2
+ pass
@@ -0,0 +1,5 @@
1
+ from .base import DecoError
2
+
3
+
4
+ class CryptoError(DecoError):
5
+ pass
@@ -0,0 +1,7 @@
1
+ from .base import DecoError
2
+
3
+
4
+ class TransportError(DecoError):
5
+ def __init__(self, message: str, status_code: int | None = None):
6
+ super().__init__(message)
7
+ self.status_code = status_code
@@ -0,0 +1,35 @@
1
+ from .client_device import ClientDevice
2
+ from .device import Device
3
+ from .device_mode import DeviceMode
4
+ from .login_result import LoginResult
5
+ from .performance import Performance
6
+ from .rsa_key import RsaKey
7
+ from .session_keys import SessionKeys
8
+ from .signal_level import SignalLevel
9
+ from .wlan_config import (
10
+ IotHost,
11
+ MloHost,
12
+ WlanBackhaul,
13
+ WlanBand,
14
+ WlanConfig,
15
+ WlanGuest,
16
+ WlanHost,
17
+ )
18
+
19
+ __all__ = [
20
+ "RsaKey",
21
+ "SessionKeys",
22
+ "LoginResult",
23
+ "Device",
24
+ "SignalLevel",
25
+ "DeviceMode",
26
+ "WlanConfig",
27
+ "WlanBand",
28
+ "WlanHost",
29
+ "WlanGuest",
30
+ "WlanBackhaul",
31
+ "IotHost",
32
+ "MloHost",
33
+ "Performance",
34
+ "ClientDevice",
35
+ ]
@@ -0,0 +1,11 @@
1
+ from base64 import b64decode
2
+
3
+
4
+ def decode_b64(value: str) -> str:
5
+ if not value:
6
+ return value
7
+ return b64decode(value).decode()
8
+
9
+
10
+ def normalize_mac(value: str) -> str:
11
+ return value.replace("-", ":").upper()
@@ -0,0 +1,45 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from ._utils import decode_b64, normalize_mac
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ClientDevice:
9
+ mac: str
10
+ ip: str
11
+ name: str
12
+ up_speed: int
13
+ down_speed: int
14
+ wire_type: str
15
+ connection_type: str
16
+ space_id: str
17
+ access_host: str
18
+ interface: str
19
+ client_type: str
20
+ owner_id: str
21
+ remain_time: int
22
+ online: bool
23
+ client_mesh: bool
24
+ enable_priority: bool
25
+
26
+ @classmethod
27
+ def from_api(cls, data: dict[str, Any]) -> "ClientDevice":
28
+ return cls(
29
+ mac=normalize_mac(data["mac"]),
30
+ ip=data.get("ip", ""),
31
+ name=decode_b64(data.get("name", "")),
32
+ up_speed=int(data.get("up_speed", 0)),
33
+ down_speed=int(data.get("down_speed", 0)),
34
+ wire_type=data.get("wire_type", ""),
35
+ connection_type=data.get("connection_type", ""),
36
+ space_id=data.get("space_id", ""),
37
+ access_host=data.get("access_host", ""),
38
+ interface=data.get("interface", ""),
39
+ client_type=data.get("client_type", ""),
40
+ owner_id=data.get("owner_id", ""),
41
+ remain_time=int(data.get("remain_time", 0)),
42
+ online=bool(data.get("online", False)),
43
+ client_mesh=bool(data.get("client_mesh", False)),
44
+ enable_priority=bool(data.get("enable_priority", False)),
45
+ )
@@ -0,0 +1,62 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from ._utils import decode_b64, normalize_mac
5
+ from .signal_level import SignalLevel
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Device:
10
+ mac: str
11
+ device_ip: str
12
+ device_model: str
13
+ device_type: str
14
+ role: str
15
+ nickname: str
16
+ custom_nickname: str
17
+ hardware_ver: str
18
+ software_ver: str
19
+ oem_id: str
20
+ hw_id: str
21
+ bssid_2g: str
22
+ bssid_5g: str
23
+ bssid_sta_2g: str
24
+ bssid_sta_5g: str
25
+ inet_status: str
26
+ inet_error_msg: str
27
+ group_status: str
28
+ signal_level: SignalLevel
29
+ product_level: int
30
+ set_gateway_support: bool
31
+ support_plc: bool
32
+ oversized_firmware: bool
33
+ nand_flash: bool
34
+
35
+ @classmethod
36
+ def from_api(cls, data: dict[str, Any]) -> "Device":
37
+ return cls(
38
+ mac=normalize_mac(data["mac"]),
39
+ device_ip=data.get("device_ip", ""),
40
+ device_model=data.get("device_model", ""),
41
+ device_type=data.get("device_type", ""),
42
+ role=data.get("role", ""),
43
+ nickname=data.get("nickname", ""),
44
+ custom_nickname=decode_b64(data.get("custom_nickname", "")),
45
+ hardware_ver=data.get("hardware_ver", ""),
46
+ software_ver=data.get("software_ver", ""),
47
+ oem_id=data.get("oem_id", ""),
48
+ hw_id=data.get("hw_id", ""),
49
+ bssid_2g=normalize_mac(data.get("bssid_2g", "")),
50
+ bssid_5g=normalize_mac(data.get("bssid_5g", "")),
51
+ bssid_sta_2g=normalize_mac(data.get("bssid_sta_2g", "")),
52
+ bssid_sta_5g=normalize_mac(data.get("bssid_sta_5g", "")),
53
+ inet_status=data.get("inet_status", ""),
54
+ inet_error_msg=data.get("inet_error_msg", ""),
55
+ group_status=data.get("group_status", ""),
56
+ signal_level=SignalLevel.from_api(data.get("signal_level", {})),
57
+ product_level=int(data.get("product_level", 0)),
58
+ set_gateway_support=bool(data.get("set_gateway_support", False)),
59
+ support_plc=bool(data.get("support_plc", False)),
60
+ oversized_firmware=bool(data.get("oversized_firmware", False)),
61
+ nand_flash=bool(data.get("nand_flash", False)),
62
+ )
@@ -0,0 +1,17 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class DeviceMode:
7
+ workmode: str
8
+ sysmode: str
9
+ region: str = ""
10
+
11
+ @classmethod
12
+ def from_api(cls, data: dict[str, Any]) -> "DeviceMode":
13
+ return cls(
14
+ workmode=data.get("workmode", ""),
15
+ sysmode=data.get("sysmode", ""),
16
+ region=data.get("region", {}).get("device", ""),
17
+ )
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class LoginResult:
6
+ stok: str
7
+ usr_lvl: int = 1
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Performance:
7
+ cpu_usage: float
8
+ mem_usage: float
9
+
10
+ @classmethod
11
+ def from_api(cls, data: dict[str, Any]) -> "Performance":
12
+ return cls(
13
+ cpu_usage=float(data["cpu_usage"]),
14
+ mem_usage=float(data["mem_usage"]),
15
+ )
@@ -0,0 +1,11 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class RsaKey:
6
+ n: int
7
+ e: int
8
+
9
+ @classmethod
10
+ def from_hex(cls, modulus_hex: str, exponent_hex: str) -> "RsaKey":
11
+ return cls(n=int(modulus_hex, 16), e=int(exponent_hex, 16))
@@ -0,0 +1,9 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class SessionKeys:
6
+ aes_key: str
7
+ aes_iv: str
8
+ session_hash: str
9
+ seq: int
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class SignalLevel:
6
+ band2_4: str
7
+ band5: str
8
+ band6: str
9
+
10
+ @classmethod
11
+ def from_api(cls, data: dict[str, str]) -> "SignalLevel":
12
+ return cls(
13
+ band2_4=data.get("band2_4", "0"),
14
+ band5=data.get("band5", "0"),
15
+ band6=data.get("band6", "0"),
16
+ )
@@ -0,0 +1,131 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from ._utils import decode_b64
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class WlanHost:
9
+ ssid: str
10
+ password: str
11
+ channel: int
12
+ enable: bool
13
+ mode: str
14
+ channel_width: str
15
+ enable_hide_ssid: bool
16
+
17
+ @classmethod
18
+ def from_api(cls, data: dict[str, Any]) -> "WlanHost":
19
+ return cls(
20
+ ssid=decode_b64(data.get("ssid", "")),
21
+ password=decode_b64(data.get("password", "")),
22
+ channel=int(data.get("channel", 0)),
23
+ enable=bool(data.get("enable", False)),
24
+ mode=data.get("mode", ""),
25
+ channel_width=data.get("channel_width", ""),
26
+ enable_hide_ssid=bool(data.get("enable_hide_ssid", False)),
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class WlanGuest:
32
+ ssid: str
33
+ password: str
34
+ enable: bool
35
+ vlan_id: int | None = None
36
+ need_set_vlan: bool | None = None
37
+
38
+ @classmethod
39
+ def from_api(cls, data: dict[str, Any]) -> "WlanGuest":
40
+ return cls(
41
+ ssid=decode_b64(data.get("ssid", "")),
42
+ password=decode_b64(data.get("password", "")),
43
+ enable=bool(data.get("enable", False)),
44
+ vlan_id=int(data["vlan_id"]) if "vlan_id" in data else None,
45
+ need_set_vlan=bool(data["need_set_vlan"]) if "need_set_vlan" in data else None,
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class WlanBackhaul:
51
+ channel: int
52
+
53
+ @classmethod
54
+ def from_api(cls, data: dict[str, Any]) -> "WlanBackhaul":
55
+ return cls(channel=int(data.get("channel", 0)))
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class WlanBand:
60
+ host: WlanHost
61
+ guest: WlanGuest
62
+ backhaul: WlanBackhaul
63
+
64
+ @classmethod
65
+ def from_api(cls, data: dict[str, Any]) -> "WlanBand":
66
+ return cls(
67
+ host=WlanHost.from_api(data.get("host", {})),
68
+ guest=WlanGuest.from_api(data.get("guest", {})),
69
+ backhaul=WlanBackhaul.from_api(data.get("backhaul", {})),
70
+ )
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class IotHost:
75
+ ssid: str
76
+ password: str
77
+ encryption_mode: str
78
+ enable: bool
79
+ enable_2g: bool
80
+ enable_5g: bool
81
+
82
+ @classmethod
83
+ def from_api(cls, data: dict[str, Any]) -> "IotHost":
84
+ return cls(
85
+ ssid=decode_b64(data.get("ssid", "")),
86
+ password=decode_b64(data.get("password", "")),
87
+ encryption_mode=data.get("encryption_mode", ""),
88
+ enable=bool(data.get("enable", False)),
89
+ enable_2g=bool(data.get("enable_2g", False)),
90
+ enable_5g=bool(data.get("enable_5g", False)),
91
+ )
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class MloHost:
96
+ ssid: str
97
+ password: str
98
+ enable: bool
99
+ band: tuple[str, ...]
100
+ enable_hide_ssid: bool
101
+
102
+ @classmethod
103
+ def from_api(cls, data: dict[str, Any]) -> "MloHost":
104
+ return cls(
105
+ ssid=decode_b64(data.get("ssid", "")),
106
+ password=decode_b64(data.get("password", "")),
107
+ enable=bool(data.get("enable", False)),
108
+ band=tuple(data.get("band", [])),
109
+ enable_hide_ssid=bool(data.get("enable_hide_ssid", False)),
110
+ )
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class WlanConfig:
115
+ band2_4: WlanBand
116
+ band5_1: WlanBand
117
+ band6: WlanBand
118
+ iot_host: IotHost
119
+ mlo_host: MloHost
120
+ is_eg: bool = False
121
+
122
+ @classmethod
123
+ def from_api(cls, data: dict[str, Any]) -> "WlanConfig":
124
+ return cls(
125
+ band2_4=WlanBand.from_api(data.get("band2_4", {})),
126
+ band5_1=WlanBand.from_api(data.get("band5_1", {})),
127
+ band6=WlanBand.from_api(data.get("band6", {})),
128
+ iot_host=IotHost.from_api(data.get("iot", {}).get("host", {})),
129
+ mlo_host=MloHost.from_api(data.get("mlo", {}).get("host", {})),
130
+ is_eg=bool(data.get("is_eg", False)),
131
+ )
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: tplink-deco-api
3
+ Version: 0.1.0
4
+ Summary: SDK Python para controle de roteadores TP-Link Deco via API HTTP interna
5
+ Project-URL: Homepage, https://github.com/roquerodrigo/tplink-deco-api
6
+ Project-URL: Repository, https://github.com/roquerodrigo/tplink-deco-api
7
+ Project-URL: Issues, https://github.com/roquerodrigo/tplink-deco-api/issues
8
+ Author-email: Rodrigo Roque <rodrigogoncalvesroque@gmail.com>
9
+ License: MIT
10
+ Keywords: api,deco,mesh,router,sdk,tplink,wifi
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Home Automation
19
+ Classifier: Topic :: System :: Networking
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: cryptography>=41.0.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # tplink-deco-api
25
+
26
+ SDK Python para controle de roteadores **TP-Link Deco** (mesh Wi-Fi) via API HTTP interna.
27
+
28
+ ## Instalação
29
+
30
+ ```bash
31
+ pip install tplink-deco-api
32
+ ```
33
+
34
+ ## Uso
35
+
36
+ ```python
37
+ from tplink_deco import DecoClient
38
+
39
+ with DecoClient("192.168.68.1", "admin", "sua-senha") as deco:
40
+ for client in deco.get_client_list():
41
+ print(client.name, client.ip, client.connection_type)
42
+ ```
43
+
44
+ ## Métodos disponíveis
45
+
46
+ | Método | Retorno |
47
+ |--------|---------|
48
+ | `login()` | `LoginResult` |
49
+ | `get_device_list()` | `list[Device]` |
50
+ | `get_device_mode()` | `DeviceMode` |
51
+ | `get_wlan_config()` | `WlanConfig` |
52
+ | `get_performance()` | `Performance` |
53
+ | `get_client_list(deco_mac?)` | `list[ClientDevice]` |
54
+
55
+ ## Modelos
56
+
57
+ Todos os métodos retornam dataclasses tipadas — sem dicionários genéricos.
58
+
59
+ ```python
60
+ client.mac # "AA:BB:CC:DD:EE:FF"
61
+ client.name # "MacBook Pro"
62
+ client.ip # "192.168.68.10"
63
+ client.connection_type # "band6"
64
+ client.online # True
65
+
66
+ device.device_model # "BE65"
67
+ device.software_ver # "1.2.10 Build 20251229"
68
+
69
+ wlan.band2_4.host.ssid # "Minha Rede"
70
+ wlan.band2_4.guest.password # "senha-visitas"
71
+
72
+ perf.cpu_usage # 0.03
73
+ perf.mem_usage # 0.42
74
+ ```
75
+
76
+ ## Requisitos
77
+
78
+ - Python 3.11+
79
+ - Roteador TP-Link Deco acessível na rede local
@@ -0,0 +1,31 @@
1
+ tplink_deco/__init__.py,sha256=XPiQyGximStuGWqguCStXSbH7bWECD_9_51oRCxUNTA,709
2
+ tplink_deco/client.py,sha256=ezT_8q2ToiyBHJ1ALFXoJYjWgRjnRuny5-q7ad25E-s,4257
3
+ tplink_deco/endpoints.py,sha256=_LWTxdq843DALka7LN8WNRupH3TukU-Cwfeu9Py7mw4,734
4
+ tplink_deco/auth/__init__.py,sha256=RkoPhXrx9uusXxvz7HQlTuZR7lNXtYJE_YpesaNwGLI,253
5
+ tplink_deco/auth/protocol.py,sha256=5Cg_OJb3zYjGLd6uzTpaA4SVxRiyQyqMaXRWbclIV3w,1561
6
+ tplink_deco/auth/session.py,sha256=4rnwj3nF5nD7L0lH0gkMI7X4BI57_YWT_aUWQ3Icm_Y,604
7
+ tplink_deco/auth/transport.py,sha256=9zHgxDOZ3IS6kEmxRao4sqdtZbCpIuTep3STxJkRW4I,1522
8
+ tplink_deco/crypto/__init__.py,sha256=LaJ6X8LVeUWTYqPwBUgwDTpIt0vJTq09Ankcz94qzDo,223
9
+ tplink_deco/crypto/aes.py,sha256=admh7_ehBQpX_evgLnyiRM3nmyl2HvI0ccGHvEYmOOY,1319
10
+ tplink_deco/crypto/hash.py,sha256=lGu0eN3lAgknMR6bo8T7_2LkRgyfRIvMcw2V23BONPA,143
11
+ tplink_deco/crypto/rsa.py,sha256=wlHtmun_2N5EZSLps1XT0EJmC9oy-1PS09xyom47jlw,864
12
+ tplink_deco/exceptions/__init__.py,sha256=rVcvwH972rKvlC22NWnQb5I1x5VbQeA2qGMt0J0Fx2g,255
13
+ tplink_deco/exceptions/api.py,sha256=ZxlqSrXHtJfQL8x_19gE1Z_aPoCbOdVfx8W6P5s3syE,201
14
+ tplink_deco/exceptions/auth.py,sha256=jwsZ_FIhAsFf-MI7fTs98_fwfPZtCqtD67_7kH0vHZ4,77
15
+ tplink_deco/exceptions/base.py,sha256=fItptY8fenLxKaCmt9TnLL9aO-sqjCoBWex7GR7jnY8,37
16
+ tplink_deco/exceptions/crypto.py,sha256=8dM1Oqhm3LgVKgZLJ6nMMtBzchUBdT4GPiGe6dEaccc,69
17
+ tplink_deco/exceptions/transport.py,sha256=wpa7B-CrBCEhtjltUUWyPfm8MVvcElW1MVs3Y6P85Ns,206
18
+ tplink_deco/models/__init__.py,sha256=QAFyTR67p9zw-tmya4_UwAoe9qnUaZblBeT-p9E0n4o,688
19
+ tplink_deco/models/_utils.py,sha256=OeG6AaqOeRIGwH2snQg2nJ1JxVCcutB24K5czeA5EPQ,225
20
+ tplink_deco/models/client_device.py,sha256=cXdrne1-_-BmBO7mRkFARwrVn_l9qIrNfBKbiT3Zhtg,1401
21
+ tplink_deco/models/device.py,sha256=7zkItDQ7Yg_2SttuFdCQJ_-r_Pk6d5Ha42J4xOqmd38,2195
22
+ tplink_deco/models/device_mode.py,sha256=IbBq5EjDT4u1-9gQak56L11M9BBij5ZRJOOadL5rVlo,419
23
+ tplink_deco/models/login_result.py,sha256=I__4ZZ0PB2-D85pLGW2-YFCUrGD7RrTNwBHee0u_3ic,117
24
+ tplink_deco/models/performance.py,sha256=yjP3YsEhid7khtm8gFVNDSD3f_wiPnSVlZLHGp5d6KQ,350
25
+ tplink_deco/models/rsa_key.py,sha256=8giW-niAxntXz37zb8Ct_KKMPdEVwn7SUkumDXw8rH0,254
26
+ tplink_deco/models/session_keys.py,sha256=2biXfh7vX3duZe_N8r0AnTllmZOLfzXN-8RHyhKfwX8,154
27
+ tplink_deco/models/signal_level.py,sha256=Z6NjYV2i8M5nzjqy05GJrmf4GqlyedkLOGIcGplLfg0,366
28
+ tplink_deco/models/wlan_config.py,sha256=6TTt-2L77sb78qjmhXQGYVDQiYs1LINDgpNso7jM7mM,3752
29
+ tplink_deco_api-0.1.0.dist-info/METADATA,sha256=Qv6sh-N4GSQyVZ0Z0jCyrWwULpWomNLm_08qMUnQRaU,2314
30
+ tplink_deco_api-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
31
+ tplink_deco_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any