blog:260513_access_tuya_devices_from_esp32_mcu

Access local Tuya devices from ESP32 MCU's

Controlling Tuya Smart Plugs Locally with MicroPython on ESP32

No cloud. No dependencies. Pure local LAN control.

I spent a few days reverse-engineering the Tuya local LAN protocol to get my AUBESS Smart Socket 20A plugs talking to an ESP32 running MicroPython — without relying on the cloud, without any third-party MicroPython libraries. This post documents everything I found, including the quirks that cost me the most time.

Why Local Control?

Tuya's cloud API is rate-limited, requires internet, and adds ~500ms latency. For home automation on a microcontroller you want local LAN control: direct TCP on port 6668, sub-10ms response and solution that works offline.

There are Python libraries for this on desktop (tinytuya), but nothing clean for MicroPython. This post fills that gap.

What You Need

  • ESP32 board running MicroPython
  • Tuya-based smart plug (tested: AUBESS Smart Socket 20A)
  • Your plug's Device ID and Local Key (see below)
  • The plug's local IP address

I assume, you already have Tuya app installed on your phone :) Its the only way to get device's Local Key- developer account is needed at Tuya platform. Free trial account is totally suitable, no need to pay.

You need the Device ID (20 chars) and Local Key (16 chars) from Tuya's developer portal.

On your PC:

pip install tinytuya
python -m tinytuya wizard

This walks you through linking your Tuya developer account and dumps all device credentials to devices.json and other JSON files. Dont forget to check those.

⚠️ The Local Key changes every time you remove and re-add a device in the Smart Life app. Don't do that after grabbing the key.

Tuya v3.3 uses plain TCP on port 6668 with AES-128-ECB encrypted JSON payloads. Each frame looks like:

[HEADER 4B][SEQUENCE 4B][COMMAND 4B][LENGTH 4B][AES'ed PAYLOAD][CRC32 4B][FOOTER 4B]

  • Header: 00 00 55 AA
  • Sequence: 00 00 00 01
  • Command: 00 00 00 0a
  • Length (hex): 00 00 00 78
  • Payload- variable length
  • CRC32: 12 34 56 78
  • Footer: 00 00 AA 55

Length is measured PAYLOAD + CRC32 + FOOTER

All my AUBESS plugs are query-style - after connecting to socket Plug waits. You must send a status query packet first.

Standard Tuya v3.3 documentation says the encrypted payload should be prefixed with 3.3\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 (15 bytes). This makes packets 151 bytes. It is required when sending set_switch command

After capturing tinytuya traffic I discovered my plugs want no prefix for the status query — 136-byte packets only. The prefix is only used for control packets (turning on/off).

This single detail isn't documented anywhere I could find.

Packet type Version prefix Packet size
Status query (cmd 0x0a) ❌ No prefix 136 bytes
Heart-beat (cmd 0x09) ❌ No prefix 136 bytes
Control (cmd 0x07) ✅ With prefix 151 bytes

MicroPython's utime.time() epoch starts at 2000-01-01, not 1970-01-01 like Unix.

Tuya validates the t field in the payload and rejects queries where the timestamp is too far from real Unix time.

Fix this by syncing NTP on boot and adding the epoch offset:

boot.py
import network, utime, ntptime
 
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.connect('YOUR_SSID', 'YOUR_PASSWORD')
while not sta.isconnected():
    utime.sleep_ms(300)
 
ntptime.settime()  # sync to real UTC
# In your main script
EPOCH_OFFSET = 946684800  # seconds between 1970 and 2000
 
def unix_now():
    return utime.time() + EPOCH_OFFSET

DPS Data Explained

The plug returns a dps dictionary. For AUBESS Smart Socket 20A:

Key Unit Description
1 bool Switch state
9 Countdown timer
18 mA Current
19 W × 10 Power (divide by 10)
20 V × 10 Voltage (divide by 10)
21 Power-on mode
22–24 Calibration values
25 mA Leakage current threshold

Mapping could be found in files, generated by tinytuya wizard

Debugging Tips

  • ECONNRESET immediately — plug rejects the packet content. Check key order in JSON and version prefix.
  • ETIMEDOUT — plug ignores the packet entirely. Check timestamp (NTP sync), and whether you're using prefix or no-prefix for the right packet type.
  • data format error in decrypted response — key is correct but payload format is wrong. Check JSON structure.
  • Only one connection at a time — close the Smart Life app on your phone before connecting from ESP32.

This monkey-patch script helped a lot. Using it i was able to prepare Microptyhon library for Tuya devices

capture.py
# Run this on PC: python capture.py
import tinytuya
import socket
import threading
import time
 
# Pinguin
DEVICE_IP = '192.168.1.55'
DEVICE_ID = '1<hidden>9'
LOCAL_KEY  = '5<hidden>b'
 
# Monkey-patch socket to capture raw bytes
_orig_connect = socket.socket.connect
_orig_send    = socket.socket.send
_orig_recv    = socket.socket.recv
_orig_sendall = socket.socket.sendall
 
def patched_connect(self, addr):
    print('[SOCKET] connect to', addr)
    return _orig_connect(self, addr)
 
def patched_send(self, data, *args):
    print('[SOCKET] send %d bytes:' % len(data),
          ' '.join('%02x' % b for b in data))
    return _orig_send(self, data, *args)
 
def patched_recv(self, size, *args):
    data = _orig_recv(self, size, *args)
    print('[SOCKET] recv %d bytes:' % len(data),
          ' '.join('%02x' % b for b in data))
    return data
 
def patched_sendall(self, data, *args):
    print('[SOCKET] sendall %d bytes:' % len(data), 
          ' '.join('%02x' % b for b in data))
    return _orig_sendall(self, data, *args)
 
socket.socket.connect = patched_connect
socket.socket.send    = patched_send
socket.socket.recv    = patched_recv
socket.socket.sendall = patched_sendall
 
d = tinytuya.OutletDevice(
    dev_id=DEVICE_ID,
    address=DEVICE_IP,
    local_key=LOCAL_KEY,
    version=3.3
)
d.set_socketTimeout(10)
data = d.status()
print('STATUS:', data)

What Didn't Work (Save Yourself the Time)

  • Sending heartbeat (0x09) before the status query — some plugs need it, mine didn't
  • utime.time() without NTP sync — plug rejects stale timestamps
  • Version prefix on status query — adds 15 bytes that this plug firmware rejects

Final Notes

This was tested on AUBESS Smart Socket 20A running Tuya firmware v3.3. Other Tuya devices may behave differently — some push status on connect without needing a query, some require a heartbeat first. The packet capture approach (monkey-patching Python's socket in tinytuya) is the most reliable way to debug unknown devices.

All code runs on standard MicroPython ESP32 builds with no additional packages. The only modules used are socket, time, struct, cryptolib, binascii, errno and json— all built-in.

Library file

Enter your comment. Wiki syntax is allowed:
Please fill all the letters into the box to prove you're human.
 
  • blog/260513_access_tuya_devices_from_esp32_mcu.txt
  • Last modified: 2026/06/15 13:43
  • by Ignas