Skip to content

Commit be06f2f

Browse files
committed
Doc layers update and network 🐰 fixes
docs ==== - layers: rework clearly explaining layers backend ======= network in: - writePixels: Virtual layer — guard against out-of-range index and uninitialised slot - handleDDP: Reject if the packet's pixel stride doesn't match the configured channel layout. network out - controls, min is 1 instead of 0 - onUpdate: Clamp controls to safe values - loopDDP: DDP only defines RGB24 (3 ch) and RGBW32 (4 ch).
1 parent 223c4ab commit be06f2f

4 files changed

Lines changed: 74 additions & 23 deletions

File tree

docs/moonlight/drivers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Sends pixel data over the network to LED controllers and DMX fixtures. Supports
112112
* **Port**: Network port. Updated automatically when switching protocol; can be overridden manually.
113113
* **FPS Limiter**: Maximum frames per second sent. Art-Net spec recommends ~44 FPS; higher rates (up to ~130 FPS tested) work with most controllers.
114114
* **Universe size**: Channels per universe (max 512). Match the setting on your controller.
115-
* **Used channels** *(read-only)*: Channels actually used per universe after rounding to whole lights (e.g. 510 for RGB at 512-channel universes).
115+
* **Used channels** *(read-only)*: Channels actually used per universe after rounding down to a whole number of lights (e.g. 510 for RGB at 512-channel universes). Always at least one light's worth of channels — if **Universe size** is set smaller than the channels per light, one full light is still included per universe.
116116
* **#Outputs per IP**: Number of physical outputs per controller. When all outputs for one IP are filled, sending continues on the next IP.
117117
* **Universes per output**: How many universes each output handles, determining the maximum lights per output.
118118
* **Total universes** *(read-only)*: Universes required to transmit all lights.

docs/moonlight/layouts.md

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,49 @@
11
# Layouts
22

3-
A layout (🚥) defines the positions of lights connected to a MoonLight device.
4-
5-
* The **coordinates** of each light are defined in a 3D coordinate space
6-
* Coordinates needs to be specified in the order the lights are wired so MoonLight knows which light is first, which is second etc.
7-
* For lights in a straight line (1D, e.g. LED strips or a LED bar), specify coordinates as [x,0,0]
8-
* For lights in a flat area (2D, e.g. LED matrix), specify coordinates as [x,y,0]
9-
* **Multiple layout nodes** can be defined which will be mapped in the order of the layouts
10-
* MoonLight will use the layout definition to generate a **mapping** of the effects to a real world light layout. Most simple example is a panel which has a snake layout. The mapping will create a layer for effects where the snake layout is hidden.
11-
* Layouts also assign groups of LEDs to esp32 GPIO pins.
3+
A layout (🚥) tells MoonLight **where your lights physically are** and **how they are wired**. Get this right and every effect — from a simple rainbow to a 3D plasma — automatically fits your fixture perfectly.
4+
5+
## How layouts work
6+
7+
### The coordinate grid
8+
9+
When you define a layout, each light gets a position in a 3D coordinate space (X, Y, Z). MoonLight takes all those positions and figures out the bounding box — the smallest grid that contains every light. Effects are then computed across that whole grid and automatically mapped to your physical lights.
10+
11+
This means:
12+
13+
- **Effects are fixture-aware.** A plasma effect on a 16×16 panel looks like a full-screen plasma. The same effect on a 3D cube wraps correctly around all three axes. You don't write a different effect for each shape.
14+
- **Wiring order doesn't matter to effects.** Your LED strip might snake left-to-right on odd rows and right-to-left on even rows (serpentine wiring). The layout describes that wiring pattern; effects just see a clean grid.
15+
- **Resolution scales automatically.** A 10×10 panel and a 32×32 panel both run the same effects — the effect adapts to however many lights you have.
16+
17+
### 1D, 2D and 3D fixtures
18+
19+
| Fixture type | Typical examples | Grid shape |
20+
|---|---|---|
21+
| **1D** — a line | LED strip, LED bar, single tube | Y axis only — X and Z are 0. The Y axis runs vertically so 1D effects like bouncing balls, drip and rain move in the natural downward direction |
22+
| **2D** — a flat surface | LED matrix, panel, ring, wheel | X and Y axes — Z is 0 |
23+
| **3D** — a volume | Cube, Christmas tree, spiral tower, Human Sized Cube | X, Y and Z axes all used |
24+
25+
MoonLight automatically detects the dimensionality from the coordinates you define, so 1D effects run on strips and 3D effects light up cubes without any extra configuration.
26+
27+
### How effects fill the grid
28+
29+
The effect runs across every point in the bounding grid, but your fixture only has lights at specific positions inside it. MoonLight builds a **mapping** that links each grid point to the nearest physical light — so even a 500-LED Christmas tree mounted inside a 25×25×100 bounding box gets a smooth, full-volume effect. Sparse fixtures (few lights in a large space) work fine as long as the pixel density is reasonable. Very sparse grids — for example, a handful of moving heads spread across a large stage — are a future use case.
30+
31+
### Multiple layout nodes
32+
33+
You can add more than one layout node — for example three separate panels or a ring combined with a bar. MoonLight merges all their positions into a single shared grid and maps effects across all of them together. Lights are assigned to the grid in the order the layouts appear in the node list.
34+
35+
### GPIO pin assignment
36+
37+
Layouts also assign groups of LEDs to the ESP32 GPIO pins that drive them. Each layout node controls which pin(s) its lights are connected to. Complex fixtures like a cube or a multi-panel wall often use one pin per face or one pin per panel so each segment can be driven in parallel, improving performance.
38+
39+
!!! tip "Start simple"
40+
Not sure which layout to use? Start with **Single Row** for a strip, **Panel** for a matrix, or **Rings** for circular fixtures. These cover the most common setups and are easy to combine into larger builds.
41+
42+
!!! tip "Custom shapes"
43+
Any shape that isn't covered by the built-in layouts can be created as a **Live Script** — a small `.sc` file with an `onLayout()` function that places lights exactly where you need them. See [Live Scripts](livescripts.md).
44+
45+
!!! note "Future: room-scale mapping"
46+
It is possible in principle to treat an entire room or stage as the coordinate space and map all fixtures — panels, moving heads, tubes — into that shared grid. This would let effects flow seamlessly from one fixture to another across physical space. MoonLight's architecture supports it, but algorithms optimised for very sparse, large-scale grids are not yet implemented.
1247

1348
## Layout 🚥 Nodes
1449

src/MoonLight/Nodes/Drivers/D_NetworkIn.h

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ class NetworkInDriver : public Node {
128128
memcpy(&layerP.lights.channelsD[ledIndex * layerP.lights.header.channelsPerLight],
129129
&dmxData[i * layerP.lights.header.channelsPerLight],
130130
layerP.lights.header.channelsPerLight);
131-
} else { // Virtual layer — guard against a slot that hasn't been created yet
132-
if (layerP.layers[layer - 1]) {
131+
} else { // Virtual layer — guard against out-of-range index and uninitialised slot
132+
if (layer - 1 < layerP.layers.size() && layerP.layers[layer - 1]) {
133133
layerP.layers[layer - 1]->forEachLightIndex(ledIndex, [&](nrOfLights_t indexP) {
134134
memcpy(&layerP.lights.channelsD[indexP * layerP.lights.header.channelsPerLight],
135135
&dmxData[i * layerP.lights.header.channelsPerLight],
@@ -173,15 +173,20 @@ class NetworkInDriver : public Node {
173173
uint8_t dataType = packetBuffer[2];
174174
if (dataType != 0x01 && dataType != 0x1A) return; // accept RGB24 and RGBW32
175175

176+
// Reject if the packet's pixel stride doesn't match the configured channel layout.
177+
// DDP offset is an absolute byte offset into the receiver's channel memory, so the
178+
// stride used here must equal channelsPerLight — otherwise pixel positions are wrong.
179+
const uint8_t packetBytesPerPixel = (dataType == 0x1A) ? 4 : 3; // RGBW32=4, RGB24=3
180+
const uint32_t channelsPerLight = static_cast<uint32_t>(layerP.lights.header.channelsPerLight);
181+
if (packetBytesPerPixel != channelsPerLight) return;
182+
176183
uint32_t offset = ((uint32_t)packetBuffer[4] << 24) | ((uint32_t)packetBuffer[5] << 16) |
177184
((uint32_t)packetBuffer[6] << 8) | (uint32_t)packetBuffer[7];
178185
uint16_t dataLen = ((uint16_t)packetBuffer[8] << 8) | packetBuffer[9];
179186

180187
uint8_t* pixelData = packetBuffer + DDP_HEADER_LEN;
181188
int payloadBytes = packetSize - DDP_HEADER_LEN;
182189
int safeDataLen = MIN(static_cast<int>(dataLen), payloadBytes);
183-
184-
const uint32_t channelsPerLight = static_cast<uint32_t>(layerP.lights.header.channelsPerLight);
185190
const uint32_t startPixelU = offset / channelsPerLight;
186191
if (startPixelU >= static_cast<uint32_t>(layerP.lights.header.nrOfLights)) return;
187192

src/MoonLight/Nodes/Drivers/D_NetworkOut.h

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,12 @@ class NetworkOutDriver : public DriverNode {
6565
addControl(controllerIP3s, "controllerIPs", "text", 0, 32);
6666
addControl(port, "port", "number", 0, 65535);
6767
addControl(FPSLimiter, "Limiter", "number", 1, 255, false, "FPS");
68-
addControl(universeSize, "universeSize", "number", 0, 512);
68+
addControl(universeSize, "universeSize", "number", 1, 512);
6969
addControl(usedChannelsPerUniverse, "usedChannels", "number", 1, 1000, true);
70-
addControl(nrOfOutputsPerIP, "#Outputs per IP", "number", 0, 255);
71-
addControl(universesPerOutput, "universesPerOutput", "number", 0, 255);
70+
addControl(nrOfOutputsPerIP, "#Outputs per IP", "number", 1, 255);
71+
addControl(universesPerOutput, "universesPerOutput", "number", 1, 255);
7272
addControl(totalUniverses, "totalUniverses", "number", 0, 65538, true);
73-
addControl(channelsPerOutput, "channelsPerOutput", "number", 0, 65538);
73+
addControl(channelsPerOutput, "channelsPerOutput", "number", 1, 65538);
7474
addControl(totalChannels, "totalChannels", "number", 0, 390000, true);
7575
// Packet template is initialised by onUpdate("protocol") inside addControl(protocol,...) above.
7676
// Do NOT call setupArtNetHeader() here — it would overwrite the correct template for
@@ -122,6 +122,13 @@ class NetworkOutDriver : public DriverNode {
122122
}
123123
}
124124

125+
// Clamp controls to safe minimums — guards against old saved values of 0 and prevents
126+
// modulo-by-zero (universesPerOutput, nrOfOutputsPerIP) and unsigned wrap (channelsPerOutput).
127+
universeSize = max<uint16_t>(universeSize, layerP.lights.header.channelsPerLight ? layerP.lights.header.channelsPerLight : 1);
128+
channelsPerOutput = max<uint16_t>(channelsPerOutput, layerP.lights.header.channelsPerLight ? layerP.lights.header.channelsPerLight : 1);
129+
universesPerOutput = max<uint8_t> (universesPerOutput, 1);
130+
nrOfOutputsPerIP = max<uint8_t> (nrOfOutputsPerIP, 1);
131+
125132
totalChannels = layerP.lights.header.nrOfLights * layerP.lights.header.channelsPerLight / (nrOfIPAddresses ? nrOfIPAddresses : 1);
126133
uint32_t lightsPerUniverse = max(1u, (uint32_t)universeSize / layerP.lights.header.channelsPerLight);
127134
usedChannelsPerUniverse = lightsPerUniverse * layerP.lights.header.channelsPerLight;
@@ -206,7 +213,7 @@ class NetworkOutDriver : public DriverNode {
206213

207214
universe = 0;
208215
packetSize = 0;
209-
channels_remaining = channelsPerOutput;
216+
channels_remaining = max((uint32_t)channelsPerOutput, (uint32_t)header->channelsPerLight);
210217
processedOutputs = 0;
211218
uint8_t actualIPIndex = 0;
212219

@@ -241,7 +248,7 @@ class NetworkOutDriver : public DriverNode {
241248
addYield(10);
242249

243250
if (channels_remaining < header->channelsPerLight) {
244-
channels_remaining = channelsPerOutput;
251+
channels_remaining = max((uint32_t)channelsPerOutput, (uint32_t)header->channelsPerLight);
245252
while (universe % universesPerOutput != 0) universe++;
246253
processedOutputs++;
247254
if (!broadcast && processedOutputs >= nrOfOutputsPerIP) {
@@ -263,7 +270,7 @@ class NetworkOutDriver : public DriverNode {
263270

264271
bool sendDDPPacket(IPAddress& ip, uint32_t channelOffset, uint16_t dataLen, bool push) {
265272
uint8_t flags = DDP_FLAGS1_VER1 | (push ? DDP_FLAGS1_PUSH : 0);
266-
uint8_t dataType = (layerP.lights.header.channelsPerLight >= 4) ? DDP_TYPE_RGBW32 : DDP_TYPE_RGB24;
273+
uint8_t dataType = (layerP.lights.header.channelsPerLight == 4) ? DDP_TYPE_RGBW32 : DDP_TYPE_RGB24;
267274

268275
packet_buffer[0] = flags;
269276
packet_buffer[1] = sequenceNumber++ & 0x0F;
@@ -280,6 +287,10 @@ class NetworkOutDriver : public DriverNode {
280287
}
281288

282289
void loopDDP(LightsHeader* header) {
290+
// DDP only defines RGB24 (3 ch) and RGBW32 (4 ch). Reject other channel counts early
291+
// so no data is accumulated in packet_buffer with a mismatched stride.
292+
if (header->channelsPerLight != 3 && header->channelsPerLight != 4) return;
293+
283294
uint32_t lightsPerIP = header->nrOfLights / nrOfIPAddresses;
284295

285296
for (uint8_t ipIdx = 0; ipIdx < nrOfIPAddresses; ipIdx++) {
@@ -383,7 +394,7 @@ class NetworkOutDriver : public DriverNode {
383394

384395
uint16_t e131universe = 1; // E1.31 universes are 1-based
385396
uint16_t e131packetSize = 0;
386-
channels_remaining = channelsPerOutput;
397+
channels_remaining = max((uint32_t)channelsPerOutput, (uint32_t)header->channelsPerLight);
387398
processedOutputs = 0;
388399
uint8_t actualIPIndex = 0;
389400

@@ -416,7 +427,7 @@ class NetworkOutDriver : public DriverNode {
416427
addYield(10);
417428

418429
if (channels_remaining < header->channelsPerLight) {
419-
channels_remaining = channelsPerOutput;
430+
channels_remaining = max((uint32_t)channelsPerOutput, (uint32_t)header->channelsPerLight);
420431
while ((e131universe - 1) % universesPerOutput != 0) e131universe++; // advance to next output boundary
421432
processedOutputs++;
422433
if (processedOutputs >= nrOfOutputsPerIP) {

0 commit comments

Comments
 (0)