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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ aiohttp
2
+ cryptography
@@ -0,0 +1 @@
1
+ tuya_mobile