Gazebo String Lights — Cracking the Tuya Pixel Format

June 2026

The gazebo’s perimeter string is an addressable RGBW LED strand — fifteen bulbs, each individually colorable — driven by a cheap WiFi controller and a Smart Life / Tuya app. Getting it into Home Assistant for on/off and color was the easy part. Getting it to render the custom patterns I wanted — a proper red-white-blue for the Fourth, Christmas red-and-green, Halloween purple-and-orange — meant reverse-engineering how Tuya represents a per-pixel pattern, because none of that is exposed as a normal light control.


Getting off the cloud first

The controller is a Tuya WiFi+BLE device (a Shenzhen Bling LSC-030-W3). Its little RF remote is proprietary 2.4 GHz and completely invisible to Tuya — pressing its buttons produces zero datapoint changes, so the remote is useless for capture. All the interesting control goes through the app.

I first tried to get rid of Tuya entirely with cloudcutter, the OTA exploit that reflashes these BK7231-class chips to open firmware (ESPHome/LibreTiny). It failed — the device is potted in silicone so there’s no UART fallback, it isn’t in cloudcutter’s device catalog, and ten-plus profile attempts never landed the right memory offsets. So the permanent path is LocalTuya: local control on the LAN using the device’s ID and local key, protocol 3.3, no cloud round-trip. Good enough — the device does everything locally once you have the key.

Basic control maps to the documented datapoints (DPs): on/off, work mode, brightness, color temperature, an HSV color field. That got me a working light entity. Patterns were the puzzle.


Two hidden datapoints

Tuya devices expose their functionality as numbered datapoints, and vendors love to bury the good stuff in undocumented, write-only ones that never show up in a status poll. To find them I ran a persistent-receive listener against the device (a plain status poll misses write-only DPs — you have to sit on the connection and watch what the app writes) while driving scenes and patterns from the Smart Life app. Two DPs carry the pattern data:

  • DP 51 — preset scene channel. Base64, write-only, never echoed in status. Format is [00, sceneID, 00×6, speed]. These are the app’s built-in canned scenes; you can replay a captured scene ID but you can’t read the current one back.
  • DP 102 — DIY addressable pattern channel. Base64, read/write. This is the one that matters — it holds a full custom per-pixel pattern, the thing you build in the app’s DIY mode.

There’s a catch in capturing DP 102: the app only writes it when you save a pattern to a slot. “Preview” drives the lights live but never persists to the DP. And a uniform single-color fill gets optimized into the plain solid-color DP, never touching 102. So to get a clean capture you have to build a genuinely multi-color pattern and save it.


The DP 102 format, fully decoded

Once I had a few saved patterns to diff, the structure fell out cleanly. No checksum, no obfuscation — just a header and a list of pixels:

Header:  00 <seq> 00 00 00 80 00 00        (8 bytes; <seq> is a save counter, any value works)
Then N × 7-byte pixel records:
         01  Hh Hl  S  V  00 00
         │   └──┬─┘  │  │  └─┴─ two reserved zero bytes
         │      │    │  └────── Value / brightness (0–100)
         │      │    └───────── Saturation (0–100)
         │      └────────────── 16-bit big-endian Hue (0–360)
         └───────────────────── flag, always 0x01

Pure-RGB patterns have no trailer at all — the byte stream ends at the last pixel. (Patterns that use the true white channel append a ~21-byte trailer I haven’t fully decoded; for whites I approximate with saturation = 0.)

The pixel-count wrinkle: the controller addresses 30 logical pixels, but this strand only has 15 physical bulbs. The first 15 logical pixels map 1:1 to the bulbs; pixels 16–30 fall off the end and are ignored. So every pattern is designed on pixels 0–14.

Synthesizing a pattern in Python is now trivial — build the header, append a 7-byte record per pixel, base64-encode, and write it to DP 102 alongside setting the work mode. Which means I can generate any pattern as a Home Assistant button without ever opening the app again:

  • Red-White-Blue — a proper even 5-5-5 split (Tuya’s only built-in RWB preset annoyingly flashes).
  • Christmas — 30 pixels alternating red and green.
  • Halloween — chunks of purple and orange.

Hue calibration matters on real LEDs. On this strand, orange at the textbook H30 reads as yellow; you have to dial it down to about H15–18 for a pumpkin orange. Reference points that actually look right here: red 0, orange ~18, green 120, blue 240, purple 270, all at full saturation and value.

Each pattern is a small script on the campsite HA, wired to a dashboard button, and mirrored to home HA over MQTT so I can set the mood from the couch before we drive up.


One firmware quirk worth flagging

Setting color works perfectly — write the HSV field and the lights change, verified physically. But the read-back is wrong: when the device is showing a color, its firmware reports the work mode as scene rather than colour, which fools LocalTuya’s “am I in color mode?” check, so the HA entity keeps reporting itself as color-temperature mode and won’t reflect the active color in its little indicator. It’s cosmetic — the color picker still applies any color you choose — but it’s a firmware behavior, not a config bug, and there’s no clean fix without breaking scene handling. Worth knowing if you go down this road: on these controllers, setting is reliable and current-color read-back is not.

The whole thing is a nice reminder that “smart” IoT devices are usually hiding a perfectly good local protocol behind a cloud app, and a couple hours of watching what the app actually writes buys you complete offline control — and, in this case, an arbitrary-pattern engine the app itself doesn’t offer.

Wesley Ray · blog · git · resume