Skip to content

Conversation

@ewowi
Copy link
Collaborator

@ewowi ewowi commented Jan 17, 2026

Summary by CodeRabbit

  • New Features

    • Improved multi-light mapping so virtual lights update all mapped physical lights consistently.
  • Refactor

    • Reworked light/channel layout and layer APIs to unify mapping and per-channel access across layers.
    • Virtual layer iteration added for centralized per-light reads/writes.
  • Bug Fixes

    • Monitor/header size and parsing thresholds adjusted for compatibility.
    • Removed legacy per-effect white channel controls (affects some effect UIs).

✏️ Tip: You can customize this high-level summary in your review settings.

Frontend
========
- monitor: header reorg

backend
=======
- Nodes: lightPreset_MH19x15W24: has a white channel - WIP
- Physical layer: add nrOfLights_t_MAX, reorg header, 'park' white calculation in loopDrivers  - WIP
- Virtual layer: add forEachLightIndex, set/get functions use forEachLightIndex
- Lights Control, use updated header structure
- MoonLight effects: remove white in solid and fixed rectangle
@coderabbitai
Copy link

coderabbitai bot commented Jan 17, 2026

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key(s) in object: 'review', 'context'
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
📝 Walkthrough

Walkthrough

Refactors LightsHeader layout (adds explicit per-channel offsets and channelsPerLight), raises monitor header size from 41→47 bytes, migrates VirtualLayer to mapping-aware iteration (forEachLightIndex) with a new setLight/getLight API, updates call sites and driver presets to match the new offsets, and removes white members from some effects.

Changes

Cohort / File(s) Summary
Header Structure & Layout
src/MoonLight/Layers/PhysicalLayer.h, src/MoonLight/Layers/PhysicalLayer.cpp
Rewrote LightsHeader public layout: added isPositions, channelsPerLight, explicit offset fields (offsetRGB, offsetRed, offsetGreen, offsetBlue, offsetWhite, offsetBrightness, pan/tilt/zoom/rotate/gobo, RGB1/2/3, brightness2), added nrOfLights_t_MAX macro, updated reset/defaults and public header size comment.
Monitor & Protocol Header
interface/src/routes/moonbase/monitor/Monitor.svelte, src/MoonLight/Modules/ModuleLightsControl.h
Increased monitor/protocol header length checks from 41 → 47; adjusted parsing offsets for lightPreset, channelsPerLight, offsetRGB, offsetWhite, and related static assertions.
Virtual Layer API & Mapping
src/MoonLight/Layers/VirtualLayer.h, src/MoonLight/Layers/VirtualLayer.cpp
Added forEachLightIndex iterator; introduced new setLight(indexV, offset, value) and getLight(indexV, offset) APIs; reimplemented getRGB/getRGB1/2/3 and RGB setters to aggregate/write across mapped physical lights; removed older pointer-based set/get methods.
Call Site Updates & Drivers
src/MoonLight/Modules/ModuleChannels.h, src/MoonLight/Nodes/Drivers/D_ArtnetIn.h, src/MoonBase/Nodes.cpp
Updated all setLight call sites to new signature (indexV, offset, value); replaced some direct calls with forEachLightIndex lambdas and memcpy loops in Art-Net/DDP handlers; adjusted preset header offset initializations in DriverNode (moved/init offsetRGB, added offsetWhite for one preset).
Effects / White Channel Removal
src/MoonLight/Nodes/Effects/E_MoonLight.h
Removed public white members and associated UI registration/usage from SolidEffect and FixedRectangleEffect.
Commented Reference Code
src/MoonLight/Layers/PhysicalLayer.cpp
Added a commented-out white-channel stripping routine adjacent to layout mapping (no functional change).

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 In headers grown and offsets clear,

I hop through mappings far and near.
ForEachLight guides each tiny sprite,
White fades from two effects' sight.
Cheers — the lights align tonight! ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Channel reorg' is vague and generic. It does not convey specific information about what channel reorganization occurred or which components were affected, making it difficult for teammates to understand the main change from the title alone. Use a more descriptive title that specifies the nature of the reorganization, such as 'Refactor channel offset handling in light header and DMX mapping' or 'Reorganize light channel offsets and virtual layer mapping'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/MoonBase/Nodes.cpp`:
- Around line 526-529: The preset lightPreset_MH19x15W24 currently assigns both
header->offsetWhite and header->offsetBrightness to channel 3, which likely
conflicts; verify the MH 19x15W-24 DMX chart to confirm whether channel 3 is a
shared white/master brightness or if white is not present. If white is not a
separate channel, set header->offsetWhite = UINT8_MAX (disabled) for
lightPreset_MH19x15W24; otherwise change header->offsetWhite to the correct
channel number per the fixture manual so it no longer duplicates
header->offsetBrightness.

In `@src/MoonLight/Layers/VirtualLayer.h`:
- Around line 214-219: The getLight function leaves local variable value
uninitialized if forEachLightIndex never calls the callback; initialize value to
a safe default (e.g., 0) before the forEachLightIndex call so getLight always
returns a defined uint8_t. Update the getLight implementation (referencing
getLight and forEachLightIndex) to assign a default to value prior to invoking
the lambda and then proceed with the existing callback-based assignment.
- Around line 245-263: getRGB1/getRGB2/getRGB3 currently memcpy from
layerP->lights.channelsE using offsets that may be UINT8_MAX (disabled) or
out-of-range; add guards to return a defined default color when mapping is
missing or invalid. For each of getRGB1/getRGB2/getRGB3, check
layerP->lights.header.offsetRGB1/offsetRGB2/offsetRGB3 != UINT8_MAX and ensure
offset + sizeof(CRGB) <= layerP->lights.header.channelsPerLight before calling
forEachLightIndex/memcpy; if the check fails return a default CRGB (e.g.,
{0,0,0}) immediately. Ensure the memcpy remains inside the existing lambda but
only executed when the checks pass.
- Around line 112-142: The unmapped branch of forEachLightIndex passes a channel
index (computed as indexV * channelsPerLight) to callback, but callback expects
a physical light index; change the unmapped branch to pass the light index
(indexV) instead and keep the same bounds check using
layerP->lights.header.channelsPerLight and layerP->lights.maxChannels to ensure
the light exists. Locate the unmapped branch in forEachLightIndex (check
mappingTableSize, layerP->lights.header.channelsPerLight,
layerP->lights.maxChannels) and replace the callback(index) call with
callback(indexV) while preserving the existing bounds guard.
🧹 Nitpick comments (1)
src/MoonLight/Layers/PhysicalLayer.cpp (1)

123-134: Potential offset calculation issue in commented white-channel stripping code.

When this code is enabled, the offset calculation may be incorrect. The code sets light to point at offsetRGB within the light's channels, then accesses light[offsetRed], light[offsetGreen], etc. If offsetRed/Green/Blue are absolute offsets from the light's start (which appears to be the convention elsewhere), this would double-count the offset.

For example, with RGBW preset: offsetRGB=0, offsetRed=0, offsetGreen=1, offsetBlue=2, offsetWhite=3. The current code would work. But for WRGB: offsetRGB=?, offsetWhite=0, offsetRed=1, etc. — the semantics become unclear.

Consider clarifying the offset semantics before enabling this code:

Suggested clarification
-      uint8_t* light = &lights.channelsD[indexP * lights.header.channelsPerLight + lights.header.offsetRGB];
-      uint8_t white = MIN(MIN(light[lights.header.offsetRed], light[lights.header.offsetGreen]), light[lights.header.offsetBlue]);
+      uint8_t* light = &lights.channelsD[indexP * lights.header.channelsPerLight];
+      uint8_t white = MIN(MIN(light[lights.header.offsetRed], light[lights.header.offsetGreen]), light[lights.header.offsetBlue]);

Comment on lines 526 to 529
header->offsetWhite = 3;
header->offsetPan = 0;
header->offsetTilt = 1;
header->offsetBrightness = 3;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential conflict: offsetWhite and offsetBrightness both set to 3.

Both offsetWhite (line 526) and offsetBrightness (line 529) are assigned to channel 3 for the lightPreset_MH19x15W24 preset. This may cause unintended behavior if code writes to both offsets expecting different channels.

If channel 3 serves dual purpose (brightness control that also affects white output), this might be intentional. Otherwise, one of these assignments may be incorrect.

Please verify the DMX channel mapping for the MH 19x15W-24 moving head fixture:

  • Is channel 3 used for both white and master brightness?
  • Or should offsetWhite be UINT8_MAX (disabled) for this fixture?
🤖 Prompt for AI Agents
In `@src/MoonBase/Nodes.cpp` around lines 526 - 529, The preset
lightPreset_MH19x15W24 currently assigns both header->offsetWhite and
header->offsetBrightness to channel 3, which likely conflicts; verify the MH
19x15W-24 DMX chart to confirm whether channel 3 is a shared white/master
brightness or if white is not present. If white is not a separate channel, set
header->offsetWhite = UINT8_MAX (disabled) for lightPreset_MH19x15W24; otherwise
change header->offsetWhite to the correct channel number per the fixture manual
so it no longer duplicates header->offsetBrightness.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/MoonLight/Layers/VirtualLayer.h`:
- Around line 220-235: The local variable color in getRGB may be returned
uninitialized if the forEachLightIndex callback never runs; initialize color to
a safe default (e.g., CRGB::Black) before calling forEachLightIndex so that the
returned value is always well-defined; update the code path under the else
branch (where forEachLightIndex, layerP->lights.channelsE, and
channelsPerLight/offsetRGB are used) to set CRGB color = CRGB::Black; then leave
the forEachLightIndex lambda unchanged so it can overwrite color when invoked.
- Around line 112-141: In forEachLightIndex, the unmapped-branch bounds check
incorrectly uses indexV * (channelsPerLight + 1), which can reject valid tail
lights; replace that math with a proper channel base calculation and compare its
end against maxChannels: compute channelBase = indexV *
layerP->lights.header.channelsPerLight and use channelBase +
layerP->lights.header.channelsPerLight <= layerP->lights.maxChannels (then call
callback(indexV) as before). Update the else branch in forEachLightIndex to use
these symbols (indexV, layerP->lights.header.channelsPerLight,
layerP->lights.maxChannels) so valid lights near the end are not dropped.
♻️ Duplicate comments (1)
src/MoonBase/Nodes.cpp (1)

521-534: Verify offsetWhite vs offsetBrightness channel overlap for MH19x15W24.

Line 527 sets offsetWhite = 3 while Line 530 sets offsetBrightness = 3. If channel 3 isn’t a shared white/brightness control per the fixture’s DMX chart, this will conflict. Please confirm the mapping and adjust offsetWhite (or disable it) accordingly.

Comment on lines +112 to +141
template <typename Callback>
void forEachLightIndex(const nrOfLights_t indexV, Callback&& callback, bool onlyOne = false) {
if (indexV < mappingTableSize) {
switch (mappingTable[indexV].mapType) {
case m_oneLight: {
nrOfLights_t indexP = mappingTable[indexV].indexP;
// if (layerP->lights.header.lightPreset == lightPreset_RGB2040) {
// indexP += (indexP / 20) * 20;
// }
callback(indexP);
break;
}
case m_moreLights:
if (mappingTable[indexV].indexesIndex < mappingTableIndexes.size()) {
for (nrOfLights_t indexP : mappingTableIndexes[mappingTable[indexV].indexesIndex]) {
// if (layerP->lights.header.lightPreset == lightPreset_RGB2040) {
// indexP += (indexP / 20) * 20;
// }
callback(indexP);
if (onlyOne) return;
}
}
break;
}
} else { // no mappnig
if (indexV * (layerP->lights.header.channelsPerLight + 1) <= layerP->lights.maxChannels) { // make sure the light is in the channels array
callback(indexV);
}
}
}
Copy link

@coderabbitai coderabbitai bot Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix unmapped bounds guard math (skips valid lights).

Line 137 uses (channelsPerLight + 1) which rejects valid indices and can silently drop updates near the tail. Use channelBase + channelsPerLight <= maxChannels to match the intended bounds check.

🐛 Proposed fix
-    if (indexV * (layerP->lights.header.channelsPerLight + 1) <= layerP->lights.maxChannels) {  // make sure the light is in the channels array
-      callback(indexV);
-    }
+    const uint32_t channelBase = indexV * layerP->lights.header.channelsPerLight;
+    if (channelBase + layerP->lights.header.channelsPerLight <= layerP->lights.maxChannels) {  // make sure the light is in the channels array
+      callback(indexV);
+    }
🤖 Prompt for AI Agents
In `@src/MoonLight/Layers/VirtualLayer.h` around lines 112 - 141, In
forEachLightIndex, the unmapped-branch bounds check incorrectly uses indexV *
(channelsPerLight + 1), which can reject valid tail lights; replace that math
with a proper channel base calculation and compare its end against maxChannels:
compute channelBase = indexV * layerP->lights.header.channelsPerLight and use
channelBase + layerP->lights.header.channelsPerLight <=
layerP->lights.maxChannels (then call callback(indexV) as before). Update the
else branch in forEachLightIndex to use these symbols (indexV,
layerP->lights.header.channelsPerLight, layerP->lights.maxChannels) so valid
lights near the end are not dropped.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indexV will be used to point to the beginning of the array of channels for one light, so I need to make sure whatever is done withhin that light is not accessing channels out of the bounds. Agree?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ewowi, Yes, your principle is correct—you need to ensure all channels for light indexV stay within bounds. However, the current formula doesn't achieve that.

The issue:

  • Light indexV has channels from position indexV * channelsPerLight to indexV * channelsPerLight + channelsPerLight - 1
  • Current code: indexV * (channelsPerLight + 1) expands to indexV * channelsPerLight + indexV
  • This incorrectly adds indexV instead of channelsPerLight to the base offset

Example where it fails:

  • Say maxChannels = 300, channelsPerLight = 3, indexV = 99
  • Light 99's channels occupy positions 297, 298, 299 (all valid)
  • Current check: 99 * 4 = 396 <= 300fails (rejects valid light)
  • Correct check: 99 * 3 + 3 = 300 <= 300passes

The fix:

const uint32_t channelBase = indexV * layerP->lights.header.channelsPerLight;
if (channelBase + layerP->lights.header.channelsPerLight <= layerP->lights.maxChannels) {

This correctly ensures the last channel at channelBase + channelsPerLight - 1 is < maxChannels. Does this clarify the math?


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: src/MoonLight/Layers/VirtualLayer.cpp:164-171
Timestamp: 2026-01-06T15:24:17.118Z
Learning: In VirtualLayer::setLight and VirtualLayer::getLight (src/MoonLight/Layers/VirtualLayer.cpp), bounds checking in the unmapped path (when indexV >= mappingTableSize or oneToOneMapping is true) is intentionally disabled for performance when driving very large LED counts (100K+). Effect developers are responsible for ensuring their code stays within bounds. This is an explicit design decision prioritizing performance over defensive checks in the hot path.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: src/MoonLight/Layers/VirtualLayer.cpp:212-221
Timestamp: 2026-01-06T15:24:29.770Z
Learning: In VirtualLayer::getLight (src/MoonLight/Layers/VirtualLayer.cpp), bounds checking for unmapped lights (the else branch around lines 212-221) is intentionally disabled for performance when driving 100K+ LEDs. The design places responsibility on effect developers to stay within maxChannels. This is an explicit performance vs safety tradeoff for extreme LED counts.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-15T12:38:06.470Z
Learning: In MoonLight: VirtualLayer.size is ONLY synchronized with PhysicalLayer.lights.header.size during pass 2 (VirtualLayer::onLayoutPre at line 349 sets size = layerP->lights.header.size). If pass 2 (requestMapVirtual) doesn't run, VirtualLayer.size remains at its default {16, 16, 1}, causing effects to allocate buffers and iterate over the wrong dimensions. Pass 2 must always run after pass 1 to ensure VirtualLayer gets the correct size from PhysicalLayer.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-15T15:13:55.357Z
Learning: In MoonLight effectTask (src/main.cpp): The memcpy(channelsE, channelsD) at line 137 happens AFTER releasing swapMutex, allowing driverTask to swap the channelsD/channelsE pointers mid-copy, causing buffer corruption. This specifically breaks effects like GameOfLife that use getRGB() to read previous-frame data for blending decisions. Other effects work because they only read current-frame data they just wrote. Fix: hold swapMutex during the entire memcpy operation to prevent pointer swap during copy.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 84
File: src/MoonLight/Modules/ModuleLightsControl.h:367-379
Timestamp: 2025-12-28T10:28:24.157Z
Learning: In MoonLight PR `#84`: wrapping effectTask's layerP.loop() with monitorMutex while reading channelsE for monitor emission is thread-safe and prevents partial frames, but introduces serialization between effects rendering and monitor emission. Reading from channelsD (driver/front buffer) instead—with monitorMutex held only during the brief pointer swap—is superior because it maintains full parallelism (effects ║ monitor ║ driver), shows actual LED output, and minimizes mutex hold time.

Comment on lines +220 to +235
CRGB getRGB(const nrOfLights_t indexV) {
if (mappingTable[indexV].mapType == m_zeroLights) {
#ifdef BOARD_HAS_PSRAM
return *(CRGB*)&mappingTable[indexV].rgb;
#else
CRGB result;
((uint8_t*)&result)[0] = (mappingTable[indexV].rgb >> 9) << 3; // R: bits [13:9]
((uint8_t*)&result)[1] = ((mappingTable[indexV].rgb >> 4) & 0x1F) << 3; // G: bits [8:4]
((uint8_t*)&result)[2] = (mappingTable[indexV].rgb & 0x0F) << 4; // B: bits [3:0]
return result;
#endif
} else {
CRGB color;
forEachLightIndex(indexV, [&](nrOfLights_t indexP) { memcpy(&color, &layerP->lights.channelsE[indexP * layerP->lights.header.channelsPerLight + layerP->lights.header.offsetRGB], sizeof(color)); }, true);
return color;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Initialize color before forEachLightIndex to avoid UB.

If the callback isn’t invoked, color is uninitialized and you return garbage. Initialize to CRGB::Black first.

🐛 Proposed fix
-      CRGB color;
+      CRGB color = CRGB::Black;
       forEachLightIndex(indexV, [&](nrOfLights_t indexP) { memcpy(&color, &layerP->lights.channelsE[indexP * layerP->lights.header.channelsPerLight + layerP->lights.header.offsetRGB], sizeof(color)); }, true);
       return color;
🤖 Prompt for AI Agents
In `@src/MoonLight/Layers/VirtualLayer.h` around lines 220 - 235, The local
variable color in getRGB may be returned uninitialized if the forEachLightIndex
callback never runs; initialize color to a safe default (e.g., CRGB::Black)
before calling forEachLightIndex so that the returned value is always
well-defined; update the code path under the else branch (where
forEachLightIndex, layerP->lights.channelsE, and channelsPerLight/offsetRGB are
used) to set CRGB color = CRGB::Black; then leave the forEachLightIndex lambda
unchanged so it can overwrite color when invoked.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 17, 2026

@coderabbitai, part of this PR is solving an issue with RGBW lights (or WRGB etc). I used a white calculator as follows in setRGB:

if (layerP->lights.header.offsetWhite != UINT8_MAX && layerP->lights.header.channelsPerLight == 4) { // W set
// using the simple algorithm of taking the minimum of RGB as white channel, this is good enough and fastest algorithm - we need speed 🔥
uint8_t rgbw[4];
rgbw[3] = MIN(MIN(color.r, color.g), color.b);
rgbw[0] = color.red - rgbw[3]; // subtract from other channels
rgbw[1] = color.green - rgbw[3];
rgbw[2] = color.blue - rgbw[3];
setLight(indexV, rgbw, layerP->lights.header.offsetRGB, sizeof(rgbw));
} else
setLight(indexV, color.raw, layerP->lights.header.offsetRGB, sizeof(color));

but this caused flickering. In this PR I removed it and placed it in driverLoop, but that also caused flickering so I commented it:

// if (lights.header.channelsPerLight == 4 && lights.header.offsetWhite != UINT8_MAX) {
// for (int indexP = 0; indexP < lights.header.nrOfLights; indexP++) {
// uint8_t* light = &lights.channelsD[indexP * lights.header.channelsPerLight + lights.header.offsetRGB];
// uint8_t white = MIN(MIN(light[lights.header.offsetRed], light[lights.header.offsetGreen]), light[lights.header.offsetBlue]);
// if (white) {
// light[lights.header.offsetWhite] = white;
// light[lights.header.offsetRed] -= white;
// light[lights.header.offsetGreen] -= white;
// light[lights.header.offsetBlue] -= white;
// }
// }
// }

I think the problem is that it does the correction in the channels array so when iterating through frames it pollutes the channel array, also a getRGB after a setRGB will give wrong data (as setRGB lowered the RGB channels).

So I think I need to solve this in each of the drivers separately:

  • parallel LED driver uses I2S clockless driver for ESP32-D0 and ESP32-S3), uses parlio driver for esp32-P4
  • Artnet-out driver,
  • FastLED driver
  • ...

Can you do a thorough analysis of the problem and analyze my proposal to solve it?

@coderabbitai
Copy link

coderabbitai bot commented Jan 17, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the PhysicalLayer.cpp file with the commented white channel code
cat src/MoonLight/Layers/PhysicalLayer.cpp | head -200

Length of output: 6635


🏁 Script executed:

#!/bin/bash
# Look at VirtualLayer setRGB and getRGB implementations
cat src/MoonLight/Layers/VirtualLayer.cpp | grep -A 30 "void VirtualLayer::setRGB"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at driver files to understand the output architecture
fd -e h -e cpp "D_.*\.h$|D_.*\.cpp$" src/MoonLight/Nodes/Drivers/ --exec echo "=== {} ===" \; --exec head -50 {}

Length of output: 13863


🏁 Script executed:

#!/bin/bash
# Look for the parallel LED driver implementation
cat src/MoonLight/Nodes/Drivers/D_ParallelLED.h | head -100

Length of output: 75


🏁 Script executed:

#!/bin/bash
# Look at how drivers access the channel data
rg -n "channelsD\[" src/MoonLight/Nodes/Drivers/ -C 3

Length of output: 657


🏁 Script executed:

#!/bin/bash
# Look at the VirtualLayer.h to see the new setRGB implementation
cat src/MoonLight/Layers/VirtualLayer.h | grep -B 5 -A 40 "void setRGB"

Length of output: 5901


🏁 Script executed:

#!/bin/bash
# Look at the ArtNet Out driver loop to see how it sends data
cat src/MoonLight/Nodes/Drivers/D_ArtnetOut.h | grep -B 10 -A 30 "void loop"

Length of output: 1458


🏁 Script executed:

#!/bin/bash
# Look at FastLED driver loop to see how it sends data
cat src/MoonLight/Nodes/Drivers/D_FastLED.h | grep -B 5 -A 50 "void loop"

Length of output: 2080


🏁 Script executed:

#!/bin/bash
# Find the parallel LED driver file (might have different name)
fd "Parallel" src/MoonLight/Nodes/Drivers/

Length of output: 49


🏁 Script executed:

#!/bin/bash
# Look at ParallelLEDDriver to see how it sends data
cat src/MoonLight/Nodes/Drivers/D_ParallelLEDDriver.h | grep -B 5 -A 80 "void loop"

Length of output: 3632


@ewowi Your analysis is spot on! You've correctly identified the root cause, and your proposed solution is the right architectural approach. Let me provide a thorough breakdown:

Why Both Previous Approaches Failed

❌ White Calc in setRGB (VirtualLayer)

// Effects write full RGB
setRGB(indexV, CRGB(255, 255, 255));

// White calc immediately modifies channelsE:
rgbw[0] = 255 - 255 = 0;  // R reduced
rgbw[1] = 255 - 255 = 0;  // G reduced  
rgbw[2] = 255 - 255 = 0;  // B reduced
rgbw[3] = 255;            // W set

// Later in same frame or next frame:
CRGB color = getRGB(indexV);  // Returns (0,0,0) NOT (255,255,255)!

Problem: Effects that call getRGB() for blending, fading, or reading previous frame data get corrupted reduced RGB values instead of the original colors they wrote. This breaks effects like GameOfLife, blur, and any effect doing getRGB() + newColor blending.

❌ White Calc in loopDrivers (PhysicalLayer)

// Frame N: channelsE has RGB(255,255,255)
// [swap] channelsD becomes RGB(255,255,255)
// loopDrivers modifies channelsD → RGB(0,0,0), W(255)
// [swap] this corrupted buffer becomes channelsE
// Frame N+1: effects read RGB(0,0,0) instead of RGB(255,255,255)

Problem: You're modifying channelsD in the driver task, which becomes channelsE on the next swap. This creates frame-to-frame pollution through the buffer cycling, similar to the memcpy corruption issue from [learning 18e4aaad]. Effects read back reduced values from the previous frame.

✅ Why Driver-Based Solution is Correct

Your proposed approach treats white channel calculation as a read-only output transformation, not a buffer modification:

Effects (channelsE) → [swap] → Drivers (channelsD) → [white calc] → Hardware
     Write RGB(255,255,255)            Read RGB(255,255,255)      Output RGBW(0,0,0,255)

Key Benefits:

  1. Data Integrity: channelsE and channelsD always contain original RGB values
  2. getRGB() Works: Effects always read back exactly what they wrote
  3. No Frame Pollution: No cross-frame corruption through buffer cycling
  4. Thread-Safe: Read-only transformation in driver task
  5. Clean Separation: Effects express intent (RGB), drivers handle hardware details (RGBW)

This is exactly like color correction and brightness—it's an output-stage transformation, not a buffer-modifying operation.

Implementation Guidance

For each driver, apply white calculation just before sending to hardware:

ParallelLEDDriver

Good news: show_parlio already receives offsetWhite parameter (line ~56 in D_ParallelLEDDriver.h)! The underlying driver may already support this or is prepared for it. Check if the parlio/Yves driver implementations already handle the white calculation internally, or add it in the show functions where they iterate over channelsD.

ArtNetOutDriver

Around line 155-158 where it copies channels:

uint8_t* c = &layerP.lights.channelsD[indexP * header->channelsPerLight];
// Add white calculation HERE before/after memcpy
// Read R,G,B, calculate white, write reduced R,G,B,W to packet_buffer

FastLEDDriver

FastLED accesses channelsD via pointer set during onLayout. You may need to:

  • Create a temporary transformation buffer, OR
  • Pre-transform before FastLED.show(), OR
  • Check if FastLED has RGBW support built-in

Performance Consideration

Calculating white on every frame output is acceptable because:

  • You already do color correction per-frame in drivers (ArtNet line 159+)
  • The algorithm is simple: MIN(MIN(R,G,B))
  • Correctness > micro-optimization
  • Only happens in driver task, not effect task

Algorithm Consistency

Use the same algorithm across all drivers:

uint8_t white = MIN(MIN(r, g), b);
r -= white;
g -= white;  
b -= white;
// Output: r, g, b, white

Your proposal is architecturally sound and aligns with the existing pattern of output transformations in drivers. This is the correct solution! 🎯


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 84
File: src/MoonLight/Modules/ModuleLightsControl.h:367-379
Timestamp: 2025-12-28T10:28:24.157Z
Learning: In MoonLight PR `#84`: wrapping effectTask's layerP.loop() with monitorMutex while reading channelsE for monitor emission is thread-safe and prevents partial frames, but introduces serialization between effects rendering and monitor emission. Reading from channelsD (driver/front buffer) instead—with monitorMutex held only during the brief pointer swap—is superior because it maintains full parallelism (effects ║ monitor ║ driver), shows actual LED output, and minimizes mutex hold time.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.067Z
Learning: For MoonLight on ESP32 at extreme loads (e.g., 49K channels @ 100 fps): Task watchdog can fire when AppDrivers/AppEffects process many nodes without yielding. Even with vTaskDelay(1) at loop end, the node iteration for-loops (PhysicalLayer::loopDrivers line 108-111, PhysicalLayer::loop line 73-75) can take >5 seconds without yielding, starving IDLE task (priority 0). Solution: add taskYIELD() or vTaskDelay(1) inside the node/layer iteration loops to give IDLE task chances to run and reset watchdog. Lowering task priority won't help since IDLE is already at 0 (lowest).

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: src/MoonLight/Layers/VirtualLayer.cpp:212-221
Timestamp: 2026-01-06T15:24:29.770Z
Learning: In VirtualLayer::getLight (src/MoonLight/Layers/VirtualLayer.cpp), bounds checking for unmapped lights (the else branch around lines 212-221) is intentionally disabled for performance when driving 100K+ LEDs. The design places responsibility on effect developers to stay within maxChannels. This is an explicit performance vs safety tradeoff for extreme LED counts.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-15T15:13:55.357Z
Learning: In MoonLight effectTask (src/main.cpp): The memcpy(channelsE, channelsD) at line 137 happens AFTER releasing swapMutex, allowing driverTask to swap the channelsD/channelsE pointers mid-copy, causing buffer corruption. This specifically breaks effects like GameOfLife that use getRGB() to read previous-frame data for blending decisions. Other effects work because they only read current-frame data they just wrote. Fix: hold swapMutex during the entire memcpy operation to prevent pointer swap during copy.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:34:40.097Z
Learning: For the MoonLight LED system: Effects task can run on Core 0 (PRO_CPU) since it only performs computation (calculating colors, writing to channels array) without timing-critical hardware interaction. WiFi preemption is acceptable for pure computation tasks. Only the driver task (using DMA, I2S, LCD, PARLIO) requires Core 1 (APP_CPU) for uninterrupted timing-critical hardware operations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants