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.
- tplink_deco/__init__.py +39 -0
- tplink_deco/auth/__init__.py +5 -0
- tplink_deco/auth/protocol.py +45 -0
- tplink_deco/auth/session.py +26 -0
- tplink_deco/auth/transport.py +43 -0
- tplink_deco/client.py +120 -0
- tplink_deco/crypto/__init__.py +5 -0
- tplink_deco/crypto/aes.py +37 -0
- tplink_deco/crypto/hash.py +5 -0
- tplink_deco/crypto/rsa.py +27 -0
- tplink_deco/endpoints.py +24 -0
- tplink_deco/exceptions/__init__.py +7 -0
- tplink_deco/exceptions/api.py +7 -0
- tplink_deco/exceptions/auth.py +5 -0
- tplink_deco/exceptions/base.py +2 -0
- tplink_deco/exceptions/crypto.py +5 -0
- tplink_deco/exceptions/transport.py +7 -0
- tplink_deco/models/__init__.py +35 -0
- tplink_deco/models/_utils.py +11 -0
- tplink_deco/models/client_device.py +45 -0
- tplink_deco/models/device.py +62 -0
- tplink_deco/models/device_mode.py +17 -0
- tplink_deco/models/login_result.py +7 -0
- tplink_deco/models/performance.py +15 -0
- tplink_deco/models/rsa_key.py +11 -0
- tplink_deco/models/session_keys.py +9 -0
- tplink_deco/models/signal_level.py +16 -0
- tplink_deco/models/wlan_config.py +131 -0
- tplink_deco_api-0.1.0.dist-info/METADATA +79 -0
- tplink_deco_api-0.1.0.dist-info/RECORD +31 -0
- tplink_deco_api-0.1.0.dist-info/WHEEL +4 -0
tplink_deco/__init__.py
ADDED
|
@@ -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,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,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,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")
|
tplink_deco/endpoints.py
ADDED
|
@@ -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,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,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,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,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,,
|