Skip to content

Latest commit

 

History

History
757 lines (625 loc) · 36.6 KB

File metadata and controls

757 lines (625 loc) · 36.6 KB

Tuya Local Protocol Reference

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()).


Quick Comparison

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.

At a Glance (All Versions)

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").


Shared Concepts

Command IDs (subset)

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)

DPS JSON Skeleton

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.

Version Header ("3.x header")

<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).

"Version Protocol" Source Header (overview)

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 same U).
  • 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 Types Quick Reference

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+.

Version 3.1

Overview

Oldest variant still common. Read (status / DP query) works without local key. Control requires key. No session negotiation. Only CONTROL payloads are encrypted.

At a Glance

  • 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

Packet Framing (55AA)

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.

CONTROL Payload Construction

  1. JSON -> UTF-8 bytes (e.g. {"dps":{"1":true}} with ids/time fields added).
  2. AES-128-ECB encrypt; result base64 encoded (library default for 3.1 CONTROL).
  3. Compute MD5 over: b"data=" + base64payload + b"||lpv=3.1||" + local_key.
  4. Take hexdigest slice [8:24] (16 chars) -> bytes.
  5. Final payload: b"3.1" + md5slice + base64payload.
  6. Insert into 55AA frame with CRC32.

STATUS / DP_QUERY are plaintext JSON (no version header, no encryption, CRC32 only).

Decryption Rules

If payload starts with b"3.1", strip version + 16 md5 chars, base64‑decode remainder, AES‑ECB decrypt, unpad -> JSON.

Version Protocol Source Header

Not present (introduced later in 3.3). If you see a source header, firmware is not 3.1.

Discovery

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.)

Example (TinyTuya)

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)

Notes

  • MD5 slice is not an HMAC; integrity is weak.
  • Replay protection minimal (sequence only at frame level; device accepts duplicates readily).

Version 3.2

Overview

Rare transitional variant; behaves almost identically to early 3.3 devices (often collapsing into device22 handling). No session negotiation.

At a Glance

  • 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

Frame & Integrity

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).

Encryption & Header Visibility

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.

Version Protocol Source Header

Not used in 3.2. Seeing one implies the device is actually 3.3+.

DP Query / device22 Behavior

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.)

Example

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

Notes

  • 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).

Version 3.3

Overview

Steady revision; AES-ECB for all encryptable commands; no session negotiation; CRC32 integrity. Introduces the Source header.

At a Glance

  • 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

Encryption Flow

For commands (except those in skip list):

  1. Build JSON.
  2. AES-128-ECB encrypt (raw bytes, no base64) with static local key.
  3. Prepend clear 3.x header (b"3.3" + 12*0x00).
  4. 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.

"Version Protocol" Source Header (v3.3)

Format (clear, at start of the payload, 15 bytes total):

3.3 | CRC32(4) | seq(4) | source id(4)

Where:

  • 3.3 – ASCII protocol version
  • CRC32 – 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).

Discovery

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).

Device22 Behavior

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_QUERY uses 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).

Example

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)

Version 3.4

Overview

Security upgrade over 3.3 adding session key handshake and stronger integrity.

At a Glance

  • 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

Key Changes vs 3.3

  • 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.

Packet Format (55AA, v3.2–v3.4)

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 footer 0000aa55
  • [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.

Session Key Handshake

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 )
Loading

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.

Session Key (Detailed)

v3.4 derives a per-connection session key using a 3‑way exchange:

  1. Client -> Device (SESS_KEY_NEG_START / 0x03): 16‑byte client nonce (AES‑ECB encrypted with the real local key).
  2. Device -> Client (SESS_KEY_NEG_RESP / 0x04): 16‑byte device nonce || HMAC-SHA256(client_nonce) encrypted with the real (static) local key (AES‑ECB).
  3. 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 result

Implementation: _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.

Payload Construction (Post-Handshake)

  1. If command not in skip list, prepend version header to plaintext JSON.
  2. AES-ECB encrypt entire block (header + JSON) with session key.
  3. Assemble 55AA frame.
  4. Compute HMAC-SHA256 over header + seq + cmd + length + ciphertext; append + footer.

DP Query / device22

Device22 override remains: library may send CONTROL_NEW / DP_QUERY_NEW if explicit DPS list required.

Discovery

Legacy broadcast only (UDP/6667). If no broadcasts appear, re-check—device might actually be 3.5.

Example

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)

Notes

  • TCP disconnect forces new handshake.
  • Sequence numbers echo (contrast with 3.5 global device counter).
  • Retcode outside ciphertext; still HMAC-protected.

"Version Protocol" Source Header (v3.4)

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).

Version 3.5

Overview

Major framing overhaul introducing AES-GCM, global response sequencing, and optional discovery solicitation.

At a Glance

  • 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)

Key Changes vs 3.4

  • 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.

Packet Format (6699, v3.5)

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 ID
  • LLLLLLLL – 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 Tag
  • 00009966 – 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.

Session Key Handshake

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) )
Loading

Session Key (Detailed)

Both v3.4 and v3.5 share the nonce/HMAC pattern; v3.5 differs in how the final session key bytes are carved out:

  1. Client sends 16‑byte nonce (as the plaintext of a GCM-encrypted START frame).
  2. 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).
  3. 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.

Payload Construction (Post-Handshake)

  1. If needed, prepend version header to plaintext JSON.
  2. Build TuyaMessage (prefix 6699). Payload initially plaintext; pack_message() performs AES-GCM encrypt (header fields as AAD) producing: IV + ciphertext + Tag.
  3. Return code (if present in responses) is decrypted first 4 bytes before JSON.

DP Query / device22

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.

Retcode & Header Decode Order

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.

Example

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)

"Version Protocol" Source Header (v3.5)

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.

Discovery (v3.5)

Two patterns:

  1. Broadcast (older 3.5) using 6699 GCM packet on UDP/6667.
  2. 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)

Discovery Packets Redux (Newer v3.5 Behavior)

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.


Device Type Nuances (default vs device22)

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.


Sub-Devices / Gateways (Zigbee via Wi‑Fi Hub)

  • Uses cid (child id) inserted into JSON; the library selects gateway payload templates based on registered child devices (self.children), not a special dev_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) with reqType selectors (e.g. subdev_online_stat_query).

Receiving & Decoding Summary

  1. Frame located by prefix (55AA or 6699).
  2. Header parsed (length validated).
  3. For 6699: Extract IV, decrypt+auth (AES-GCM) using session key; for 55AA: payload + CRC/HMAC validated, decrypt if needed (version dependent).
  4. Strip retcode (if present / 55AA always device->client; 6699 inside decrypted area).
  5. 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.
  6. JSON decode; normalize nested data.dps into top-level dps.

Full 3.5 Control Flow

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
Loading

Troubleshooting Tips

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

Minimal End-to-End Example (3.5)

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())

Credit

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).


Appendix: Field Reference

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

Appendix: 3.5 Sequence Behavior

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

Appendix: Annotated Hex Examples

A. 55AA Frame (v3.4 CONTROL_NEW example)

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.

B. Version Protocol Source Header (v3.3 STATUS response)

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 version
  • 00000000 – CRC32 zeroed (LAN-sourced)
  • 00000002 – per-source sequence = 2
  • 00000001 – source id (the device itself)

Immediately after this 15-byte header the payload continues with the (possibly encrypted) JSON.

C. 6699 Frame (v3.5 DP_QUERY_NEW response with retcode)

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"}}

D. Mixed Ordering (3.5 CONTROL_NEW ack)

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.

E. Practical Hex Capture Tips

  1. Use tcpdump -i <iface> -s 0 -X 'tcp port 6668' (replace port if different) to capture.
  2. Extract single frame, remove TCP/IP headers, then annotate fields per tables above.
  3. 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.