tuya-mobile 1.0.0__tar.gz
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.
- tuya_mobile-1.0.0/PKG-INFO +66 -0
- tuya_mobile-1.0.0/README.md +55 -0
- tuya_mobile-1.0.0/pyproject.toml +21 -0
- tuya_mobile-1.0.0/setup.cfg +4 -0
- tuya_mobile-1.0.0/tuya_mobile/__init__.py +31 -0
- tuya_mobile-1.0.0/tuya_mobile/client.py +219 -0
- tuya_mobile-1.0.0/tuya_mobile/mqtt_auth.py +78 -0
- tuya_mobile-1.0.0/tuya_mobile/signer.py +186 -0
- tuya_mobile-1.0.0/tuya_mobile.egg-info/PKG-INFO +66 -0
- tuya_mobile-1.0.0/tuya_mobile.egg-info/SOURCES.txt +11 -0
- tuya_mobile-1.0.0/tuya_mobile.egg-info/dependency_links.txt +1 -0
- tuya_mobile-1.0.0/tuya_mobile.egg-info/requires.txt +2 -0
- tuya_mobile-1.0.0/tuya_mobile.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tuya-mobile
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pure-Python Tuya mobile-app API signer, client, and MQTT signaling credentials (no native libs, no qemu)
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/abovecolin/tuya-mobile
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: aiohttp
|
|
10
|
+
Requires-Dist: cryptography
|
|
11
|
+
|
|
12
|
+
# tuya-mobile
|
|
13
|
+
|
|
14
|
+
Pure-Python reimplementation of Tuya's **mobile-app** API security layer —
|
|
15
|
+
request signing (`thing_security`), the encrypted mobile API client, and the
|
|
16
|
+
MQTT signaling credential derivation.
|
|
17
|
+
|
|
18
|
+
Tuya apps sign their mobile API requests with a native library
|
|
19
|
+
(`libthing_security.so`). This package reimplements that algorithm in pure
|
|
20
|
+
Python (HMAC-SHA256 / SHA256 / MD5 over ASCII) — so you can call the Tuya mobile
|
|
21
|
+
API with **no external signer service, no qemu, and no native `.so`**.
|
|
22
|
+
|
|
23
|
+
It is **generic across Tuya-based apps**: only a handful of *application*
|
|
24
|
+
constants differ per app (extracted from that app's APK). Supply them and it
|
|
25
|
+
works.
|
|
26
|
+
|
|
27
|
+
## What it provides
|
|
28
|
+
|
|
29
|
+
- **`PurePythonTuyaSigner(app_id, app_secret, cert_sha256_hex, app_key, package)`**
|
|
30
|
+
— `sign(canonical)`, `derive_key(request_id, ecode)`, `channel_key()`.
|
|
31
|
+
- **`TuyaMobileClient(signer, session)`** — the encrypted mobile API flow
|
|
32
|
+
(`thing.m.user.third.login`, signed/encrypted `_call`, local-key retrieval,
|
|
33
|
+
cloud DP get/publish).
|
|
34
|
+
- **`mqtt_credentials(signer, uid=…, ecode=…, partner_id=…)`** — MQTT broker
|
|
35
|
+
username/password + signaling topics for the `smart/mb` channel.
|
|
36
|
+
- **`mqtt_client_id(package)`** — isolated mobile-format client ID for a
|
|
37
|
+
secondary client such as a local bridge.
|
|
38
|
+
- **`NativeTuyaSigner`** — optional legacy fallback that shells out to an
|
|
39
|
+
external signer (executable or HTTP), for parity/testing.
|
|
40
|
+
|
|
41
|
+
## App credentials
|
|
42
|
+
|
|
43
|
+
The five app constants (`app_id`, `app_secret`, `cert_sha256_hex`, `app_key`,
|
|
44
|
+
`package`) are the only app-specific inputs; the algorithm is identical across
|
|
45
|
+
Tuya apps. This package intentionally ships **no** vendor credentials — the
|
|
46
|
+
caller supplies them (e.g. `petsseries` supplies the Philips Pet Series values).
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import aiohttp
|
|
52
|
+
from tuya_mobile import PurePythonTuyaSigner, TuyaMobileClient
|
|
53
|
+
|
|
54
|
+
signer = PurePythonTuyaSigner(
|
|
55
|
+
app_id="…", app_secret="…", cert_sha256_hex="…", app_key="…",
|
|
56
|
+
package="com.example.app",
|
|
57
|
+
)
|
|
58
|
+
async with aiohttp.ClientSession() as session:
|
|
59
|
+
client = TuyaMobileClient(signer, session)
|
|
60
|
+
await client.login_with_jwt(id_token, country_code="1", platform="…")
|
|
61
|
+
status = await client.get_device_status(device_id)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# tuya-mobile
|
|
2
|
+
|
|
3
|
+
Pure-Python reimplementation of Tuya's **mobile-app** API security layer —
|
|
4
|
+
request signing (`thing_security`), the encrypted mobile API client, and the
|
|
5
|
+
MQTT signaling credential derivation.
|
|
6
|
+
|
|
7
|
+
Tuya apps sign their mobile API requests with a native library
|
|
8
|
+
(`libthing_security.so`). This package reimplements that algorithm in pure
|
|
9
|
+
Python (HMAC-SHA256 / SHA256 / MD5 over ASCII) — so you can call the Tuya mobile
|
|
10
|
+
API with **no external signer service, no qemu, and no native `.so`**.
|
|
11
|
+
|
|
12
|
+
It is **generic across Tuya-based apps**: only a handful of *application*
|
|
13
|
+
constants differ per app (extracted from that app's APK). Supply them and it
|
|
14
|
+
works.
|
|
15
|
+
|
|
16
|
+
## What it provides
|
|
17
|
+
|
|
18
|
+
- **`PurePythonTuyaSigner(app_id, app_secret, cert_sha256_hex, app_key, package)`**
|
|
19
|
+
— `sign(canonical)`, `derive_key(request_id, ecode)`, `channel_key()`.
|
|
20
|
+
- **`TuyaMobileClient(signer, session)`** — the encrypted mobile API flow
|
|
21
|
+
(`thing.m.user.third.login`, signed/encrypted `_call`, local-key retrieval,
|
|
22
|
+
cloud DP get/publish).
|
|
23
|
+
- **`mqtt_credentials(signer, uid=…, ecode=…, partner_id=…)`** — MQTT broker
|
|
24
|
+
username/password + signaling topics for the `smart/mb` channel.
|
|
25
|
+
- **`mqtt_client_id(package)`** — isolated mobile-format client ID for a
|
|
26
|
+
secondary client such as a local bridge.
|
|
27
|
+
- **`NativeTuyaSigner`** — optional legacy fallback that shells out to an
|
|
28
|
+
external signer (executable or HTTP), for parity/testing.
|
|
29
|
+
|
|
30
|
+
## App credentials
|
|
31
|
+
|
|
32
|
+
The five app constants (`app_id`, `app_secret`, `cert_sha256_hex`, `app_key`,
|
|
33
|
+
`package`) are the only app-specific inputs; the algorithm is identical across
|
|
34
|
+
Tuya apps. This package intentionally ships **no** vendor credentials — the
|
|
35
|
+
caller supplies them (e.g. `petsseries` supplies the Philips Pet Series values).
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import aiohttp
|
|
41
|
+
from tuya_mobile import PurePythonTuyaSigner, TuyaMobileClient
|
|
42
|
+
|
|
43
|
+
signer = PurePythonTuyaSigner(
|
|
44
|
+
app_id="…", app_secret="…", cert_sha256_hex="…", app_key="…",
|
|
45
|
+
package="com.example.app",
|
|
46
|
+
)
|
|
47
|
+
async with aiohttp.ClientSession() as session:
|
|
48
|
+
client = TuyaMobileClient(signer, session)
|
|
49
|
+
await client.login_with_jwt(id_token, country_code="1", platform="…")
|
|
50
|
+
status = await client.get_device_status(device_id)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tuya-mobile"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Pure-Python Tuya mobile-app API signer, client, and MQTT signaling credentials (no native libs, no qemu)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"aiohttp",
|
|
14
|
+
"cryptography",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/abovecolin/tuya-mobile"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
include = ["tuya_mobile*"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""tuya-mobile: pure-Python Tuya *mobile* API signer + client.
|
|
2
|
+
|
|
3
|
+
A dependency-free reimplementation of Tuya's ``thing_security`` mobile-app
|
|
4
|
+
request signing, the encrypted mobile API client, and the MQTT signaling
|
|
5
|
+
credential derivation. Generic across Tuya-based apps — supply your app's Tuya
|
|
6
|
+
application credentials (extracted from its APK) and it works with no external
|
|
7
|
+
signer service, no qemu, and no native libraries.
|
|
8
|
+
"""
|
|
9
|
+
from .signer import (
|
|
10
|
+
NativeSignerError,
|
|
11
|
+
NativeTuyaSigner,
|
|
12
|
+
PurePythonTuyaSigner,
|
|
13
|
+
colon_hex,
|
|
14
|
+
)
|
|
15
|
+
from .client import TuyaMobileClient, canonical_string
|
|
16
|
+
from .mqtt_auth import mqtt_client_id, mqtt_credentials, mqtt_password, mqtt_username
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"PurePythonTuyaSigner",
|
|
20
|
+
"NativeTuyaSigner",
|
|
21
|
+
"NativeSignerError",
|
|
22
|
+
"colon_hex",
|
|
23
|
+
"TuyaMobileClient",
|
|
24
|
+
"canonical_string",
|
|
25
|
+
"mqtt_credentials",
|
|
26
|
+
"mqtt_client_id",
|
|
27
|
+
"mqtt_username",
|
|
28
|
+
"mqtt_password",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Encrypted Tuya *mobile* API client (the flow used by Tuya Android apps).
|
|
2
|
+
|
|
3
|
+
Generic across Tuya apps: the request signing / session-key derivation is
|
|
4
|
+
delegated to a signer (see :mod:`tuya_mobile.signer`), and the app identity
|
|
5
|
+
(``app_id``) comes from that signer or is passed explicitly. Nothing here is
|
|
6
|
+
vendor-specific.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import base64
|
|
12
|
+
import gzip
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
20
|
+
|
|
21
|
+
import aiohttp
|
|
22
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
SIGN_KEYS = {
|
|
27
|
+
"a", "appVersion", "chKey", "clientId", "deviceId", "et", "h5",
|
|
28
|
+
"h5Token", "lang", "lat", "lon", "n4h5", "os", "postData", "requestId",
|
|
29
|
+
"sid", "sp", "time", "ttid", "v",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _swap_md5(value: str) -> str:
|
|
34
|
+
digest = hashlib.md5(value.encode()).hexdigest()
|
|
35
|
+
return digest[8:16] + digest[0:8] + digest[24:32] + digest[16:24]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def canonical_string(params: Dict[str, str]) -> str:
|
|
39
|
+
parts = []
|
|
40
|
+
for key in sorted(params):
|
|
41
|
+
value = params[key]
|
|
42
|
+
if key in SIGN_KEYS and value:
|
|
43
|
+
if key == "postData":
|
|
44
|
+
value = _swap_md5(value)
|
|
45
|
+
parts.append(f"{key}={value}")
|
|
46
|
+
return "||".join(parts)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _encrypt(key: bytes, payload: Dict[str, Any]) -> str:
|
|
50
|
+
nonce = os.urandom(12)
|
|
51
|
+
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
|
|
52
|
+
return base64.b64encode(nonce + AESGCM(key).encrypt(nonce, body, None)).decode()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _decrypt(key: bytes, value: str) -> Dict[str, Any]:
|
|
56
|
+
raw = base64.b64decode(value)
|
|
57
|
+
plain = AESGCM(key).decrypt(raw[:12], raw[12:], None)
|
|
58
|
+
try:
|
|
59
|
+
plain = gzip.decompress(plain)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
return json.loads(plain.decode())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _walk(value: Any) -> Iterable[Dict[str, Any]]:
|
|
66
|
+
if isinstance(value, dict):
|
|
67
|
+
yield value
|
|
68
|
+
for child in value.values():
|
|
69
|
+
yield from _walk(child)
|
|
70
|
+
elif isinstance(value, list):
|
|
71
|
+
for child in value:
|
|
72
|
+
yield from _walk(child)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TuyaMobileClient:
|
|
76
|
+
"""Call the encrypted Tuya mobile endpoints used by Tuya Android apps."""
|
|
77
|
+
|
|
78
|
+
BASE_URL = "https://a1.tuyaeu.com/api.json"
|
|
79
|
+
APP_VERSION = "2.2.1"
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
signer,
|
|
84
|
+
session: aiohttp.ClientSession,
|
|
85
|
+
*,
|
|
86
|
+
app_id: Optional[str] = None,
|
|
87
|
+
device_id: str = "",
|
|
88
|
+
) -> None:
|
|
89
|
+
self.signer = signer
|
|
90
|
+
self.session = session
|
|
91
|
+
# App identity comes from the signer (which holds the app credentials)
|
|
92
|
+
# unless overridden explicitly.
|
|
93
|
+
self.app_id = app_id or getattr(signer, "app_id", "") or ""
|
|
94
|
+
self.sid: Optional[str] = None
|
|
95
|
+
self.ecode: Optional[str] = None
|
|
96
|
+
self.uid: Optional[str] = None
|
|
97
|
+
self.mobile_url = self.BASE_URL
|
|
98
|
+
# Tuya validates a stable installation ID; supply the captured value.
|
|
99
|
+
self.device_id = device_id or os.environ.get("PETSERIES_TUYA_DEVICE_ID", "")
|
|
100
|
+
|
|
101
|
+
async def _call(self, action: str, payload: Dict[str, Any], *, version: str = "1.0") -> Dict[str, Any]:
|
|
102
|
+
request_id = str(uuid.uuid4())
|
|
103
|
+
key = await asyncio.to_thread(self.signer.derive_key, request_id, self.ecode)
|
|
104
|
+
encrypted = _encrypt(key, payload)
|
|
105
|
+
params: Dict[str, str] = {
|
|
106
|
+
"a": action,
|
|
107
|
+
"v": version,
|
|
108
|
+
"clientId": self.app_id,
|
|
109
|
+
"os": "Android",
|
|
110
|
+
"appVersion": self.APP_VERSION,
|
|
111
|
+
"channel": "sdk",
|
|
112
|
+
"osSystem": "14",
|
|
113
|
+
"sdkVersion": "6.7.0",
|
|
114
|
+
"deviceCoreVersion": "6.7.0",
|
|
115
|
+
"platform": "Pixel 7",
|
|
116
|
+
"timeZoneId": os.environ.get("PETSERIES_TUYA_TIMEZONE") or "UTC",
|
|
117
|
+
"cp": "gzip",
|
|
118
|
+
"nd": "1",
|
|
119
|
+
"bizDM": "ipc",
|
|
120
|
+
"lang": os.environ.get("PETSERIES_TUYA_LANG") or "en",
|
|
121
|
+
"ttid": "android",
|
|
122
|
+
"et": "3",
|
|
123
|
+
"chKey": await asyncio.to_thread(self.signer.channel_key),
|
|
124
|
+
"deviceId": self.device_id or self.uid or "",
|
|
125
|
+
"time": str(int(time.time())),
|
|
126
|
+
"requestId": request_id,
|
|
127
|
+
"postData": encrypted,
|
|
128
|
+
}
|
|
129
|
+
if self.sid:
|
|
130
|
+
params["sid"] = self.sid
|
|
131
|
+
params["sign"] = await asyncio.to_thread(self.signer.sign, canonical_string(params))
|
|
132
|
+
async with self.session.post(self.mobile_url, data=params) as response:
|
|
133
|
+
envelope = await response.json(content_type=None)
|
|
134
|
+
if "result" not in envelope:
|
|
135
|
+
error_code = envelope.get("errorCode") or envelope.get("code") or "unknown"
|
|
136
|
+
error_msg = envelope.get("errorMsg") or envelope.get("msg") or "no result"
|
|
137
|
+
raise RuntimeError(f"Tuya mobile API request {action} failed: {error_code} {error_msg}")
|
|
138
|
+
return _decrypt(key, envelope["result"])
|
|
139
|
+
|
|
140
|
+
async def login_with_jwt(self, id_token: str, country_code: str = "",
|
|
141
|
+
platform: str = "PhilipsDA") -> Dict[str, Any]:
|
|
142
|
+
"""Third-party (JWT) login. ``platform`` is the app's third-party tag."""
|
|
143
|
+
result = await self._call(
|
|
144
|
+
"thing.m.user.third.login",
|
|
145
|
+
{
|
|
146
|
+
"countryCode": country_code or os.environ.get("PETSERIES_TUYA_COUNTRY_CODE") or "1",
|
|
147
|
+
"accessToken": id_token,
|
|
148
|
+
"type": "jwt",
|
|
149
|
+
"extraInfo": json.dumps({"platform": platform}),
|
|
150
|
+
"options": '{"group": 1}',
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
data: Dict[str, Any] = result
|
|
154
|
+
while isinstance(data.get("result"), dict):
|
|
155
|
+
data = data["result"]
|
|
156
|
+
self.sid = data.get("sid") or data.get("session") or data.get("sessionId")
|
|
157
|
+
self.ecode = data.get("ecode") or data.get("eCode") or data.get("encryptCode")
|
|
158
|
+
self.uid = data.get("uid") or data.get("userId")
|
|
159
|
+
if data.get("success") is False or data.get("errorCode") or data.get("errorMsg"):
|
|
160
|
+
raise RuntimeError(
|
|
161
|
+
"Tuya third-party login failed: "
|
|
162
|
+
f"{data.get('errorCode') or 'unknown'} {data.get('errorMsg') or ''}".strip()
|
|
163
|
+
)
|
|
164
|
+
if not isinstance(self.ecode, str) or not self.ecode:
|
|
165
|
+
raise RuntimeError("Tuya third-party login returned no encryption code")
|
|
166
|
+
mobile_url = data.get("domain", {}).get("mobileApiUrl") or data.get("mobileApiUrl")
|
|
167
|
+
if mobile_url:
|
|
168
|
+
self.mobile_url = mobile_url.rstrip("/") + "/api.json"
|
|
169
|
+
if not self.sid:
|
|
170
|
+
raise RuntimeError("Tuya third-party login returned no session")
|
|
171
|
+
return data
|
|
172
|
+
|
|
173
|
+
# Back-compat alias (petsseries historically called this name).
|
|
174
|
+
login_with_philips_token = login_with_jwt
|
|
175
|
+
|
|
176
|
+
async def get_local_keys(self, device_ids: List[str]) -> List[Dict[str, Any]]:
|
|
177
|
+
homes = await self._call("m.life.home.space.list", {})
|
|
178
|
+
wanted = set(device_ids)
|
|
179
|
+
records = []
|
|
180
|
+
gids = {
|
|
181
|
+
obj.get("gid") or obj.get("groupId") or obj.get("homeId")
|
|
182
|
+
for obj in _walk(homes)
|
|
183
|
+
if obj.get("gid") or obj.get("groupId") or obj.get("homeId")
|
|
184
|
+
}
|
|
185
|
+
for gid in gids:
|
|
186
|
+
response = await self._call("m.life.my.group.device.list", {"gid": gid}, version="2.2")
|
|
187
|
+
for obj in _walk(response):
|
|
188
|
+
local_key = obj.get("localKey") or obj.get("local_key")
|
|
189
|
+
device_id = obj.get("devId") or obj.get("deviceId") or obj.get("id")
|
|
190
|
+
if local_key and (not wanted or str(device_id) in wanted):
|
|
191
|
+
records.append({
|
|
192
|
+
"device_id": device_id, "local_key": local_key,
|
|
193
|
+
"ip": obj.get("ip") or obj.get("lanIp"), "name": obj.get("name"),
|
|
194
|
+
"uuid": obj.get("uuid"),
|
|
195
|
+
"is_online": obj.get("isOnline", obj.get("cloudOnline")),
|
|
196
|
+
})
|
|
197
|
+
unique = {(str(i.get("device_id")), str(i.get("local_key"))): i for i in records}
|
|
198
|
+
return list(unique.values())
|
|
199
|
+
|
|
200
|
+
async def get_device_status(self, device_id: str, gateway_id: str = "") -> Dict[str, Any]:
|
|
201
|
+
result = await self._call("s.m.dev.dp.get", {"devId": device_id, "gwId": gateway_id})
|
|
202
|
+
if result.get("success") is False or result.get("errorCode") or result.get("errorMsg"):
|
|
203
|
+
raise RuntimeError(
|
|
204
|
+
"Tuya status request failed: "
|
|
205
|
+
f"{result.get('errorCode') or 'unknown'} {result.get('errorMsg') or ''}".strip()
|
|
206
|
+
)
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
async def publish_dps(self, device_id: str, dps: Dict[str, Any], gateway_id: str = "") -> Dict[str, Any]:
|
|
210
|
+
result = await self._call(
|
|
211
|
+
"thing.m.device.dp.publish",
|
|
212
|
+
{"devId": device_id, "gwId": gateway_id, "dps": json.dumps(dps, separators=(",", ":"))},
|
|
213
|
+
)
|
|
214
|
+
if result.get("success") is False or result.get("errorCode") or result.get("errorMsg"):
|
|
215
|
+
raise RuntimeError(
|
|
216
|
+
"Tuya DP publish failed: "
|
|
217
|
+
f"{result.get('errorCode') or 'unknown'} {result.get('errorMsg') or ''}".strip()
|
|
218
|
+
)
|
|
219
|
+
return result
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Tuya mobile MQTT signaling credentials (pure Python).
|
|
2
|
+
|
|
3
|
+
Implements the Tuya Android SDK scheme (``com.thingclips.sdk.mqtt``):
|
|
4
|
+
the MQTT broker username/password are a straightforward MD5-hex derivation over
|
|
5
|
+
session fields — no native code involved (``MD5Util.md5AsBase64`` is misnamed;
|
|
6
|
+
it returns MD5 *hex*). Source references:
|
|
7
|
+
|
|
8
|
+
* password (``dbqqppp.qddqppb``): ``md5hex(ecode)[8:24]``
|
|
9
|
+
* username (``dbpdpbp.bdpdqbp``):
|
|
10
|
+
``partner_id + "_v1_" + app_id + "_" + chKey + "_mb_" + token +
|
|
11
|
+
md5hex(md5hex(app_id) + ecode)[-16:]``
|
|
12
|
+
|
|
13
|
+
``chKey`` is the signer's ``channel_key()``; ``token`` is the session token
|
|
14
|
+
(the login ``uid`` in observed traffic); ``partner_id`` is the app's Tuya
|
|
15
|
+
partner prefix (e.g. ``p2065237`` for the Philips app).
|
|
16
|
+
|
|
17
|
+
NOTE: the MQTT ``client_id`` derivation (ends ``_DEFAULT``) is not yet confirmed
|
|
18
|
+
byte-for-byte and is validated during camera-bridge bring-up.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import secrets
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _md5hex(value: str) -> str:
|
|
28
|
+
return hashlib.md5(value.encode()).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def mqtt_password(global_material: str, ecode: str) -> str:
|
|
32
|
+
"""16-char MQTT password.
|
|
33
|
+
|
|
34
|
+
Verified against a live device capture: the SDK's native command 2 computes
|
|
35
|
+
``md5(md5(G) + ecode)`` (32 hex) and takes its middle 16 chars, where ``G``
|
|
36
|
+
is the signer's global key material (package_colonCert_appKey_appSecret).
|
|
37
|
+
"""
|
|
38
|
+
return _md5hex(_md5hex(global_material) + ecode)[8:24]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def mqtt_username(app_id: str, ch_key: str, sid: str, ecode: str,
|
|
42
|
+
partner_id: str) -> str:
|
|
43
|
+
"""MQTT username for the ``smart/mb`` signaling channel.
|
|
44
|
+
|
|
45
|
+
Verified against a live device capture: the ``_mb_`` body is the session
|
|
46
|
+
``sid`` followed by ``md5(md5(app_id) + ecode)[-16:]`` (the "token" the SDK
|
|
47
|
+
concatenates is the sid, not the uid).
|
|
48
|
+
"""
|
|
49
|
+
tail = _md5hex(_md5hex(app_id) + ecode)[-16:]
|
|
50
|
+
return f"{partner_id}_v1_{app_id}_{ch_key}_mb_{sid}{tail}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def mqtt_client_id(package: str, *, installation_id: Optional[str] = None,
|
|
54
|
+
tag: str = "DEFAULT") -> str:
|
|
55
|
+
"""Create a mobile MQTT client ID in the SDK's accepted shape.
|
|
56
|
+
|
|
57
|
+
The opaque installation component is not an account credential. Generating
|
|
58
|
+
a fresh one lets a local bridge coexist with the official app.
|
|
59
|
+
"""
|
|
60
|
+
install = installation_id or secrets.token_hex(24)
|
|
61
|
+
return f"{package}_mb_{install}_{tag}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def mqtt_credentials(signer, *, uid: str, sid: str, ecode: str,
|
|
65
|
+
partner_id: str) -> dict:
|
|
66
|
+
"""Build the MQTT signaling credentials from a signer + session fields.
|
|
67
|
+
|
|
68
|
+
``signer`` supplies ``app_id`` and ``channel_key()``. The username embeds the
|
|
69
|
+
session ``sid``; the topics use the ``uid``.
|
|
70
|
+
"""
|
|
71
|
+
ch_key = signer.channel_key()
|
|
72
|
+
return {
|
|
73
|
+
"username": mqtt_username(signer.app_id, ch_key, sid, ecode, partner_id),
|
|
74
|
+
"password": mqtt_password(signer.global_material(), ecode),
|
|
75
|
+
# Subscribe/publish topics for the mobile signaling channel.
|
|
76
|
+
"publish_topic": f"smart/mb/in/{uid}",
|
|
77
|
+
"subscribe_topic": f"smart/mb/out/{uid}",
|
|
78
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Pure-Python reimplementation of Tuya's ``thing_security`` mobile signer.
|
|
2
|
+
|
|
3
|
+
Implements the Tuya mobile signing scheme:
|
|
4
|
+
computes three things, and all three are plain HMAC-SHA256 / SHA256 over ASCII
|
|
5
|
+
inputs — there is no whitebox cipher and no native dependency. This lets any
|
|
6
|
+
Tuya *mobile* app's API be called with no external signer, no qemu, and no
|
|
7
|
+
native blobs.
|
|
8
|
+
|
|
9
|
+
Everything hangs off one "global key material" string::
|
|
10
|
+
|
|
11
|
+
G = package + "_" + colon_hex(cert) + "_" + app_key + "_" + app_secret
|
|
12
|
+
|
|
13
|
+
Primitives (ASCII in/out):
|
|
14
|
+
|
|
15
|
+
* ``channel_key()`` -> ``HMAC_SHA256(app_id, package + "_" + colon_hex(cert)).hex()[8:16]``
|
|
16
|
+
* ``derive_key(request_id, ecode)`` -> 16-byte AES key = ASCII of
|
|
17
|
+
``HMAC_SHA256(request_id, G [+ "_" + ecode]).hex()[:16]``
|
|
18
|
+
* ``sign(canonical)`` -> ``HMAC_SHA256(SHA256(G), canonical).hex()``
|
|
19
|
+
|
|
20
|
+
The signer is **parameterized by the app's Tuya application credentials**
|
|
21
|
+
(``app_id``, ``app_secret``, ``cert_sha256_hex``, ``app_key``, ``package``).
|
|
22
|
+
These are constant per app (extracted from that app's APK) and are the only
|
|
23
|
+
app-specific inputs — the algorithm itself is generic across Tuya apps.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import hmac
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import subprocess
|
|
32
|
+
import urllib.request
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NativeSignerError(RuntimeError):
|
|
38
|
+
"""A configured external signer failed or returned invalid output."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def colon_hex(cert_sha256_hex: str) -> str:
|
|
42
|
+
"""Render a cert SHA-256 as uppercase, colon-separated hex (Android style)."""
|
|
43
|
+
h = cert_sha256_hex.replace(":", "").strip()
|
|
44
|
+
return ":".join(h[i : i + 2] for i in range(0, len(h), 2)).upper()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PurePythonTuyaSigner:
|
|
48
|
+
"""Drop-in ``thing_security`` signer implemented in pure Python."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
app_id: str,
|
|
53
|
+
app_secret: str,
|
|
54
|
+
cert_sha256_hex: str,
|
|
55
|
+
app_key: str,
|
|
56
|
+
package: str,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.app_id = app_id
|
|
59
|
+
self.app_secret = app_secret
|
|
60
|
+
self.cert_sha256_hex = cert_sha256_hex
|
|
61
|
+
self.app_key = app_key
|
|
62
|
+
self.package = package
|
|
63
|
+
|
|
64
|
+
# -- internal helpers ------------------------------------------------
|
|
65
|
+
def cert_msg(self) -> str:
|
|
66
|
+
return f"{self.package}_{colon_hex(self.cert_sha256_hex)}"
|
|
67
|
+
|
|
68
|
+
def global_material(self) -> str:
|
|
69
|
+
"""The shared "global key material" string G."""
|
|
70
|
+
return f"{self.cert_msg()}_{self.app_key}_{self.app_secret}"
|
|
71
|
+
|
|
72
|
+
def _signing_key(self) -> bytes:
|
|
73
|
+
return hashlib.sha256(self.global_material().encode()).digest()
|
|
74
|
+
|
|
75
|
+
# -- public interface ------------------------------------------------
|
|
76
|
+
def sign(self, canonical: str) -> str:
|
|
77
|
+
"""Sign a canonical request string -> lowercase hex HMAC-SHA256."""
|
|
78
|
+
return hmac.new(
|
|
79
|
+
self._signing_key(), canonical.encode(), hashlib.sha256
|
|
80
|
+
).hexdigest()
|
|
81
|
+
|
|
82
|
+
def derive_key(self, request_id: str, ecode: Optional[str]) -> bytes:
|
|
83
|
+
"""Return the 16-byte per-request AES key."""
|
|
84
|
+
g = self.global_material()
|
|
85
|
+
message = g if not ecode else f"{g}_{ecode}"
|
|
86
|
+
digest = hmac.new(
|
|
87
|
+
request_id.encode(), message.encode(), hashlib.sha256
|
|
88
|
+
).hexdigest()
|
|
89
|
+
return digest[:16].encode()
|
|
90
|
+
|
|
91
|
+
def channel_key(self) -> str:
|
|
92
|
+
"""Return the account/app-global channel key (chKey)."""
|
|
93
|
+
return hmac.new(
|
|
94
|
+
self.app_id.encode(), self.cert_msg().encode(), hashlib.sha256
|
|
95
|
+
).hexdigest()[8:16]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NativeTuyaSigner:
|
|
99
|
+
"""Legacy adapter that shells out to an external signer (executable or HTTP).
|
|
100
|
+
|
|
101
|
+
Kept only as a fallback for environments that prefer to delegate signing to
|
|
102
|
+
an out-of-process ``thing_security`` (e.g. a qemu-hosted native lib). The
|
|
103
|
+
pure-Python signer above is preferred and needs none of this.
|
|
104
|
+
|
|
105
|
+
The command must implement: ``sign`` (canonical on stdin -> hex),
|
|
106
|
+
``key <request_id> <ecode>`` (-> hex AES key), ``chkey`` (-> channel key).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
command: "str | os.PathLike[str]",
|
|
112
|
+
*,
|
|
113
|
+
app_id: str,
|
|
114
|
+
app_secret: str,
|
|
115
|
+
cert_sha256: str,
|
|
116
|
+
key_global: Optional[str] = None,
|
|
117
|
+
android_root: "Optional[str | os.PathLike[str]]" = None,
|
|
118
|
+
service_token: str = "",
|
|
119
|
+
) -> None:
|
|
120
|
+
raw = str(command)
|
|
121
|
+
self.command = raw if raw.startswith(("http://", "https://")) else str(Path(raw).expanduser())
|
|
122
|
+
self.android_root = str(android_root) if android_root else None
|
|
123
|
+
self.service_token = service_token or os.environ.get("TUYA_SIGNER_SERVICE_TOKEN", "")
|
|
124
|
+
self.environment = os.environ.copy()
|
|
125
|
+
self.environment.update(
|
|
126
|
+
TUYA_APP_ID=app_id, TUYA_APP_SECRET=app_secret, TUYA_CERT_SHA256_HEX=cert_sha256
|
|
127
|
+
)
|
|
128
|
+
if key_global:
|
|
129
|
+
self.environment["TUYA_KEY_GLOBAL"] = key_global
|
|
130
|
+
|
|
131
|
+
def _run(self, operation: str, *arguments: str, stdin: Optional[str] = None,
|
|
132
|
+
extra_env: Optional[dict] = None) -> str:
|
|
133
|
+
if self.command.startswith(("http://", "https://")):
|
|
134
|
+
payload = {
|
|
135
|
+
"operation": operation, "arguments": list(arguments),
|
|
136
|
+
"stdin": stdin or "", "canonical": (extra_env or {}).get("TUYA_CANONICAL_STRING", ""),
|
|
137
|
+
}
|
|
138
|
+
req = urllib.request.Request(
|
|
139
|
+
self.command, data=json.dumps(payload).encode(),
|
|
140
|
+
headers={"Content-Type": "application/json",
|
|
141
|
+
"Authorization": f"Bearer {self.service_token}"},
|
|
142
|
+
method="POST",
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
with urllib.request.urlopen(req, timeout=25) as resp:
|
|
146
|
+
result = json.loads(resp.read())
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
raise NativeSignerError(f"Signer service request failed: {exc}") from exc
|
|
149
|
+
if not result.get("ok"):
|
|
150
|
+
raise NativeSignerError(result.get("error", "Signer service failed"))
|
|
151
|
+
value = str(result.get("value", "")).strip()
|
|
152
|
+
if not value:
|
|
153
|
+
raise NativeSignerError(f"Signer op {operation!r} returned no output")
|
|
154
|
+
return value
|
|
155
|
+
env = self.environment.copy()
|
|
156
|
+
if extra_env:
|
|
157
|
+
env.update(extra_env)
|
|
158
|
+
if self.android_root:
|
|
159
|
+
env["PETSERIES_TUYA_ANDROID_ROOT"] = self.android_root
|
|
160
|
+
try:
|
|
161
|
+
done = subprocess.run([self.command, operation, *arguments], input=stdin,
|
|
162
|
+
text=True, capture_output=True, check=False, env=env, timeout=20)
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
raise NativeSignerError(f"Unable to execute signer: {exc}") from exc
|
|
165
|
+
if done.returncode:
|
|
166
|
+
raise NativeSignerError(f"Signer op {operation!r} failed: {done.stderr.strip()[-400:]}")
|
|
167
|
+
value = done.stdout.strip()
|
|
168
|
+
if not value:
|
|
169
|
+
raise NativeSignerError(f"Signer op {operation!r} returned no output")
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
def sign(self, canonical: str) -> str:
|
|
173
|
+
return self._run("sign", extra_env={"TUYA_CANONICAL_STRING": canonical})
|
|
174
|
+
|
|
175
|
+
def derive_key(self, request_id: str, ecode: Optional[str]) -> bytes:
|
|
176
|
+
value = self._run("key", request_id, ecode or "-")
|
|
177
|
+
try:
|
|
178
|
+
result = bytes.fromhex(value)
|
|
179
|
+
except ValueError as exc:
|
|
180
|
+
raise NativeSignerError("Signer returned an invalid key") from exc
|
|
181
|
+
if len(result) not in (16, 24, 32):
|
|
182
|
+
raise NativeSignerError("Signer returned an unexpected key length")
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
def channel_key(self) -> str:
|
|
186
|
+
return self._run("chkey")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tuya-mobile
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pure-Python Tuya mobile-app API signer, client, and MQTT signaling credentials (no native libs, no qemu)
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/abovecolin/tuya-mobile
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: aiohttp
|
|
10
|
+
Requires-Dist: cryptography
|
|
11
|
+
|
|
12
|
+
# tuya-mobile
|
|
13
|
+
|
|
14
|
+
Pure-Python reimplementation of Tuya's **mobile-app** API security layer —
|
|
15
|
+
request signing (`thing_security`), the encrypted mobile API client, and the
|
|
16
|
+
MQTT signaling credential derivation.
|
|
17
|
+
|
|
18
|
+
Tuya apps sign their mobile API requests with a native library
|
|
19
|
+
(`libthing_security.so`). This package reimplements that algorithm in pure
|
|
20
|
+
Python (HMAC-SHA256 / SHA256 / MD5 over ASCII) — so you can call the Tuya mobile
|
|
21
|
+
API with **no external signer service, no qemu, and no native `.so`**.
|
|
22
|
+
|
|
23
|
+
It is **generic across Tuya-based apps**: only a handful of *application*
|
|
24
|
+
constants differ per app (extracted from that app's APK). Supply them and it
|
|
25
|
+
works.
|
|
26
|
+
|
|
27
|
+
## What it provides
|
|
28
|
+
|
|
29
|
+
- **`PurePythonTuyaSigner(app_id, app_secret, cert_sha256_hex, app_key, package)`**
|
|
30
|
+
— `sign(canonical)`, `derive_key(request_id, ecode)`, `channel_key()`.
|
|
31
|
+
- **`TuyaMobileClient(signer, session)`** — the encrypted mobile API flow
|
|
32
|
+
(`thing.m.user.third.login`, signed/encrypted `_call`, local-key retrieval,
|
|
33
|
+
cloud DP get/publish).
|
|
34
|
+
- **`mqtt_credentials(signer, uid=…, ecode=…, partner_id=…)`** — MQTT broker
|
|
35
|
+
username/password + signaling topics for the `smart/mb` channel.
|
|
36
|
+
- **`mqtt_client_id(package)`** — isolated mobile-format client ID for a
|
|
37
|
+
secondary client such as a local bridge.
|
|
38
|
+
- **`NativeTuyaSigner`** — optional legacy fallback that shells out to an
|
|
39
|
+
external signer (executable or HTTP), for parity/testing.
|
|
40
|
+
|
|
41
|
+
## App credentials
|
|
42
|
+
|
|
43
|
+
The five app constants (`app_id`, `app_secret`, `cert_sha256_hex`, `app_key`,
|
|
44
|
+
`package`) are the only app-specific inputs; the algorithm is identical across
|
|
45
|
+
Tuya apps. This package intentionally ships **no** vendor credentials — the
|
|
46
|
+
caller supplies them (e.g. `petsseries` supplies the Philips Pet Series values).
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import aiohttp
|
|
52
|
+
from tuya_mobile import PurePythonTuyaSigner, TuyaMobileClient
|
|
53
|
+
|
|
54
|
+
signer = PurePythonTuyaSigner(
|
|
55
|
+
app_id="…", app_secret="…", cert_sha256_hex="…", app_key="…",
|
|
56
|
+
package="com.example.app",
|
|
57
|
+
)
|
|
58
|
+
async with aiohttp.ClientSession() as session:
|
|
59
|
+
client = TuyaMobileClient(signer, session)
|
|
60
|
+
await client.login_with_jwt(id_token, country_code="1", platform="…")
|
|
61
|
+
status = await client.get_device_status(device_id)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tuya_mobile/__init__.py
|
|
4
|
+
tuya_mobile/client.py
|
|
5
|
+
tuya_mobile/mqtt_auth.py
|
|
6
|
+
tuya_mobile/signer.py
|
|
7
|
+
tuya_mobile.egg-info/PKG-INFO
|
|
8
|
+
tuya_mobile.egg-info/SOURCES.txt
|
|
9
|
+
tuya_mobile.egg-info/dependency_links.txt
|
|
10
|
+
tuya_mobile.egg-info/requires.txt
|
|
11
|
+
tuya_mobile.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tuya_mobile
|