Compiling WLED Yourself: A Hidden Feature and a Partition Table OTA Can't Touch

What this is about Link to heading

In part 7 of my homelab series, the wireless devices fall into three groups: it just works, it works with effort, and it doesn’t work at all. That last group held exactly one device โ€” my light controller running WLED.

The release notes list 802.1X support as a feature. The web interface has no fields for it. The explanation was in the corresponding pull request: the feature was implemented, merged, and then deliberately put behind a compile switch, because it costs over 100 KB of flash.

This post is the resolution. It doesn’t need the series as a prerequisite โ€” if you have an ESP32 running WLED and you’re missing a feature that supposedly exists, you’re in the right place.

Why the feature is hidden Link to heading

The reason is more sensible than it first sounds, and it hangs on a quirk of the hardware.

My board is a QuinLED Dig-Next-2 with an ESP32-PICO-V3-02, 8 MB of flash and 2 MB of PSRAM. That’s one line to confirm:

~/.venv-pio/bin/esptool.py --port /dev/ttyUSB0 flash_id

The thing is, boards in this family don’t consistently ship with the same flash size. Sometimes it’s 4 MB, sometimes 8. The product page states one number, the box may contain something else, and the buyer only finds out by measuring. I find that hard to defend โ€” flash size isn’t a footnote, it decides what can run on the device at all.

For the firmware this has a direct consequence. A published binary has to run on every board, which means the smallest one. In the 4 MB layout each application partition gets about 1.5 MB. Whatever doesn’t fit can’t be in there โ€” and 802.1X at over 100 KB is a good candidate to cut, because few people need it.

That isn’t a bad decision. It just leads to a feature that exists, is documented, and is nonetheless out of reach for anyone who doesn’t build it themselves.

The build Link to heading

Nothing exotic, but two details that can cost time.

The environment for this board is not in platformio.ini, it’s in platformio_override.ini. Search only the main file and you won’t find it:

[env:dig-next-2]
board_build.partitions = tools/WLED_ESP32_8MB.csv
board_upload.flash_size = 8MB
board_upload.maximum_size = 8388608
build_flags = ${common.build_flags} -D WLED_ENABLE_WPA_ENTERPRISE

The WLED_ENABLE_WPA_ENTERPRISE flag is the whole switch. The 8 MB partitioning was already correctly declared in the environment โ€” which turns out to be the actual punchline later on.

Building happens from the source tree with PlatformIO. Mine lives in a virtual environment; there is no global pio:

~/.venv-pio/bin/pio run -e dig-next-2

With all the usermods I wanted โ€” audioreactive, multi_relay, sht โ€” the image comes to 1,794,117 bytes.

One small thing that confuses: the resulting file is called WLED_16.0.1-dev_Dig-Next-2.bin. The -dev is only the version string from the source tree and is not a debug build. Each environment produces exactly one binary, which is then merely renamed.

The actual problem Link to heading

Image built, uploaded through the web interface, and then:

Bad Size Given

This is where it gets interesting. The chip has 8 MB, the environment is configured for 8 MB, the image is 1.79 MB. And it still doesn’t fit.

Comparison: in the factory partition table for 4 MB the app partition is 1.5 MB, and the self-built image at 1.79 MB does not fit. In the 8 MB table the partition is 2 MB, where it fits at 85.6 percent full.

The chip has 8 MB. The table sitting on it knows nothing about that.

What was still on the chip was the partition table from the factory flash, and that one is laid out for 4 MB: app0 and app1 at 0x180000 each, so 1.5 MB. The 1.79 MB image is roughly 290 KB too large. The error message is entirely correct, it just doesn’t say what it’s referring to.

And here the circle closes: the same conservative table that causes 802.1X to be cut from the released binaries then prevents you from flashing the image you built yourself.

Rule of thumb: OTA fundamentally cannot change the partition table at offset 0x8000. Serial only. No web installer and no repartition hack gets around that sensibly.

That isn’t a limitation of the implementation but of the method: an over-the-air update writes into whichever application partition is inactive and then switches over. By construction it cannot change the structure it lives in.

The fix Link to heading

One cable, once, and never again.

~/.venv-pio/bin/esptool.py --port /dev/ttyUSB0 flash_id        # 8MB confirmed
~/.venv-pio/bin/esptool.py --port /dev/ttyUSB0 erase-flash
~/.venv-pio/bin/pio run -e dig-next-2 -t upload --upload-port /dev/ttyUSB0

My USB bridge shows up as /dev/ttyUSB0; on other systems it may be ttyACM0. The erase-flash is the decisive step โ€” it clears the old table out too, so the subsequent upload writes the one from the environment.

Afterwards the layout looks like this:

app0      0x010000  2M
app1      0x210000  2M
spiffs    0x410000  3968K
coredump  0x7f0000  64K

And you can verify it rather than assume:

~/.venv-pio/bin/esptool.py --port /dev/ttyUSB0 read-flash 0x8000 0xc00 /tmp/part.bin
python3 ~/.platformio/packages/framework-arduinoespressif32/tools/gen_esp32part.py /tmp/part.bin

On current Debian systems it’s python3, not python โ€” the unprefixed command doesn’t exist there any more.

Future updates go over OTA as normal, as long as the image stays under 2 MB. You need the cable exactly once.

If space does get tight Link to heading

Should it ever get close, it’s worth knowing what actually eats the space. My expectation was wrong.

audioreactive costs only around 46 KB. It’s the usermod you suspect first because it sounds heavy โ€” it isn’t.

The large items are IRremoteESP8266, esp_dmx, AnimatedGIF with its GifDecoder, espalexa and QuickEspNow. If you need to save, start there and not with microphone support.

The device on the network Link to heading

With the self-built image, the controller now sits on the same SSID as everything else, authenticates over PEAP with credentials of its own, and gets its VLAN assigned by the RADIUS server. Exactly what didn’t work in part 7.

One piece of advice for the setup: leave the USB cable attached until you’ve proven the authentication works. A mistyped username otherwise means the device can’t reach the network at all โ€” and then you’re back at the screwdriver.

And what it’s all for Link to heading

The controller drives 177 LEDs across two data outputs, and the light follows the picture on the TV. The image analysis is done by Hyperion.NG on the media player, which sends the colour values to WLED.

The protocol is DDP over UDP 4048. 177 LEDs means 531 channels. E1.31 would need two universes for that plus the addressing arithmetic, while DDP fits it in one packet โ€” and Hyperion’s “WLED” device type speaks DDP anyway.

Because my media player and controller sit in separate networks, this is a single firewall rule: UDP from one to the other on port 4048, nothing else. What that rule set looks like is in part 10.

Two things that are easy to miss: the LED count has to match on both sides โ€” in WLED under Config โ†’ LED Preferences, in Hyperion in the device settings. And under Config โ†’ Sync Interfaces it’s worth checking the realtime timeout and whether WLED’s brightness is applied to incoming data. Both explain effects you’d otherwise blame on the network path.


Belongs thematically to my series about rebuilding my home network, but stands on its own. Questions, corrections and your own experiences are welcome in the comments.