Do you want to run an arbitrary number of WLED devices without needing to register them in your LED software? Or clone a single WLED device pattern to any number of WLED devices? LED Bloom automatically discovers WLED devices on your network, receives a single stream of pixels and fans them out to all of the devices.
You point any DDP source (xLights, WLED Sync, a custom renderer, etc.) at this service. It discovers WLED devices on your LAN, learns their matrix dimensions, places each one at a random position inside a virtual master canvas, then slices each frame and forwards the corresponding pixels to each device over DDP.
Built to power the syncing of wearable LED costume pieces for Lava Lounge events. As each new wearable comes into wifi range, this software picks it up, registers it, and starts sending, leading to really fun synced effects.
See a quick demo of it in action here: https://www.youtube.com/watch?v=PqPnnG8uz24
DDP source
│ (one stream of WxH frames)
▼
┌──────────────────────┐ ┌──────────────────────┐
│ DdpFrameReceiver │ │ WledDiscoveryRunner │
│ (UDP :4048) │ │ mDNS + IP scan │
└──────────┬───────────┘ └──────────┬───────────┘
│ frames │ devices
▼ ▼
┌──────────────────────────────────────┐
│ DdpForwarder │
│ per-device DeviceMapping slices │
│ the frame and sends DDP packets │
└──────────────────────────────────────┘
│
▼
WLED matrix #1, #2, #3, ...
- Receiver — listens for DDP on
ledbloom.ddp-listen-port(default4048) forframe-width × frame-heightRGB frames. - Discovery — runs every
discovery-interval-seconds(default60s). Combines mDNS (_wled._tcp) with a/24IP-range probe. WLED HTTP APIs (/json/info,/ledmap.json) are queried for matrix dimensions; if those are absent, dimensions are inferred from LED count. - Mapping — each newly seen device is placed at a random
(translateX, translateY)inside the master frame. Pixel offsets are pre-computed once per device for fast slicing. - Forwarder — for every received frame, each device gets its sub-region forwarded as a DDP packet to its own IP.
- Java 17+
- Gradle (the wrapper is included)
- Network reachability to your WLED devices (UDP 4048, HTTP 80)
./gradlew bootRunOr build a fat jar:
./gradlew bootJar
java -jar build/libs/LED-Bloom-0.1.0.jarThe HTTP Web interface and API listen on :8901, the DDP receiver on :4048 (defaults). The status web page at http://led-bloom.local:8901/ will list the active config parameters and discovered devices.
Edit src/main/resources/application.yaml, override with --key=value CLI flags, or set env vars (LEDBLOOM_FRAME_WIDTH=128 etc.).
| Key | Default | Notes |
|---|---|---|
server.port |
8901 |
REST API port |
ledbloom.ddp-listen-port |
4048 |
UDP port for incoming DDP |
ledbloom.frame-width |
64 |
Master canvas width (pixels) |
ledbloom.frame-height |
48 |
Master canvas height (pixels) |
ledbloom.ip-block |
auto | E.g. 192.168.1. — overrides auto-detection |
ledbloom.discovery-interval-seconds |
60 |
How often to rescan |
ledbloom.device-timeout-minutes |
5 |
Devices not seen for this long are purged |
ledbloom.skip-ips |
[] |
IPs to ignore during discovery. Auto-extended at runtime: any IP we receive DDP frames from is added here (and removed from the device registry) so a source is never treated as a forwarding target. |
logging.level.org.llled.ledbloom |
DEBUG |
App log level |
LED Bloom is just a DDP receiver. You point any DDP-capable renderer at it and it fans the frames out to your WLED devices — so from the source's point of view, LED Bloom looks like one big virtual matrix. There's nothing to install on the source side; you only need to tell it where to send packets.
Aim your source at:
- Host — the machine running LED Bloom (its LAN IP, or
led-bloom.local). - Port —
4048(the WLED/DDP default; matchesledbloom.ddp-listen-port). - Resolution — match the master canvas:
frame-width × frame-height(default64 × 48). That'swidth × heightpixels =width × height × 3RGB channels (default3072px /9216channels). LED Bloom slices this canvas across your devices, so the source should render to these dimensions, not to any individual device's size.
In xLights, LED Bloom is added as a single Ethernet controller speaking DDP (xLights Ethernet controller setup):
- Setup → Add Ethernet, then select the new controller row.
- Set IP Address to the LED Bloom host (LAN IP or
led-bloom.local). - Set Protocol to DDP.
- Leave Channels Per Packet at
1440and enable Keep Channels Per Packet. - Set Channels to cover the master canvas (
frame-width × frame-height × 3;9216for the defaults), then lay out a matrix/model offrame-width × frame-heightagainst it.
Any other DDP source works the same way — point it at the LED Bloom host on port 4048 and
have it stream frame-width × frame-height RGB frames. See the
WLED DDP documentation for background on the protocol and
WLED's DDP support. A minimal custom sender just needs to emit standard DDP packets (RGB,
pixel-data destination) at your target framerate.
Base path: /api/v1
| Method | Path | Description |
|---|---|---|
GET |
/status |
Uptime, frame counts, error counts, device counts, and forwarder throughput metrics |
GET |
/devices |
All devices with their mapping |
GET |
/devices/{id} |
One device by id (ip:port) or bare ip |
POST |
/devices |
Manually add a device (bypasses discovery); body {ip, port?, name?, ledCount, width, height} |
DELETE |
/devices/{id} |
Remove one device by id (ip:port) or bare ip |
DELETE |
/devices |
Remove all manually-added (pinned) devices |
POST |
/discovery/trigger |
Force an immediate discovery sweep |
Example:
curl http://localhost:8901/api/v1/status
curl http://localhost:8901/api/v1/devices
curl -X POST http://localhost:8901/api/v1/discovery/trigger- Devices larger than the master frame are clamped (and a warning is logged); consider raising
frame-width/frame-height. - Mapping positions are randomized on registration, so a restart re-shuffles where each device pulls its pixels from.
- mDNS discovery runs for ~5s per cycle; the IP-range probe walks
.2–.254of the detected/24and pings each address with a 500 ms timeout.
A load-test harness lives in org.llled.ledbloom.loadtest. It measures how many devices
LED Bloom can push to at a target framerate. Typical setup: run LED Bloom and the
sender on one machine (the sender → ingress hop is loopback) and the virtual receiver on a
second machine; all N virtual devices point at the receiver's IP across a port range, so only
the real egress fan-out crosses the LAN.
Run LED Bloom with discovery neutralized so it doesn't add noise:
./gradlew bootRun --args='--spring.profiles.active=loadtest'On the receiver machine, bind a range of ports (one per virtual device):
./gradlew runVirtualReceiver "-Dvr.basePort=5000" "-Dvr.count=100"On the LED Bloom machine, register the devices and stream frames at the target fps:
./gradlew runLoadTest "-Dlt.receiverHost=<receiver-ip>" "-Dlt.devices=200" "-Dlt.baseEgressPort=5000" "-Dlt.fps=60" "-Dlt.durationSeconds=30" "-Dlt.masterW=64" "-Dlt.masterH=48" "-Dlt.devW=16" "-Dlt.devH=16"The -D arguments are quoted because PowerShell otherwise mangles an unquoted token that
starts with - and contains ./= (Gradle then reports Task '.xxx' not found). The quotes
are harmless in bash/sh too.
Watch the ceiling on GET /api/v1/status: framesPerSecond should hold at the target,
packetsPerSecond ≈ framesPerSecond × activeForwarders, and fanoutMicrosAvg rising toward
frameIntervalMicrosAt60 (16667 µs) is the leading indicator that the single-threaded fan-out
is saturated. The receiver logs aggregate + per-port FPS so you can spot stragglers. Step
lt.devices/vr.count up until one of those breaks. The runner cleans up its devices on exit
(-Dlt.cleanup=false to leave them). See org.llled.ledbloom.loadtest for all -Dvr.*/-Dlt.* options.
- Test on Raspberry Pi, both for core functionality and scale.
- Ability to remove device via API and web interface in case something shouldn't get traffic and you don't want to restart.
- Create better distribution with config file on CLI for easier running.
- Visual display of where each device is in the grid.