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
Step 1 — Set-up developer account at Tuya Cloud
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.
Step 2 — Get Device Credentials
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.
Step 3 — Understand the Protocol
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.
Step 4 — Another Quirk: The Version Prefix
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 |
Step 5 — Fix the Timestamp
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 errorin 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
Access it here Library








Discussion