Control any WiZ bulb or lightstrip, including every individual light zone on a strip, and sync them to your screen colors in real time.
- Real-time screen capture light sync
- Full 12-zone RGBIC support per lightstrip
- Independent zone width control for each strip segment
- Supports WiZ lightstrips (12 zones) and WiZ bulbs (single zone)
- Automatic network discovery of WiZ devices
- Live output preview bar showing the current strip colors
- Manual color control mode with color wheel, RGB sliders and HEX input
- Multi-zone selection
- Adjustable brightness, FPS and color smoothing
- Full RGB color calibration
- System tray support
Download the exe from releases, or grab the source code.
Run this to install the prerequisites before running the .py file:
python -m pip install -r requirements.txt
WiZ RGBIC strips have per-segment color control over the local network, but it isn't documented anywhere. I reverse engineered it and this is everything I've worked out, all of it tested against real hardware. Anything I haven't actually confirmed is marked as untested instead of guessed at.
Test device
moduleName |
ESP25_MHORGB_01 |
fwVersion |
1.38.0 |
| Product | WiZ RGBIC LEDStrip, 5m section cut down to about 4m |
| Physical segments | 17 (on this specific install, see Segment count) |
| Transport | UDP, port 38899, fire and forget |
sceneId values from 256 up are custom mode slots. They're writable, and when you write one you can attach an elm object carrying per-segment color data. That's how you get manual RGBIC control.
Three things will catch you out, and none of them are obvious:
- A slot that already holds a saved custom mode ignores your
elmpayload and plays the stored mode instead. You need an empty slot. - The device replies
{"success": true}either way. That only means the packet parsed, not that anything was applied. - 12 steps is a hard firmware cap. 13 or more gets rejected.
{
"method": "setPilot",
"params": {
"mac": "<device mac>",
"state": true,
"sceneId": 258,
"elm": {
"modifier": 100,
"support": 17,
"steps": [
[0, 255, 0, 0, 0, 0, 0, 100, 0, 0, 0, 0, 1],
[0, 0, 0, 255, 0, 0, 0, 100, 0, 0, 0, 0, 1]
]
}
}
}Sent as compact JSON to <device ip>:38899. No handshake, no registration, no init packet. This works as the very first thing you ever send to the device.
elm fields
| Field | Value | Notes |
|---|---|---|
modifier |
100 |
Static mode, literal per-segment colors, no animation. 110 seems to select gradients and saved effects, where the firmware renders across the whole strip itself. Values other than 100 aren't fully mapped. |
support |
17 |
Found by trial and error. Works every time. 0b10001 makes me suspect a capability bitmask but that's unconfirmed. |
steps |
array | 1 to 12 step vectors, see below. |
Step vector, 13 elements each:
| Index | Meaning |
|---|---|
| 0 | Unknown, held at 0 |
| 1 | Red, 0-255 |
| 2 | Green, 0-255 |
| 3 | Blue, 0-255 |
| 4 | Unknown, held at 0. Possibly cool white, since WiZ orders channels r,g,b,c,w everywhere else |
| 5 | Unknown, held at 0. Possibly warm white |
| 6 | Unknown, held at 0 |
| 7 | Dimming for this step, 0-100 |
| 8-11 | Unknown, held at 0. Could be timing or fade, since elm also drives animated modes |
| 12 | Width, how many physical segments this step covers |
Everything marked unknown sits at 0 and the strip works fine, so none of them are needed for static color.
sceneId 256 and up act as custom mode slots. The WiZ app allows up to 10 user modes, so the range is probably 256-265, but only the values below were actually tested.
sceneId |
Slot state | Response | What the strip did |
|---|---|---|---|
| 257 | Held a mode I'd saved in the WiZ app | {"success": true} |
Played the saved mode. elm ignored completely. |
| 258 | Empty | {"success": true} |
Rendered my colors correctly. |
| 256 | — | {"success": true} |
Untested visually, don't rely on this row. |
A saved mode beats a written one. The payload gets accepted, acknowledged as successful, and then silently dropped.
Worth spelling out because it looks exactly like the feature having been patched out. It hasn't. Point the same payload at an empty slot and it works.
So don't hardcode a scene ID. Probe 256-265 at startup and use whichever slot the user hasn't filled. Anyone who makes custom modes in the WiZ app will have occupied slots, and they're exactly the people most likely to want RGBIC control.
Tested against an empty slot (258):
| Steps | Response |
|---|---|
| 11 | {"success": true}, renders |
| 12 | {"success": true}, renders |
| 13 | {"error": {"code": -32602, "message": "Invalid params"}} |
| 14 | Same error |
12 is a hard firmware cap, not something inherited from the app UI. Fewer than 12 is fine.
Which also means 12 is the maximum number of independently colored regions you can ever get, no matter how long the strip is. Longer strips don't get more colors, they get wider ones.
Note the firmware is willing to return errors. That's what makes the silent drop on an occupied slot so misleading, you get a success response for a payload that did nothing at all.
Width (index 12) is an absolute count of physical segments, not a relative weight. Steps get laid down in order from the start of the strip, each one consuming its width. Anything past sum(widths) stays dark.
Measured on the 17 segment test strip:
| Widths | Result |
|---|---|
| 12 steps, all width 1 | 12 segments lit, 5 dark |
[1,1,2,1,2,1,1,2,1,2,1,2], sums to 17 |
Whole strip lit |
So the rule is just sum(widths) == segment count for full coverage.
To fill a strip of any length, spread 12 steps across N segments as evenly as the division allows:
def fill_widths(n_segments, n_steps=12):
"""Widths that sum to exactly n_segments, spread evenly."""
return [(i + 1) * n_segments // n_steps - i * n_segments // n_steps
for i in range(n_steps)]fill_widths(17) gives [1, 1, 2, 1, 2, 1, 1, 2, 1, 2, 1, 2], summing to 17. Always sums to exactly N, never leaves a dark tail, keeps the widths as even as possible.
There's no way to ask the device how many segments it has.
The full getSystemConfig response contains mac, homeId, roomId, rgn, moduleName, fwVersion, groupId, ping, accUdpPropRate and rdIdUidHash. Nothing about length, segments or LED count.
You can't infer it from the model either, because these strips are cuttable. The test strip is a 5m unit cut down to roughly 4m and the firmware has no idea. A model to segment count lookup table will be wrong for everyone who cut theirs.
What's probably consistent per product line is density, around 5-6 LEDs per segment on this one. Total segments then just follows from installed length.
Best approach is to calibrate. Light segments one at a time and have the user say where the strip ends, or let them set the widths by hand. This app uses manual sliders, which sidesteps needing the count at all.
getPilot never echoes the elm block back:
{"result": {"state": true, "sceneId": 257, "speed": 100, "dimming": 100}}You get the current sceneId but no segment data. So there's no way to inspect what the WiZ app is sending by polling the device, you'd have to capture the UDP traffic.
- Repeated writes to the same slot apply fine. Streaming at 30+ fps works with no rate limiting and no dropped frames, which is what makes screen sync possible at all.
- Nothing waits for an ACK. The response is useful for catching malformed payloads but useless for confirming what's actually on the strip.
- Non-RGBIC devices seem to no-op on an
elmpayload rather than break. The light goes out but the device stays reachable. Still worth gating on capability, since a dark lamp is a bug from the user's point of view.
- Step indices 0, 4, 5, 6 and 8-11
- The full
modifierrange past 100 (static) and 110 (gradients and effects) - What
supportactually encodes, and whether it has to change alongsidemodifier - Whether
sceneId256 and 259-265 behave the same as 258 - Whether the 5-6 LEDs per segment density holds on other WiZ RGBIC products, including the TV sync box
- home-assistant/feature-requests discussion #1313, a request for RGBIC gradient and custom dynamic mode support. Someone there reports the same
ESP25_MHORGB_01module, so this should transfer straight to their hardware. sbidy/pywizlight, which as of 0.6.3 has noelmsupport and a scene table that stops well below 256, so custom modes can't be expressed through it yet.
Findings are from one device. Corrections and results from other WiZ RGBIC hardware are welcome, especially on the unknown step indices since that's the main gap left.