This document explains how TinyTuya communicates locally with Tuya Wi‑Fi (and some Zigbee-over-gateway) devices across protocol revisions 3.1 → 3.5. It focuses on on‑LAN packet formats, encryption, session key negotiation, discovery, and command construction. (Cloud protocol is out of scope here.)
Supported revisions: 3.1, 3.2, 3.3, 3.4, 3.5. Each revision incrementally tightens security or changes framing.
Sections below are self‑contained; skim the one matching your device firmware (see discovery output or Device.set_version()).
| Version | Handshake | Transport Frame | Payload Crypto | Header Visibility | Integrity | Monitoring Requires Key? | Control Requires Key? |
|---|---|---|---|---|---|---|---|
| 3.1 | None | 55AA legacy | AES-ECB (CONTROL only) + MD5 slice | Version+MD5 prefix (CONTROL) | CRC32 | No | Yes (CONTROL) |
| 3.2* | None | 55AA legacy | AES-ECB all cmds (except some DP_QUERY clear header rules) | 3.x header (clear) after encryption | CRC32 | Yes | Yes |
| 3.3 | None | 55AA legacy | AES-ECB all cmds (like 3.2) | 3.x header (clear) after encryption | CRC32 | Yes | Yes |
| 3.4 | Session key (3-way) | 55AA legacy | AES-ECB (session key) | 3.x header INCLUDED INSIDE encryption | HMAC-SHA256 | Yes | Yes |
| 3.5 | Session key (3-way) | 6699 (GCM) | AES-GCM per packet (session key) | 3.x header inside ciphertext | AES-GCM Tag | Yes | Yes |
*3.2 devices are rare; behavior effectively mirrors a 3.3 "device22" (see device type notes).
Terminology: "device22" = devices whose gwId length is 22 chars requiring CONTROL_NEW (0x0d) for DP queries with an explicit DPS list.
| Ver | Frame | Integrity | Crypto Scope | Handshake | Version Header Visibility | Source Header | Retcode Location | Notable Command Quirk |
|---|---|---|---|---|---|---|---|---|
| 3.1 | 55AA | CRC32 | CONTROL only (AES-ECB+base64) | None | N/A (MD5 slice prefix only) | No | Clear field | Status plaintext, control encrypted |
| 3.2 | 55AA | CRC32 | All encryptable cmds (AES-ECB) | None | Clear (when present) | No | Clear field | May require device22 DP list |
| 3.3 | 55AA | CRC32 | All encryptable cmds (AES-ECB) | None | Clear | Clear (updates) | Clear field | Introduces Source header |
| 3.4 | 55AA | HMAC-SHA256 | All (AES-ECB session key) | 3-msg | Encrypted | Encrypted | Clear field (HMAC covered) | Session key negotiation |
| 3.5 | 6699 | GCM Tag | All (AES-GCM) | 3-msg | Encrypted | Encrypted | Inside ciphertext | Global seq; optional solicitation discovery |
Cross-reference: framing & packing logic lives in tinytuya/core/message_helper.py (pack_message, unpack_message); session negotiation in tinytuya/core/XenonDevice.py (_negotiate_session_key*); device22 detection in XenonDevice._decode_payload (look for "data unvalid").
0x07 CONTROL Set DP values
0x08 STATUS (legacy status report)
0x09 HEART_BEAT
0x0A DP_QUERY Query Data Points
0x0D CONTROL_NEW New control frame (used for device22 + 3.4+ override)
0x10 DP_QUERY_NEW New DP query (used in 3.4+/3.5 overrides)
0x12 UPDATEDPS Request async DP refresh
0x13 UDP_NEW Encrypted UDP discovery broadcast (port 6667)
0x23 BOARDCAST_LPV34 Discovery broadcast (v3.4)
0x25 REQ_DEVINFO Discovery solicitation request (UDP/7000, v3.5)
0x03/0x04/0x05 Session key negotiation (3.4+)
0x40 LAN_EXT_STREAM Extended (e.g., gateway subdevice ops)
Typical control payload (pre‑encryption) for a basic switch:
{"devId":"<id>", "uid":"<id>", "t":"<epoch>", "dps":{"1":true}}3.4+/3.5 reduce required identifiers; many fields become optional (see per‑version details). Sub‑devices (Zigbee via gateway) add cid and wrap DPS inside data.
<ascii version><12 x 0x00> (e.g. b"3.3\x00..." length = 3 + 12). Presence and encryption differs by version and command (skipped for DP_QUERY, DP_QUERY_NEW, UPDATEDPS, HEART_BEAT, session negotiation, LAN_EXT_STREAM).
For v3.3+ some messages (STATUS/DP updates, sometimes CONTROL responses) begin with a 15-byte prefix before the encrypted or decrypted JSON payload: the ASCII version followed by three 32-bit big-endian binary fields (same overall size and position as the all-zeros version header):
3.x | CCCC | SSSS | UUUU (3 ASCII bytes + 4 + 4 + 4 binary bytes = 15 bytes)
Where:
3.x– ASCII protocol version (e.g.3.3,3.4,3.5).CCCC– 32-bit CRC32 of message for cloud/MQTT sourced traffic; zero for LAN-sourced frames.SSSS– 32-bit per-source monotonic sequence counter (device discards repeats or lower values for sameU).UUUU– 32-bit unique source identifier. Device uses 1; clients should generate & persist a random non-zero value. Device caches only the last ~5 distinct IDs.
If the source identifier is 0, sequence checking is bypassed (all accepted). Header handling differs per version (clear vs encrypted) and is reiterated below per section for clarity. TinyTuya does not parse the individual fields; it simply strips the entire 15-byte block the same way it strips the all-zeros version header (XenonDevice._decode_payload).
| Header Type | Byte Pattern | Purpose | Introduced | Visible in Clear | Encrypted With JSON? |
|---|---|---|---|---|---|
| Version header | 3.x + 12 zero bytes |
Internal payload version marker | 3.2* | 3.2–3.3 yes (when present); 3.4+ no | 3.4+ yes |
| Source header | 3.x + CRC32(4) + seq(4) + source id(4) |
Per‑source sequencing & origin identity | 3.3 | 3.3 yes; 3.4+ no | 3.4+ yes |
| 3.1 CONTROL prefix | 3.1 + 16 MD5 hex slice |
Lightweight integrity tag for CONTROL | 3.1 | Yes | N/A (outer) |
- Practical appearance begins with 3.2/3.3 devices. 3.1 does not embed a version header (only the CONTROL MD5 prefix). 3.1 and 3.2 do NOT use the Source header; its presence implies 3.3+.
Oldest variant still common. Read (status / DP query) works without local key. Control requires key. No session negotiation. Only CONTROL payloads are encrypted.
- Handshake: None
- Frame: 55AA legacy
- Integrity: CRC32
- Crypto: AES-ECB (CONTROL only) + base64 + MD5 slice
- Version header: Not used
- Source header: Not used
- Monitoring requires key: No
- Control requires key: Yes
000055AA | seq (4B) | cmd (4B) | length (4B) | [retcode? 4B device->client] | payload... | CRC32 (4B) | 0000AA55
length counts from first byte of (optional) retcode through and including footer.
- JSON -> UTF-8 bytes (e.g.
{"dps":{"1":true}}with ids/time fields added). - AES-128-ECB encrypt; result base64 encoded (library default for 3.1 CONTROL).
- Compute MD5 over:
b"data=" + base64payload + b"||lpv=3.1||" + local_key. - Take hexdigest slice
[8:24](16 chars) -> bytes. - Final payload:
b"3.1" + md5slice + base64payload. - Insert into 55AA frame with CRC32.
STATUS / DP_QUERY are plaintext JSON (no version header, no encryption, CRC32 only).
If payload starts with b"3.1", strip version + 16 md5 chars, base64‑decode remainder, AES‑ECB decrypt, unpad -> JSON.
Not present (introduced later in 3.3). If you see a source header, firmware is not 3.1.
v3.1 devices announce themselves with plaintext JSON broadcasts on UDP/6666 — no key needed to read them. (Encrypted UDP/6667 broadcasts belong to 3.3+ firmware; see those sections.)
import tinytuya
d = tinytuya.Device(dev_id, ip, local_key)
d.set_version(3.1)
print(d.status()) # Works without key (monitoring)
d.set_value(1, True) # Requires key (CONTROL encryption path)- MD5 slice is not an HMAC; integrity is weak.
- Replay protection minimal (sequence only at frame level; device accepts duplicates readily).
Rare transitional variant; behaves almost identically to early 3.3 devices (often collapsing into device22 handling). No session negotiation.
- Handshake: None
- Frame: 55AA legacy
- Integrity: CRC32
- Crypto: AES-ECB (all encryptable commands)
- Version header: Present (clear) when not skipped
- Source header: Not used
- Monitoring requires key: Yes
- Control requires key: Yes
55AA frame identical to 3.3: seq | cmd | len | (retcode on replies) | payload | CRC32 | footer. CRC32 covers prefix through final payload byte (before CRC field).
Build JSON → AES-ECB (local key) → prepend clear version header b"3.2" + 12*0x00 (unless command in skip list: DP_QUERY, DP_QUERY_NEW, UPDATEDPS, HEART_BEAT, LAN_EXT_STREAM; handshake not applicable) → frame.
Not used in 3.2. Seeing one implies the device is actually 3.3+.
Most 3.2 devices require explicit DPS list via CONTROL_NEW (device22 pattern). TinyTuya does not wait for a data unvalid error here: set_version(3.2) unconditionally sets dev_type='device22' and, if no DPS are known yet, immediately brute-force scans for them (detect_available_dps()), switching the query command & inserting a null-valued DPS map. (The data unvalid auto-detection is only used for 3.3/3.4.)
import tinytuya
dev = tinytuya.Device(dev_id, ip, local_key)
dev.set_version(3.2)
print(dev.status()) # Encrypted (requires key)
dev.set_value(1, True) # CONTROL- Treated like a 3.3 variant internally, but always forced into device22 mode (with an up-front DPS brute-force scan) by
set_version(3.2). - Upgrade path firmware sometimes reports 3.2 but responds as 3.3 (library adapts).
Steady revision; AES-ECB for all encryptable commands; no session negotiation; CRC32 integrity. Introduces the Source header.
- Handshake: None
- Frame: 55AA legacy
- Integrity: CRC32
- Crypto: AES-ECB (all encryptable commands)
- Version header: Present (clear) when not skipped
- Source header: Present (clear) on many STATUS/update responses
- Monitoring requires key: Yes
- Control requires key: Yes
For commands (except those in skip list):
- Build JSON.
- AES-128-ECB encrypt (raw bytes, no base64) with static local key.
- Prepend clear 3.x header (
b"3.3" + 12*0x00). - Frame in 55AA with CRC32.
DP_QUERY / DP_QUERY_NEW / UPDATEDPS / HEART_BEAT / LAN_EXT_STREAM are sent without the header (and for DP_QUERY may be empty JSON dict depending on device type). Some devices respond with encrypted payload that begins WITH the 3.3 header (clear) followed by ciphertext.
Format (clear, at start of the payload, 15 bytes total):
3.3 | CRC32(4) | seq(4) | source id(4)
Where:
3.3– ASCII protocol versionCRC32– 32-bit CRC32 of message for cloud/MQTT sourced traffic; zero for LAN-sourced frames.seq– 32-bit per-source monotonic sequence counter (device discards repeats or lower values for the same source id).source id– 32-bit unique source identifier. Device uses 1; clients should generate & persist a random non-zero value. Device caches only the last ~5 distinct IDs.
Handling:
- Present mainly on STATUS / spontaneous updates.
- TinyTuya strips the whole 15-byte block before JSON parsing (same code path as the all-zeros version header; the individual fields are not parsed).
- LAN traffic normally shows CRC32 = 0.
- Provides duplicate-suppression and missed-message detection (look for gaps in the sequence field).
Legacy broadcast (UDP/6667), AES-ECB encrypted with the well-known key MD5(b"yGAdlopoPVldABfn") — the same key later reused for 3.5 GCM discovery. (Plaintext UDP/6666 broadcasts are only sent by 3.1 devices.) No solicitation port 7000 behavior (see 3.5).
If device replies with error substring data unvalid while using default type, TinyTuya flips to dev_type="device22" (this auto-detection fires only for 3.3/3.4):
DP_QUERYuses command ID 0x0D (CONTROL_NEW).- Payload includes explicit DPS map (keys mapped to
null). - Library maintains
dps_to_request(auto-populated via brute force if needed).
import tinytuya
d = tinytuya.Device(dev_id, ip, local_key)
d.set_version(3.3)
print(d.status()) # Encrypted query
d.set_value(1, True) # CONTROL (encrypted)Security upgrade over 3.3 adding session key handshake and stronger integrity.
- Handshake: 3‑message session key negotiation (START / RESP / FINISH)
- Frame: 55AA legacy
- Integrity: HMAC-SHA256 (CRC replaced)
- Crypto: AES-ECB (session key per connection)
- Version header: Encrypted (inside ciphertext)
- Source header: Encrypted (if present)
- Monitoring requires key: Yes (after handshake)
- Control requires key: Yes
- Session key negotiation (3-way handshake) precedes data commands.
- CRC32 replaced by HMAC-SHA256 (covers frame fields + ciphertext + retcode if present).
- Version header visibility removed (now encrypted with JSON).
- Static local key only used to validate handshake & derive session key.
The 55AA frame used by v3.2, v3.3 and v3.4 devices has this binary layout (hex shown):
000055aaSSSSSSSSMMMMMMMMLLLLLLLL[RRRRRRRR]DD..DDCC..CC0000aa55
Where:
000055aa– fixed 4‑byte prefix (big‑endian value 0x000055AA)SSSSSSSS– 32‑bit sequence number (big‑endian)MMMMMMMM– 32‑bit command ID (big‑endian)LLLLLLLL– 32‑bit packet length: counts every byte starting at the first byte of the (optional) return code field (or first payload byte if client→device) through and including the 4‑byte footer0000aa55[RRRRRRRR]– 32‑bit return code present ONLY in device→client packets (0 = success). Omitted in client→device packets.DD..DD– variable length payload (encrypted or plaintext depending on version/command)CC..CC– Integrity field:- v3.2–v3.3: 32‑bit CRC32 of everything from prefix through final payload byte (just before CRC)
- v3.4: 32‑byte HMAC‑SHA256 over prefix..retcode(optional)..ciphertext (session key as HMAC key)
0000aa55– 4‑byte footer (big‑endian value 0x0000AA55)
Encryption differences:
- v3.2–v3.3: AES-128-ECB of JSON; version header then prepended (clear) using static key.
- v3.4: Version header + JSON encrypted together (AES-ECB, session key). Retcode (device→client) stays clear but HMAC-covered.
All multi‑byte integers are big‑endian. Aside from the HMAC vs CRC and encrypted version header, v3.4 framing is identical to prior 55AA revisions.
Commands: START (0x03), RESP (0x04), FINISH (0x05). All three use normal 55AA framing, and all three payloads are AES-ECB encrypted with the real (static) local key — the nonces/HMACs are never sent in the clear. (These commands skip only the version header.)
Handshake Sequence:
sequenceDiagram
autonumber
participant C as Client
participant D as Device
C->>D: SESS_KEY_NEG_START (cmd=0x03) payload = ENC( client_nonce (16B) )
D-->>C: SESS_KEY_NEG_RESP (0x04) payload = ENC( device_nonce || HMAC(client_nonce) )
C->>D: SESS_KEY_NEG_FINISH (0x05) payload = ENC( HMAC(device_nonce) )
Note over C,D: Both sides derive session_key = AES_ECB_realKey( client_nonce XOR device_nonce )
HMAC = HMAC-SHA256(real_local_key, nonce); ENC = AES-ECB(real_local_key, ...).
Code reference: see _negotiate_session_key_generate_step_1/3/finalize() in tinytuya/core/XenonDevice.py.
v3.4 derives a per-connection session key using a 3‑way exchange:
- Client -> Device (SESS_KEY_NEG_START / 0x03): 16‑byte client nonce (AES‑ECB encrypted with the real local key).
- Device -> Client (SESS_KEY_NEG_RESP / 0x04): 16‑byte device nonce || HMAC-SHA256(client_nonce) encrypted with the real (static) local key (AES‑ECB).
- Client -> Device (SESS_KEY_NEG_FINISH / 0x05): HMAC-SHA256(device_nonce) (AES‑ECB encrypted with the real local key).
Validation: Client recomputes HMAC(client_nonce) using real key and compares to bytes 16..48 of the decrypted step 2 payload. If valid, it sends step 3 and both sides derive the session key.
Derivation (3.4): XOR the two nonces byte-wise, AES-ECB encrypt with real key (no IV, no padding change) and use the full 16‑byte result as session key. (Implementation stores encrypted XOR directly.)
Pseudocode (logic concept – 3.4 ECB version):
tmp_key = bytes(a ^ b for a,b in zip(device_nonce, client_nonce))
cipher = tinytuya.AESCipher(real_key)
session_key = cipher.encrypt(tmp_key, use_base64=False, pad=False) # full block resultImplementation: _negotiate_session_key_generate_finalize() stores the derived session key (replacing the static key) before subsequent pack_message calls.
All subsequent payload encryption & HMAC use this session key (HMAC-SHA256 for frame integrity, AES-ECB for payload confidentiality). Losing the TCP socket triggers a new negotiation.
- If command not in skip list, prepend version header to plaintext JSON.
- AES-ECB encrypt entire block (header + JSON) with session key.
- Assemble 55AA frame.
- Compute HMAC-SHA256 over header + seq + cmd + length + ciphertext; append + footer.
Device22 override remains: library may send CONTROL_NEW / DP_QUERY_NEW if explicit DPS list required.
Legacy broadcast only (UDP/6667). If no broadcasts appear, re-check—device might actually be 3.5.
import tinytuya
d = tinytuya.Device(dev_id, ip, local_key)
d.set_version(3.4)
d.set_socketPersistent(True) # reuse negotiated session key
print(d.status())
d.set_value(1, False)- TCP disconnect forces new handshake.
- Sequence numbers echo (contrast with 3.5 global device counter).
- Retcode outside ciphertext; still HMAC-protected.
Because 3.4 encrypts the version header region, this source header (if present) is inside the AES-ECB encrypted payload:
3.4 | CRC32(4) | seq(4) | source id(4)
- Same semantics as v3.3 (CRC / sequence / source id); 15 bytes total, occupying the same slot as the all-zeros version header.
- TinyTuya decrypts the entire payload, then strips this 15-byte block (it does not parse the individual fields).
Major framing overhaul introducing AES-GCM, global response sequencing, and optional discovery solicitation.
- Handshake: 3‑message session key negotiation (same messages; different key slice)
- Frame: 6699 (GCM)
- Integrity: AES-GCM Tag
- Crypto: AES-GCM per packet (session key)
- Version header: Encrypted
- Source header: Encrypted
- Monitoring requires key: Yes
- Control requires key: Yes
- Discovery: Broadcast and/or solicitation (UDP/7000)
- 6699 frame replaces 55AA for data.
- AES-GCM (nonce + tag) supplants ECB + HMAC.
- Return code encrypted within payload (no clear retcode field).
- Device uses a global incrementing sequence independent of request seq.
Raw hex layout:
00006699UUUUSSSSSSSSMMMMMMMMLLLLLLLL(II*12)DD..DD(TT*16)00009966
Field breakdown:
00006699– 4‑byte prefix (big‑endian 0x00006699)UUUU– 16‑bit reserved/unknown (always 0x0000 in observed devices; ignored on RX)SSSSSSSS– 32‑bit sequence number (big‑endian). Device responses advance a global counter (not per‑request echo).MMMMMMMM– 32‑bit command IDLLLLLLLL– 32‑bit length counting from the first byte of IV through (and including) the final 16‑byte GCM Tag; DOES NOT include footer(II*12)– 12‑byte IV / nonce (unique per packet)DD..DD– Encrypted payload (ciphertext). First 4 bytes (after decrypt) may be a 32‑bit return code followed by JSON/version header data.(TT*16)– 16‑byte AES‑GCM authentication Tag00009966– 4‑byte footer (big‑endian 0x00009966)
Properties:
- Associated Data (AAD) = bytes from after prefix up to (but not including) IV (i.e. UUUU + seq + cmd + length).
- Payload is NOT padded (GCM streaming block mode).
- All multi‑byte integers big‑endian.
- Entire payload (and optional retcode) is both encrypted and authenticated; header fields are only authenticated.
- After decrypt: for TCP data frames the library always strips the first 4 bytes as the retcode before JSON decode. (A
{-lookahead heuristic exists only for UDP discovery decrypts, where the retcode may be absent.)
length counts bytes from IV start through end of Tag (excludes footer).
Associated Data (AAD) exact formula:
AAD = reserved(2) || seq(4) || cmd(4) || length(4) (total 14 bytes). AAD is authenticated, not encrypted.
Same three-message flow / math as 3.4 except:
- All three messages travel as plaintext inside GCM-encrypted 6699 frames keyed with the real (static) local key — the frame layer itself provides the encryption, so no additional AES-ECB pass is applied to the payloads. TinyTuya only performs an explicit payload decrypt of RESP for 3.4; for 3.5 the frame decrypt has already exposed it.
- Session key derivation uses AES-GCM with IV = first 12 bytes of client nonce and takes slice
[12:28]of the result ciphertext+tag blob:tmp = bytes(a^b for a,b in zip(device_nonce, client_nonce)) session_key = AESCipher(real_key).encrypt(tmp, use_base64=False, pad=False, iv=client_nonce[:12])[12:28]
Handshake Sequence:
sequenceDiagram
autonumber
participant C as Client
participant D as Device
C->>D: START 0x03 (client_nonce)
D-->>C: RESP 0x04 (device_nonce || HMAC(client_nonce))
C->>D: FINISH 0x05 (HMAC(device_nonce))
Note over C,D: session_key = GCM_Derive( XOR(client_nonce, device_nonce) )
Both v3.4 and v3.5 share the nonce/HMAC pattern; v3.5 differs in how the final session key bytes are carved out:
- Client sends 16‑byte nonce (as the plaintext of a GCM-encrypted START frame).
- Device responds with 16‑byte device nonce + HMAC-SHA256(client_nonce) (for 3.5 this arrives inside a GCM-encrypted 6699 frame keyed with the real local key; the frame decrypt exposes it — TinyTuya handles it transparently).
- Client validates HMAC, returns HMAC-SHA256(device_nonce).
Derivation (3.5): XOR the nonces -> encrypt with AES-GCM using real key and IV = first 12 bytes of client nonce; take bytes 12..28 of the resulting (IV||ciphertext||tag) buffer as the session key. This matches device behavior.
Reference implementation snippet:
tmp_key = bytes( a ^ b for (a,b) in zip(device_nonce, client_nonce) )
cipher = tinytuya.AESCipher(real_key)
session_key = cipher.encrypt(tmp_key, use_base64=False, pad=False, iv=client_nonce[:12])[12:28]Implementation reference: see _negotiate_session_key_generate_finalize() for slice usage when version >= 3.5.
Notes:
- Slice
[12:28]selects 16 bytes immediately following the 12‑byte nonce position from the GCM encryption output, matching device derivation. - Session key provides both confidentiality & integrity (no separate HMAC).
- Tag failure or disconnect triggers a fresh handshake.
- If needed, prepend version header to plaintext JSON.
- Build TuyaMessage (prefix 6699). Payload initially plaintext;
pack_message()performs AES-GCM encrypt (header fields as AAD) producing:IV + ciphertext + Tag. - Return code (if present in responses) is decrypted first 4 bytes before JSON.
Device22 command overrides still apply if dev_type='device22' was set manually or carried over (CONTROL_NEW / DP_QUERY_NEW), but the data unvalid auto-detection never fires on 3.5 (it is gated to 3.3/3.4). Library wraps resulting JSON in GCM.
Typical decrypted ordering:
[retcode(4)] [version or source header (15B)] { JSON }
The version header and the source header occupy the same 15-byte slot (3.5 + 12 bytes); a payload carries at most one of them.
Pseudo-order logic (illustrative):
ptr = 0
ret = u32(payload[0:4]); ptr += 4 # retcode (always stripped for TCP frames)
if payload[ptr:ptr+3] == b'3.5':
ptr += 15 # skip 15-byte version/source header block
json_bytes = payload[ptr:]
Library handles this automatically.
import tinytuya
d = tinytuya.Device(dev_id, ip, local_key)
d.set_version(3.5)
d.set_socketPersistent(True)
print(d.status()) # triggers handshake automatically
d.set_value(1, True)Encrypted within the GCM ciphertext (after the retcode). Layout unchanged:
3.5 | CRC32(4) | seq(4) | source id(4)
Differences vs earlier versions:
- Outer 6699 frame sequence is GLOBAL and independent; the inner sequence field remains per source.
- TinyTuya decrypts, strips the retcode, then strips this 15-byte block, then proceeds to JSON.
- Same CRC zeroing for LAN; same small cache of recent source ids.
Two patterns:
- Broadcast (older 3.5) using 6699 GCM packet on UDP/6667.
- Solicited: Send client broadcast (UDP/7000) GCM-encrypted JSON:
{"from":"app","ip":"<your-ip>"}. Device replies unicast (port 7000) encrypted.
Broadcast encryption key = MD5("yGAdlopoPVldABfn") (16 bytes). Same key/IV rules as normal 3.5 packets (per-packet IV, Tag verify). This is the same well-known key that decrypts the pre-3.5 AES-ECB broadcasts on UDP/6667; TinyTuya's decrypt_udp() transparently handles all three broadcast formats (raw AES-ECB blob, 55AA-framed plaintext or ECB payload, and 6699 GCM).
Minimal listener (PyCryptodome):
from hashlib import md5
from Cryptodome.Cipher import AES
import socket
key = md5(b'yGAdlopoPVldABfn').digest()
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('', 6667))
data, addr = s.recvfrom(4096)
if data.startswith(b'\x00\x00\x66\x99'):
hdr = data[4:18] # reserved+seq+cmd+len
iv = data[18:30]
body_tag = data[30:-4]
ciphertext, tag = body_tag[:-16], body_tag[-16:]
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
cipher.update(hdr)
plain = cipher.decrypt_and_verify(ciphertext, tag)
# first 4 bytes retcode? then JSON
print(addr, plain)Some recent v3.5 devices no longer emit unsolicited UDP/6667 broadcasts. Instead they remain silent until a client announces itself on port 7000.
Client solicitation:
- Send a single 6699-format AES-GCM packet (same key rules) via UDP to the subnet broadcast (e.g.
192.168.1.255:7000). - Plaintext JSON before encryption:
{"from":"app","ip":"<your-ip>"}(the target IP the device should respond to). - Device replies with its discovery packet directly (unicast) to
<your-ip>:7000, encrypted the same way.
Edge case:
- If the client sets
"ip":"255.255.255.255"some devices respond with a broadcast discovery packet; this appears incidental, not guaranteed behavior—avoid relying on it.
Example sender (PyCryptodome) – replace YOUR_IP and interface broadcast as needed:
from hashlib import md5
from Cryptodome.Cipher import AES
import json, socket
key = md5(b'yGAdlopoPVldABfn').digest() # same discovery key
payload = json.dumps({"from":"app","ip":"YOUR_IP"}).encode()
def pack6699(seq, cmd, raw):
# Minimal 6699 pack for discovery (no retcode). Not production-hardened.
import os
prefix = b'\x00\x00\x66\x99'
reserved = b'\x00\x00'
seq_b = seq.to_bytes(4,'big')
cmd_b = cmd.to_bytes(4,'big')
iv = os.urandom(12)
# GCM does not pad, so length (IV + ciphertext + tag) is known up front.
# The header MUST be built with the final length BEFORE encrypting,
# because the AAD must match the transmitted header bytes exactly.
length = (12 + len(raw) + 16).to_bytes(4,'big')
header = prefix + reserved + seq_b + cmd_b + length
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
cipher.update(header[4:]) # AAD = header bytes after the prefix (as in the library)
ct, tag = cipher.encrypt_and_digest(raw)
return header + iv + ct + tag + b'\x00\x00\x99\x66'
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
packet = pack6699(1, 0x25, payload) # 0x25 REQ_DEVINFO / discovery request
sock.sendto(packet, ('192.168.1.255', 7000))Listen on UDP/7000 for the unicast encrypted reply and decrypt with the same key; structure matches other discovery packets.
| Aspect | default | device22 |
|---|---|---|
| DP Query cmd | 0x0A (DP_QUERY) | 0x0D (CONTROL_NEW) override |
| Required DPS list | Optional | Required (sent as null-valued map) |
| Auto-detection | N/A | Triggered by "data unvalid" response on first attempt (3.3/3.4 only; 3.2 is forced at set_version(), 3.5 never auto-detects) |
TinyTuya sets self.dev_type and rebuilds internal payload dictionary accordingly.
- Uses
cid(child id) inserted into JSON; the library selects gateway payload templates based on registered child devices (self.children), not a specialdev_type. - 3.4/3.5 Zigbee control encloses
{ "protocol":5, "t":"int", "data":{"cid":"...","dps":{...}}}inside encrypted payload. - Extended operations use
LAN_EXT_STREAM(0x40) withreqTypeselectors (e.g.subdev_online_stat_query).
- Frame located by prefix (55AA or 6699).
- Header parsed (length validated).
- For 6699: Extract IV, decrypt+auth (AES-GCM) using session key; for 55AA: payload + CRC/HMAC validated, decrypt if needed (version dependent).
- Strip retcode (if present / 55AA always device->client; 6699 inside decrypted area).
- Remove headers in order:
- 3.1 CONTROL: strip
3.1+ MD5 slice. - 3.3: if payload starts with
3.3\0..remove it (unencrypted header). - 3.4/3.5: header was inside ciphertext; library already handles.
- 3.1 CONTROL: strip
- JSON decode; normalize nested
data.dpsinto top-leveldps.
sequenceDiagram
participant C as Client
participant D as Device
C->>D: TCP Connect
C->>D: START (0x03) client_nonce
D-->>C: RESP (0x04) device_nonce||HMAC(client_nonce)
C->>D: FINISH (0x05) HMAC(device_nonce)
Note over C,D: Derive session_key
C->>D: DP_QUERY_NEW (0x10) (GCM, JSON encrypted, no version header)
D-->>C: Response (retcode + JSON encrypted)
C->>D: CONTROL_NEW (0x0D) set dps
D-->>C: Ack / updated state
| Symptom | Likely Cause | Action |
|---|---|---|
ERR_KEY_OR_VER |
Wrong version or key length (not 16) | Confirm firmware version; call set_version() explicitly |
data unvalid |
Needs device22 mode | Let TinyTuya retry; or set dev_type='device22' manually |
| Empty payloads then timeout | Encrypted null ACKs | Increase socketRetryLimit or enable set_retry(True) |
| GCM failure (3.5) | Session lost or key mismatch | Reconnect (disable persistence) to renegotiate |
import tinytuya
dev = tinytuya.Device(DEV_ID, IP_ADDR, LOCAL_KEY)
dev.set_version(3.5)
dev.set_socketPersistent(True)
st = dev.status() # triggers handshake
print('Status:', st)
dev.set_value(1, True) # turn on
print('Updated:', dev.status())Protocol reverse-engineering contributions by many in the community; specific 3.4/3.5 framing & session key insights by @uzlonewolf (see discussion #260). TinyTuya consolidates these behaviors in core/XenonDevice.py and helpers (message_helper.py, crypto_helper.py).
| Field | Meaning |
|---|---|
| seqno | Incrementing per message (global in 3.5 responses) |
| cmd | Command ID (see list) |
| retcode | Device return code (0 = success) |
| length | Payload + trailer length field semantics differ: 55AA counts from retcode (or first payload byte for tx) through footer; 6699 counts IV..Tag (excludes footer) |
| CRC32 | 3.1–3.3 integrity over header+payload (unencrypted) |
| HMAC-SHA256 | 3.4 integrity (key = session key) |
| AES-GCM Tag | 3.5 authenticity + integrity |
| Version Header | Present (clear) 3.3, encrypted 3.4+, absent or special 3.1 control |
| Aspect | Behavior |
|---|---|
| Request seq | Client-controlled increment (library) |
| Response seq | Device global counter (may not match request) |
| Correlation | Library uses command context & timing, ignores mismatch |
| Loss detection | Source header sequence gaps post-decrypt |
| Replay guard | Device discards duplicate Source header seq for same source id |
Below is a fabricated (shortened) hex frame for a v3.4 CONTROL_NEW command turning DP 1 on. Session key already negotiated.
000055aa 0000002a 0000000d 00000074
<ciphertext ... 80 bytes ...>
5f1c3a9b 7b1d2c0f 0000aa55
Breaking it down:
| Bytes (hex) | Meaning |
|---|---|
| 000055aa | Prefix |
| 0000002a | seq = 42 |
| 0000000d | cmd = 0x0D CONTROL_NEW |
| 00000074 | length (0x74 = 116) = ciphertext (80) + HMAC (32) + footer (4) [device->client replies also count the 4‑byte retcode] |
| …ciphertext… | AES‑ECB(session_key, padded( version_header + JSON )) |
| 5f1c3a9b 7b1d2c0f | (first 8 bytes of 32‑byte HMAC-SHA256 shown) |
| 0000aa55 | Footer |
Decrypted (after AES‑ECB & unpad) plaintext (pretty‑printed):
{
"protocol": 5,
"t": 1710000000,
"data": { "dps": { "1": true } }
}The plaintext inside the ciphertext started with the 3.4 version header (33 2e 34 00 ... 00 total 15 bytes) followed by the compact 55‑byte JSON: 15 + 55 = 70 bytes, PKCS#7 padded to the 80‑byte ciphertext above.
Sample leading bytes before JSON (unencrypted for 3.3), 15 bytes total:
33 2e 33 | 00 00 00 00 | 00 00 00 02 | 00 00 00 01
"3.3" CRC32 = 0 seq = 2 source id = 1
Interpreted:
3.3– ASCII version00000000– CRC32 zeroed (LAN-sourced)00000002– per-source sequence = 200000001– source id (the device itself)
Immediately after this 15-byte header the payload continues with the (possibly encrypted) JSON.
Fabricated hex (grouped):
00006699 0000 00000035 00000010 00000057
1a2b3c4d5e6f708192a3b4c5
<ciphertext ... 59 bytes ...>
00112233445566778899aabbccddeeff
00009966
| Segment | Meaning |
|---|---|
| 00006699 | Prefix |
| 0000 | Reserved/ignored |
| 00000035 | seq (global) = 53 |
| 00000010 | cmd = 0x10 DP_QUERY_NEW |
| 00000057 | length (0x57 = 87) = IV (12) + ciphertext (59) + Tag (16); footer excluded |
| 1a2b3c…c5 | 12‑byte IV/nonce |
| …ciphertext… | 59 bytes (GCM, unpadded) – encrypted retcode (4) + 15‑byte header + 40‑byte JSON |
| 0011..eeff | 16‑byte GCM Tag |
| 00009966 | Footer |
After AES‑GCM decrypt/tag verify using session key (59 bytes plaintext):
00000000 | 33 2e 35 00000000 00000002 00000001 | 7b 22 64 70 73 ... 7d 7d
retcode=0 "3.5" CRC32=0 seq=2 srcid=1 JSON (40 bytes)
- First 4 bytes: retcode (0 = success).
- Next 15 bytes: Version Protocol source header (
3.5+ CRC32 + seq + source id). - Remaining 40 bytes: JSON.
Decoded JSON (toy example):
{"dps":{"1":true,"20":123,"101":"blue"}}Some responses: retcode || header(15B) || JSON, where the 15-byte block is either the all-zeros version header or the CRC/seq/source-id variant (never both). TinyTuya strips the retcode then the 15-byte block; if you craft tooling replicate the same parse sequence.
- Use
tcpdump -i <iface> -s 0 -X 'tcp port 6668'(replace port if different) to capture. - Extract single frame, remove TCP/IP headers, then annotate fields per tables above.
- For 3.4+ compute HMAC (or for 3.5 verify Tag) using the negotiated session key—TinyTuya internal logs (debug on) show derived key.
These examples are illustrative; exact lengths and values vary by device, DPS list size, and timing.
End of document.