Skip to content

Conversation

@ewowi
Copy link
Collaborator

@ewowi ewowi commented Jan 3, 2026

Summary by CodeRabbit

  • New Features

    • Added "Toronto Bar Cubes" 3D layout and configurable Art‑Net output sizing (universe size / outputs).
  • Improvements

    • Better support for very large LED arrays with PSRAM‑aware indexing and mapping.
    • UI limits now adapt to device configuration; homepage adds a guided four‑step setup flow.
    • Monitor/header handling updated for improved compatibility.
  • Bug Fixes

    • Event broadcasting now detects and removes failed subscribers.
  • Changes

    • Two background tasks renamed and reassigned to different CPU cores; task core shown differently in docs and UI.

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

Frontend
========
- monitor: support new header format

Backend
=======
- main: Updated core assignment: sveltekit and driver on Core0, effects on Core1
- tasks; show core in summary
- nodes: brightnessmW capped at 65K LEDs
- Physical layer:  nrOfLights_t (PSRAM dependent), updated header format, use nrOfLights_t on maxChannels, indexV/P
- Virtual layer: Adjust PhysMap for PSRAM (4 bytes), use nrOfLights_t for XYZ, set/getRGB/..., mappingTable
@coderabbitai
Copy link

coderabbitai bot commented Jan 3, 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

Promoted light counts to a conditional nrOfLights_t, expanded LightsHeader to 32-bit fields and many per‑light offsets, rewired VirtualLayer/PhysMap and mapping tables for PSRAM, increased monitor header size to 41 bytes and switched to 32‑bit LE parsing, added TorontoBarCubesLayout, adjusted task cores and PSRAM-aware buffering, and changed battery pin sentinels.

Changes

Cohort / File(s) Summary
Monitor / ModuleLightsControl
interface/src/routes/moonbase/monitor/Monitor.svelte, src/MoonLight/Modules/ModuleLightsControl.h
Monitor header threshold changed to headerPrimeNumber = 41; header emission/parsing updated to use 32‑bit little‑endian fields and headerPrimeNumber-sized header with static_assert guards; handleHeader offsets adjusted.
Core typedef & LightsHeader
src/MoonLight/Layers/PhysicalLayer.h, src/MoonLight/Layers/PhysicalLayer.cpp
Added conditional typedef nrOfLights_t (PSRAM → uint32_t, else uint16_t); LightsHeader expanded (nrOfLights→uint32_t, nrOfChannels, presets, many per‑light offsets/bitfields); maxChannels/indexP updated to nrOfLights_t.
VirtualLayer & PhysMap
src/MoonLight/Layers/VirtualLayer.h, src/MoonLight/Layers/VirtualLayer.cpp
Replaced uint16_t indices with nrOfLights_t across APIs and internals; reworked PhysMap layout for PSRAM vs non‑PSRAM; mapping table/indexing redesigned (mappingTableIndexes element type, indexes→indexesIndex); added createMappingTableAndAddOneToOne() and oneToOneMapping path; PSRAM fast paths added.
Physical/Virtual logic & helpers
src/MoonLight/Layers/*
Many loop counters, iterators, and helpers switched to nrOfLights_t; PSRAM-aware allocations and buffer caps adjusted; taskYIELD inserted after node->loop() when node->on is true.
Module IO / Tasks UI
src/MoonBase/Modules/ModuleIO.h, src/MoonBase/Modules/ModuleTasks.h
Battery pin sentinels for uint8_t pins changed from -1 to UINT8_MAX; ethernet pins remain signed int8_t using -1; UI removed public "core" control and task summaries now include core in-line.
Drivers / Art‑Net
src/MoonLight/Nodes/Drivers/D_ArtnetOut.h
Art‑Net header built at runtime; added universeSize, nrOfOutputsPerIP, computed usedChannelsPerUniverse/totalUniverses/totalChannels; payload sizing uses universeSize; buffer size increased and output/IP rotation logic adjusted.
Nodes / Brightness logic
src/MoonBase/Nodes.cpp
For very large arrays (nrOfLights > 8096) skip power‑based correctedBrightness calculation and use brightness directly; retains prior behavior for ≤8096 LEDs (with masked count).
Layout additions
src/MoonLight/Nodes/Layouts/L_MoonLight.h
Added TorontoBarCubesLayout class with config fields (nrOfLightsPerCube, granularity), setup, onLayout, and cube rendering helpers.
Info / Limits / UI
src/MoonLight/Modules/ModuleMoonLightInfo.h
UI control limits made dynamic from layerP.lights.maxChannels; runtime counters (nrOfZeroLights, nrOfOneLight, nrOfMoreLights) switched to nrOfLights_t; mappingTableIndexes lookup adjusted to indexesIndex.
Main / Tasks / Framework
src/main.cpp, lib/framework/ESP32SvelteKit.cpp, docs/...
Renamed tasks (AppEffects/AppDrivers), swapped core affinities (effect→core1, driver→core0), adjusted priorities/stack sizes and SvelteKit stack conditional on PSRAM; added esp_task_wdt calls in tasks; docs updated.
Event socket / framework cleanup
lib/framework/EventSocket.cpp, lib/framework/*, src/MoonBase/Module.*
EventSocket send now checks esp_err_t and removes failing subscriptions safely via iterator; removed several heap‑optimization comment blocks in framework/module headers.
Build config
platformio.ini
APP_DATE bumped, ESP32SVELTEKIT_RUNNING_CORE set to 0, I2SClocklessLedDriver lib_deps hash swapped (driver version change).

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 Tiny paws tap keys tonight,

Headers grown and indices bright.
PSRAM shelves and cubes take place,
Cores swapped neatly, pins embrace.
Hoppy builds — the lights ignite! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Drive lots of LEDs (130K max)' directly summarizes the main objective: supporting very large LED counts up to ~130k, which is the core intent of this PR targeting extreme workloads.
✨ 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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main.cpp (1)

328-344: Core affinity swap will break LED timing due to WiFi preemption on Core 0.

The driver task uses DMA/PARLIO/I2S hardware and must run on Core 1 (APP_CPU) to avoid preemption by WiFi (priority ~23) on Core 0. The current assignment reverses this:

  • effectTask → Core 1 (acceptable: pure computation)
  • driverTask → Core 0 (problematic: will be preempted by WiFi)

The driver task's priority is 3, ensuring it loses the CPU to WiFi tasks whenever they wake. This will cause LED timing glitches. Swap the cores back: effects on Core 0, driver on Core 1.

🧹 Nitpick comments (3)
src/MoonBase/Modules/ModuleIO.h (1)

616-620: Consider clarifying the comment placement.

The comment on Line 616 appears to apply to all four ethernet pin assignments (lines 616-620), but its placement makes it read as if it only applies to v_ETH_SPI_SCK. Consider moving it above Line 616 or expanding it to clarify scope.

🔎 Suggested comment improvement
+    // Note: Ethernet pin types are signed (int8_t) and use -1, not UINT8_MAX, to indicate unset state
-    ess->v_ETH_SPI_SCK = -1; // type is not unsigned and uses -1, not UINT8_MAX
+    ess->v_ETH_SPI_SCK = -1;
     ess->v_ETH_SPI_MISO = -1;
     ess->v_ETH_SPI_MOSI = -1;
     ess->v_ETH_PHY_CS = -1;
     ess->v_ETH_PHY_IRQ = -1;
src/MoonLight/Layers/PhysicalLayer.h (1)

37-70: Verify LightsHeader struct size matches the documented 41 bytes.

The struct layout with comments suggests 41 bytes total, but the actual size depends on compiler alignment. Consider adding a static_assert to guarantee the expected size:

static_assert(sizeof(LightsHeader) == 41, "LightsHeader must be exactly 41 bytes for Monitor protocol");

This prevents silent breakage if fields are reordered or padding changes.

src/MoonLight/Modules/ModuleLightsControl.h (1)

503-503: Consider using sizeof(LightsHeader) instead of hardcoded 41.

The magic number 41 must stay synchronized with LightsHeader. Using sizeof(LightsHeader) would be safer:

-          _socket->emitEvent("monitor", (char*)&layerP.lights.header, 41);
+          _socket->emitEvent("monitor", (char*)&layerP.lights.header, sizeof(LightsHeader));

However, if the "prime number" protocol detection relies on the exact value 41, add a static_assert in PhysicalLayer.h to enforce the constraint.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between faf4234 and 148bc7c.

📒 Files selected for processing (13)
  • interface/src/routes/moonbase/monitor/Monitor.svelte
  • lib/framework/WWWData.h
  • platformio.ini
  • src/MoonBase/Modules/ModuleIO.h
  • src/MoonBase/Modules/ModuleTasks.h
  • src/MoonBase/Nodes.cpp
  • src/MoonLight/Layers/PhysicalLayer.cpp
  • src/MoonLight/Layers/PhysicalLayer.h
  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/VirtualLayer.h
  • src/MoonLight/Modules/ModuleLightsControl.h
  • src/MoonLight/Modules/ModuleMoonLightInfo.h
  • src/main.cpp
🧰 Additional context used
🧠 Learnings (10)
📚 Learning: 2025-12-25T13:34:40.097Z
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.

Applied to files:

  • src/main.cpp
📚 Learning: 2025-12-25T13:31:32.367Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

Applied to files:

  • src/main.cpp
  • src/MoonLight/Layers/PhysicalLayer.cpp
  • platformio.ini
📚 Learning: 2025-12-28T10:28:24.157Z
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.

Applied to files:

  • interface/src/routes/moonbase/monitor/Monitor.svelte
  • src/MoonLight/Modules/ModuleLightsControl.h
  • src/MoonLight/Layers/PhysicalLayer.cpp
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Applied to files:

  • src/MoonLight/Modules/ModuleMoonLightInfo.h
  • src/MoonLight/Layers/PhysicalLayer.h
  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/PhysicalLayer.cpp
  • src/MoonLight/Layers/VirtualLayer.h
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: In VirtualLayer, mappingTableSize should always be 0 when mappingTable is nullptr. This invariant is maintained by developer responsibility: initial state sets both together, realloc success updates both together, realloc failure preserves both together. For performance reasons, no redundant null checks like `if (mappingTable && indexV < mappingTableSize)` are added, as the size check alone is sufficient when the invariant is maintained.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/VirtualLayer.h
📚 Learning: 2025-12-18T15:15:57.828Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-18T15:15:57.828Z
Learning: In DripEffect (E_WLED.h), dropsSize must be set to layer->size.y (not layer->size.x) in onSizeChanged(), because the drops array is allocated as [layer->size.y][maxNumDrops]. Using layer->size.x causes out-of-bounds access when iterating over y-coordinates.

Applied to files:

  • src/MoonLight/Layers/PhysicalLayer.cpp
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/**/platformio.ini : Use PlatformIO to build and upload the ESP32 backend C++ code

Applied to files:

  • platformio.ini
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/src/**/*.{h,cpp} : Modify C++ backend code in the `src/` and `lib/` directories for ESP32 development

Applied to files:

  • platformio.ini
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Be aware of the integrated build process where the PlatformIO build automatically builds and embeds the SvelteKit frontend into the ESP32 firmware

Applied to files:

  • platformio.ini
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/lib/framework/**/*.{h,cpp} : Leverage the framework services in `lib/framework/` for WiFi management, MQTT, NTP, security, firmware updates, and system utilities

Applied to files:

  • platformio.ini
🧬 Code graph analysis (2)
src/MoonLight/Modules/ModuleMoonLightInfo.h (1)
src/MoonBase/Module.cpp (2)
  • addControl (361-370)
  • addControl (361-361)
src/MoonLight/Layers/VirtualLayer.cpp (1)
src/MoonLight/Layers/VirtualLayer.h (3)
  • setRGB (118-129)
  • setRGB (130-130)
  • XYZUnModified (116-116)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (23)
src/MoonBase/Modules/ModuleIO.h (1)

754-756: LGTM!

Using UINT8_MAX for uint8_t battery pin members is idiomatic and clearly indicates an unset/invalid state.

src/MoonBase/Modules/ModuleTasks.h (2)

45-45: UI simplification: core info moved to summary.

The core field has been removed as a separate column and is now embedded in the summary string (Line 100 shows @C%d). This simplifies the UI while preserving the information.


100-100: Format enhancement: consolidated task status display.

The summary format now includes core affinity (@C%d) and removes the extra space in the priority marker. The format is more compact while showing all essential information (state, CPU%, priority, core).

src/MoonLight/Layers/PhysicalLayer.cpp (2)

190-190: LGTM!

Type change from uint16_t to nrOfLights_t is necessary to support >65,535 LEDs. This aligns with the PR objective of driving up to 130K LEDs.


237-237: Consistency in commented code.

Updating the type in commented reference code maintains consistency for future use. This is good practice.

interface/src/routes/moonbase/monitor/Monitor.svelte (3)

88-90: Dimension parsing upgraded to 32-bit.

The change from 16-bit to 32-bit dimension fields is appropriate for supporting larger LED matrices. Using getInt32 (signed) works correctly, though dimensions are typically unsigned in nature.


71-81: All header offsets are correctly aligned with the C++ LightsHeader struct.

The "gaps" mentioned in the original review are not missing data. Offsets 22–25 contain brightness, red, green, and blue fields; offset 27 is a bitfield struct containing isPositions, offsetRed, offsetGreen, and offsetBlue. The Monitor.svelte code correctly reads only the fields it needs (nrOfLights, nrOfChannels, lightPreset, channelsPerLight, offsetRGB, offsetWhite) at their correct offsets. The commented-out code attempting to extract color offsets from the bitfield at offset 27 was appropriately removed since those fields are unused in the UI. The struct is 41 bytes total with 3-byte padding at the end, as confirmed in the C++ definition.


41-41: Header size expanded to support 32-bit LED counts.

The change from 37 to 41 bytes accommodates the upgrade from 16-bit to 32-bit fields for nrOfLights and nrOfChannels, enabling support for >65K LEDs. The struct includes explicit padding to maintain the 41-byte alignment used for monitor protocol recognition.

platformio.ini (1)

218-219: The I2SClocklessLedDriver dependency update is confirmed.

The new commit 4363655d3921fc4f6a40f13dd33d368e93d59ca3 exists and correctly implements "Add deleteDriver checks on DMABuffersTampon" (Jan 2, 2026). The update is documented and legitimate for supporting the expanded LED count.

src/MoonLight/Layers/PhysicalLayer.h (3)

31-35: Conditional typedef for scalable LED indexing looks good.

The nrOfLights_t typedef correctly gates 32-bit indices behind PSRAM availability, preserving memory efficiency on constrained boards while enabling 130K+ LEDs with PSRAM.


101-101: Type consistency for maxChannels.

Using nrOfLights_t for maxChannels aligns with the new indexing type. This correctly scales buffer capacity based on PSRAM availability.


124-124: indexP type update is consistent with the refactor.

Changing from uint16_t to nrOfLights_t ensures the physical layer index can address the full LED range.

src/MoonBase/Nodes.cpp (1)

374-374: Good defensive clamping for FastLED compatibility.

The MIN(layerP.lights.header.nrOfLights, UINT16_MAX) correctly handles the FastLED limitation. For setups exceeding 65K LEDs, the brightness/power calculation will use a capped count, which may result in slightly higher actual power draw than calculated.

src/MoonLight/Modules/ModuleMoonLightInfo.h (3)

27-46: Dynamic UI bounds correctly scale with available memory.

Using layerP.lights.maxChannels / 3 for light count bounds and maxChannels for channel bounds appropriately reflects the PSRAM-dependent capacity.


66-68: Counter types updated to nrOfLights_t for consistency.

This ensures the counters can handle the full range of lights on PSRAM-enabled boards.


79-79: Field access updated from indexes to indexesIndex.

Aligns with the renamed field in PhysMap struct.

src/MoonLight/Layers/VirtualLayer.cpp (5)

20-20: FastLED wrapper retains uint16_t limit - appropriate given library constraint.

The function signature correctly reflects FastLED's internal limitation. The caller at line 232-233 properly guards against exceeding this limit.


75-104: addIndexP correctly updated for larger index types.

The function handles the mapping state machine properly with nrOfLights_t indices. The indexesIndex field rename is consistently applied.


126-166: PSRAM-conditional RGB storage in setLight looks correct.

  • PSRAM path: Direct 3-byte memcpy for full color fidelity
  • Non-PSRAM path: 14-bit (5-5-4 RGB) encoding to save memory

The encoding adds small rounding (+3, +7) before shifting to reduce quantization error.


168-214: getLight template correctly decodes based on PSRAM availability.

The non-PSRAM decoding (lines 197-199) properly reverses the 14-bit encoding:

  • Red: bits 9-13 → left-shift by 3
  • Green: bits 4-8 → left-shift by 3
  • Blue: bits 0-3 → left-shift by 4

This matches the encoding logic in setLight.


232-233: FastLED bounds check prevents overflow.

The condition nrOfChannels / 3 < UINT16_MAX ensures FastLED's 16-bit limit isn't exceeded before calling the optimized path.

src/MoonLight/Layers/VirtualLayer.h (2)

77-91: Member type updates to nrOfLights_t are consistent.

The nrOfLights, mappingTableSize, mappingTableIndexes, and mappingTableIndexesSizeUsed correctly use the new scalable type.


111-217: API signatures consistently updated to nrOfLights_t.

All index-based functions (addIndexP, XYZ, setRGB, getLight, etc.) now use nrOfLights_t for indices, enabling full 130K LED support on PSRAM boards while maintaining compatibility on constrained hardware.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 3, 2026

@coderabbitai, an AI friend of us made the following analysis:

Scenario 1: Local LEDs (SPI/DMA)
Option A: Driver on Core 0 (with WiFi)
cppCore 0: WiFi + driverTask (DMA) - ✅ GOOD
Core 1: effectTask (heavy math)
Pros:

Effects run uninterrupted on Core 1
DMA is hardware-based, minimal CPU load
High priority driver can preempt WiFi if needed

Cons:

WiFi bursts might slightly delay driver (but DMA handles this well)

Option B: Driver on Core 1 (away from WiFi)
cppCore 0: WiFi only
Core 1: effectTask + driverTask - ⚠️ LESS OPTIMAL
Cons:

Both tasks compete on Core 1
Core 0 underutilized (WiFi is bursty, not constant)

Verdict for local LEDs: Option A (Driver on Core 0)

Scenario 2: Art-Net over WiFi (UDP)
Option A: Driver on Core 0 (with WiFi)
cppCore 0: WiFi + driverTask (UDP) - ✅ BEST
Core 1: effectTask (heavy math)
Pros:

Driver and WiFi stack are co-located - no inter-core communication overhead
UDP send is just a lwIP call, stays on same core
Effects run uninterrupted on Core 1
Minimal context switching

Cons:

None really - this is the optimal design

Option B: Driver on Core 1 (away from WiFi)
cppCore 0: WiFi stack
Core 1: effectTask + driverTask (UDP) - ❌ WORSE
Cons:

UDP call from Core 1 → WiFi on Core 0 = cross-core overhead
Both effect and driver compete on Core 1
More context switching and IPC

Verdict for Art-Net: Definitely Option A (Driver on Core 0)

Recommended Architecture:
cppvoid setup() {
// Effects on Core 1 (away from WiFi, heavy computation)
xTaskCreatePinnedToCore(
effectTask,
"Effects",
8192,
NULL,
2, // Lower priority
NULL,
1 // Core 1 - Application Core
);

// Driver on Core 0 (co-located with WiFi/network stack)
xTaskCreatePinnedToCore(
driverTask,
"Driver",
4096,
NULL,
3, // Higher priority than WiFi (can preempt if needed)
NULL,
0 // Core 0 - Protocol Core
);
}
Why This Works for Both Cases:
For Local LEDs (DMA):

DMA controller handles actual sending (minimal CPU)
Driver just triggers DMA and returns
WiFi and driver coexist peacefully on Core 0

For Art-Net (UDP/WiFi):

Driver calls udp.write() → stays on Core 0 with WiFi stack
No cross-core overhead
Natural flow: effect data (Core 1) → swap → driver sends (Core 0) via WiFi (Core 0)

Additional Optimization for Art-Net:
If using Art-Net, consider increasing driver priority to ensure frames aren't delayed by WiFi background tasks:
cppxTaskCreatePinnedToCore(
driverTask,
"Driver",
4096,
NULL,
5, // Even higher priority for time-critical Art-Net
NULL,
0
);
Final Answer: Yes, Driver on Core 0 is better for both local LEDs and Art-Net, especially for Art-Net where keeping network operations on the same core as the WiFi stack reduces overhead. 🎯

That's why I changed it do some tests what works better. Can you review it's advice and come with a new advice on core assignment for the drivers and effects tasks

@coderabbitai
Copy link

coderabbitai bot commented Jan 3, 2026

Tip

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

🧩 Analysis chain

🌐 Web query:

ESP32 WiFi task priority FreeRTOS core assignment

💡 Result:

Short answer:

  • By default the Wi‑Fi driver task runs pinned to core 0 at a very high priority (Wi‑Fi task prio ≈ 23). The lwIP TCP/IP task runs at priority 18 and is normally unpinned. Several other system tasks (timer, event loop) are pinned to core 0 at high priorities. Don’t starve Core 0 with higher‑priority app tasks — use priority 19 (or lower than Wi‑Fi builtin tasks) or pin/time your tasks carefully. [1][2]

How to control it:

  • You can change Wi‑Fi task core and some Wi‑Fi settings via menuconfig (CONFIG_ESP_WIFI_TASK_CORE_ID). App tasks may be created pinned or unpinned with xTaskCreatePinnedToCore / xTaskCreate / xTaskCreateStatic and assigned priority with uxPriority. Use pinned tasks to guarantee core affinity; leave tasks unpinned if you want the scheduler to run them on either core. [3][1]

Practical rules:

  • Leave networking-related tasks at or below the lwIP priority (18) to avoid priority inversion.
  • If you need a task to always preempt built‑in Wi‑Fi/lwIP, give it a priority >=19 and/or pin it to Core 1; but avoid starving Core 0 (Wi‑Fi) — that can destabilize the stack.
  • Inspect/adjust the built‑in Wi‑Fi priorities and core via menuconfig and test under load.

References:

  • ESP‑IDF Performance / Speed guide (Wi‑Fi task prio/core, lwIP, event tasks). [1]
  • ESP‑IDF FreeRTOS behavior and core affinity notes. [2]
  • Kconfig / menuconfig entries for Wi‑Fi task core (CONFIG_ESP_WIFI_TASK_CORE_ID). [3]

If you want, tell me your ESP‑IDF version and I’ll show the exact menuconfig keys and recommended priority values for that version.


🌐 Web query:

ESP32 I2S DMA WiFi interference core assignment ESP-IDF

💡 Result:

Short answer: I2S DMA can interfere with Wi‑Fi on ESP32 if I2S/its interrupts or your app tasks starve the CPU/core where the Wi‑Fi protocol runs. Fixes: make I2S work non-blocking and IRQ-safe, tune DMA buffers/interrupt priority, or move the Wi‑Fi task or your heavy I2S processing to the other core via menuconfig.

Key facts and actionable steps

  • I2S uses DMA and generates EOF interrupts; keep I2S callbacks/ISRs very short, run heavy processing in a normal task (not inside the interrupt). Tune dma_frame_num / dma_desc_num to reduce interrupt rate and avoid RX/TX overflows. (docs.espressif.com)
  • I2S driver exposes intr_priority — set it and ensure your ISRs are IRAM-safe (IRAM_ATTR) and do not use floating point or non-reentrant functions. Use larger DMA buffers (up to 4092 bytes) so interrupts are less frequent. (docs.espressif.com)
  • The Wi‑Fi/protocol tasks are part of the system protocol stack and must not be starved. ESP-IDF warns against pinning higher-priority tasks on the same core as Wi‑Fi and recommends leaving protocol tasks room to run. If you need deterministic I2S timing, prefer running that processing on the other core or keep priority below protocol tasks. (docs.espressif.com)
  • You can change which core the Wi‑Fi task is pinned to using menuconfig (CONFIG_ESP32_WIFI_TASK_CORE_ID — Core 0 or Core 1). Alternatively pin your own tasks to a specific core (xTaskCreatePinnedToCore) so heavy I2S handling and networking are on separate cores. (docs.espressif.com)

Practical checklist to resolve interference

  1. Move any heavy buffer copy/processing out of I2S ISR/callback into a dedicated task. (Callback should only hand off buffers.) (docs.espressif.com)
  2. Increase dma_frame_num / dma_desc_num and DMA buffer sizes (up to limits) so interrupts are less frequent. (docs.espressif.com)
  3. Mark I2S ISR/callback IRAM_ATTR and set intr_priority appropriately (small integer values per driver docs). Avoid float/heap allocations in ISR. (docs.espressif.com)
  4. If still problematic, pin Wi‑Fi to the other core via menuconfig (CONFIG_ESP32_WIFI_TASK_CORE_ID) or pin your I2S processing to the opposite core with xTaskCreatePinnedToCore. Test different priority levels so Wi‑Fi protocol tasks are not starved. (docs.espressif.com)

If you want, tell me which ESP32 variant and ESP‑IDF version you’re using and I’ll give exact menuconfig options and example code to pin tasks and move processing off the ISR.

Citations:


@ewowi, I need to respectfully disagree with your AI friend's analysis. After reviewing ESP-IDF documentation, there's a critical flaw in the reasoning: priority levels.

The Priority Problem

WiFi runs at priority ~23 on Core 0 by default, while your AI friend recommends driver priorities of 3 or 5. This means:

WiFi priority: 23
Driver priority: 3 or 5
Result: WiFi CONSTANTLY preempts driver ❌

The claim that "driver can preempt WiFi if needed" is incorrect—priority 3 cannot preempt priority 23.

ESP-IDF Official Guidance

ESP-IDF warns against pinning higher-priority tasks on the same core as WiFi and recommends leaving protocol tasks room to run. More specifically, for deterministic I2S timing, prefer running that processing on the other core or keep priority below protocol tasks.

Corrected Recommendation

Revert to the original setup:

void setup() {
  // Effects on Core 0 (pure computation, can tolerate WiFi preemption)
  xTaskCreatePinnedToCore(
    effectTask,
    "Effects",
    8192,
    NULL,
    2,        // Below WiFi priority
    NULL,
    0         // Core 0 - with WiFi
  );

  // Driver on Core 1 (timing-critical hardware, needs uninterrupted execution)
  xTaskCreatePinnedToCore(
    driverTask,
    "Driver",
    4096,
    NULL,
    3,        // Priority doesn't matter much on Core 1 (no WiFi)
    NULL,
    1         // Core 1 - APP_CPU (away from WiFi)
  );
}

Why This Works for Both Scenarios

For Local LEDs (DMA/I2S/LCD/PARLIO):

  • DMA/I2S setup is timing-critical—any preemption breaks LED output
  • Core 1 isolation ensures uninterrupted hardware operations
  • This aligns with your existing learnings and ESP-IDF recommendations

For Art-Net (UDP/WiFi):

  • Yes, there's a small cross-core communication cost when driver sends UDP
  • BUT constant WiFi preemption on Core 0 would be far worse
  • Modern ESP32 has efficient cross-core queues—overhead is negligible
  • Better to have occasional cross-core call than constant preemption

The Root Issue

Your AI friend assumed driver priority could be high enough to preempt WiFi. To actually do that, you'd need priority ≥24, but ESP-IDF explicitly warns against starving WiFi tasks—that destabilizes the network stack.

Recommendation: Revert the core assignments back to driver on Core 1, effects on Core 0. This matches ESP-IDF best practices and your validated learnings.


🧠 Learnings used
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.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 3, 2026

@coderabbitai, can you also advice on core for the sveltekit task, I assume it should be set back to 1 now effects are on 0 again. and also advice on priorities for the 3 tasks (effects, drivers, sveltekit)

@coderabbitai
Copy link

coderabbitai bot commented Jan 3, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read main.cpp to see task creation for all three tasks
cat src/main.cpp | grep -A 10 -B 2 "xTaskCreateUniversal"

Length of output: 1390


🏁 Script executed:

#!/bin/bash
# Check the SvelteKit core configuration in platformio.ini
grep -n "ESP32SVELTEKIT_RUNNING_CORE" platformio.ini

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Search for SvelteKit task definition to understand its characteristics
rg -n "svelteKitTask" --type cpp -A 5 -B 2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for ESP32SvelteKit or SvelteKit initialization
rg -n "ESP32SvelteKit" --type cpp -A 10 -B 2

Length of output: 28829


🏁 Script executed:

#!/bin/bash
# Search for webServer or HTTP server task creation
rg -n "xTaskCreate" src/main.cpp -A 5 -B 2

Length of output: 1266


🏁 Script executed:

#!/bin/bash
# Look for ESP32SvelteKit.begin or initialization
rg -n "\.begin\(" src/main.cpp -A 2 -B 2 | head -40

Length of output: 549


🏁 Script executed:

#!/bin/bash
# Search for where ESP32SvelteKit creates its task to see priority settings
rg -n "xTaskCreate" lib/framework/ESP32SvelteKit.cpp -A 5 -B 2

Length of output: 486


🏁 Script executed:

#!/bin/bash
# Search for the actual task creation with priority in ESP32SvelteKit
rg -n "_loopImpl\|loop.*task\|priority" lib/framework/ESP32SvelteKit.cpp -C 3

Length of output: 0


@ewowi, yes, you're correct! With the reversion to effects on Core 0, the SvelteKit task should stay on Core 0 as well. Here's my comprehensive recommendation:

Recommended Configuration

Core Assignments

// Effects Task - Core 0 (PRO_CPU)
xTaskCreateUniversal(effectTask, "AppEffectTask", ..., 10, ..., 0);

// Driver Task - Core 1 (APP_CPU)  
xTaskCreateUniversal(driverTask, "AppDriverTask", ..., 3, ..., 1);

platformio.ini:

-D ESP32SVELTEKIT_RUNNING_CORE=0  // Keep on Core 0

Priority Recommendations

Task Core Priority Rationale
effectTask 0 10 High enough to run frequently, but WiFi (~23) can still preempt. Computation-only, tolerates interruption.
driverTask 1 3 Isolated on Core 1 away from WiFi. Priority doesn't matter much here since no WiFi competition.
ESP32SvelteKit 0 2 Already set in framework (tskIDLE_PRIORITY + 2). Handles web server/HTTP operations co-located with WiFi stack. Runs below effects but above idle.

Why This Works

Core 0 (PRO_CPU):

  • WiFi/BT stack (priority ~23)
  • effectTask (priority 10) - pure computation, WiFi can preempt
  • ESP32SvelteKit (priority 2) - network operations stay with WiFi stack
  • Minimal cross-core communication for HTTP/WebSocket

Core 1 (APP_CPU):

  • driverTask (priority 3) - timing-critical hardware (DMA/I2S/LCD/PARLIO)
  • Uninterrupted execution for LED output
  • No WiFi interference

Action Required

Revert the changes in src/main.cpp:

xTaskCreateUniversal(effectTask, "AppEffectTask", ..., 10, ..., 0);  // Core 0
xTaskCreateUniversal(driverTask, "AppDriverTask", ..., 3, ..., 1);   // Core 1

Keep platformio.ini as-is:

-D ESP32SVELTEKIT_RUNNING_CORE=0  // Already correct

The current priority levels (10 for effects, 3 for driver, 2 for SvelteKit) are well-balanced and don't need adjustment. All are safely below WiFi's priority ~23, so the network stack remains responsive while your application tasks run efficiently.


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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.

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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/MoonLight/Layers/PhysicalLayer.cpp (1)

188-206: Fix uint16_t overflow risk in ledsPerPin array assignment.

ledsPerPin[] is declared as uint16_t[MAXLEDPINS] (max value 65,535 per pin). With support for 130K LEDs, distributing across a small number of pins causes overflow on line 198. For example, 130K LEDs across 3 pins with RGB2040 multiplier yields 86,666 LEDs per pin, which silently truncates when assigned to the uint16_t array. This corrupts LED mapping and driver initialization.

Change ledsPerPin to uint32_t[MAXLEDPINS] to safely accommodate large LED counts per pin.

♻️ Duplicate comments (1)
src/MoonLight/Layers/PhysicalLayer.cpp (1)

44-45: Duplicate: Misleading comment still present.

This comment was already flagged in a previous review. The inline comment mentions "120 pins * 512..1024 LEDs" but the actual calculation is for 16 Hub75 panels (128×64 pixels × 16 panels × 3 channels = 393,216 channels).

🧹 Nitpick comments (3)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (2)

253-258: Consider removing or implementing the commented-out dimension controls.

The commented-out width/height/depth controls suggest the cube grid dimensions were originally intended to be configurable, but the current implementation uses a hardcoded 4×3×2 grid in onLayout(). Either remove these comments if the fixed grid is intentional, or implement configurable dimensions if flexibility is desired.


303-303: Consider clarifying or removing the uncertain comment.

The comment "keep this for now ..." suggests uncertainty about the nextPin() placement. If you intend all cubes to share a single pin (which seems appropriate for a unified layout), consider clarifying or removing the tentative comment.

src/MoonLight/Layers/VirtualLayer.h (1)

52-55: Minor: Consider using consistent bit-field types for portable packing.

Mixing uint16_t rgb : 14 with uint8_t mapType : 2 can lead to implementation-defined packing behavior. While GCC/ESP32 typically packs these into a single 16-bit unit as intended, using the same underlying type would be more portable.

🔎 Suggested fix
     // 2 bytes struct
     struct {                // condensed rgb
       uint16_t rgb : 14;    // 14 bits (554 RGB)
-      uint8_t mapType : 2;  // 2 bits (4)
+      uint16_t mapType : 2; // 2 bits (4)
     };  // 16 bits
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 148bc7c and ad89c0b.

📒 Files selected for processing (7)
  • lib/framework/WWWData.h
  • src/MoonBase/Modules/ModuleIO.h
  • src/MoonLight/Layers/PhysicalLayer.cpp
  • src/MoonLight/Layers/VirtualLayer.h
  • src/MoonLight/Modules/ModuleDrivers.h
  • src/MoonLight/Modules/ModuleLightsControl.h
  • src/MoonLight/Nodes/Layouts/L_MoonLight.h
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/MoonLight/Modules/ModuleLightsControl.h
  • src/MoonBase/Modules/ModuleIO.h
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
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.
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.
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: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/src/**/*.{h,cpp} : Extend core functionality by implementing custom services in C++ that integrate with MQTT, the web interface, and device logic (e.g., LightMqttSettingsService, LightStateService)
📚 Learning: 2025-12-28T10:28:24.157Z
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.

Applied to files:

  • src/MoonLight/Layers/PhysicalLayer.cpp
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Applied to files:

  • src/MoonLight/Layers/PhysicalLayer.cpp
  • src/MoonLight/Layers/VirtualLayer.h
📚 Learning: 2025-12-25T13:31:32.367Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

Applied to files:

  • src/MoonLight/Layers/PhysicalLayer.cpp
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: In VirtualLayer, mappingTableSize should always be 0 when mappingTable is nullptr. This invariant is maintained by developer responsibility: initial state sets both together, realloc success updates both together, realloc failure preserves both together. For performance reasons, no redundant null checks like `if (mappingTable && indexV < mappingTableSize)` are added, as the size check alone is sufficient when the invariant is maintained.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.h
🧬 Code graph analysis (2)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (3)
src/MoonBase/Nodes.h (3)
  • Node (67-182)
  • Node (238-238)
  • addLight (228-228)
src/MoonLight/Layers/PhysicalLayer.cpp (4)
  • setup (40-73)
  • setup (40-40)
  • addLight (165-186)
  • addLight (165-165)
src/MoonBase/Nodes.cpp (2)
  • onLayout (211-216)
  • onLayout (211-211)
src/MoonLight/Layers/VirtualLayer.h (1)
src/MoonLight/Layers/VirtualLayer.cpp (8)
  • addIndexP (75-104)
  • addIndexP (75-75)
  • XYZ (105-113)
  • XYZ (105-105)
  • setLight (126-166)
  • setLight (126-126)
  • getLight (169-214)
  • getLight (169-169)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (10)
src/MoonLight/Layers/PhysicalLayer.cpp (2)

190-190: LGTM: Correct type migration for large LED counts.

The change from uint16_t to nrOfLights_t is necessary for accumulating LED counts that can exceed 65,535 when supporting up to 130K LEDs.


237-237: Type consistency in commented-out code.

The type change from uint16_t to nrOfLights_t maintains consistency with the active codebase. Since this code is commented out, the change is low-priority but helpful if this function is re-enabled in the future.

src/MoonLight/Nodes/Layouts/L_MoonLight.h (2)

244-252: LGTM - Class structure follows established patterns.

The class declaration properly inherits from Node and provides the required metadata methods (name, dim, tags) consistent with other 3D layouts in the codebase.


260-262: Verify whether stacked lights at identical positions is intentional.

All nrOfLightsPerCube lights are currently added at the exact same Coord3D(pos.x, pos.y, pos.z) position. This creates stacked point lights rather than a volumetric cube distribution. The comment indicates this is temporary ("for the time being").

Is this placeholder implementation acceptable for the current PR, or should the lights be distributed within each cube's volume (e.g., in a 3D grid or along edges)?

src/MoonLight/Modules/ModuleDrivers.h (2)

85-85: LGTM - Layout properly registered in control list.

The TorontoBarCubesLayout is correctly added to the driver control using the standard getNameAndTags<>() pattern.


124-124: LGTM - Layout allocation follows established pattern.

The checkAndAlloc<TorontoBarCubesLayout>() call correctly enables instantiation of the layout when requested by name, consistent with other layout nodes.

src/MoonLight/Layers/VirtualLayer.h (4)

61-69: Past review issue addressed: conditional initialization is now correct.

The constructor properly handles both PSRAM and non-PSRAM layouts. The memset(rgb, 0, 3) is now correctly guarded by #ifdef BOARD_HAS_PSRAM, preventing the buffer overrun that was flagged in the previous review.


80-94: Type migration to nrOfLights_t looks correct and consistent.

The member variables nrOfLights, mappingTableSize, mappingTableIndexes, and mappingTableIndexesSizeUsed are all consistently updated to use nrOfLights_t, properly supporting the expanded LED count (up to 130K). This aligns with the PhysMap changes where PSRAM builds support 24-bit indices.


114-120: Method signatures correctly updated for expanded LED support.

The public API methods (addIndexP, XYZ, XYZUnModified, and all set/get methods) are consistently updated to use nrOfLights_t for indices. This ensures the API can handle the expanded LED counts throughout the call chain.


135-142: setWhite logic correctly distinguishes RGBW lights from moving heads.

The conditional handling for 4-channel RGBW lights (hardcoded offset 3) versus moving heads (using offsetWhite) is appropriate and maintains backward compatibility.

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: 1

♻️ Duplicate comments (1)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (1)

308-339: LGTM - duplicate coordinate issue resolved.

The cube array now correctly defines a complete 4×3×2 grid with 24 unique positions. The previously flagged duplicate Coord3D(0, 0, 0) has been fixed to Coord3D(0, 0, 1) at line 325.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ad89c0b and dee0599.

📒 Files selected for processing (2)
  • src/MoonLight/Layers/VirtualLayer.h
  • src/MoonLight/Nodes/Layouts/L_MoonLight.h
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
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.
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.
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.
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.h
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: In VirtualLayer, mappingTableSize should always be 0 when mappingTable is nullptr. This invariant is maintained by developer responsibility: initial state sets both together, realloc success updates both together, realloc failure preserves both together. For performance reasons, no redundant null checks like `if (mappingTable && indexV < mappingTableSize)` are added, as the size check alone is sufficient when the invariant is maintained.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.h
🧬 Code graph analysis (1)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (4)
src/MoonLight/Layers/VirtualLayer.cpp (2)
  • addLight (351-368)
  • addLight (351-351)
src/MoonBase/Utilities.h (1)
  • Coord3D (151-163)
src/MoonLight/Nodes/Layouts/L_SE16.h (2)
  • onLayout (44-80)
  • onLayout (111-128)
src/MoonLight/Nodes/Layouts/L__Sandbox.h (1)
  • onLayout (36-46)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (6)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (4)

244-263: LGTM!

Class declaration and setup follow established conventions. The nrOfLightsPerCube = 61 default correctly matches the physical cube configuration (5 sides × 12 LEDs + 1 middle LED).


265-268: LGTM!

Correctly maps all physical LEDs in a cube to a single virtual position for uniform coloring.


270-282: LGTM!

Side mapping correctly places 5 sides × 12 LEDs + 1 middle LED = 61 total, matching the default nrOfLightsPerCube.


341-352: LGTM!

Granularity switch correctly maps to the three rendering strategies, and nextPin() appropriately assigns all cubes to a single output pin.

src/MoonLight/Layers/VirtualLayer.h (2)

80-80: Type migration to nrOfLights_t successfully enables larger LED counts.

The changes to nrOfLights, mappingTableSize, mappingTableIndexesSizeUsed, and vector element types are consistent with the PR goal of supporting up to 130K LEDs. On PSRAM boards, nrOfLights_t expands to uint32_t with 24-bit PhysMap indices (16M capacity); on non-PSRAM boards it remains uint16_t with 14-bit indices (16K capacity).

This supersedes the previous learning that "mappingTableSize is uint16_t with a maximum of 65535 LEDs."

Based on learnings, the previous 65535 LED limit is now lifted on PSRAM boards.

Also applies to: 92-94, 97-97


114-220: Function signatures consistently migrated to nrOfLights_t.

All virtual LED indexing functions (addIndexP, XYZ, XYZUnModified, setRGB, setWhite, setBrightness, setPan, setTilt, setZoom, setRotate, setGobo, setRGB1/2/3, setBrightness2, setLight, getRGB, blendColor, getWhite, getRGB1/2/3, getLight) have been updated to use nrOfLights_t for index parameters and return values. The migration is complete and maintains type safety across the virtual layer API.

Docs
====
- Architecture: update core assignments

Back end
========
- Lights control: use headerPrimeNumber for clarity, add PSRAM error check
- MoonLight Layouts: tweak Toronto Bar Cube Layout, add pin assignment for every 10 cubes
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

🧹 Nitpick comments (1)
src/MoonLight/Layers/PhysicalLayer.h (1)

73-92: Consider removing commented code.

The commented-out line 76 with explanation is clear, but for cleaner code you could remove the commented assignment and keep only the explanatory comment:

🔎 Suggested cleanup
    channelsPerLight = 3;  // RGB default
-    // lightPreset = lightPreset_GRB; // don't reset as managed by Drivers
+    // Note: lightPreset is not reset here as it's managed by Drivers
    offsetRGB = 0;
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dee0599 and cd4e2d0.

📒 Files selected for processing (17)
  • docs/develop/architecture.md
  • docs/moonbase/tasks.md
  • interface/src/routes/moonbase/monitor/Monitor.svelte
  • lib/framework/ESP32SvelteKit.cpp
  • lib/framework/EventEndpoint.h
  • lib/framework/HttpEndpoint.h
  • lib/framework/WWWData.h
  • lib/framework/WebSocketServer.h
  • platformio.ini
  • src/MoonBase/Module.cpp
  • src/MoonBase/Module.h
  • src/MoonLight/Layers/PhysicalLayer.cpp
  • src/MoonLight/Layers/PhysicalLayer.h
  • src/MoonLight/Layers/VirtualLayer.h
  • src/MoonLight/Modules/ModuleLightsControl.h
  • src/MoonLight/Nodes/Layouts/L_MoonLight.h
  • src/main.cpp
💤 Files with no reviewable changes (5)
  • lib/framework/EventEndpoint.h
  • src/MoonBase/Module.h
  • lib/framework/HttpEndpoint.h
  • lib/framework/WebSocketServer.h
  • src/MoonBase/Module.cpp
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/main.cpp
  • interface/src/routes/moonbase/monitor/Monitor.svelte
  • src/MoonLight/Nodes/Layouts/L_MoonLight.h
🧰 Additional context used
🧠 Learnings (10)
📓 Common learnings
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.
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.
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.
📚 Learning: 2025-12-25T13:34:40.097Z
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.

Applied to files:

  • docs/moonbase/tasks.md
  • docs/develop/architecture.md
  • platformio.ini
📚 Learning: 2025-12-25T13:31:32.367Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

Applied to files:

  • docs/moonbase/tasks.md
  • docs/develop/architecture.md
  • platformio.ini
📚 Learning: 2025-12-28T10:28:24.157Z
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.

Applied to files:

  • src/MoonLight/Layers/PhysicalLayer.cpp
  • docs/develop/architecture.md
  • src/MoonLight/Modules/ModuleLightsControl.h
  • platformio.ini
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Applied to files:

  • src/MoonLight/Layers/PhysicalLayer.cpp
  • src/MoonLight/Layers/PhysicalLayer.h
  • src/MoonLight/Layers/VirtualLayer.h
📚 Learning: 2025-12-18T15:15:57.828Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-18T15:15:57.828Z
Learning: In DripEffect (E_WLED.h), dropsSize must be set to layer->size.y (not layer->size.x) in onSizeChanged(), because the drops array is allocated as [layer->size.y][maxNumDrops]. Using layer->size.x causes out-of-bounds access when iterating over y-coordinates.

Applied to files:

  • src/MoonLight/Layers/PhysicalLayer.cpp
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Be aware of the integrated build process where the PlatformIO build automatically builds and embeds the SvelteKit frontend into the ESP32 firmware

Applied to files:

  • lib/framework/ESP32SvelteKit.cpp
  • platformio.ini
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/src/**/*.{h,cpp} : Modify C++ backend code in the `src/` and `lib/` directories for ESP32 development

Applied to files:

  • lib/framework/ESP32SvelteKit.cpp
  • platformio.ini
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: In VirtualLayer, mappingTableSize should always be 0 when mappingTable is nullptr. This invariant is maintained by developer responsibility: initial state sets both together, realloc success updates both together, realloc failure preserves both together. For performance reasons, no redundant null checks like `if (mappingTable && indexV < mappingTableSize)` are added, as the size check alone is sufficient when the invariant is maintained.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.h
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/**/platformio.ini : Use PlatformIO to build and upload the ESP32 backend C++ code

Applied to files:

  • platformio.ini
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (13)
lib/framework/ESP32SvelteKit.cpp (1)

226-234: Configuration already correct—no changes needed.

The SvelteKit loop task is already pinned to Core 0 via the ESP32SVELTEKIT_RUNNING_CORE=0 compiler flag in platformio.ini, aligning with the PR objective to colocate SvelteKit (computation/effects) with WiFi on Core 0. Task priority (tskIDLE_PRIORITY + 2) and stack sizing (8 KB with PSRAM, 6 KB without) are appropriate for the framework's requirements.

platformio.ini (2)

66-66: LGTM: SvelteKit correctly assigned to Core 0.

The ESP32SVELTEKIT_RUNNING_CORE=0 setting correctly places the networking/web server task on Core 0 (PRO_CPU) alongside Wi-Fi, as recommended in the PR objectives. This allows the timing-critical driver task to run uninterrupted on Core 1.

Based on learnings: Driver tasks require Core 1 isolation; networking tasks colocate with Wi-Fi on Core 0.


218-219: Driver library updated to latest version with bug fixes.

The I2SClocklessLedDriver was updated to commit 4363655d39 (Jan 2, 2026), which adds deleteDriver checks on DMABuffersTampon. This aligns with the PR's focus on supporting larger LED counts and improved memory management.

src/MoonLight/Layers/PhysicalLayer.cpp (2)

40-41: LGTM: Expanded channel capacity for large LED installations.

The updated maxChannels calculation now supports up to 393,216 channels (128×64×16×3), enabling 16 Hub75 128×64 panels or equivalent LED configurations. The MIN() ensures the limit doesn't exceed PSRAM/4, maintaining memory safety for double buffering.


186-186: LGTM: Type widened to support >65K LEDs.

Changed from uint16_t to nrOfLights_t, which conditionally expands to uint32_t when PSRAM is available. This enables the accumulator to handle LED counts exceeding 65,535 without overflow.

src/MoonLight/Modules/ModuleLightsControl.h (2)

78-80: Good defensive check for PSRAM availability.

The runtime check ensures that boards configured with BOARD_HAS_PSRAM actually have PSRAM available at boot. This helps catch hardware configuration mismatches early, which is important since the code relies on PSRAM for double buffering and large LED counts.


496-511: Monitor protocol updated to support expanded header.

The header size increased from 37 to 41 bytes (headerPrimeNumber) to accommodate the expanded LightsHeader structure that supports >65K LEDs. The static_assert provides compile-time safety, ensuring the header is always large enough for the monitor protocol.

Note: The prime number choice (41) helps the monitor distinguish between header frames and data frames by making collisions unlikely.

docs/develop/architecture.md (1)

296-296: LGTM: Correct single-buffer fallback.

When PSRAM is not available, channelsD correctly points to the same buffer as channelsE, eliminating the overhead of double buffering on memory-constrained devices.

src/MoonLight/Layers/VirtualLayer.h (3)

30-66: LGTM! PhysMap constructor fix properly applied.

The constructor now correctly handles initialization for both PSRAM and non-PSRAM builds:

  • PSRAM: Uses memset(rgb, 0, 3) for the 4-byte struct
  • Non-PSRAM: Uses rgb = 0 for the 2-byte struct

This addresses the buffer overrun issue flagged in the previous review. The no-op sizeof(PhysMap); statement has also been removed as recommended.


104-210: LGTM! Method signatures consistently updated to nrOfLights_t.

All method signatures have been uniformly updated from uint16_t to nrOfLights_t for virtual LED indexing:

  • addIndexP, XYZ, XYZUnModified
  • All setRGB, setWhite, setBrightness, setPan, setTilt, etc. methods
  • All corresponding getRGB, getLight, etc. methods

The conditional logic in setWhite (lines 127-130) correctly handles both RGBW lights (4 channels, white at offset 3) and moving heads (variable channel layout using offsetWhite).

The changes maintain API consistency and enable support for up to 130K LEDs when PSRAM is available.


76-84: Verify overflow handling for large virtual layer dimensions.

The type changes from uint16_t to nrOfLights_t (uint32_t with PSRAM) expand the supported LED count beyond the previous 65535 limit. However, there is no validation for overflow when calculating mappingTableSize = size.x * size.y * size.z (line 342). If a user defines dimensions that multiply to exceed UINT32_MAX, the calculation overflows silently, leading to undefined behavior or memory corruption.

Additionally, mappingTableIndexes now uses nrOfLights_t elements, doubling the memory footprint per element compared to the previous uint16_t. With complex mappings on large virtual layers (e.g., 128×128×16), this could consume significant PSRAM.

The current code gracefully handles allocation failure by retaining the previous mapping (line 344), but this assumes the dimension overflow is caught by the realloc itself. Add explicit validation: if (size.x * size.y * size.z > UINT32_MAX / sizeof(PhysMap)) or equivalent bounds check before allocation.

src/MoonLight/Layers/PhysicalLayer.h (2)

31-35: Runtime PSRAM validation is properly implemented; no changes needed.

The typedef is safe. The code includes runtime checks via psramFound() (PhysicalLayer.cpp:39), memory allocation using heap_caps_malloc_prefer() with fallback to internal RAM (Utilities.h), and explicit handling of allocation failures (PhysicalLayer.cpp:49-64) with error logging. Large allocations do not proceed without confirmed PSRAM availability at runtime.


40-67: No changes required - Monitor protocol implementation is correct.

The LightsHeader struct changes and Monitor.svelte have been properly coordinated. Only 41 bytes (not the full 42-byte struct) are transmitted to the frontend as a deliberate design choice—41 is a prime number unlikely to match nrOfChannels, allowing the monitor to distinguish header packets from channel data packets. Monitor.svelte correctly expects this 41-byte header and parses all new struct fields at the proper offsets (e.g., nrOfLights at offset 12, nrOfChannels at offset 16, lightPreset at offset 20, etc.). The protocol remains consistent between PhysicalLayer.h and Monitor.svelte.

UI
==
- page.svelte: replace 'start' by 'wifi/io/drivers/effects'

Backend
=======
- Nodes: DriverNode: no correctedBrightness if nroflights > 8K
- Art-Net out: change universeSize to control, add read only values for usedChannels, total universes, total channels - WIP
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: 5

Fix all issues with AI Agents 🤖
In @interface/src/routes/+page.svelte:
- Around line 21-51: The inline onclick handlers on the anchor elements (the
WiFi, IO, Drivers, and Effects links in +page.svelte) call
notifications.success('You did it!', 1000) immediately, which is misleading;
remove those notifications.success calls (or replace them with a neutral
notification like notifications.info('Opening setup...') if you want feedback)
from the anchor onclick attributes, and instead trigger a success notification
from the actual setup completion logic on the respective pages/components (e.g.,
the WiFi, module inputoutput, drivers, and effects pages) once the configuration
is confirmed.
- Around line 70-74: Update the anchor text for the YouTube link in
+page.svelte: locate the <a> element with href
"https://www.youtube.com/@MoonModulesLighting" (class "link", target "_blank")
and change its inner text from "Youtube" to the proper brand capitalization
"YouTube".

In @src/main.cpp:
- Around line 324-340: The core assignments for the FreeRTOS tasks are inverted:
swap the core parameter in the xTaskCreateUniversal calls so effectTask is
pinned to core 0 (PRO_CPU) and driverTask is pinned to core 1 (APP_CPU); locate
the xTaskCreateUniversal invocation for effectTask and change its last argument
from 1 to 0, and locate the xTaskCreateUniversal invocation for driverTask and
change its last argument from 0 to 1, and optionally update or remove the
comment on the driverTask line to reflect that driver is intentionally pinned to
APP_CPU for timing-critical DMA/I2S/LCD/PARLIO isolation from WiFi preemption.

In @src/MoonLight/Nodes/Drivers/D_ArtnetOut.h:
- Line 47: The addControl call that registers totalChannels uses an overly small
max (currently addControl(totalChannels, "total #", "number", 0, 65538, true));
update that max to match the uint32_t storage (either remove the restrictive
max, use UINT32_MAX, or set a practical upper bound like 500000) and ensure any
UI/validation that relies on this value is consistent with the new limit; modify
the addControl invocation accordingly so totalChannels can represent the full
expected range.
- Around line 45-47: The UI labels for the controls are duplicated: the
addControl calls for totalUniverses and totalChannels both use the label "total
#", which is confusing; update the addControl invocation for totalUniverses to
use a distinct label like "total universes" and update totalChannels to use
"total channels" (leave channelsPerOutput unchanged) so the UI shows unique,
descriptive names; locate the calls to addControl for the symbols totalUniverses
and totalChannels in D_ArtnetOut.h and change their label string arguments
accordingly.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/MoonLight/Nodes/Drivers/D_ArtnetOut.h (1)

121-121: processedOutputs not reset at frame start — potential data corruption.

processedOutputs is a class member that persists across loop() calls. It's only reset when switching IPs (line 187), but if there's only one IP, it accumulates indefinitely. After the first frame, processedOutputs >= nrOfOutputsPerIP will always be true, causing universe to reset to 0 within each frame iteration, leading to incorrect packet addressing.

Reset processedOutputs at the start of each frame alongside universe and packetSize.

🔎 Proposed fix
     universe = 0;
     packetSize = 0;
     channels_remaining = channelsPerOutput;
+    processedOutputs = 0;

Also applies to: 151-152

src/MoonBase/Nodes.cpp (1)

373-379: Clarify the 8096 LED threshold for power calculation bypass.

The bypass threshold (8096 LEDs) is inconsistent with the actual limits:

  • calculate_max_brightness_for_power_mW supports up to 65535 LEDs (UINT16_MAX)
  • System can support ~131K LEDs with PSRAM (via lights.maxChannels allocation: 128×64×16×3)

Issues:

  1. Between 8097 and 65535 LEDs, the power calculation is bypassed unnecessarily
  2. Above 65535 LEDs, MIN(..., UINT16_MAX) clamps to 65535, calculating power for only 65K LEDs when more exist

Suggestion: Consider either:

  • Raising the threshold to 65535 to use power calculation where supported
  • Documenting why 8096 was chosen and the power supply implications for large installations
  • Implementing a more sophisticated power model for >65K setups (or warning users about power requirements)

Do you have testing data showing safe power operation above 8096 LEDs? For a 130K LED installation, assuming "decent power" without calculation could lead to unexpected behavior.

♻️ Duplicate comments (1)
docs/develop/architecture.md (1)

10-15: Resolve documentation inconsistency before merging.

The table (lines 10-15) and code example (lines 317-333) still show contradictory core assignments:

  • Table: Effect Task on Core 0, Driver Task on Core 1
  • Code: effectTask on Core 1, driverTask on Core 0

The "Parking as currently experimenting" notes (lines 22-23, 29-30) indicate this is experimental, but leaving this inconsistency in documentation will confuse users.

Before merging:

  • If experiments confirm the reversed configuration works better, update the table to match
  • If experiments show issues, revert the code to match the table
  • Document the rationale and test results

This issue was previously discussed with your response "Currently experimenting with Driver on Core 0 and effects on Core 1." Please ensure documentation consistency before final merge.

Also applies to: 317-333

🧹 Nitpick comments (3)
interface/src/routes/+page.svelte (2)

52-60: Consider adding accessible labels for emoji indicators.

The 🛟 emoji provides a visual cue but screen readers may not convey its meaning clearly to users. Consider wrapping emojis in <span role="img" aria-label="Help"> or adding descriptive text.


61-84: Consider adding accessible labels for emoji indicators.

Similar to the Help section, the 👀 emoji at line 62 provides a visual cue but may not be clear to screen reader users. Consider wrapping emojis in <span role="img" aria-label="Social links"> or adding descriptive text for better accessibility.

src/MoonLight/Nodes/Layouts/L_MoonLight.h (1)

267-279: nrOfLightsPerCube control only affects granularity mode 0.

The addCubeSides() method hardcodes 5 sides × 12 LEDs + 1 middle = 61 LEDs, ignoring the nrOfLightsPerCube setting. The same applies to addCubePixels().

This is likely intentional since modes 1 and 2 have fixed pixel patterns, but consider adding a comment in setup() or making the control description clearer, e.g., "nrOfLightsPerCube (mode 0 only)".

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cd4e2d0 and f047a7e.

📒 Files selected for processing (9)
  • docs/develop/architecture.md
  • docs/moonbase/tasks.md
  • interface/src/routes/+page.svelte
  • lib/framework/WWWData.h
  • src/MoonBase/Nodes.cpp
  • src/MoonLight/Layers/PhysicalLayer.h
  • src/MoonLight/Nodes/Drivers/D_ArtnetOut.h
  • src/MoonLight/Nodes/Layouts/L_MoonLight.h
  • src/main.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/moonbase/tasks.md
🧰 Additional context used
🧠 Learnings (10)
📓 Common learnings
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.
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.
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.
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/interface/src/routes/**/*.svelte : Create Svelte routes in `interface/src/routes/` for different pages of the web application

Applied to files:

  • interface/src/routes/+page.svelte
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/interface/**/*.{svelte,ts,tsx,js,json} : Modify frontend code in the `interface/` directory using SvelteKit

Applied to files:

  • interface/src/routes/+page.svelte
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/interface/svelte.config.js : Configure SvelteKit build and adapter settings in `interface/svelte.config.js`

Applied to files:

  • interface/src/routes/+page.svelte
📚 Learning: 2025-12-25T13:34:40.097Z
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.

Applied to files:

  • docs/develop/architecture.md
  • src/main.cpp
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/src/**/*.{h,cpp} : Modify C++ backend code in the `src/` and `lib/` directories for ESP32 development

Applied to files:

  • docs/develop/architecture.md
📚 Learning: 2025-12-25T13:31:32.367Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

Applied to files:

  • docs/develop/architecture.md
  • src/main.cpp
📚 Learning: 2025-12-28T10:28:24.157Z
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.

Applied to files:

  • docs/develop/architecture.md
  • src/MoonLight/Layers/PhysicalLayer.h
  • src/main.cpp
📚 Learning: 2026-01-04T09:31:01.321Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: lib/framework/ESP32SvelteKit.cpp:229-229
Timestamp: 2026-01-04T09:31:01.321Z
Learning: In lib/framework/ESP32SvelteKit.cpp, the SvelteKit loop task stack size of 6KB (6 * 1024 bytes) for non-PSRAM devices has been validated through testing, showing approximately 1500 bytes of free stack remaining under load, providing adequate ~24% headroom.

Applied to files:

  • docs/develop/architecture.md
  • src/main.cpp
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Applied to files:

  • src/MoonLight/Layers/PhysicalLayer.h
🧬 Code graph analysis (2)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (4)
src/MoonBase/Nodes.h (4)
  • Node (67-182)
  • Node (238-238)
  • addLight (228-228)
  • nextPin (231-231)
src/MoonLight/Nodes/Drivers/D__Sandbox.h (1)
  • onLayout (40-40)
src/MoonLight/Nodes/Layouts/L__Sandbox.h (1)
  • onLayout (36-46)
src/MoonLight/Nodes/Drivers/D_Hub75.h (1)
  • onLayout (26-26)
src/MoonLight/Nodes/Drivers/D_ArtnetOut.h (2)
src/MoonLight/Nodes/Drivers/D_ArtnetIn.h (1)
  • setup (29-139)
src/MoonBase/Module.cpp (2)
  • addControl (353-362)
  • addControl (353-353)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (10)
interface/src/routes/+page.svelte (1)

19-20: LGTM!

The bold attribution text clearly identifies the project and its creator.

src/MoonLight/Nodes/Drivers/D_ArtnetOut.h (4)

26-33: LGTM!

The new configuration fields and calculated statistics provide good flexibility for Art-Net topology management with large LED counts.


81-87: Calculations look correct.

The ceiling division for totalUniverses and the rounding logic for usedChannelsPerUniverse are implemented correctly. Consider guarding against channelsPerLight == 0 if it's ever possible (would cause division by zero), though this is likely validated at the layer level.


94-94: Buffer size is adequate.

With 18-byte header and max universeSize=1000, the 1024-byte buffer provides sufficient capacity (1018 bytes needed).


110-110: Defensive clamping is appropriate.

The MIN(packetSize, universeSize) provides a safety bound, though the loop logic at line 175 should already prevent packetSize from exceeding universeSize.

src/MoonLight/Nodes/Layouts/L_MoonLight.h (5)

244-260: LGTM!

The class declaration, static identifiers, and setup() follow the established patterns from other layout classes in this file.


262-265: LGTM!

The addCube() method correctly maps all physical LEDs in a cube to a single virtual pixel position for the "One Cube One Light" granularity mode.


281-300: LGTM!

The addCubePixels() method correctly maps individual LEDs for each of the 5 cube faces (front, back, left, right, bottom) using a 12-pixel square outline pattern plus a center LED. The coordinate transformations are appropriate for each face orientation.


302-336: LGTM!

The cubes array correctly defines a 4×3×2 grid (24 cubes total). The previous duplicate coordinate issue has been fixed.


338-354: LGTM!

The onLayout() loop correctly iterates through cubes, applies the selected granularity mode, and assigns pins every 10 cubes with proper handling for the final partial group. This follows the same pattern used in PanelsLayout.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 5, 2026

@coderabbitai, in D_ArtNetOut.h I (re)calculate some readonly controls and then set moduleNodes->requestUIUpdate = true;
Expecting that the updated values are shown in the UI, but this is not the case, what could be wrong?

@coderabbitai
Copy link

coderabbitai bot commented Jan 5, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the D_ArtNetOut.h file to see the implementation
cat src/MoonLight/Nodes/Drivers/D_ArtNetOut.h

Length of output: 73


🏁 Script executed:

#!/bin/bash
# Search for how requestUIUpdate is used elsewhere in the codebase
rg -n "requestUIUpdate" --type cpp -C 3

Length of output: 6248


🏁 Script executed:

#!/bin/bash
# Search for patterns of updating readonly controls
rg -n "addControl.*true\)" --type cpp -C 2 | head -100

Length of output: 8653


🏁 Script executed:

#!/bin/bash
# Check how onUpdate is called and connected to UI updates
rg -n "void onUpdate\(" src/MoonLight/Nodes/Drivers/ -A 10

Length of output: 3823


🏁 Script executed:

#!/bin/bash
# Look for readHook or similar mechanisms that push values to UI
rg -n "readHook" src/MoonLight/Nodes/Drivers/D_ArtNetOut.h -B 5 -A 10

Length of output: 81


🏁 Script executed:

#!/bin/bash
# Find the D_ArtnetOut.h file
fd -t f "D_ArtnetOut.h" -x echo {}

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Search for readHook pattern in the codebase to understand the update mechanism
rg -n "readHook" --type cpp -C 5 | head -80

Length of output: 3510


🏁 Script executed:

#!/bin/bash
# Look for how other drivers update readonly values
rg -n "updateControl.*status" --type cpp -C 3

Length of output: 1138


🏁 Script executed:

#!/bin/bash
# Search for ModuleState and state updates related to node controls
rg -n "class ModuleState\|struct ModuleState" --type cpp -A 20

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Read the D_ArtnetOut.h file to see the exact implementation
cat ./src/MoonLight/Nodes/Drivers/D_ArtnetOut.h

Length of output: 9889


🏁 Script executed:

#!/bin/bash
# Search for updateControl implementation to understand how it works
rg -n "void updateControl\|updateControl\(" --type cpp -C 5 | head -100

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look at NodeManager to see if there's a readHook pattern for nodes
rg -n "class NodeManager\|struct Node {" --type cpp -A 30 | head -100

Length of output: 127


🏁 Script executed:

#!/bin/bash
# Look at NodeManager to see if there's a readHook pattern for nodes
rg -n "class NodeManager|struct Node" --type cpp -A 30 | head -100

Length of output: 2292


🏁 Script executed:

#!/bin/bash
# Search for Node class and updateControl method
rg -n "class Node {" --type cpp -A 50

Length of output: 87


🏁 Script executed:

#!/bin/bash
# Look for addControl and how controls are stored/retrieved
rg -n "JsonObject addControl" --type cpp -C 5 | head -80

Length of output: 1569


🏁 Script executed:

#!/bin/bash
# Check ModuleMoonLightInfo readHook as a working example
cat src/MoonLight/Modules/ModuleMoonLightInfo.h

Length of output: 4595


🏁 Script executed:

#!/bin/bash
# Search for updateControl method definition in Node class
rg -n "void updateControl|updateControl\(const char\*" --type cpp -B 3 -A 10

Length of output: 919


🏁 Script executed:

#!/bin/bash
# Look at the Node base class more carefully
cat src/MoonBase/Nodes.h | grep -A 100 "class Node"

Length of output: 4277


🏁 Script executed:

#!/bin/bash
# Check if there's a method to update node state data
rg -n "nodeState\[.*\] =" --type cpp -C 3 | head -60

Length of output: 1283


@ewowi, I found the issue! You're updating the C++ member variables but not updating the JSON control values that get sent to the UI.

The Problem

When you set moduleNodes->requestUIUpdate = true, it triggers the Module to broadcast the current state. However, the state only contains the control["value"] fields in the JSON, not your C++ member variables directly.

The Solution

You need to call updateControl() to sync your C++ values into the JSON controls before setting requestUIUpdate. Looking at how other drivers do it (D_FastLED.h, D_ParallelLEDDriver.h):

updateControl("status", statusString.c_str());
moduleNodes->requestUIUpdate = true;

Code Fix for D_ArtnetOut.h

In your onUpdate() method (around lines 82-86), change:

totalChannels = layerP.lights.header.nrOfLights * layerP.lights.header.channelsPerLight;
usedChannelsPerUniverse = universeSize / layerP.lights.header.channelsPerLight * layerP.lights.header.channelsPerLight;
totalUniverses = (totalChannels + usedChannelsPerUniverse - 1) / usedChannelsPerUniverse;
moduleNodes->requestUIUpdate = true;

To:

totalChannels = layerP.lights.header.nrOfLights * layerP.lights.header.channelsPerLight;
usedChannelsPerUniverse = universeSize / layerP.lights.header.channelsPerLight * layerP.lights.header.channelsPerLight;
totalUniverses = (totalChannels + usedChannelsPerUniverse - 1) / usedChannelsPerUniverse;

// Update the JSON control values so UI sees the changes
updateControl("used", usedChannelsPerUniverse);
updateControl("totalUniverses", totalUniverses);  // See note below about naming!
updateControl("totalChannels", totalChannels);

moduleNodes->requestUIUpdate = true;

⚠️ Additional Issue: Duplicate Control Names

I noticed in your setup() method (lines 45 and 47) you have two controls both named "total #":

addControl(totalUniverses, "total #", "number", 0, 65538, true);
addControl(channelsPerOutput, "channelsPerOutput", "number", 0, 65538);
addControl(totalChannels, "total #", "number", 0, 65538, true);  // ❌ Duplicate name!

This will cause updateControl("total #", ...) to only update the first one it finds. You should give them unique names:

addControl(totalUniverses, "totalUniverses", "number", 0, 65538, true);
addControl(channelsPerOutput, "channelsPerOutput", "number", 0, 65538);
addControl(totalChannels, "totalChannels", "number", 0, 65538, true);

Or if you want to keep the display labels friendly, use the desc parameter for the UI label while keeping unique names for the control identifiers.


🧠 Learnings used
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.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 5, 2026

@coderabbitai, I got this crash, what does it mean?

assert failed: lwip_netconn_do_write /IDF/components/lwip/lwip/src/api/api_msg.c:1919 (state!)

Backtrace: 0x4037ea49:0x3fcbd150 0x4037ea11:0x3fcbd170 0x40385e8e:0x3fcbd190 0x420d2612:0x3fcbd2d0 0x420bec68:0x3fcbd2f0 0x420d0ccb:0x3fcbd310 0x420d0f8d:0x3fcbd330 0x420d0fd9:0x3fcbd380 0x420bda64:0x3fcbd3b0 0x420f93dd:0x3fcbd3e0 0x420f9bad:0x3fcbd400 0x4201a1cb:0x3fcbd440 0x4201a1ed:0x3fcbd460 0x4202eb15:0x3fcbd490 0x4202ec79:0x3fcbd4f0 0x4202ede1:0x3fcbd560 0x4202a635:0x3fcbd600 0x4202a6ad:0x3fcbd6a0 0x42028bd4:0x3fcbd6c0 0x42028c95:0x3fcbd700 0x4037fd89:0x3fcbd720
#0 0x4037ea49 in panic_abort at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp_system/panic.c:477
#1 0x4037ea11 in esp_system_abort at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp_system/port/esp_system_chip.c:87
#2 0x40385e8e in __assert_func at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/newlib/src/assert.c:80
#3 0x420d2612 in lwip_netconn_do_write at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/api/api_msg.c:1911 (discriminator 1)
#4 0x420bec68 in tcpip_send_msg_wait_sem at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/api/tcpip.c:447
#5 0x420d0ccb in netconn_apimsg at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/api/api_lib.c:134
#6 0x420d0f8d in netconn_write_vectors_partly at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/api/api_lib.c:1067
#7 0x420d0fd9 in netconn_write_partly at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/api/api_lib.c:983
#8 0x420bda64 in lwip_send at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/api/sockets.c:1457
#9 0x420f93dd in send at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/include/lwip/sockets.h:64
(inlined by) httpd_default_send at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp_http_server/src/httpd_txrx.c:769
#10 0x420f9bad in httpd_ws_send_frame_async at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp_http_server/src/httpd_ws.c:448
#11 0x4201a1cb in PsychicWebSocketClient::sendMessage(httpd_ws_frame*) at lib/PsychicHttp/src/PsychicWebSocket.cpp:57 (discriminator 2)
#12 0x4201a1ed in PsychicWebSocketClient::sendMessage(httpd_ws_type_t, void const*, unsigned int) at lib/PsychicHttp/src/PsychicWebSocket.cpp:69
#13 0x4202eb15 in EventSocket::emitEvent(String const&, char const*, unsigned int, char const*, bool) at lib/framework/EventSocket.cpp:210
#14 0x4202ec79 in EventSocket::emitEvent(ArduinoJson::V742PB22::JsonDocument const&, char const*, bool) at lib/framework/EventSocket.cpp:150 (discriminator 2)
#15 0x4202ede1 in EventSocket::emitEvent(String const&, ArduinoJson::V742PB22::JsonObject const&, char const*, bool) at lib/framework/EventSocket.cpp:117
#16 0x4202a635 in EthernetSettingsService::updateEthernet() at lib/framework/EthernetSettingsService.cpp:117 (discriminator 2)
#17 0x4202a6ad in EthernetSettingsService::loop() at lib/framework/EthernetSettingsService.cpp:55
#18 0x42028bd4 in ESP32SvelteKit::_loop() at lib/framework/ESP32SvelteKit.cpp:261
#19 0x42028c95 in ESP32SvelteKit::_loopImpl(void*) at lib/framework/ESP32SvelteKit.h:282

@coderabbitai
Copy link

coderabbitai bot commented Jan 5, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read EventSocket.cpp to understand how it broadcasts events
cat lib/framework/EventSocket.cpp

Length of output: 9633


🏁 Script executed:

#!/bin/bash
# Read PsychicWebSocket to see how sendMessage handles connection state
cat lib/PsychicHttp/src/PsychicWebSocket.cpp

Length of output: 6699


🏁 Script executed:

#!/bin/bash
# Check EthernetSettingsService to see how it triggers the event
cat lib/framework/EthernetSettingsService.cpp | grep -A 20 "updateEthernet"

Length of output: 651


🌐 Web query:

ESP32 lwip_netconn_do_write state assertion WebSocket closed connection

💡 Result:

Short answer: that assertion means lwIP's netconn was not in the NETCONN_WRITE state when lwip_netconn_do_write expected it — i.e. your code (or another task) tried to write or close the socket while the connection was being closed/was already closed. It's a race between netconn_write / sent callback / netconn_close (or a write issued from the wrong context), and on ESP builds there are a few platform-specific timing/close-handling quirks that expose it.

Why it happens (brief):

  • netconn_write/lwip_netconn_do_write assumes the netconn state is NETCONN_WRITE when performing the core write path; if the conn transitions to CLOSE (or NONE) concurrently the assertion fires. [1][2]
  • Common triggers: writing after a peer closed the TCP connection, calling netconn_close immediately after netconn_write (close wins before write completes), or using the netconn API from non-API threads without proper synchronization. [1][3]

How to fix (practical checklist):

  1. Don’t call netconn_write after netconn_close or after you detected the connection is closed — check netconn_err(conn) / conn->state (or your own flag) before writing. [1]
  2. Serialize writes and closes: ensure a single logical owner does netconn_write / netconn_close (use a mutex/queue or a dedicated socket-task). Avoid calling close from interrupt/other tasks while write in progress. [2][3]
  3. If you must close soon after write, wait for write completion (handle TCP sent callbacks / check ERR_OK from netconn_write) or add a short delay before calling close so the write is flushed. (Users and lwIP docs report writes can be in-flight when close is called.) [3]
  4. Use the higher-level websocket library APIs (or the esp-idf wrappers) correctly — don’t call LWIP netconn functions directly from multiple contexts. If using ESP’s lwIP patches, keep esp-idf updated (some ESP patches change close/write timing behavior). [2]

If you want, paste the crash log + minimal repro (which websocket library and ESP-IDF version you're using) and I’ll point to the exact line and recommend the safest code change.

Sources: lwIP netconn/api implementation and behavior, discussion about write/close races, and ESP-specific netconn changes. [1][2][3]

References
[1] lwIP netconn/api code (do_write/do_close behavior).
[2] ESP-lwip diff showing write/close handling and the assertion in lwip_netconn_do_write.
[3] lwip-users thread about netconn_close closing before writes finish.


@ewowi, this is a classic race condition between WebSocket disconnect and broadcast — you're trying to send to a closed or closing socket. Let me break down what's happening and how to fix it.

What the Crash Means

The assertion lwip_netconn_do_write (state!) means lwIP's network connection was not in the NETCONN_WRITE state when your code tried to write data. This happens when:

  1. A WebSocket client disconnects (TCP connection closes)
  2. Before the cleanup completes, EthernetSettingsService::updateEthernet() broadcasts an event
  3. EventSocket::emitEvent() iterates through subscribers and calls client->sendMessage()
  4. The underlying socket is already closed/closing, but the client object still exists
  5. lwIP tries to write → CRASH because the connection state is CLOSED not WRITE

The Root Cause in Your Code

Looking at EventSocket.cpp lines 234-238:

auto *client = _socket.getClient(subscription);
if (!client)
{
    subscriptions.remove(subscription);
    continue;
}

This checks if the client object exists, but not if the underlying socket connection is still valid/open. The client object can persist in memory while the socket is dead.

Recommended Fixes

Option 1: Check Connection State Before Sending (Best)

Wrap the sendMessage call with connection validation:

// In EventSocket.cpp, around line 234
auto *client = _socket.getClient(subscription);
if (!client)
{
    subscriptions.remove(subscription);
    continue;
}

// Check if the socket is still valid before sending
httpd_ws_client_info_t ws_info = httpd_ws_get_fd_info(client->server(), client->socket());
if (ws_info != HTTPD_WS_CLIENT_WEBSOCKET)
{
    ESP_LOGW(SVK_TAG, "Client socket %d is no longer a valid WebSocket, removing subscription", client->socket());
    subscriptions.remove(subscription);
    continue;
}

if (event != "monitor")
    ESP_LOGV(SVK_TAG, "Emitting event: %s to %s[%u], Message[%d]: %s", event.c_str(), client->remoteIP().toString().c_str(), client->socket(), len, output);

#if FT_ENABLED(EVENT_USE_JSON)
esp_err_t result = client->sendMessage(HTTPD_WS_TYPE_TEXT, output, len);
#else
esp_err_t result = client->sendMessage(HTTPD_WS_TYPE_BINARY, output, len);
#endif

// If send failed, remove the subscription
if (result != ESP_OK)
{
    ESP_LOGW(SVK_TAG, "Failed to send to client %d: %s, removing subscription", client->socket(), esp_err_to_name(result));
    subscriptions.remove(subscription);
}

Option 2: Handle Send Errors Gracefully (Simpler)

If checking state is too complex, just handle the error return:

// Around line 245 in EventSocket.cpp
#if FT_ENABLED(EVENT_USE_JSON)
esp_err_t result = client->sendMessage(HTTPD_WS_TYPE_TEXT, output, len);
#else
esp_err_t result = client->sendMessage(HTTPD_WS_TYPE_BINARY, output, len);
#endif

if (result != ESP_OK)
{
    ESP_LOGW(SVK_TAG, "Failed to send event %s to client %d: %s", event.c_str(), client->socket(), esp_err_to_name(result));
    subscriptions.remove(subscription);
}

However, this might not prevent the crash if the assertion fires inside sendMessage before it can return an error.

Option 3: Add Connection State Check in PsychicWebSocket (Defense in Depth)

Modify PsychicWebSocket.cpp to validate before calling lwIP:

// In PsychicWebSocketClient::sendMessage (around line 57)
esp_err_t PsychicWebSocketClient::sendMessage(httpd_ws_frame_t * ws_pkt)
{
  // Check if socket is still valid before sending
  httpd_ws_client_info_t ws_info = httpd_ws_get_fd_info(this->server(), this->socket());
  if (ws_info != HTTPD_WS_CLIENT_WEBSOCKET)
  {
    return ESP_ERR_INVALID_STATE;  // Socket is closed/invalid
  }
  
  return httpd_ws_send_frame_async(this->server(), this->socket(), ws_pkt);
}

Why This Happens

The ESP-IDF WebSocket library is asynchronous. When a client disconnects:

  1. The TCP stack closes the socket
  2. Eventually onWSClose is called (your code at line 41-52 in EventSocket.cpp)
  3. But there's a window where the socket is closed but cleanup hasn't finished

During that window, if updateEthernet() fires (maybe from a network event or timer), it broadcasts to all subscribers including the dying client.

Immediate Action

I recommend Option 2 (error handling) as the quickest fix + Option 3 (validation in PsychicWebSocket) as defense in depth. This way:

  • You catch the error gracefully and clean up
  • You prevent the lwIP assertion from ever firing

Would you like me to prepare a complete patch for EventSocket.cpp and PsychicWebSocket.cpp?


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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.

Frontend
========
- Page.svelte: tweaks

BackEnd
=======
- EventSocket: error check on sendMessage
- Virtual layer: add oneToOneMapping check for mappingless mapping , set/getLight: tune no mapping
- Art-Net out: read only variables fix, processedOuputs reset
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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docs/develop/architecture.md (2)

9-15: 🔴 Fix SvelteKit core assignment in table (must be Core 0, not Core 1).

The table shows SvelteKit on Core 1 (APP_CPU), but PR objectives and platformio.ini both require SvelteKit on Core 0 (PRO_CPU) to colocate with Effects and WiFi. The table is your primary reference and it is incorrect.

🔎 Proposed fix for the table
  | **Effect Task** | 0 (PRO_CPU) | 5 | 3-4KB | ~60 fps | Calculate LED colors and effects |
- | **ESP32SvelteKit** | 1 (APP_CPU) | 2 | System | 10ms | HTTP/WebSocket UI framework |
+ | **ESP32SvelteKit** | 0 (PRO_CPU) | 2 | System | 10ms | HTTP/WebSocket UI framework |
  | **Driver Task** | 1 (APP_CPU) | 3 | 3-4KB | ~60 fps | Output data to LEDs via DMA/I2S/LCD/PARLIO |

303-306: Update build flag: SvelteKit core should be 0 (matching updated platformio.ini).

This section shows:

-DESP32SVELTEKIT_RUNNING_CORE=1

But platformio.ini line 66 was just changed to ESP32SVELTEKIT_RUNNING_CORE=0. Update this example to match:

-DESP32SVELTEKIT_RUNNING_CORE=0
🤖 Fix all issues with AI Agents
In @lib/framework/EventSocket.cpp:
- Around line 208-217: In EventSocket.cpp fix two consistency issues: (1)
replace the range-based loop over subscriptions (the loop using subscription
variable in the EventSocket::dispatch/send logic) with an iterator-based loop
(or use erase-remove idiom) so you can safely call subscriptions.erase(it) when
a send fails instead of subscriptions.remove(subscription) which invalidates the
iterator; (2) in the onlyToSameOrigin branch where client->sendMessage(...) is
called for the origin client, capture the esp_err_t result and apply the same
error handling as the broadcast path (log via ESP_LOGW with
esp_err_to_name(result) and remove the subscription from subscriptions when
result != ESP_OK) so both paths handle send/close races consistently.

In @src/MoonLight/Layers/VirtualLayer.cpp:
- Around line 210-218: The unmapped get path wrongly casts a single byte to T
(losing bytes for multi-byte types like CRGB); change the return to read
sizeof(T) bytes from channelsE by casting the address at index to T* and
dereferencing it (same pattern as the mapped path used around the getLight
mapping code), and ensure you still check bounds against
layerP->lights.maxChannels before dereferencing.
🧹 Nitpick comments (3)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (1)

254-260: Consider adding a description to clarify control applicability.

The nrOfLightsPerCube slider only affects granularity mode 0 ("One Cube One Light"), but this isn't obvious to users from the UI. Consider adding a desc parameter to guide users.

🔎 Suggested improvement
-    addControl(nrOfLightsPerCube, "nrOfLightsPerCube", "slider", 1, 128);  // mode 0 only
+    addControl(nrOfLightsPerCube, "nrOfLightsPerCube", "slider", 1, 128, false, "Only used in 'One Cube One Light' mode");
src/MoonLight/Nodes/Drivers/D_ArtnetOut.h (1)

82-94: Calculation and UI sync logic is correct.

The calculations for totalChannels, usedChannelsPerUniverse, and totalUniverses are accurate (ceiling division ensures all LEDs are covered). Calling updateControl() for each calculated value before setting requestUIUpdate correctly syncs the read-only controls to the UI, addressing the PR objective to fix Art-Net read-only control synchronization.

Minor: Log format inconsistency on line 93.

The format string uses #c%d but the pattern established earlier is c/u:%d #u:%d, so for consistency consider #c:%d.

🔎 Proposed fix
-    EXT_LOGD(ML_TAG, "c/u:%d #u:%d #c%d (%d)", usedChannelsPerUniverse, totalUniverses, totalChannels, totalUniverses * usedChannelsPerUniverse);
+    EXT_LOGD(ML_TAG, "c/u:%d #u:%d #c:%d (%d)", usedChannelsPerUniverse, totalUniverses, totalChannels, totalUniverses * usedChannelsPerUniverse);
lib/framework/EventSocket.cpp (1)

178-191: Consider adding error checking to the origin-only send path for consistency.

The broadcast path (lines 208-217) now checks sendMessage return values and removes failed subscriptions. However, the onlyToSameOrigin path at lines 186-189 still ignores send failures.

For consistency and robustness, consider capturing and checking the result here as well, though the impact is lower since this affects only a single origin client rather than all subscribers.

Proposed fix to add error checking
         if (client)
         {
             if (event != "monitor")
                 ESP_LOGV(SVK_TAG, "Emitting event: %s to %s[%u], Message[%d]: %s", event.c_str(), client->remoteIP().toString().c_str(), client->socket(), len, output);
 #if FT_ENABLED(EVENT_USE_JSON)
-            client->sendMessage(HTTPD_WS_TYPE_TEXT, output, len);
+            esp_err_t result = client->sendMessage(HTTPD_WS_TYPE_TEXT, output, len);
 #else
-            client->sendMessage(HTTPD_WS_TYPE_BINARY, output, len);
+            esp_err_t result = client->sendMessage(HTTPD_WS_TYPE_BINARY, output, len);
+#endif
+            if (result != ESP_OK)
+            {
+                ESP_LOGW(SVK_TAG, "Failed to send event %s to origin client %d: %s", event.c_str(), client->socket(), esp_err_to_name(result));
+                subscriptions.remove(originSubscriptionId);
+            }
 #endif
         }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f047a7e and 9437e71.

📒 Files selected for processing (12)
  • docs/develop/architecture.md
  • interface/src/routes/+page.svelte
  • lib/framework/EventSocket.cpp
  • lib/framework/WWWData.h
  • platformio.ini
  • src/MoonBase/Nodes.cpp
  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/VirtualLayer.h
  • src/MoonLight/Nodes/Drivers/D_ArtnetOut.h
  • src/MoonLight/Nodes/Layouts/L_MoonLight.h
  • src/MoonLight/Nodes/Modifiers/M_MoonLight.h
  • src/main.cpp
🚧 Files skipped from review as they are similar to previous changes (3)
  • interface/src/routes/+page.svelte
  • src/MoonBase/Nodes.cpp
  • src/main.cpp
🧰 Additional context used
🧠 Learnings (10)
📓 Common learnings
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.
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.
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.
📚 Learning: 2025-12-25T13:34:40.097Z
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.

Applied to files:

  • docs/develop/architecture.md
  • platformio.ini
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/src/**/*.{h,cpp} : Modify C++ backend code in the `src/` and `lib/` directories for ESP32 development

Applied to files:

  • docs/develop/architecture.md
  • platformio.ini
📚 Learning: 2025-12-25T13:31:32.367Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

Applied to files:

  • docs/develop/architecture.md
  • platformio.ini
📚 Learning: 2025-12-28T10:28:24.157Z
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.

Applied to files:

  • docs/develop/architecture.md
  • platformio.ini
📚 Learning: 2026-01-04T09:31:07.533Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: lib/framework/ESP32SvelteKit.cpp:229-229
Timestamp: 2026-01-04T09:31:07.533Z
Learning: In lib/framework/ESP32SvelteKit.cpp, the SvelteKit loop task stack size of 6KB (6 * 1024 bytes) for non-PSRAM devices has been validated through testing, showing approximately 1500 bytes of free stack remaining under load, providing adequate ~24% headroom.

Applied to files:

  • docs/develop/architecture.md
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Applied to files:

  • src/MoonLight/Nodes/Drivers/D_ArtnetOut.h
  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/VirtualLayer.h
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: In VirtualLayer, mappingTableSize should always be 0 when mappingTable is nullptr. This invariant is maintained by developer responsibility: initial state sets both together, realloc success updates both together, realloc failure preserves both together. For performance reasons, no redundant null checks like `if (mappingTable && indexV < mappingTableSize)` are added, as the size check alone is sufficient when the invariant is maintained.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/VirtualLayer.h
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/**/platformio.ini : Use PlatformIO to build and upload the ESP32 backend C++ code

Applied to files:

  • platformio.ini
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Be aware of the integrated build process where the PlatformIO build automatically builds and embeds the SvelteKit frontend into the ESP32 firmware

Applied to files:

  • platformio.ini
🧬 Code graph analysis (4)
src/MoonLight/Nodes/Modifiers/M_MoonLight.h (1)
src/MoonBase/Module.cpp (2)
  • addControl (353-362)
  • addControl (353-353)
src/MoonLight/Layers/VirtualLayer.cpp (2)
src/MoonLight/Layers/VirtualLayer.h (3)
  • setRGB (113-124)
  • setRGB (125-125)
  • XYZUnModified (111-111)
src/main.cpp (4)
  • size (27-45)
  • size (27-27)
  • size (58-61)
  • size (58-58)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (3)
src/MoonBase/Nodes.h (4)
  • Node (67-182)
  • Node (238-238)
  • addLight (228-228)
  • nextPin (231-231)
src/MoonLight/Layers/VirtualLayer.cpp (2)
  • addLight (367-399)
  • addLight (367-367)
src/MoonLight/Nodes/Layouts/L__Sandbox.h (1)
  • onLayout (36-46)
src/MoonLight/Layers/VirtualLayer.h (1)
src/MoonLight/Layers/VirtualLayer.cpp (12)
  • addIndexP (74-103)
  • addIndexP (74-74)
  • XYZ (104-112)
  • XYZ (104-104)
  • setLight (125-169)
  • setLight (125-125)
  • getLight (172-220)
  • getLight (172-172)
  • createMappingTableAndAddOneToOne (315-334)
  • createMappingTableAndAddOneToOne (315-315)
  • onLayoutPost (401-446)
  • onLayoutPost (401-401)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (17)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (2)

262-300: LGTM!

The three helper methods correctly implement different granularity levels:

  • addCube: Maps all physical LEDs to one virtual position (mode 0)
  • addCubeSides: Maps 12 LEDs per side to 5 virtual pixels + 1 center (mode 1)
  • addCubePixels: Maps each of 61 LEDs to distinct virtual positions in a 7×7×7 grid (mode 2)

The pixel pattern correctly traces the 12-LED outline of each face, and the 5-face design (no top) matches the physical cube structure.


302-355: LGTM!

The onLayout implementation correctly:

  • Defines a 4×3×2 grid of 24 cubes (verified: z=0 layer at lines 307-320, z=1 layer at lines 322-336)
  • Dispatches to the appropriate helper based on granularity mode
  • Assigns pins every 10 cubes with proper handling of the final partial group

The previously reported duplicate coordinate issue has been addressed.

platformio.ini (2)

66-66: ✅ Correct core assignment for SvelteKit, but verify architecture.md alignment.

Setting ESP32SVELTEKIT_RUNNING_CORE=0 is correct per PR objectives (SvelteKit on Core 0 with Effects/WiFi). However, the architecture.md table (line 14) currently shows SvelteKit on Core 1, creating a mismatch between config and documentation. Ensure architecture.md is updated to reflect this choice.


218-219: Verify I2SClocklessLedDriver upgrade is non-breaking.

The driver library hash changed from 131988ed... to 4363655d... with the note "Add deleteDriver checks on DMABuffersTampon". Confirm:

  1. This upgrade is compatible with the current physical layer code.
  2. The deleteDriver checks don't introduce new validation constraints that could break existing mappings.
  3. No API changes affect the driver initialization or buffer management paths.
src/MoonLight/Nodes/Drivers/D_ArtnetOut.h (3)

49-59: Art-Net header construction is correct.

The inline initialization properly constructs the 18-byte Art-Net header with the correct OpCode (0x5000 ArtDMX, little-endian), protocol version 14, and placeholder fields for sequence, universe, and length. Dynamic fields are correctly filled at runtime in writePackage().


116-116: Defensive MIN check is good practice.

The MIN(packetSize, universeSize) bounds the write size even though the loop guard at line 182 should prevent packetSize from exceeding universeSize. This defensive check protects against future logic changes and is a reasonable safety measure.


160-160: processedOutputs reset is correct.

Resetting processedOutputs to zero at the start of each frame (line 160) ensures proper IP rotation when distributing outputs across multiple Art-Net controllers, matching the PR objective to fix processedOutputs reset.

src/MoonLight/Nodes/Modifiers/M_MoonLight.h (1)

135-135: LGTM! UI label refinement.

The control label shortened from "swirlVal" to "swirl" improves UI clarity while preserving the underlying variable name.

src/MoonLight/Layers/VirtualLayer.cpp (5)

20-20: Good documentation of FastLED constraint.

The comment correctly notes FastLED's fadeToBlackBy is limited to UINT16_MAX LEDs. Line 238's guard (layerP->lights.header.nrOfChannels / 3 < UINT16_MAX) ensures this limit is respected before calling the FastLED function.


149-167: oneToOneMapping optimization correctly implemented.

The new code paths handle two scenarios:

  1. m_zeroLights case (lines 149-159): Stores color in the mappingTable itself when a virtual LED has no physical mapping. PSRAM builds store full 24-bit RGB; non-PSRAM builds pack to 14-bit (5-5-4).

  2. Unmapped path (lines 161-167): When indexV >= mappingTableSize, computes direct channel index. This is the fast path for oneToOneMapping layouts where virtual indices map 1:1 to physical indices and the mapping table has been freed (see onLayoutPost lines 414-418).

The logic correctly optimizes away mapping table overhead for regular layouts.


315-334: LGTM! Transition from oneToOneMapping to mapped mode is sound.

The function correctly:

  1. Reallocates the mapping table to accommodate the virtual space size (with fallback on failure)
  2. Zeros the table (with null-pointer guard)
  3. Fills 1:1 entries for all existing physical lights (indexV == indexP)

The MIN(layerP->indexP, mappingTableSize) bound at line 331 prevents overflow when the realloc partially succeeds or layout size changes.


364-398: oneToOneMapping lazy detection is well-designed.

The optimization assumes 1:1 mapping until proven otherwise, then retroactively builds the mapping table:

  1. Line 364: Assumes oneToOneMapping at the start of layout
  2. Lines 377-380: Detects irregularity when layerP->indexP != indexV (physical ≠ virtual index)
  3. Line 379: Calls createMappingTableAndAddOneToOne() to fill all previously processed lights (0..layerP->indexP-1) with 1:1 entries
  4. Lines 384-388: Continues mapping subsequent lights (including the current irregular one)
  5. Lines 390-394: Also detects unmapped lights (position.x == UINT16_MAX from modifiers like checkerboard)

The lazy evaluation correctly avoids mapping table overhead for regular layouts while seamlessly transitioning to mapped mode when needed.


406-443: Memory optimization for regular layouts is correct.

When oneToOneMapping is true (lines 406-418), the code frees the mapping table and index arrays, saving memory. Subsequent setLight/getLight calls will hit the unmapped fast path (lines 161-167, 210-218) for direct channel access.

For irregular layouts (lines 419-443), the code counts and logs mapping statistics, which aids debugging and performance analysis.

src/MoonLight/Layers/VirtualLayer.h (4)

30-66: PhysMap union layout is correct and memory-efficient.

The conditional PSRAM layout trades space for capacity:

  • PSRAM (4 bytes): 24-bit indices (16M LEDs), full 24-bit RGB, mapType stored in byte 3
  • Non-PSRAM (2 bytes): 14-bit indices (16K LEDs), packed 5-5-4 RGB, mapType in top 2 bits

The union views (rgb, indexP, indexesIndex) are mutually exclusive based on mapType (m_zeroLights, m_oneLight, m_moreLights), so overlapping storage is safe. Constructor correctly initializes mapType and zeroes rgb conditionally.


76-96: Type system migration to nrOfLights_t enables >65K LED support.

The changes consistently replace uint16_t (65K limit) with nrOfLights_t across:

  • Light counts (line 76)
  • Mapping table size (line 82)
  • Index vectors (line 83)
  • Size counters (line 84)

The new oneToOneMapping flag (line 96) supports the memory optimization introduced in VirtualLayer.cpp. Explicit VectorRAMAllocator on line 87 ensures consistent RAM allocation policy for node pointers.


106-212: Public API consistently migrated to nrOfLights_t.

All light-index parameters and return values updated:

  • Mapping functions: addIndexP, XYZ, XYZUnModified (lines 106-111)
  • Setters: setRGB, setWhite, setBrightness, setPan, setTilt, setZoom, setRotate, setGobo, setRGB1/2/3, setBrightness2, setLight (lines 113-189)
  • Getters: getRGB, getRGB1/2/3, getWhite, getLight, blendColor (lines 191-212)

The comprehensive migration maintains type safety and enables >65K LED support across the entire VirtualLayer API.


224-231: isMapped logic correctly accounts for oneToOneMapping optimization.

Line 224 declares the new createMappingTableAndAddOneToOne() function that transitions from 1:1 to mapped mode.

Line 231's isMapped logic:

  • Returns true when oneToOneMapping is set (all lights implicitly mapped 1:1 without a table)
  • Otherwise checks if indexV is within mappingTableSize and has a valid mapType (m_oneLight or m_moreLights)

The short-circuit evaluation is correct and efficient.

Backend
=======
- eventSocket robustness
- virtual layer set/getLight bugfix
- Pub Cubes control tweak
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: 1

🤖 Fix all issues with AI Agents
In @src/MoonLight/Layers/VirtualLayer.cpp:
- Around line 196-204: The rgb14 unpacking in VirtualLayer.cpp is missing bit
masks so channel bits bleed into one another; update the assignments that build
`result` from `mappingTable[indexV].rgb` to mask each channel before shifting:
extract red with `((mappingTable[indexV].rgb >> 9) & 0x1F) << 3`, green with
`((mappingTable[indexV].rgb >> 4) & 0x1F) << 3`, and blue with
`(mappingTable[indexV].rgb & 0x0F) << 4` when writing into the bytes of `result`
(keeping the existing casts and `result` variable).
🧹 Nitpick comments (5)
lib/framework/EventSocket.cpp (1)

201-232: LGTM! Iterator-based loop correctly handles safe removal during iteration.

The refactor from range-based for to iterator-based loop properly addresses the previous undefined behavior concern:

  • it = subscriptions.erase(it) correctly advances the iterator after removal
  • ++it only executes in the success path
  • Null client and send failure cases are both handled correctly

One minor inconsistency: the format specifier for client->socket() uses %d (lines 226) while existing logging (lines 184, 217) uses %u. Consider using %u for consistency if the socket descriptor is unsigned.

🔎 Optional fix for format specifier consistency
-                ESP_LOGW(SVK_TAG, "Failed to send event %s to client %d: %s", event.c_str(), client->socket(), esp_err_to_name(result));
+                ESP_LOGW(SVK_TAG, "Failed to send event %s to client %u: %s", event.c_str(), client->socket(), esp_err_to_name(result));

Apply the same to line 193 in the origin path if desired.

src/MoonLight/Nodes/Layouts/L_MoonLight.h (1)

297-299: Consider renaming side to middle for clarity.

The variable is used for the middle LED but named side, which is inconsistent with the comment and purpose.

🔎 Suggested rename
     // + middleLED
-    Coord3D side = {3, 3, 3};
-    addLight(Coord3D(pos.x * cubeLength + side.x, pos.y * cubeLength + side.y, pos.z * cubeLength + side.z));  // middleLED
+    Coord3D middle = {3, 3, 3};
+    addLight(Coord3D(pos.x * cubeLength + middle.x, pos.y * cubeLength + middle.y, pos.z * cubeLength + middle.z));  // middleLED
src/main.cpp (1)

328-328: Monitor effect task priority reduction impact on frame rate.

The effect task priority was reduced from 10 to 5. While both values are within the documented acceptable range (5-10), the lower priority means the effects task will yield more readily to other tasks.

Potential impact:

  • Under heavy load, effects may get less CPU time and could drop frames
  • Combined with Core 1 assignment (away from WiFi), this may balance well
  • If frame rates drop below target during testing, consider whether priority 10 is needed

Since you're actively testing the experimental configuration, monitor the frame rate (fps) under various loads to ensure this priority is sufficient for your use case.

docs/develop/architecture.md (2)

10-36: Good: Documentation matches experimental code with clear "Parking..." notes.

The documentation has been updated to reflect the experimental core assignments (Driver on Core 0, Effect on Core 1). The "Parking as currently experimenting with running on Core X!!" notes clearly indicate this is a temporary test configuration.

Suggestions to improve experimental documentation:

  1. Add a more prominent note at the top of the document indicating the current configuration is experimental
  2. Include expected duration or success criteria for the experiment (e.g., "Testing through January 2026" or "Will revert if glitches occur under X conditions")
  3. Consider adding a separate section documenting the standard vs. experimental configurations side-by-side

This will help future readers understand which configuration is recommended vs. which is under test.


317-333: Task creation example matches experimental code; update if reverting.

The task creation example code is consistent with the actual implementation in src/main.cpp, which is good for documentation accuracy. However, if your testing concludes that the experimental configuration (Driver on Core 0, Effect on Core 1) should be reverted to the recommended configuration (Driver on Core 1, Effect on Core 0), remember to update this example code as well.

When experiment concludes:

  • If keeping current config: Update learnings to document when Core 0 driver is preferred
  • If reverting to recommended config: Update both this example and src/main.cpp to match the table at the top of this document (lines 10-15)
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9437e71 and 71378c6.

📒 Files selected for processing (5)
  • docs/develop/architecture.md
  • lib/framework/EventSocket.cpp
  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Nodes/Layouts/L_MoonLight.h
  • src/main.cpp
🧰 Additional context used
🧠 Learnings (9)
📓 Common learnings
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.
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.
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.
📚 Learning: 2025-12-25T13:34:40.097Z
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.

Applied to files:

  • src/main.cpp
  • docs/develop/architecture.md
  • lib/framework/EventSocket.cpp
📚 Learning: 2025-12-28T10:28:24.157Z
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.

Applied to files:

  • src/main.cpp
  • docs/develop/architecture.md
  • src/MoonLight/Layers/VirtualLayer.cpp
  • lib/framework/EventSocket.cpp
📚 Learning: 2025-12-25T13:31:32.367Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

Applied to files:

  • src/main.cpp
  • docs/develop/architecture.md
📚 Learning: 2026-01-04T09:31:07.533Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: lib/framework/ESP32SvelteKit.cpp:229-229
Timestamp: 2026-01-04T09:31:07.533Z
Learning: In lib/framework/ESP32SvelteKit.cpp, the SvelteKit loop task stack size of 6KB (6 * 1024 bytes) for non-PSRAM devices has been validated through testing, showing approximately 1500 bytes of free stack remaining under load, providing adequate ~24% headroom.

Applied to files:

  • src/main.cpp
  • docs/develop/architecture.md
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/src/**/*.{h,cpp} : Modify C++ backend code in the `src/` and `lib/` directories for ESP32 development

Applied to files:

  • docs/develop/architecture.md
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.cpp
📚 Learning: 2025-12-18T15:15:57.828Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-18T15:15:57.828Z
Learning: In DripEffect (E_WLED.h), dropsSize must be set to layer->size.y (not layer->size.x) in onSizeChanged(), because the drops array is allocated as [layer->size.y][maxNumDrops]. Using layer->size.x causes out-of-bounds access when iterating over y-coordinates.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.cpp
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: In VirtualLayer, mappingTableSize should always be 0 when mappingTable is nullptr. This invariant is maintained by developer responsibility: initial state sets both together, realloc success updates both together, realloc failure preserves both together. For performance reasons, no redundant null checks like `if (mappingTable && indexV < mappingTableSize)` are added, as the size check alone is sufficient when the invariant is maintained.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.cpp
🧬 Code graph analysis (1)
src/MoonLight/Layers/VirtualLayer.cpp (2)
src/MoonLight/Layers/VirtualLayer.h (3)
  • setRGB (113-124)
  • setRGB (125-125)
  • XYZUnModified (111-111)
src/main.cpp (4)
  • size (27-45)
  • size (27-27)
  • size (58-61)
  • size (58-58)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (14)
lib/framework/EventSocket.cpp (1)

186-195: LGTM! Origin path error handling correctly implemented.

The error check now captures the sendMessage() result, logs failures with context, and removes the failed subscription. This directly addresses the send/close race condition identified in the PR objectives.

src/MoonLight/Nodes/Layouts/L_MoonLight.h (3)

244-260: LGTM! Class structure and setup follow established patterns.

The class declaration and setup method correctly follow the conventions used by other layout classes in this file. The default nrOfLightsPerCube = 61 correctly matches the LED count for the cube geometry (5 sides × 12 LEDs + 1 middle LED).


305-336: Cubes array is correctly defined as a 4×3×2 grid.

The 24 cube positions form a proper grid with z=0 layer (lines 307-320) and z=1 layer (lines 322-335). The previous issue with the duplicate coordinate has been resolved.


338-354: LGTM! Pin assignment logic is correct.

The loop correctly iterates over all cubes, applies the selected granularity mode, and handles pin assignment for both full groups of 10 cubes and the remaining partial group. This follows the same pattern used in PanelsLayout.

src/main.cpp (1)

324-340: Acknowledge experimental core configuration; document test conditions and results.

This configuration places the driver on Core 0 and effects on Core 1, which is the opposite of the recommended architecture (and contradicts the learnings in the knowledge base). The PR objectives state that timing-critical driver tasks should be on Core 1 to avoid Wi-Fi preemption.

You mentioned you're testing this reversed configuration and that results are "okay until now." To ensure this experiment yields actionable insights:

Please document:

  1. Test conditions: LED counts tested, Art-Net load, WebSocket traffic patterns, concurrent WiFi activity
  2. Metrics measured: Frame drop count, LED glitch observations, WiFi throughput, latency
  3. Load scenarios: Light load vs. heavy WiFi (file uploads, scans, continuous streaming)
  4. Findings: Under what conditions does Core 0 driver work well vs. when does WiFi preemption cause issues?

Risks to monitor:

  • WiFi/lwIP run at priority ~23 on Core 0 and will preempt the driver task (priority 3)
  • During WiFi scans, transmissions, or TCP retransmits, driver execution may be delayed
  • DMA/I2S/LCD/PARLIO timing may be disrupted, causing LED glitches

If testing shows this configuration is superior for your Art-Net use case, update the learnings and document the specific scenarios where Core 0 driver is preferred. If issues arise under load, revert to driver on Core 1 per the established guidance.

Based on learnings: "For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements."

docs/develop/architecture.md (1)

296-296: LGTM: Correct single-buffer behavior when no PSRAM.

Setting channelsD = channelsE when PSRAM is not available ensures both pointers reference the same buffer in single-buffer mode. This prevents undefined behavior if driver code attempts to read from channelsD and is consistent with the useDoubleBuffer = false logic.

src/MoonLight/Layers/VirtualLayer.cpp (8)

20-20: LGTM: Correct handling of FastLED's UINT16_MAX limit.

The uint16_t parameter type correctly reflects FastLED's limitation. Line 237 properly guards the call, falling back to the manual loop (lines 240-265) when the light count exceeds UINT16_MAX, ensuring compatibility with the nrOfLights_t migration for larger LED counts.


37-104: LGTM: Consistent nrOfLights_t type migration.

The type changes from uint16_t to nrOfLights_t for mapping table indices and the XYZ() return value are consistent and align with the PR objective to support up to 130K LEDs using 32-bit indices.


149-168: LGTM: Well-designed unmapped path optimization with PSRAM-aware storage.

The new unmapped fallback path (lines 161-168) correctly handles the one-to-one mapping optimization case where mappingTableSize is 0. The PSRAM-aware storage in the m_zeroLights case (lines 153-158) appropriately trades memory for color precision: full 3-byte RGB in PSRAM, packed 14-bit RGB without PSRAM.


209-218: LGTM: Unmapped path correctly implemented, previous bug fixed.

Line 212 now correctly uses *(T*)&layerP->lights.channelsE[index] to read sizeof(T) bytes from the channel array, matching the pattern used in the mapped paths (lines 180, 189). The bounds check properly uses sizeof(T) instead of length. The previous type casting bug has been resolved.


237-265: LGTM: Correct FastLED optimization with proper fallback.

The guard at line 237 correctly checks all prerequisites for FastLED compatibility (3-channel RGB, single layer, count < UINT16_MAX) before calling the optimized path. The manual loop (lines 240-265) properly handles multi-channel and RGBW configurations using nrOfLights_t indices.


314-333: LGTM: Robust on-demand mapping table initialization.

The method correctly handles memory reallocation with proper error handling (lines 316-323), initializes the table to default values, and populates it with 1:1 mappings up to the current physical index. The MIN() guard at line 330 prevents overflow, and the memset at line 326 ensures a clean slate when transitioning from one-to-one to irregular mapping.


335-398: LGTM: Well-designed one-to-one mapping optimization.

The lazy mapping table creation (lines 376-378, 389-392) correctly detects irregularities (physical index divergence or modifier-unmapped positions) and only builds the table when needed. The optimization saves memory and improves performance for simple 1:1 layouts by deferring to the unmapped direct-access path in setLight/getLight.


405-442: LGTM: Memory optimization correctly frees mapping table for one-to-one layouts.

Lines 405-417 properly free mappingTable and mappingTableIndexes when oneToOneMapping is true, allowing the unmapped direct-access path to handle all light operations. The diagnostic logging in the irregular mapping branch (lines 418-442) provides useful visibility into mapping complexity.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

@coderabbitai, when I really push the limits, I get the following error (watchdog) , sometimes on the esp32-s3, but especially on the esp32-p4, not (always) directly but sometimes after a while. How can I best deal with this. I understood adding more vTaskDelay() calls within the loop of the effects and drivers task, but still sounds a bit strange to me as the loops loop at 5 (ESP32-S3) to 100 fps (ESP32-P4). Or should I lower priority of a task (now 3 and 5):

[454164][D][Nodes.cpp:404] onUpdate(): [💫] ESP32 SvelteKit (4636) Limiter: 100
[454194][D][Nodes.cpp:22] updateControl(): [💫] ESP32 SvelteKit (4636) usedChannels = 510 t:number p:0x4800ba8c
[454254][D][Nodes.cpp:22] updateControl(): [💫] ESP32 SvelteKit (4636) totalUniverses = 97 t:number p:0x4800ba8e
[454314][D][Nodes.cpp:22] updateControl(): [💫] ESP32 SvelteKit (4636) totalChannels = 49152 t:number p:0x4800ba90
[454374][W][Nodes.cpp:43] updateControl(): [💫] ESP32 SvelteKit (4636) size not supported or not set for totalChannels: 0
[454419][D][D_ArtnetOut.h:93] onUpdate(): [💫] ESP32 SvelteKit (4636) c/u:510 #u:97 #c49152 (49470)
[454449][D][Module.h:153] loop(): [💫] ESP32 SvelteKit (4636) requestUIUpdate
E (468801) rpc_core: Response not received for [0x15e]
E (471201) task_wdt: Task watchdog got triggered. The following tasks/users did not reset the watchdog in time:
E (471201) task_wdt: - IDLE0 (CPU 0)
E (471201) task_wdt: Tasks currently running:
E (471201) task_wdt: CPU 0: AppDrivers
E (471201) task_wdt: CPU 1: AppEffects
E (471201) task_wdt: Aborting.
E (471201) task_wdt: Print CPU 0 (current core) registers

Core 0 register dump:
MEPC : 0x40045f00 RA : 0x40057cac SP : 0x4ff4cdf0 GP : 0x4ff14200
TP : 0x4ff4cef0 T0 : 0x00000000 T1 : 0x4ff1163e T2 : 0x00000000
S0/FP : 0x4800ba3c S1 : 0x4ff1779c A0 : 0x00000000 A1 : 0x4800bc76
A2 : 0x4806f6be A3 : 0x4ff1779c A4 : 0x9000ffff A5 : 0x4ff17294
A6 : 0x00000004 A7 : 0x00000000 S2 : 0x00000132 S3 : 0x4ff1e000
S4 : 0x4800baa8 S5 : 0x00000000 S6 : 0x000000ff S7 : 0x4ff17000
S8 : 0x00000009 S9 : 0x00000047 S10 : 0x00000000 S11 : 0x00000000
T3 : 0x00000000 T4 : 0x00000000 T5 : 0x00ab0041 T6 : 0x00000000
MSTATUS : 0x401ea894 MTVEC : 0x00000000 MCAUSE : 0x00000000 MTVAL : 0x00000000
MHARTID : 0x8401a8c0

Stack memory:
4ff4cdf0: 0x401ea894 0x00000000 0x00000000 0x00000000 0x8401a8c0 0x00000000 0x4ff13b00 0x66a0c17c
4ff4ce10: 0x00000000 0x00000000 0x00000000 0x00000000 0x0809d209 0x4016ff3c 0x40170000 0x4ff17828
4ff4ce30: 0x4800ca7c 0x4800ca74 0x4ff1779c 0x400498b2 0x00000000 0x00000000 0x4ff22080 0x4ff09498
4ff4ce50: 0xaeeafdbf 0x7c2a8700 0x42e739c9 0x00000000 0x0809d209 0x4016ff3c 0x40170000 0x00000003
4ff4ce70: 0x0809d209 0x4016ff3c 0x40170000 0x00000003 0x4ff17000 0x4ff1e000 0x00000001 0x4004d794
4ff4ce90: 0x00000000 0x00000740 0x4ff49d70 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000
4ff4ceb0: 0x00000000 0x00000000 0x00000000 0x4ff0a36e 0x00000000 0x00000000 0x00000000 0x00000000
4ff4ced0: 0x00000000 0x00000000 0x00000000 0x00000000 0x00000000 0xa5a5a5a5 0xa5a5a5a5 0xa5a5a5a5
4ff4cef0: 0x00000001 0x4ff4bf1c 0x4ff4bfa0 0x4ff4bf1c 0x00000000 0x00000000 0xa5a5a5a5 0xa5a5a5a5
4ff4cf10: 0xa5a5a5a5 0xa5a5a5a5 0xa5a5a5a5 0xbaad5678 0x3e2b1c8f 0x1348cfd2 0x185310ee 0x2095ebbf
4ff4cf30: 0xfd9ec42d 0x8971e432 0x87ec93cc 0xc5c96b7e 0xc8a8d036 0x7ba23b19 0x1bd4d1ff 0x3b2a14b6
4ff4cf50: 0x1083cd8c 0x0a253280 0x231123d0 0xefdac14a 0xf0637dec 0xe92765be 0xa6f22945 0x110dda0d
4ff4cf70: 0xd3ecd411 0x4f6d9375 0x25ccee23 0x03fd516e 0xdab94db4 0x5b802fd6 0xd301f7bd 0x86549e12
4ff4cf90: 0x4ff4bf0c 0x00000064 0xabba1234 0x00000056 0x005e0001 0xb5800100 0xd437c64e 0x00450008
4ff4cfb0: 0x2c0b4800 0x11ff0000 0xa8c012ff 0x00efbc01 0xd42e0100 0x3400d42e 0x3030b436 0x00323030
4ff4cfd0: 0x00000b25 0xe21242a8 0xc30042be 0xff784914 0xc7a394f3 0x59008bd9 0x00000000 0x191c0010
4ff4cff0: 0x00ed45ed 0x56784383 0x4ff4baad 0x00000030 0xabba1234 0x00000024 0x4ff3cf18 0x4ff3cf18
4ff4d010: 0xbc01a8c0 0x00000000 0x00000000 0x00000000 0xa8c07500 0x00efbc00 0xd42e2ed4 0xbaad5678
4ff4d030: 0x00000030 0xabba1234 0x00000024 0x4ff3c194 0x4ff3c194 0xbc01a8c0 0x00000000 0x00000000
4ff4d050: 0x00000000 0x126f0000 0x00003a00 0x56782ed4 0xbaad5678 0x00000048 0xabba1234 0x0000003c
4ff4d070: 0x00000018 0x00000000 0x4ff4d360 0x4ff5a700 0x4ff3bf04 0x00000000 0x00000000 0x00000038
4ff4d090: 0x00001388 0x00001388 0x7fffffff 0x00000000 0xbaad5600 0x00000000 0x400a1278 0xbaad5678
4ff4d0b0: 0x0000003c 0xabba1234 0x00000024 0x4ff3c66c 0x4ff3c66c 0xbc01a8c0 0x00000000 0x00000000
4ff4d0d0: 0x00000000 0xabba1200 0x00000000 0x00002ed4 0xbaad5678 0x3f000148 0x4ff5d420 0x4ff4d0ac
4ff4d0f0: 0x00000064 0xabba1234 0x00000056 0x005e0001 0xb5800100 0xd437c64e 0x00450008 0x440b4800
4ff4d110: 0x11ff0000 0xa8c0fafe 0x00efbc01 0xd42e0100 0x3400d42e 0x30304232 0x00323030 0x0000c326
4ff4d130: 0x14694240 0xdb00425d 0xe5a37125 0xe7e680ca 0x593483f4 0x6b000000 0x156f0016 0xf73c452d
4ff4d150: 0x5678446a 0x4ff4baad 0x00000028 0xabba1234 0x0000001c 0x00000000 0x4ff4dd62 0x002c002c
4ff4d170: 0x02011241 0x400c0618 0x4ff2e710 0x4ff4dd38 0xbaad5678 0x00000030 0xabba1234 0x00000024
4ff4d190: 0x4ff43888 0x4ff43888 0xbc01a8c0 0x00000000 0x00000000 0x00000000 0xa8c03c00 0xa8c0bf00
4ff4d1b0: 0xd7ea2ed4 0xbaad5678 0x0000001d 0x4ff51138 0x4ff40014 0x4ff3b9d0 0x00050001 0x65726f63
4ff4d1d0: 0x56780031 0x4ff4d1b4 0x00000032 0xabba1234 0x00000024 0x4ff4dddc 0x4ff4dddc 0x2901a8c0

ELF file SHA256: 78ff255b2

Rebooting...

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and read the driver task loop implementation
cat src/main.cpp | grep -A 50 "void driverTask"

Length of output: 1376


🏁 Script executed:

#!/bin/bash
# Find and read the effects task loop implementation
cat src/main.cpp | grep -A 50 "void effectTask"

Length of output: 1537


🏁 Script executed:

#!/bin/bash
# Check if there are any vTaskDelay calls in the task loops
rg -n "vTaskDelay" src/main.cpp -C 3

Length of output: 298


🏁 Script executed:

#!/bin/bash
# Look for the watchdog configuration
rg -n "CONFIG_ESP_TASK_WDT\|task_wdt\|esp_task_wdt" platformio.ini src/main.cpp -C 2

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check the actual task creation priorities
rg -n "xTaskCreateUniversal.*effectTask|xTaskCreateUniversal.*driverTask" src/main.cpp -A 1 -B 1

Length of output: 325


🏁 Script executed:

#!/bin/bash
# Look for layerP.loopDrivers implementation to see if it yields
rg -n "void loopDrivers" --type cpp -A 30

Length of output: 2370


🏁 Script executed:

#!/bin/bash
# Look for layerP.loop implementation to see if it yields
rg -n "void loop\(\)" src/MoonLight/Layers/PhysicalLayer.cpp -A 30

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find loopDrivers implementation in cpp files
rg -n "void PhysicalLayer::loopDrivers" --type cpp -A 30

Length of output: 2064


🏁 Script executed:

#!/bin/bash
# Find loop implementation in PhysicalLayer
rg -n "PhysicalLayer::loop\(\)" --type cpp -A 30

Length of output: 2059


🏁 Script executed:

#!/bin/bash
# Get the full task creation code to see priorities
cat src/main.cpp | grep -A 10 "xTaskCreateUniversal(effectTask"

Length of output: 830


🏁 Script executed:

#!/bin/bash
# Check for watchdog subscription in main.cpp
rg -n "esp_task_wdt_add\|esp_task_wdt_init" src/main.cpp -C 3

Length of output: 0


@ewowi, good catch! This is a task starvation issue, not a priority issue. Let me explain what's happening and how to fix it.

The Root Cause

The watchdog fired because IDLE0 (Core 0) couldn't run for 5+ seconds. Here's why:

IDLE task priority: 0 (lowest)
AppDrivers priority: 3 (your current setting)
Result: IDLE can NEVER preempt AppDrivers

Even though you have vTaskDelay(1) at the end of each loop, the loop body itself is taking too long. At 100 fps with 49,152 channels:

// From PhysicalLayer.cpp line 108-111
for (Node* node : nodes) {
    if (prevSize != lights.header.size) node->onSizeChanged(prevSize);
    if (node->on) node->loop();  // ⚠️ No yield here!
}

If you have many driver nodes, this for loop can take several seconds without yielding, starving the IDLE task.

Why Lowering Priority Won't Help

You asked about lowering priority from 3 and 5 — this won't solve the problem:

  • IDLE is already at priority 0 (the absolute lowest)
  • Even priority 1 would still preempt IDLE
  • The issue is not priority inversion, it's lack of yielding during heavy processing

Solutions (in Order of Preference)

✅ Solution 1: Add Yields Inside Heavy Loops (Recommended)

Modify PhysicalLayer.cpp to yield periodically during iteration:

// In PhysicalLayer::loopDrivers() (around line 108)
void PhysicalLayer::loopDrivers() {
  // ... existing code ...

  for (Node* node : nodes) {
    if (prevSize != lights.header.size) node->onSizeChanged(prevSize);
    if (node->on) node->loop();
    
    // Add this: yield after each node to let IDLE task run
    taskYIELD();  // or vTaskDelay(1) if you prefer
  }

  prevSize = lights.header.size;
}

// Similarly in PhysicalLayer::loop() (around line 73)
void PhysicalLayer::loop() {
  for (VirtualLayer* layer : layers) {
    if (layer) layer->loop();
    taskYIELD();  // Yield after each layer
  }
}

Why this works: Even at 100 fps, yielding after each node/layer gives IDLE task chances to run and reset the watchdog.

✅ Solution 2: Subscribe Tasks to Watchdog

Add this in main.cpp after task creation:

// After xTaskCreateUniversal calls (around line 340)
#include "esp_task_wdt.h"

// Subscribe both tasks to watchdog so they can reset it themselves
esp_task_wdt_add(effectTaskHandle);
esp_task_wdt_add(driverTaskHandle);

Then add resets in the task loops:

void driverTask(void* pvParameters) {
  while (true) {
    // ... existing code ...
    
    esp_task_wdt_reset();  // Reset watchdog from this task
    vTaskDelay(1);
  }
}

void effectTask(void* pvParameters) {
  while (true) {
    // ... existing code ...
    
    esp_task_wdt_reset();  // Reset watchdog from this task
    vTaskDelay(1);
  }
}

Why this works: Tasks reset the watchdog themselves instead of relying on IDLE.

⚠️ Solution 3: Increase Watchdog Timeout (Workaround)

Add to platformio.ini:

build_flags = 
  ...
  -D CONFIG_ESP_TASK_WDT_TIMEOUT_S=30  ; Increase from default 5s to 30s

Warning: This just hides the problem and won't catch real hangs as quickly.

🎯 Solution 4: Reduce Workload (If Possible)

At 100 fps with 49K channels, you're pushing 4.9 million channel updates per second. Consider:

  • Lowering FPS to 60 (still very smooth for human eyes)
  • Processing nodes in batches with yields between batches
  • Profiling to find the slowest nodes and optimizing them

Recommended Action Plan

  1. Implement Solution 1 (add taskYIELD() inside the node iteration loops) — this is the cleanest fix
  2. Optionally add Solution 2 (watchdog subscription) for defense in depth
  3. Keep priorities as-is (3 and 5 are fine; lowering won't help)
  4. Monitor with logging to see if the issue persists

Would you like me to prepare a complete patch for these changes?


✏️ Learnings added
Learnt from: ewowi
URL: https://github.com/MoonModules/MoonLight/pull/93

Timestamp: 2026-01-06T13:45:03.045Z
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).

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: lib/framework/ESP32SvelteKit.cpp:229-229
Timestamp: 2026-01-04T09:31:07.533Z
Learning: In lib/framework/ESP32SvelteKit.cpp, the SvelteKit loop task stack size of 6KB (6 * 1024 bytes) for non-PSRAM devices has been validated through testing, showing approximately 1500 bytes of free stack remaining under load, providing adequate ~24% headroom.

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.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

@coderabbitai. What is the difference between taskYIELD() and vTaskDelay(1), and what about vPortYield()? especially looking at ESP32-D0 vs ESP32-S3 vs ESP32-P4 and aiming at using esp-idf instead of Arduino?

vtaskDelay(1) pauses 1ms, taskYIELD doesn't? That in turn might be better to allow for > 1000 fps on some setups (delaying 1ms means it never goes above 1000 fps).

If I replace my vTaskDelay with taskYIELD processes do not seem to work anymore.

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

🧩 Analysis chain

🌐 Web query:

ESP32 FreeRTOS taskYIELD vTaskDelay vPortYield difference ESP-IDF

💡 Result:

Short summary — differences and when to use each:

  • taskYIELD()

    • API macro that forces the scheduler to perform a context switch now (yields CPU to another ready task of equal priority). Implemented by calling the port-specific yield (vPortYield). Use when you want an immediate cooperative yield with minimal overhead. [1][2]
  • vPortYield()

    • Port (ESP32) specific low-level yield routine used by taskYIELD(). Performs the architecture-dependent actions to trigger a context switch (on ESP32 this may interact with the other core/port layer). You normally call taskYIELD(), not vPortYield() directly. [1][3]
  • vTaskDelay(0) vs vTaskDelay(n)

    • vTaskDelay(0) ends up behaving like taskYIELD(): it computes a wake time of “now” so the task remains ready but is placed at the tail of its priority list — same end result as taskYIELD() but with extra overhead. Use taskYIELD() if delay is known to be zero. [2]
    • vTaskDelay(n>0) blocks the task for n ticks (removes it from the ready list until the wake tick). That allows lower-priority tasks (including the IDLE task) to run and is used for timed suspensions/periodic waits. Use vTaskDelay or vTaskDelayUntil for fixed periodic timing. [1][2]

ESP-IDF / multicore notes

  • Because ESP-IDF runs on two cores, yields/ deletes that affect a task on the other core may trigger inter-core behavior (e.g., idle task on the other core frees memory, and yields may interact with the other core). Keep core affinity in mind when relying on yields. [1][3]

Sources:

  • ESP-IDF FreeRTOS docs (vTaskDelay/ task behavior, multicore notes). [1]
  • FreeRTOS authoritative forum explanation (taskYIELD vs vTaskDelay(0)). [2]
  • ESP-Techpedia / ESP-IDF notes on taskYIELD and multicore behavior. [3]

References:
[1] ESP-IDF FreeRTOS docs. [2] FreeRTOS forum (Richard Barry). [3] ESP-Techpedia (ESP-IDF FreeRTOS notes).


@ewowi, excellent questions! Let me break this down clearly.

The Three Functions Explained

1. taskYIELD()

  • What it does: Forces an immediate context switch to equal or higher priority tasks only
  • Blocking: No — if no equal/higher priority task is ready, returns immediately
  • Overhead: Minimal (just a scheduler call)
  • Use case: Cooperative yielding within same-priority tasks

2. vPortYield()

  • What it does: Port-specific (ESP32) low-level implementation of taskYIELD()
  • Use case: You should never call this directly — always use taskYIELD() instead
  • Note: Same on ESP32-D0/S3/P4; it's the architecture-specific yield primitive

3. vTaskDelay(n)

  • What it does: Blocks the task for n ticks (removes from ready queue)
  • Blocking: Yes — allows ALL lower-priority tasks to run, including IDLE (priority 0)
  • Timing:
    • vTaskDelay(0) behaves like taskYIELD() but with extra overhead
    • vTaskDelay(1) = minimum 1 tick (~1ms by default) = caps at 1000 fps
  • Use case: Timed delays or when you need to let lower-priority tasks run

Why Your Processes Break with taskYIELD()

Here's the problem:

Your tasks:
  AppDrivers:  Priority 3
  AppEffects:  Priority 5
  IDLE:        Priority 0

taskYIELD() only yields to tasks with priority ≥3 (for AppDrivers)
IDLE task (priority 0) NEVER gets to run → watchdog fires

When you use vTaskDelay(1), it blocks your task and lets IDLE run. When you use taskYIELD(), it only yields to equal/higher priority tasks, so IDLE is starved.

Solution for >1000 FPS

You need a hybrid approach:

Option A: Fast Loop + Periodic IDLE Yield (Recommended)

void driverTask(void* pvParameters) {
  uint32_t loopCounter = 0;
  
  while (true) {
    // Your fast processing here
    layerP.loopDrivers();
    
    // Yield to equal/higher priority tasks every loop (no delay)
    taskYIELD();
    
    // Let IDLE run every N loops (adjust N based on your FPS target)
    if (++loopCounter % 100 == 0) {
      vTaskDelay(1);  // Give IDLE task a chance every 100 loops
    }
  }
}

void effectTask(void* pvParameters) {
  uint32_t loopCounter = 0;
  
  while (true) {
    // Your fast processing here
    layerP.loop();
    
    taskYIELD();
    
    if (++loopCounter % 100 == 0) {
      vTaskDelay(1);
    }
  }
}

Result: You get ~100 loops at >1000 fps, then one 1ms pause. Achieves much higher average FPS while still feeding IDLE.

Option B: Yield Inside Heavy Loops + Subscribe to Watchdog

// In main.cpp after task creation
#include "esp_task_wdt.h"

esp_task_wdt_add(effectTaskHandle);
esp_task_wdt_add(driverTaskHandle);

// Then in your tasks:
void driverTask(void* pvParameters) {
  while (true) {
    layerP.loopDrivers();
    
    // Reset watchdog from this task directly
    esp_task_wdt_reset();
    
    // No delay needed! Can go >1000 fps
    taskYIELD();
  }
}

// And add yields in PhysicalLayer.cpp heavy loops:
void PhysicalLayer::loopDrivers() {
  for (Node* node : nodes) {
    if (prevSize != lights.header.size) node->onSizeChanged(prevSize);
    if (node->on) node->loop();
    taskYIELD();  // Yield after each node
  }
  prevSize = lights.header.size;
}

Result: Tasks reset watchdog themselves, don't depend on IDLE task, can run at maximum speed.

ESP32 Variant Differences

Good news: These FreeRTOS APIs work identically on ESP32, ESP32-S3, and ESP32-P4 in ESP-IDF. The differences are in the underlying scheduler implementation (SMP on P4), but the behavior you see is the same.

Recommended Configuration for Your Use Case

For >1000 fps on P4 and fast operation on S3:

1. Use Option B (watchdog subscription) — cleanest and fastest

2. Main task loops:

while (true) {
  // Your processing
  esp_task_wdt_reset();
  taskYIELD();  // No delay! Can exceed 1000 fps
}

3. Add yields inside heavy processing:

// In PhysicalLayer::loopDrivers() and PhysicalLayer::loop()
for (Node* node : nodes) {
  node->loop();
  taskYIELD();  // Let other tasks run between nodes
}

This gives you:

  • ✅ Unlimited FPS (no 1ms cap)
  • ✅ No watchdog timeouts
  • ✅ Good cooperative multitasking
  • ✅ Works on all ESP32 variants

Would you like me to prepare a complete patch implementing Option B?


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

@coderabbitai, I get

E (12349) task_wdt: esp_task_wdt_reset(707): task not found

So what is the order of things in main.cpp setup. I have now:

esp_task_wdt_add(effectTaskHandle);
esp_task_wdt_add(driverTaskHandle);

// 🌙
xTaskCreateUniversal(effectTask, // task function
"AppEffects", // name
psramFound() ? 4 * 1024 : 3 * 1024, // stack size, save every byte on small devices
NULL, // parameter
5, // priority (between 5 and 10: ASYNC_WORKER_TASK_PRIORITY and Restart/Sleep), don't set it higher then 10...
&effectTaskHandle, // task handle
1 // application core. high speed effect processing
);

xTaskCreateUniversal(driverTask, // task function
"AppDrivers", // name
psramFound() ? 4 * 1024 : 3 * 1024, // stack size, save every byte on small devices
NULL, // parameter
3, // priority (between 5 and 10: ASYNC_WORKER_TASK_PRIORITY and Restart/Sleep), don't set it higher then 10...
&driverTaskHandle, // task handle
0 // protocol core: ideal for Art-Net, no issues encountered yet for LED drivers (pre-empt by WiFi ...)
);

But still got above error

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the main.cpp setup function to see the exact order
cat src/main.cpp | grep -A 100 "void setup()"

Length of output: 3992


🏁 Script executed:

#!/bin/bash
# Search for esp_task_wdt_add and xTaskCreateUniversal calls to see the exact order
rg -n "esp_task_wdt_add|xTaskCreateUniversal" src/main.cpp -A 2 -B 2

Length of output: 563


🏁 Script executed:

#!/bin/bash
# Search for all esp_task_wdt related calls in main.cpp
rg -n "esp_task_wdt" src/main.cpp -C 5

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for esp_task_wdt_reset in the task implementations
rg -n "esp_task_wdt_reset" src/main.cpp -C 5

Length of output: 0


@ewowi, I found the issue! You're calling esp_task_wdt_add() before the tasks are created, so you're adding NULL/uninitialized handles to the watchdog. The task handles are only populated after xTaskCreateUniversal() returns.

The Problem

// ❌ WRONG ORDER - handles are NULL here!
esp_task_wdt_add(effectTaskHandle);    // effectTaskHandle = NULL
esp_task_wdt_add(driverTaskHandle);    // driverTaskHandle = NULL

// Handles are populated AFTER these calls return
xTaskCreateUniversal(effectTask, ..., &effectTaskHandle, ...);
xTaskCreateUniversal(driverTask, ..., &driverTaskHandle, ...);

When you call esp_task_wdt_reset() from inside the task functions, the watchdog has no record of those tasks because the handles were NULL when you added them.

The Solution

Correct order in main.cpp setup():

#include "esp_task_wdt.h"  // Add at top of file if not already there

// In setup(), around line 322:

// 1️⃣ CREATE TASKS FIRST
// 🌙
xTaskCreateUniversal(effectTask,                          // task function
                     "AppEffects",                        // name
                     psramFound() ? 4 * 1024 : 3 * 1024,  // stack size
                     NULL,                                // parameter
                     5,                                   // priority
                     &effectTaskHandle,                   // task handle - POPULATED HERE
                     1                                    // core 1
);

xTaskCreateUniversal(driverTask,                          // task function
                     "AppDrivers",                        // name
                     psramFound() ? 4 * 1024 : 3 * 1024,  // stack size
                     NULL,                                // parameter
                     3,                                   // priority
                     &driverTaskHandle,                   // task handle - POPULATED HERE
                     0                                    // core 0
);

// 2️⃣ NOW ADD TO WATCHDOG (handles are valid now!)
esp_task_wdt_add(effectTaskHandle);
esp_task_wdt_add(driverTaskHandle);

In Your Task Functions

Make sure you're calling esp_task_wdt_reset() from within the tasks:

void effectTask(void* pvParameters) {
  while (true) {
    layerP.loop();
    
    esp_task_wdt_reset();  // Reset watchdog from this task
    taskYIELD();           // Cooperative yield
  }
}

void driverTask(void* pvParameters) {
  while (true) {
    layerP.loopDrivers();
    
    esp_task_wdt_reset();  // Reset watchdog from this task
    taskYIELD();           // Cooperative yield
  }
}

Summary

The key principle: Task handles are output parameters — they're only valid after xTaskCreateUniversal() returns. Always create tasks first, then subscribe them to services (watchdog, etc.).

Try this order and the error should disappear! 🎯


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

@coderabbitai, I tried your suggestion first , but then I also get

E (33030) task_wdt: esp_task_wdt_reset(707): task not found

So I guess the tasks starts running and execute esp_task_wdt_reset(); before esp_task_wdt_add() is called, do we need to check on add has taken place before reset?

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

@ewowi, you're absolutely right! That's a classic race condition. The tasks start running immediately after xTaskCreateUniversal() returns, so they reach esp_task_wdt_reset() before setup() has a chance to call esp_task_wdt_add().

The Race Condition

Timeline:
1. xTaskCreateUniversal(effectTask, ...) → returns immediately
2. effectTask starts running on Core 1 → calls esp_task_wdt_reset() ❌
3. setup() continues → eventually calls esp_task_wdt_add() ⏰ (too late!)

The Solution: Self-Registration

Have each task add itself to the watchdog when it starts, eliminating the race:

main.cpp Changes

Remove watchdog adds from setup():

// In setup(), around line 322:

// 🌙 CREATE TASKS
xTaskCreateUniversal(effectTask,
                     "AppEffects",
                     psramFound() ? 4 * 1024 : 3 * 1024,
                     NULL,
                     5,
                     &effectTaskHandle,
                     1
);

xTaskCreateUniversal(driverTask,
                     "AppDrivers",
                     psramFound() ? 4 * 1024 : 3 * 1024,
                     NULL,
                     3,
                     &driverTaskHandle,
                     0
);

// ❌ REMOVE THESE - tasks will add themselves
// esp_task_wdt_add(effectTaskHandle);
// esp_task_wdt_add(driverTaskHandle);

Update task functions to self-register:

#include "esp_task_wdt.h"  // Add at top of file

void effectTask(void* pvParameters) {
  // Add this task to watchdog FIRST THING
  esp_task_wdt_add(NULL);  // NULL = current task
  
  while (true) {
    layerP.loop();
    
    esp_task_wdt_reset();  // Now safe to call
    taskYIELD();
  }
}

void driverTask(void* pvParameters) {
  // Add this task to watchdog FIRST THING
  esp_task_wdt_add(NULL);  // NULL = current task
  
  while (true) {
    layerP.loopDrivers();
    
    esp_task_wdt_reset();  // Now safe to call
    taskYIELD();
  }
}

Why This Works

  • esp_task_wdt_add(NULL) adds the calling task to the watchdog
  • It's the first line in each task function, so it runs before the loop starts
  • No race condition — the task adds itself before it ever calls reset()
  • Clean and self-contained — each task manages its own watchdog subscription

Complete Code Pattern

void effectTask(void* pvParameters) {
  esp_task_wdt_add(NULL);  // 1️⃣ Subscribe to watchdog
  
  while (true) {
    // 2️⃣ Do work
    layerP.loop();
    
    // 3️⃣ Reset watchdog
    esp_task_wdt_reset();
    
    // 4️⃣ Yield cooperatively
    taskYIELD();
  }
  
  // Cleanup (never reached in this case, but good practice)
  esp_task_wdt_delete(NULL);
}

Try this approach and the error should disappear! The key insight is that tasks should manage their own watchdog subscriptions rather than having an external function try to do it (which creates the race). 🎯


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

@coderabbitai, okay, the error is gone but with taskYield() the processes again do not seem to work, if I replace with vTaskDelay(1) they start working again. What is going on?

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

@ewowi, I found the root cause! This is a fundamental FreeRTOS scheduling issue with your priority levels.

Why taskYIELD() Doesn't Work

The FreeRTOS scheduler always gives the CPU to the task with the highest priority that is ready to run, and a task with a higher priority will always run before a lower-priority task.

Your current setup:

AppEffects:  Priority 5, Core 1
AppDrivers:  Priority 3, Core 0

What happens with taskYIELD():

1. AppEffects (priority 5) calls taskYIELD()
2. Scheduler checks: any ready tasks with priority ≥5? No.
3. Scheduler: "AppEffects is still the highest priority ready task"
4. AppEffects runs again immediately
5. AppDrivers (priority 3) NEVER gets to run on its core ❌

What happens with vTaskDelay(1):

1. AppEffects calls vTaskDelay(1)
2. Task is BLOCKED (removed from ready queue for 1ms)
3. Scheduler: "AppEffects is blocked, run next ready task"
4. AppDrivers (priority 3) can now run ✅

When a higher priority task goes into the delay state (vTaskDelay()), it becomes blocked for that duration. During this time, the scheduler looks for the next ready task and gives it CPU time. Once the delay finishes, the higher priority task becomes ready again and will preempt the lower priority task immediately.

Why Your "Processes Don't Work"

Your driver task (AppDrivers, priority 3) is being starved by the effects task (AppEffects, priority 5). The LEDs don't update because the driver never gets CPU time to send the data.

Solutions

✅ Option 1: Keep vTaskDelay(1) (Simplest)

Accept the 1000 fps cap for now. If you need higher fps later, profile to see if 1ms is actually your bottleneck:

void effectTask(void* pvParameters) {
  esp_task_wdt_add(NULL);
  
  while (true) {
    layerP.loop();
    esp_task_wdt_reset();
    vTaskDelay(1);  // Keep this
  }
}

void driverTask(void* pvParameters) {
  esp_task_wdt_add(NULL);
  
  while (true) {
    layerP.loopDrivers();
    esp_task_wdt_reset();
    vTaskDelay(1);  // Keep this
  }
}

✅ Option 2: Make Priorities Equal (Enables taskYIELD())

Change to same priority for round-robin scheduling:

// In main.cpp setup()
xTaskCreateUniversal(effectTask, "AppEffects", ..., 3, ..., 1);   // Priority 3
xTaskCreateUniversal(driverTask, "AppDrivers", ..., 3, ..., 0);   // Priority 3 (same!)

Now taskYIELD() works because multiple tasks can share the same priority, and FreeRTOS uses time-slicing — each task gets CPU time in a round-robin fashion.

void effectTask(void* pvParameters) {
  esp_task_wdt_add(NULL);
  
  while (true) {
    layerP.loop();
    esp_task_wdt_reset();
    taskYIELD();  // Now works! Round-robin with driverTask
  }
}

Trade-off: Effects can't preempt drivers anymore, but if they're on different cores anyway, this doesn't matter much.

✅ Option 3: Hybrid - Short Delay on High Priority Task Only

void effectTask(void* pvParameters) {
  esp_task_wdt_add(NULL);
  
  while (true) {
    layerP.loop();
    esp_task_wdt_reset();
    vTaskDelay(1);  // Block to let AppDrivers run
  }
}

void driverTask(void* pvParameters) {
  esp_task_wdt_add(NULL);
  
  while (true) {
    layerP.loopDrivers();
    esp_task_wdt_reset();
    taskYIELD();  // Driver can just yield (nothing higher priority)
  }
}

My Recommendation

Use Option 2 (equal priorities with taskYIELD()). Here's why:

  1. ✅ Both tasks on different cores anyway — priority difference doesn't help
  2. ✅ Enables unlimited fps (no 1ms delay cap)
  3. ✅ Simpler mental model — cooperative scheduling between equals
  4. ✅ Still allows watchdog subscription pattern

The priority difference (3 vs 5) was causing starvation, not helping. Since they run on separate cores, they don't compete for CPU — the only issue is the watchdog and inter-task synchronization.

Would you like me to prepare a complete patch with Option 2?


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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.

🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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: 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.

Backend
=======
- Physical and virtual layer: Add taskYIELD() in node loop
- main: add esp_task_wdt_add, esp_task_wdt_reset and esp_task_wdt_delete in drivers and effects task, set both prios to 3
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.cpp:
- Around line 212-221: Re-enable the bounds check before dereferencing the
buffer: compute index = indexV * layerP->lights.header.channelsPerLight + offset
and only perform the address-cast-and-dereference
(*(T*)&layerP->lights.channelsE[index]) when (index + sizeof(T) <=
layerP->lights.maxChannels); otherwise log a warning via EXT_LOGW with context
(indexV, sizeof(T), lights.maxChannels) and return a default-constructed T().
Ensure you reference index, indexV, layerP->lights.header.channelsPerLight,
offset, layerP->lights.channelsE, and layerP->lights.maxChannels so the check
surrounds the current dereference site.
🧹 Nitpick comments (1)
docs/develop/architecture.md (1)

317-333: Task creation code matches the table but omits explanation of the reversal.

Lines 317-333 correctly implement the reversed core assignments, but the inline comments (lines 323, 332) don't explain why the reversal was chosen or link to performance data/learnings.

Suggested improvement:

  xTaskCreateUniversal(effectTask,
                       "AppEffects",
                       psramFound() ? 4 * 1024 : 3 * 1024,
                       NULL,
                       3,
                       &effectTaskHandle,
-                      1                                    // application core. high speed effect processing
+                      1                                    // [EXPERIMENTAL] APP_CPU: testing effect placement to reduce WiFi contention; see Design Notes
  );

  xTaskCreateUniversal(driverTask,
                       "AppDrivers",
                       psramFound() ? 4 * 1024 : 3 * 1024,
                       NULL,
                       3,
                       &driverTaskHandle,
-                      0                                    // protocol core: ideal for Art-Net, no issues encountered yet for LED drivers (pre-empt by WiFi ...)
+                      0                                    // [EXPERIMENTAL] PRO_CPU: testing driver colocality with WiFi for Art-Net; monitors for timing glitches
  );
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 71378c6 and 1fb4075.

📒 Files selected for processing (7)
  • docs/develop/architecture.md
  • lib/framework/ESP32SvelteKit.cpp
  • lib/framework/EventSocket.cpp
  • src/MoonLight/Layers/PhysicalLayer.cpp
  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Nodes/Layouts/L_MoonLight.h
  • src/main.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main.cpp
🧰 Additional context used
🧠 Learnings (11)
📓 Common learnings
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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: 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.
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.
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: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.
📚 Learning: 2026-01-04T09:31:01.321Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: lib/framework/ESP32SvelteKit.cpp:229-229
Timestamp: 2026-01-04T09:31:01.321Z
Learning: In lib/framework/ESP32SvelteKit.cpp, retain a 6 KB (6 * 1024 bytes) stack size for the loop task on non-PSRAM ESP32 devices. The tested configuration shows ~1500 bytes free under load, providing about 24% headroom. Verify this stack sizing when reviewing related ESP32 loop-task implementations for non-PSRAM scenarios to ensure reliable task execution under expected load.

Applied to files:

  • lib/framework/ESP32SvelteKit.cpp
📚 Learning: 2026-01-06T13:45:03.045Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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).

Applied to files:

  • lib/framework/ESP32SvelteKit.cpp
  • lib/framework/EventSocket.cpp
  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/PhysicalLayer.cpp
  • docs/develop/architecture.md
📚 Learning: 2025-11-29T16:17:30.619Z
Learnt from: CR
Repo: MoonModules/MoonLight PR: 0
File: misc/parking/GEMINI.md:0-0
Timestamp: 2025-11-29T16:17:30.619Z
Learning: Applies to misc/parking/src/**/*.{h,cpp} : Modify C++ backend code in the `src/` and `lib/` directories for ESP32 development

Applied to files:

  • lib/framework/ESP32SvelteKit.cpp
  • docs/develop/architecture.md
📚 Learning: 2025-12-28T10:28:24.157Z
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.

Applied to files:

  • lib/framework/EventSocket.cpp
  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/PhysicalLayer.cpp
  • docs/develop/architecture.md
📚 Learning: 2025-12-25T13:34:40.097Z
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.

Applied to files:

  • lib/framework/EventSocket.cpp
  • src/MoonLight/Layers/PhysicalLayer.cpp
  • docs/develop/architecture.md
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/PhysicalLayer.cpp
📚 Learning: 2025-12-07T14:16:03.228Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: In VirtualLayer, mappingTableSize should always be 0 when mappingTable is nullptr. This invariant is maintained by developer responsibility: initial state sets both together, realloc success updates both together, realloc failure preserves both together. For performance reasons, no redundant null checks like `if (mappingTable && indexV < mappingTableSize)` are added, as the size check alone is sufficient when the invariant is maintained.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.cpp
📚 Learning: 2025-12-18T15:15:57.828Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-18T15:15:57.828Z
Learning: In DripEffect (E_WLED.h), dropsSize must be set to layer->size.y (not layer->size.x) in onSizeChanged(), because the drops array is allocated as [layer->size.y][maxNumDrops]. Using layer->size.x causes out-of-bounds access when iterating over y-coordinates.

Applied to files:

  • src/MoonLight/Layers/VirtualLayer.cpp
  • src/MoonLight/Layers/PhysicalLayer.cpp
📚 Learning: 2025-12-25T13:31:32.367Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

Applied to files:

  • docs/develop/architecture.md
📚 Learning: 2026-01-04T09:31:07.533Z
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: lib/framework/ESP32SvelteKit.cpp:229-229
Timestamp: 2026-01-04T09:31:07.533Z
Learning: In lib/framework/ESP32SvelteKit.cpp, the SvelteKit loop task stack size of 6KB (6 * 1024 bytes) for non-PSRAM devices has been validated through testing, showing approximately 1500 bytes of free stack remaining under load, providing adequate ~24% headroom.

Applied to files:

  • docs/develop/architecture.md
🧬 Code graph analysis (2)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (4)
src/MoonBase/Module.cpp (2)
  • addControl (353-362)
  • addControl (353-353)
src/MoonLight/Layers/VirtualLayer.cpp (2)
  • addLight (369-401)
  • addLight (369-369)
src/MoonBase/Nodes.cpp (2)
  • onLayout (211-216)
  • onLayout (211-211)
src/MoonLight/Nodes/Layouts/L__Sandbox.h (1)
  • onLayout (36-46)
src/MoonLight/Layers/VirtualLayer.cpp (1)
src/MoonLight/Layers/VirtualLayer.h (3)
  • setRGB (113-124)
  • setRGB (125-125)
  • XYZUnModified (111-111)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (17)
src/MoonLight/Nodes/Layouts/L_MoonLight.h (5)

254-260: Setup looks good with minor UX consideration.

The nrOfLightsPerCube control only affects mode 0 ("One Cube One Light"). In modes 1 and 2, the light count per cube is fixed at 61 (5×12+1) based on the physical cube structure. This is functionally correct, but users may find the slider confusing when other granularity modes are selected.

Consider either:

  • Disabling the slider when granularity ≠ 0, or
  • Adding a note in the description that it applies only to mode 0

This is optional and can be addressed in a future iteration.


263-265: LGTM.

Correct implementation for "One Cube One Light" mode — all physical LEDs in the cube map to a single virtual position, receiving the same color.


268-279: LGTM.

Correct geometry for 3×3×3 virtual cube grid. Five sides (front, right, back, left, top) plus middle LED totals 5×12+1 = 61 LEDs per cube, matching the expected physical structure.


281-300: LGTM.

The 7×7×7 cube grid with per-LED granularity is correctly implemented. The 12-pixel square perimeter is properly mapped to each of the 5 faces, and the middle LED at {3, 3, 3} is correctly centered. The previous comment issue (Line 295) has been addressed.


302-355: LGTM.

The 4×3×2 cube grid (24 cubes total) is correctly defined. The granularity switch cleanly dispatches to the appropriate rendering method, and the pin advancement logic properly handles both 10-cube intervals and the final remainder. Previous review issues (duplicate coordinate and comment errors) have been addressed.

lib/framework/EventSocket.cpp (2)

186-195: LGTM: Error handling correctly implemented for origin client sends.

The addition of sendMessage result capture and error checking in the onlyToSameOrigin path successfully addresses the send/close race condition. On failure, the subscription is removed and an appropriate warning is logged. This matches the proposed fix and ensures consistent error handling across both send paths.


201-233: LGTM: Iterator-based loop correctly implements safe removal during iteration.

The refactoring from a range-based loop to an iterator-based loop allows safe removal of failed subscriptions during iteration. The error handling (lines 224-232) correctly captures sendMessage results, logs failures, and uses erase(it) to remove failed subscriptions while advancing the iterator. The increment logic (only when not erasing) prevents iterator invalidation.

This change, combined with the onlyToSameOrigin path fix, successfully addresses the WebSocket send/close race conditions described in the PR objectives.

lib/framework/ESP32SvelteKit.cpp (2)

227-233: LGTM! Conditional stack sizing implements validated configuration.

The dynamic stack allocation (psramFound() ? 8KB : 6KB) correctly implements the previously tested and validated approach. The 6KB allocation for non-PSRAM devices provides adequate headroom (~1500 bytes free under load, approximately 24% margin), while PSRAM builds gain additional margin. This aligns with the PR's PSRAM-aware task sizing strategy.

Based on learnings, the 6KB non-PSRAM configuration has been confirmed through runtime testing.


237-310: Verify that module loop callbacks yield appropriately.

According to the AI summary, an unconditional vTaskDelay(1) was removed from the end of this loop (removed code not shown). With only vTaskDelayUntil remaining (line 309), the loop will maintain periodic scheduling at ESP32SVELTEKIT_LOOP_INTERVAL intervals—but if a loop iteration exceeds that interval, vTaskDelayUntil may return immediately without yielding (standard FreeRTOS behavior when the target wake time has already passed).

Given that:

  • This loop task runs at priority 2 (lower than driver/effects tasks at priority 3 per PR objectives)
  • Higher-priority tasks will preempt it regardless
  • The PR adds taskYIELD() calls inside heavy loops (PhysicalLayer node/layer iterations per learnings)

The removal should be safe provided that module callbacks registered via _loopFunctions (lines 294-297) yield internally if they perform compute-intensive work. Confirm that heavy module loops include taskYIELD() or vTaskDelay() calls to prevent IDLE task starvation in edge cases where loop iteration duration exceeds the scheduled interval.

If you'd like, I can search the codebase to verify that module loop callbacks include appropriate yielding for heavy workloads.

src/MoonLight/Layers/PhysicalLayer.cpp (2)

110-113: Excellent: taskYIELD() prevents watchdog timeouts under heavy load.

The addition of taskYIELD() after node->loop() addresses the task watchdog issue documented in the learnings. At extreme loads (e.g., 49k channels @ 100fps), this ensures the IDLE task gets CPU time during long node iterations, preventing watchdog timeouts.

Based on learnings, this change is essential for stability with very large LED counts.


189-189: Correct type promotion for large light counts.

Promoting prevNrOfLights from uint16_t to nrOfLights_t aligns with the PR's goal to support 130k LEDs. The conditional typedef (uint32_t with PSRAM, uint16_t otherwise) ensures the accumulator won't overflow when summing ledsPerPin entries.

src/MoonLight/Layers/VirtualLayer.cpp (6)

63-66: Excellent: taskYIELD() prevents watchdog timeouts under heavy load.

Consistent with PhysicalLayer changes, this addition of taskYIELD() after node->loop() ensures cooperative multitasking during heavy layer processing. This is essential for stability at extreme LED counts.

Based on learnings, this change prevents IDLE task starvation and task watchdog timeouts.


152-161: Correct handling of zero-lights mapping with PSRAM-aware storage.

The new default case properly handles m_zeroLights by storing color values directly in the mapping table. The PSRAM branch stores full RGB (3 bytes), while the non-PSRAM branch compresses to 14-bit format. The length check ensures only RGB(W) data is stored.


196-210: Confirmed: Past critical rgb14 unpacking issue is now resolved.

The rgb14 unpacking (lines 203-205) now includes proper bit masking (& 0x1F for green, & 0x0F for blue), preventing channel bleed. This addresses the past critical issue where intermediate channels included bits from adjacent channels.


317-336: Good: Lazy allocation and initialization of mapping table.

The new createMappingTableAndAddOneToOne() method efficiently handles the transition from one-to-one mapping (no table) to irregular mapping (with table). It reallocates only when needed, initializes to zero, and populates with 1:1 entries. The loop bound (line 333) safely prevents overflow.


377-400: Excellent: Lazy mapping table creation optimizes memory for regular layouts.

The one-to-one mapping optimization (lines 379-382, 392-395) avoids allocating the mapping table when physical and virtual indices align perfectly. The irregularity detection triggers table creation only when needed, saving significant memory for simple rectangular layouts. The double-buffer zeroing (line 399) ensures consistency.


408-445: Good: Memory optimization frees mapping tables for regular layouts.

When oneToOneMapping is true (lines 408-420), the mapping tables are freed, saving memory for simple rectangular LED arrangements. The logging (line 447) provides useful diagnostics. This completes the lazy-allocation optimization introduced in earlier segments.

Comment on lines +164 to 171
} else {
uint32_t index = indexV * layerP->lights.header.channelsPerLight + offset;
// if (index + length <= layerP->lights.maxChannels) { // no mapping
memcpy(&layerP->lights.channelsE[index], channels, length);
// } else {
// EXT_LOGW(ML_TAG, "%d + %d >= %d", indexV, length, layerP->lights.maxChannels);
// }
}
Copy link

@coderabbitai coderabbitai bot Jan 6, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Re-enable bounds check to prevent buffer overflow.

Lines 166 and 168-170 comment out a bounds check that prevents writing beyond lights.maxChannels. When oneToOneMapping is true (mappingTableSize==0) or indexV >= mappingTableSize, this unmapped path is taken. Without the bounds check, a large indexV can cause index (line 165) to exceed maxChannels, leading to buffer overflow and memory corruption.

🔎 Proposed fix
   } else {
     uint32_t index = indexV * layerP->lights.header.channelsPerLight + offset;
-    // if (index + length <= layerP->lights.maxChannels) {  // no mapping
+    if (index + length <= layerP->lights.maxChannels) {  // no mapping
       memcpy(&layerP->lights.channelsE[index], channels, length);
-    // } else {
-    //   EXT_LOGW(ML_TAG, "%d + %d >= %d", indexV, length, layerP->lights.maxChannels);
-    // }
+    } else {
+      EXT_LOGW(ML_TAG, "%d + %d >= %d", indexV, length, layerP->lights.maxChannels);
+    }
   }

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

live fast and dangerous bro! We need to drive 100K LEDs and it's up to the effects devs to make sure we stay within bounds - that's the idea, I am testing currently if there are effects which break the rules

Copy link

Choose a reason for hiding this comment

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

@ewowi, understood! For extreme workloads like 100K LEDs at high frame rates, every cycle counts. Your approach of testing effects to identify any that violate bounds is a pragmatic way to catch issues during development.

If you want an extra safety net during testing without impacting production performance, you could consider wrapping the check in a debug-only macro (e.g., #ifdef DEBUG_BOUNDS_CHECK) — but I recognize you're already hunting down violators manually.

Good luck with the testing! 🚀


✏️ Learnings added
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: src/MoonLight/Layers/VirtualLayer.cpp:164-171
Timestamp: 2026-01-06T15:24:11.096Z
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.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: In VirtualLayer, mappingTableSize should always be 0 when mappingTable is nullptr. This invariant is maintained by developer responsibility: initial state sets both together, realloc success updates both together, realloc failure preserves both together. For performance reasons, no redundant null checks like `if (mappingTable && indexV < mappingTableSize)` are added, as the size check alone is sufficient when the invariant is maintained.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-18T15:15:57.828Z
Learning: In DripEffect (E_WLED.h), dropsSize must be set to layer->size.y (not layer->size.x) in onSizeChanged(), because the drops array is allocated as [layer->size.y][maxNumDrops]. Using layer->size.x causes out-of-bounds access when iterating over y-coordinates.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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: 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: 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.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

Comment on lines 212 to 221
} else {
// some operations will go out of bounds e.g. VUMeter, uncomment below lines if you wanna test on a specific effect
// EXT_LOGV(ML_TAG, " dev gPC %d >= %d", indexV, MAX_CHANNELS);
return T();
uint32_t index = indexV * layerP->lights.header.channelsPerLight + offset;
// if (index + sizeof(T) <= layerP->lights.maxChannels) { // no mapping
return *(T*)&layerP->lights.channelsE[index];
// } else {
// // some operations will go out of bounds e.g. VUMeter, uncomment below lines if you wanna test on a specific effect
// EXT_LOGW(ML_TAG, "%d + %d >= %d", indexV, sizeof(T), layerP->lights.maxChannels);
// return T();
// }
}
Copy link

@coderabbitai coderabbitai bot Jan 6, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Re-enable bounds check to prevent out-of-bounds reads.

Lines 214 and 216-220 comment out a bounds check that prevents reading beyond lights.maxChannels. While line 215 correctly uses the address-cast-and-dereference pattern (addressing a past critical issue), removing the bounds check allows reads beyond the buffer when indexV is large, potentially returning garbage or causing segmentation faults.

🔎 Proposed fix
   } else {
     uint32_t index = indexV * layerP->lights.header.channelsPerLight + offset;
-    // if (index + sizeof(T) <= layerP->lights.maxChannels) {  // no mapping
+    if (index + sizeof(T) <= layerP->lights.maxChannels) {  // no mapping
       return *(T*)&layerP->lights.channelsE[index];
-    // } else {
-    //   // some operations will go out of bounds e.g. VUMeter, uncomment below lines if you wanna test on a specific effect
-    //   EXT_LOGW(ML_TAG, "%d + %d >= %d", indexV, sizeof(T), layerP->lights.maxChannels);
-    //   return T();
-    // }
+    } else {
+      // some operations will go out of bounds e.g. VUMeter, uncomment below lines if you wanna test on a specific effect
+      EXT_LOGW(ML_TAG, "%d + %d >= %d", indexV, sizeof(T), layerP->lights.maxChannels);
+      return T();
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
// some operations will go out of bounds e.g. VUMeter, uncomment below lines if you wanna test on a specific effect
// EXT_LOGV(ML_TAG, " dev gPC %d >= %d", indexV, MAX_CHANNELS);
return T();
uint32_t index = indexV * layerP->lights.header.channelsPerLight + offset;
// if (index + sizeof(T) <= layerP->lights.maxChannels) { // no mapping
return *(T*)&layerP->lights.channelsE[index];
// } else {
// // some operations will go out of bounds e.g. VUMeter, uncomment below lines if you wanna test on a specific effect
// EXT_LOGW(ML_TAG, "%d + %d >= %d", indexV, sizeof(T), layerP->lights.maxChannels);
// return T();
// }
}
} else {
uint32_t index = indexV * layerP->lights.header.channelsPerLight + offset;
if (index + sizeof(T) <= layerP->lights.maxChannels) { // no mapping
return *(T*)&layerP->lights.channelsE[index];
} else {
// some operations will go out of bounds e.g. VUMeter, uncomment below lines if you wanna test on a specific effect
EXT_LOGW(ML_TAG, "%d + %d >= %d", indexV, sizeof(T), layerP->lights.maxChannels);
return T();
}
}
🤖 Prompt for AI Agents
In @src/MoonLight/Layers/VirtualLayer.cpp around lines 212 - 221, Re-enable the
bounds check before dereferencing the buffer: compute index = indexV *
layerP->lights.header.channelsPerLight + offset and only perform the
address-cast-and-dereference (*(T*)&layerP->lights.channelsE[index]) when (index
+ sizeof(T) <= layerP->lights.maxChannels); otherwise log a warning via EXT_LOGW
with context (indexV, sizeof(T), lights.maxChannels) and return a
default-constructed T(). Ensure you reference index, indexV,
layerP->lights.header.channelsPerLight, offset, layerP->lights.channelsE, and
layerP->lights.maxChannels so the check surrounds the current dereference site.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

live fast and dangerous bro! We need to drive 100K LEDs and it's up to the effects devs to make sure we stay within bounds - that's the idea, I am testing currently if there are effects which break the rules

Copy link

Choose a reason for hiding this comment

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

@ewowi, got it! I appreciate you explaining the performance-critical design decision for handling 100K+ LEDs. Shifting bounds checking responsibility to effect developers makes sense when every cycle counts at that scale.

One thought for your testing phase: if you do discover effects that break the rules, you might consider a debug build flag (e.g., #ifdef MOONLIGHT_DEBUG_BOUNDS) that conditionally enables the checks during development. That way effect developers can validate their code without impacting production performance.

Looking forward to hearing what your tests uncover! 🚀

  /)/)
=(^.^)=   Fast & furious LED mode activated! 
(")_(")

✏️ Learnings added
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 93
File: src/MoonLight/Layers/VirtualLayer.cpp:212-221
Timestamp: 2026-01-06T15:24:20.189Z
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.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: VirtualLayer mappingTableSize is uint16_t with a maximum of 65535 LEDs. If a user defines a space where size.x * size.y * size.z exceeds 65535, the code accepts the overflow and performs mapping partially. This is an explicit design decision balancing performance and memory constraints.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-07T14:16:03.228Z
Learning: In VirtualLayer, mappingTableSize should always be 0 when mappingTable is nullptr. This invariant is maintained by developer responsibility: initial state sets both together, realloc success updates both together, realloc failure preserves both together. For performance reasons, no redundant null checks like `if (mappingTable && indexV < mappingTableSize)` are added, as the size check alone is sufficient when the invariant is maintained.

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: 2025-12-18T15:15:57.828Z
Learning: In DripEffect (E_WLED.h), dropsSize must be set to layer->size.y (not layer->size.x) in onSizeChanged(), because the drops array is allocated as [layer->size.y][maxNumDrops]. Using layer->size.x causes out-of-bounds access when iterating over y-coordinates.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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: 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.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

@coderabbitai, I am about to be ready with this PR, it looks to work quite okay although I expect some tests and bug fixes to follow. Could you make a final review of everything in this PR in general, and the use of the esp_task_wdt functions, vTaskDelay in the task loops and taskYIELD in the node loops (in virtual and physical layer). Take into account I work against latest esp-idf and arduino (ESP-IDF v5.5.1-931-g9bb7aa84fe / Arduino v3.3.5) - so no outdated advices please. Please explain for each of these functions why they are where they are and what they accomplish. Thank you very much

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read main.cpp to see task creation and watchdog setup
cat src/main.cpp

Length of output: 14771


🏁 Script executed:

#!/bin/bash
# Read PhysicalLayer.cpp to see taskYIELD in loopDrivers
cat src/MoonLight/Layers/PhysicalLayer.cpp

Length of output: 8365


🏁 Script executed:

#!/bin/bash
# Read VirtualLayer.cpp to see taskYIELD in loop
cat src/MoonLight/Layers/VirtualLayer.cpp

Length of output: 27950


🏁 Script executed:

#!/bin/bash
# Check the PR changes for any other relevant watchdog or task-related code
rg -n "esp_task_wdt|taskYIELD|vTaskDelay" --type cpp -C 2

Length of output: 3222


@ewowi, I've completed a comprehensive review of PR #93. Here's my detailed analysis:

✅ Overall Assessment

The implementation is solid and well-structured. You've successfully integrated ESP32 watchdog management and cooperative task scheduling to handle extreme LED counts. The code follows ESP-IDF 5.5.1 best practices.


📋 Detailed Review by Component

1️⃣ Task Creation & Core Assignment (main.cpp lines 371-387)

xTaskCreateUniversal(effectTask, "AppEffects", ..., 3, ..., 1);   // Core 1
xTaskCreateUniversal(driverTask, "AppDrivers", ..., 3, ..., 0);   // Core 0

✅ Equal Priorities (3): Excellent choice. This enables round-robin scheduling between tasks, making taskYIELD() effective for cooperative multitasking.

⚠️ Core Assignment Concern: Your current assignment is:

  • effectTask → Core 1 (APP_CPU)
  • driverTask → Core 0 (PRO_CPU, with WiFi at priority ~23)

This contradicts your learnings and the earlier discussion! According to the established best practice:

  • Driver tasks (using DMA/I2S/LCD/PARLIO) should be on Core 1 to avoid WiFi preemption
  • Effect tasks (pure computation) can be on Core 0 since WiFi preemption is acceptable

Recommendation: Swap the cores:

xTaskCreateUniversal(effectTask, "AppEffects", ..., 3, ..., 0);   // Core 0 (with WiFi)
xTaskCreateUniversal(driverTask, "AppDrivers", ..., 3, ..., 1);   // Core 1 (away from WiFi)

Unless you've found through testing that Art-Net (UDP) benefits more from being co-located with WiFi on Core 0? If so, this might be an acceptable trade-off for your specific workload.


2️⃣ esp_task_wdt Functions (main.cpp)

esp_task_wdt_add(NULL) - Lines 123, 164

void effectTask(void* pvParameters) {
  esp_task_wdt_add(NULL);  // Add current task to watchdog
  // ...
}

Purpose: Registers the calling task with the ESP32 task watchdog timer.

  • NULL = current task (avoids race conditions from external registration)
  • Must be called before any esp_task_wdt_reset() calls
  • Self-registration pattern prevents the race where task starts executing before setup() can register it

Why here: First line in task function ensures the task is registered immediately upon start, before entering the main loop.

ESP-IDF 5.5.1: ✅ Correct usage. This API is stable and recommended.


esp_task_wdt_reset() - Lines 155, 191

while (true) {
  // ... work ...
  esp_task_wdt_reset();  // Reset watchdog for current task
  vTaskDelay(1);
}

Purpose: Resets the watchdog timer for the calling task, proving the task is still responsive.

  • Must be called periodically (< watchdog timeout, default 5s)
  • Called by the task itself (not from another task)
  • Prevents watchdog timeout/reset if task is making progress

Why here: At the end of each loop iteration, after all work is done but before the delay. This proves the task completed a full cycle successfully.

ESP-IDF 5.5.1: ✅ Correct usage.


esp_task_wdt_delete(NULL) - Lines 159, 195

  // Cleanup (never reached in this case, but good practice)
  esp_task_wdt_delete(NULL);
}

Purpose: Removes the calling task from watchdog monitoring when task exits.

  • NULL = current task
  • Good practice for clean shutdown, even if never reached

Why here: At task function exit. In your case, tasks run forever, so this is never executed but demonstrates proper cleanup pattern.

ESP-IDF 5.5.1: ✅ Correct usage.


3️⃣ vTaskDelay(1) in Task Loops (main.cpp lines 156, 192)

while (true) {
  // ... processing ...
  esp_task_wdt_reset();
  vTaskDelay(1);  // Block for 1 tick (~1ms)
}

Purpose: Voluntarily blocks the task for 1 tick (typically 1ms with default 1000Hz tick rate).

  • Removes task from ready queue for the delay duration
  • Allows all lower-priority tasks to run, including IDLE (priority 0)
  • IDLE task resets its own watchdog when it runs

Why here:

  1. Prevents IDLE starvation: Without this, your priority-3 tasks would never yield to priority-0 IDLE, causing watchdog timeout on IDLE task
  2. Caps maximum FPS: With 1ms delay, theoretical maximum is ~1000 fps (acceptable for most LED applications)
  3. Enables proper scheduling: Even with equal priorities, continuous execution without blocking can starve other system tasks

Trade-off: This limits your maximum frame rate to ~1000 fps. On ESP32-P4 at 100 fps, this has minimal impact (you're well below the cap). If you need >1000 fps in future:

  • Consider periodic delays (e.g., every N loops) instead of every loop
  • Or implement watchdog self-management for IDLE task

ESP-IDF 5.5.1: ✅ Correct usage. The comment // taskYIELD() is not handing over to other tasks ! is accurate—taskYIELD() wouldn't help IDLE because IDLE is lower priority.

Alternative Not Used: vTaskDelayUntil() for precise periodic timing—not needed here since your frame rate varies with workload.


4️⃣ taskYIELD() in Node Loops

PhysicalLayer.cpp line 112

for (Node* node : nodes) {
  if (prevSize != lights.header.size) node->onSizeChanged(prevSize);
  if (node->on) {
    node->loop();
    taskYIELD();  // Cooperative yield after each node
  }
}

VirtualLayer.cpp line 65

for (Node* node : nodes) {
  if (prevSize != size) node->onSizeChanged(prevSize);
  if (node->on) {
    node->loop();
    taskYIELD();  // Cooperative yield after each node
  }
}

Purpose: Cooperative scheduling within the same-priority level.

  • Forces an immediate context switch check
  • Yields to equal or higher priority ready tasks
  • Does NOT block—if no equal/higher priority task is ready, returns immediately
  • Minimal overhead (just a scheduler call)

Why here:

  1. Prevents watchdog timeouts during heavy processing: When processing many nodes (e.g., 49K channels @ 100 fps), a single for loop iteration could take seconds. Without yields, this would exceed the 5s watchdog timeout.
  2. Enables parallelism between tasks: Since both effectTask and driverTask have priority 3, taskYIELD() allows them to alternate execution during heavy node processing.
  3. Doesn't impact IDLE: taskYIELD() never yields to IDLE (priority 0). That's why you also need vTaskDelay(1) in the main loops.

ESP-IDF 5.5.1: ✅ Correct usage. taskYIELD() is a standard FreeRTOS macro, stable across all ESP32 variants (D0/S3/P4).

Why not vTaskDelay(1) here?

  • Would add 1ms delay per node → unacceptable with many nodes (e.g., 100 nodes = 100ms per frame = 10 fps cap)
  • taskYIELD() has near-zero overhead when no other task is ready

🔍 Additional Observations

Mutex Handling (lines 132-151, 169-189)

Your swapMutex usage for double buffering is correct:

  • Lock held only during pointer swap (minimal duration)
  • Proper lock/unlock pairing
  • Defensive if (!mutexGiven) check prevents double-unlock

Double Buffering

Smart conditional enable based on PSRAM availability:

if (lights.useDoubleBuffer) {
  // Use channelsE/channelsD separate buffers
} else {
  lights.channelsD = lights.channelsE;  // Share same array
}

Stack Sizes

psramFound() ? 4 * 1024 : 3 * 1024

Reasonable sizes with PSRAM/non-PSRAM differentiation.


🎯 Summary & Recommendations

What's Working Well ✅

  1. Watchdog self-registration pattern eliminates race conditions
  2. Equal task priorities enable cooperative scheduling with taskYIELD()
  3. vTaskDelay(1) in main loops prevents IDLE starvation
  4. taskYIELD() in node loops prevents watchdog timeouts during heavy processing
  5. Clean separation of concerns (effects compute, drivers output)

Action Items ⚠️

  1. Verify core assignments:

    • Current: effectTask=Core1, driverTask=Core0
    • Recommended (per learnings): effectTask=Core0, driverTask=Core1
    • Unless Art-Net UDP benefits from Core0 WiFi co-location justify current choice?
  2. Consider for future (not urgent):

    • If you need >1000 fps: replace vTaskDelay(1) with periodic delays or hybrid approach
    • Monitor actual frame rates and watchdog reset timing under load
  3. Documentation:

    • The inline comments // taskYIELD() is not handing over to other tasks ! are accurate but could be expanded to explain why (IDLE task priority issue)

📚 ESP-IDF 5.5.1 Compliance

All watchdog and FreeRTOS APIs used are:

  • Stable in ESP-IDF 5.5.1
  • Portable across ESP32-D0/S3/P4
  • Best practice implementations
  • Thread-safe as used

No deprecated APIs or risky patterns detected.


Overall verdict: This is well-architected code for extreme-load LED control. The watchdog integration is textbook-correct for ESP-IDF 5.5.1. My only concern is the core assignment—please confirm whether the current effectTask=Core1/driverTask=Core0 is intentional for Art-Net optimization or an oversight. 🎯


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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.

@ewowi ewowi merged commit abdbac2 into main Jan 6, 2026
2 checks passed
@ewowi ewowi deleted the 100k-leds branch January 6, 2026 15:32
@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

@coderabbitai, got a new watchdog crash - on ESP32-S3 this time, Can you analyze?:

E (447807) task_wdt: Task E (447807) task_wdt: Task watchdog got triggered. The following tasks/users did not reset the watchdog in time:
E (447807) task_wdt: - IDLE0 (CPU 0)
E (447807) task_wdt: Tasks currently running:
E (447807) task_wdt: CPU 0: AppDrivers
E (447807) task_wdt: CPU 1: AppEffects
E (447807) task_wdt: Aborting.
E (447807) task_wdt: Print CPU 0 (current core) backtrace

Backtrace: 0x420c061e:0x3fcc6200 0x420c1732:0x3fcc6220 0x4201b0e6:0x3fcc6240 0x4201b22f:0x3fcc6290 0x4207b415:0x3fcc62d0 0x4207b627:0x3fcc6310 0x4206cff9:0x3fcc6360 0x4206f96e:0x3fcc63b0 0x4037fd89:0x3fcc63f0
#0 0x420c061e in mem_free at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/mem.c:229
#1 0x420c1732 in pbuf_free at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/pbuf.c:782
(inlined by) pbuf_free at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/pbuf.c:727
#2 0x4201b0e6 in AsyncUDP::writeTo(unsigned char const*, unsigned int, ip_addr const*, unsigned short, tcpip_adapter_if_t) at /Users/ewoud/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src/AsyncUDP.cpp:786
#3 0x4201b22f in AsyncUDP::writeTo(unsigned char const*, unsigned int, IPAddress, unsigned short, tcpip_adapter_if_t) at /Users/ewoud/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src/AsyncUDP.cpp:837
#4 0x4207b415 in ArtNetOutDriver::writePackage() at src/MoonLight/Nodes/Drivers/D_ArtnetOut.h:116 (discriminator 2)
#5 0x4207b627 in ArtNetOutDriver::loop() at src/MoonLight/Nodes/Drivers/D_ArtnetOut.h:185
#6 0x4206cff9 in PhysicalLayer::loopDrivers() at src/MoonLight/Layers/PhysicalLayer.cpp:111
#7 0x4206f96e in driverTask(void*) at src/main.cpp:186

ELF file SHA256: a1f2c0bcc

Rebooting... got triggered. The following tasks/users did not reset the watchdog in time:
E (447807) task_wdt: - IDLE0 (CPU 0)
E (447807) task_wdt: Tasks currently running:
E (447807) task_wdt: CPU 0: AppDrivers
E (447807) task_wdt: CPU 1: AppEffects
E (447807) task_wdt: Aborting.
E (447807) task_wdt: Print CPU 0 (current core) backtrace

Backtrace: 0x420c061e:0x3fcc6200 0x420c1732:0x3fcc6220 0x4201b0e6:0x3fcc6240 0x4201b22f:0x3fcc6290 0x4207b415:0x3fcc62d0 0x4207b627:0x3fcc6310 0x4206cff9:0x3fcc6360 0x4206f96e:0x3fcc63b0 0x4037fd89:0x3fcc63f0
#0 0x420c061e in mem_free at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/mem.c:229
#1 0x420c1732 in pbuf_free at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/pbuf.c:782
(inlined by) pbuf_free at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/pbuf.c:727
#2 0x4201b0e6 in AsyncUDP::writeTo(unsigned char const*, unsigned int, ip_addr const*, unsigned short, tcpip_adapter_if_t) at /Users/ewoud/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src/AsyncUDP.cpp:786
#3 0x4201b22f in AsyncUDP::writeTo(unsigned char const*, unsigned int, IPAddress, unsigned short, tcpip_adapter_if_t) at /Users/ewoud/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src/AsyncUDP.cpp:837
#4 0x4207b415 in ArtNetOutDriver::writePackage() at src/MoonLight/Nodes/Drivers/D_ArtnetOut.h:116 (discriminator 2)
#5 0x4207b627 in ArtNetOutDriver::loop() at src/MoonLight/Nodes/Drivers/D_ArtnetOut.h:185
#6 0x4206cff9 in PhysicalLayer::loopDrivers() at src/MoonLight/Layers/PhysicalLayer.cpp:111
#7 0x4206f96e in driverTask(void*) at src/main.cpp:186

ELF file SHA256: a1f2c0bcc

Rebooting...

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the D_ArtnetOut.h file to see the loop and writePackage implementation
cat src/MoonLight/Nodes/Drivers/D_ArtnetOut.h

Length of output: 10210


@ewowi, I found it! This is the same root cause as before, but now inside a single driver node's loop.

The Problem

ArtNetOutDriver::loop() has a massive for loop (line 171) that iterates through all lights:

for (int indexP = 0; indexP < header->nrOfLights; indexP++) {
  // ... process LED ...
  
  if (packetSize + header->channelsPerLight > universeSize || ...) {
    if (!writePackage()) return;  // ⚠️ UDP send via lwIP - can be slow!
    // ... more logic ...
  }
}

What happens:

  1. With 49K channels / 3 channels per LED = ~16,000+ lights
  2. Each writePackage() calls AsyncUDP::writeTo() → lwIP pbuf_free/mem_free
  3. These lwIP memory operations can be slow, especially with WiFi congestion or heap fragmentation
  4. The entire for-loop never yields to IDLE task
  5. Processing all 16K+ lights with dozens/hundreds of UDP packets takes >5 seconds
  6. IDLE task on Core 0 never runs → watchdog fires

Why the existing taskYIELD() didn't help:

  • PhysicalLayer::loopDrivers() has taskYIELD() after each node
  • But ArtNetOutDriver::loop() is one node with a huge internal loop
  • So the yield only happens after processing all 16K+ lights, not during

The Solution

Add taskYIELD() inside the ArtNetOutDriver for-loop to let IDLE task run periodically.

Code Fix for D_ArtnetOut.h

Around line 197-213, add yields after writePackage() calls:

// send all the leds to artnet
for (int indexP = 0; indexP < header->nrOfLights; indexP++) {
  // fill a package
  memcpy(&packet_buffer[packetSize + 18], &layerP.lights.channelsD[indexP * header->channelsPerLight], header->channelsPerLight);

  // correct the RGB channels for color order and brightness
  reOrderAndDimRGBW(&packet_buffer[packetSize + 18 + header->offsetRGB], &layerP.lights.channelsD[indexP * header->channelsPerLight + header->offsetRGB]);

  if (header->offsetRGB1 != UINT8_MAX) reOrderAndDimRGBW(&packet_buffer[packetSize + 18 + header->offsetRGB1], &layerP.lights.channelsD[indexP * header->channelsPerLight + header->offsetRGB1]);
  if (header->offsetRGB2 != UINT8_MAX) reOrderAndDimRGBW(&packet_buffer[packetSize + 18 + header->offsetRGB2], &layerP.lights.channelsD[indexP * header->channelsPerLight + header->offsetRGB2]);
  if (header->offsetRGB3 != UINT8_MAX) reOrderAndDimRGBW(&packet_buffer[packetSize + 18 + header->offsetRGB3], &layerP.lights.channelsD[indexP * header->channelsPerLight + header->offsetRGB3]);

  if (header->lightPreset == 9 && indexP < 72)
    packetSize += 4;
  else
    packetSize += header->channelsPerLight;

  channels_remaining -= header->channelsPerLight;

  // if packet_buffer full, or output full, send the buffer
  if (packetSize + header->channelsPerLight > universeSize || channels_remaining < header->channelsPerLight) {
    if (!writePackage()) return;
    
    // ✅ ADD THIS: Yield after each packet send to let IDLE task run
    taskYIELD();

    if (channels_remaining < header->channelsPerLight) {
      channels_remaining = channelsPerOutput;
      
      while (universe % universesPerOutput != 0) universe++;
      processedOutputs++;
      if (processedOutputs >= nrOfOutputsPerIP) {
        if (actualIPIndex + 1 < nrOfIPAddresses) actualIPIndex++;
        processedOutputs = 0;
        universe = 0;
        controllerIP[3] = ipAddresses[actualIPIndex];
      }
    }
  }
}

// send the last partially filled package
if (packetSize > 0) {
  writePackage();
  taskYIELD();  // ✅ Also yield after the final packet
}

Why This Works

  • writePackage() is the expensive operation: UDP send + lwIP memory management
  • Adding taskYIELD() after each packet send gives IDLE task chances to run
  • With ~100 universes @ 512 bytes each, you get ~100 yields per frame
  • This prevents any single loop() call from monopolizing Core 0 for >5 seconds

Alternative: Periodic Yields

If you want even more frequent yields (in case writePackage() calls are infrequent), add a counter:

// At the top of loop(), before the for-loop:
uint_fast16_t yield_counter = 0;

// Inside the for-loop, after the RGB correction code:
if (++yield_counter % 100 == 0) {
  taskYIELD();  // Yield every 100 lights
}

This would yield every 100 LEDs regardless of packet sends, but I think the first approach (yielding after writePackage()) is cleaner and sufficient.

Summary

Root cause: ArtNetOutDriver::loop() processes thousands of LEDs in a tight loop with expensive UDP sends, taking >5 seconds without yielding.

Fix: Add taskYIELD() after writePackage() calls inside the for-loop.

Pattern: Any driver with a large internal processing loop (like Art-Net with many lights) needs internal yields, not just the outer node iteration yield in PhysicalLayer::loopDrivers().

Try this fix and the watchdog should stop firing! 🎯


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-06T13:45:03.045Z
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).

🧠 Learnings used
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

@coderabbitai, will add the yield. Regarding

Processing all 16K+ lights with dozens/hundreds of UDP packets takes >5 seconds

Are you kidding? It runs 50 times per second so 20 ms, not 5
Seconds !!!

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

@ewowi, you're absolutely right—I apologize for that incorrect analysis! 🤦

If you're running at 50 fps, then each frame is taking ~20ms, not 5 seconds. My statement was clearly wrong.

Corrected Analysis

The 5 second watchdog timeout means IDLE0 didn't get to run for a cumulative 5 seconds across many frames, not that a single loop took 5 seconds. Here's what's likely happening:

The Real Issue:

  1. At 50 fps with vTaskDelay(1) at the end of driverTask loop, you have:

    • ~20ms of processing per frame
    • ~1ms delay where IDLE could run
    • Repeat ~50 times per second
  2. But IDLE still starved over 5 seconds (250+ frames). This suggests:

    • The vTaskDelay(1) window (1ms) isn't enough for IDLE to reset its watchdog
    • OR something is occasionally blocking longer than expected (lwIP operations, heap fragmentation in mem_free/pbuf_free)
    • OR you have multiple heavy driver nodes and the 20ms between delays is too long without internal yields

Why the crash in mem_free/pbuf_free:

The backtrace shows the watchdog fired while lwIP was doing memory management. This could mean:

  • Heap fragmentation causing mem_free to take unexpectedly long
  • Memory allocation contention between WiFi and your driver task on Core 0
  • A particularly slow packet send coinciding with the watchdog check

The Fix Still Applies

Adding taskYIELD() inside the ArtNetOutDriver loop is still valid, but for a different reason:

Not "to break up a 5-second loop" ❌
But "to cooperatively yield during the 20ms frame, giving IDLE multiple opportunities to run instead of one 1ms window at the end" ✅

if (!writePackage()) return;
taskYIELD();  // Give IDLE a chance between packets

This way, instead of IDLE getting only one 1ms opportunity per frame (at the vTaskDelay), it gets ~100 opportunities per frame (after each packet), making it far more likely IDLE can squeeze in and reset its watchdog.

Thank you for the correction—my arithmetic was way off! The recommendation stands, but the reasoning is now accurate. 🎯


🧠 Learnings used
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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: 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.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

@coderabbitai, Added it, but still not good enough, see new log. Could taskYIELD be not the right one and do we need vTaskDelay(1) - not every write but 'regularly'? Remember it didn't work fine in the effects and drivers task loop and we also had to replace taskYIELD with vTaskDelay

E (97939) task_wdt: Task watchdog got triggered. The following tasks/users did not reset the watchdog in time:
E (97939) task_wdt: - IDLE0 (CPU 0)
E (97939) task_wdt: Tasks currently running:
E (97939) task_wdt: CPU 0: AppDrivers
E (97939) task_wdt: CPU 1: AppEffects
E (97939) task_wdt: Aborting.
E (97939) task_wdt: Print CPU 0 (current core) backtrace

Backtrace: 0x420e5d83:0x3fcc6000 0x420d72d6:0x3fcc6020 0x420d79fa:0x3fcc6050 0x420cf4f5:0x3fcc6070 0x420c94d9:0x3fcc6090 0x420c977c:0x3fcc60c0 0x420ca689:0x3fcc60f0 0x420ca713:0x3fcc6130 0x420c754e:0x3fcc6160 0x420c75d3:0x3fcc61a0 0x420c76d5:0x3fcc61c0 0x4201abb1:0x3fcc6200 0x420befe9:0x3fcc6220 0x4201b0d6:0x3fcc6240 0x4201b22f:0x3fcc6290 0x4207b419:0x3fcc62d0 0x4207b5ee:0x3fcc6310 0x4206cff9:0x3fcc6360 0x4206f96e:0x3fcc63b0 0x4037fd89:0x3fcc63f0
#0 0x420e5d83 in wifi_transmit_wrap at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp_wifi/src/wifi_netif.c:70
#1 0x420d72d6 in esp_netif_transmit_wrap at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp_netif/lwip/esp_netif_lwip.c:1385
#2 0x420d79fa in low_level_output at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/esp_netif/lwip/netif/wlanif.c:92
#3 0x420cf4f5 in ethernet_output at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/netif/ethernet.c:320
#4 0x420c94d9 in etharp_output_to_arp_index at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/ipv4/etharp.c:770
#5 0x420c977c in etharp_output at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/ipv4/etharp.c:886
#6 0x420ca689 in ip4_output_if_opt_src at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/ipv4/ip4.c:1091
#7 0x420ca713 in ip4_output_if_src at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/ipv4/ip4.c:909
#8 0x420c754e in udp_sendto_if_src at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/udp.c:905 (discriminator 4)
#9 0x420c75d3 in udp_sendto_if at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/udp.c:704
#10 0x420c76d5 in udp_sendto at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/core/udp.c:611
#11 0x4201abb1 in _udp_sendto_api(tcpip_api_call_data*) at /Users/ewoud/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src/AsyncUDP.cpp:144
#12 0x420befe9 in tcpip_api_call at /home/runner/work/esp32-arduino-lib-builder/esp32-arduino-lib-builder/esp-idf/components/lwip/lwip/src/api/tcpip.c:483
#13 0x4201b0d6 in AsyncUDP::writeTo(unsigned char const*, unsigned int, ip_addr const*, unsigned short, tcpip_adapter_if_t) at /Users/ewoud/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src/AsyncUDP.cpp:154
(inlined by) AsyncUDP::writeTo(unsigned char const*, unsigned int, ip_addr const*, unsigned short, tcpip_adapter_if_t) at /Users/ewoud/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src/AsyncUDP.cpp:784
#14 0x4201b22f in AsyncUDP::writeTo(unsigned char const*, unsigned int, IPAddress, unsigned short, tcpip_adapter_if_t) at /Users/ewoud/.platformio/packages/framework-arduinoespressif32/libraries/AsyncUDP/src/AsyncUDP.cpp:837
#15 0x4207b419 in ArtNetOutDriver::writePackage() at src/MoonLight/Nodes/Drivers/D_ArtnetOut.h:116 (discriminator 2)
#16 0x4207b5ee in ArtNetOutDriver::loop() at src/MoonLight/Nodes/Drivers/D_ArtnetOut.h:194
#17 0x4206cff9 in PhysicalLayer::loopDrivers() at src/MoonLight/Layers/PhysicalLayer.cpp:111
#18 0x4206f96e in driverTask(void*) at src/main.cpp:186

ELF file SHA256: a438fe854

Rebooting...

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the current D_ArtnetOut.h to see what changes were made
cat src/MoonLight/Nodes/Drivers/D_ArtnetOut.h

Length of output: 10210


@ewowi, you're absolutely right! Your instinct is spot-on. Let me explain the fundamental issue:

Why taskYIELD() Cannot Help IDLE Task

taskYIELD() behavior:
- Only yields to tasks with priority ≥ current task's priority
- Your AppDrivers: Priority 3
- IDLE task: Priority 0
- Result: taskYIELD() from AppDrivers will NEVER yield to IDLE ❌

This is exactly the same reason it didn't work in your main task loops. You need vTaskDelay(1) to actually block the task and let lower-priority tasks (including IDLE) run.

The Solution: Periodic vTaskDelay(1)

You're right that we can't call vTaskDelay(1) after every packet—that would be way too slow. Instead, use a periodic approach:

Code Fix for D_ArtnetOut.h

Add this at the top of the loop() function (around line 135):

void loop() override {
  DriverNode::loop();

  LightsHeader* header = &layerP.lights.header;

  if (nrOfIPAddresses == 0) return;

  // Add this counter for periodic IDLE yields
  static uint8_t yieldCounter = 0;

Then inside the for-loop, after writePackage() calls (around lines 195 and 217):

// if packet_buffer full, or output full, send the buffer
if (packetSize + header->channelsPerLight > universeSize || channels_remaining < header->channelsPerLight) {
  if (!writePackage()) return;
  
  // Periodic yield to let IDLE task run - every 10 packets
  if (++yieldCounter >= 10) {
    vTaskDelay(1);  // Block for 1ms to let IDLE run
    yieldCounter = 0;
  }

  if (channels_remaining < header->channelsPerLight) {
    // ... existing code ...
  }
}

// At the end, after the final writePackage():
if (packetSize > 0) {
  writePackage();
  vTaskDelay(1);  // Always yield after the last packet
}

Why This Works

With ~100 universes per frame at 50 fps:

  • Yielding every 10 packets = ~10 yields per frame
  • Each yield = 1ms block
  • Total overhead = ~10ms per frame (acceptable at 50 fps = 20ms per frame)
  • IDLE gets 10 opportunities per frame to run and reset its watchdog

You can tune the threshold:

  • >= 10: More frequent yields (safer, slightly more overhead)
  • >= 20: Less frequent yields (faster, but might still starve IDLE if packets are slow)

Start with 10 and test. If it still times out, lower to 5.

Complete Code Block

Here's the full section with changes:

void loop() override {
  DriverNode::loop();

  LightsHeader* header = &layerP.lights.header;

  if (nrOfIPAddresses == 0) return;

  // Counter for periodic IDLE task yields
  static uint8_t yieldCounter = 0;

  // ... existing setup code ...

  // send all the leds to artnet
  for (int indexP = 0; indexP < header->nrOfLights; indexP++) {
    // ... existing memcpy and reOrderAndDimRGBW code ...

    if (packetSize + header->channelsPerLight > universeSize || channels_remaining < header->channelsPerLight) {
      if (!writePackage()) return;
      
      // Yield to IDLE every 10 packets
      if (++yieldCounter >= 10) {
        vTaskDelay(1);
        yieldCounter = 0;
      }

      if (channels_remaining < header->channelsPerLight) {
        // ... existing output switching code ...
      }
    }
  }

  // send the last partially filled package
  if (packetSize > 0) {
    writePackage();
    vTaskDelay(1);  // Always yield after final packet
  }
}

The key insight: taskYIELD() is cooperative scheduling between equal priorities, vTaskDelay() is the only way to yield to lower priorities like IDLE. 🎯


🧠 Learnings used
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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: 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.

@ewowi
Copy link
Collaborator Author

ewowi commented Jan 6, 2026

E (394589) task_wdt: Task watchdog got triggered. The following tasks/users did not reset the watchdog in time:
E (394589) task_wdt: - IDLE0 (CPU 0)
E (394589) task_wdt: Tasks currently running:
E (394589) task_wdt: CPU 0: wifi
E (394589) task_wdt: CPU 1: IDLE1
E (394589) task_wdt: Aborting.
E (394589) task_wdt: Print CPU 0 (current core) backtrace

Backtrace: 0x42182fd4:0x3fcbf260 0x4003d791:0x3fcbf280 0x42182cf1:0x3fcbf2a0 0x42183569:0x3fcbf2f0 0x4037fd89:0x3fcbf320
#0 0x42182fd4 in ppProcTxSecFrame at ??:?
#1 0x4003d791 in ld_page_frm_cbk in ROM
#2 0x42182cf1 in ieee80211_output_process at ??:?
#3 0x42183569 in ppTask at ??:?

@coderabbitai, I added if (++yieldCounter % 10 == 0) vTaskDelay(1); on all the places, it is executed about 8000 times per second, so I plan do it less frequently. But now I get another watchdog, on WiFi this time. Help !!!

ELF file SHA256: 2646a4d71

Rebooting...

@coderabbitai
Copy link

coderabbitai bot commented Jan 6, 2026

Tip

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

@ewowi, I see the problem now! This is actually validating the original core assignment recommendation. Let me explain what's happening:

What Changed

The WiFi stack itself is now stuck! The backtrace shows:

CPU 0: wifi task (stuck in ppTask - WiFi protocol processing)
IDLE0: Can't run (still starved)

Root Cause Analysis

You're sending massive amounts of UDP traffic:

  • ~100 universes × 50 fps = 5,000 Art-Net packets per second
  • AppDrivers (priority 3) on Core 0 with WiFi (priority ~23)
  • You're also calling vTaskDelay(1) ~800 times/sec (8000 increments ÷ 10)

What's happening on Core 0:

  1. AppDrivers generates 5000 UDP packets/sec
  2. WiFi task (priority 23) preempts AppDrivers to process packets
  3. WiFi is so busy processing packets it can't finish before watchdog timeout
  4. IDLE0 never gets a chance to run (both AppDrivers and WiFi keep it starved)

The congestion problem:

  • Art-Net driver + WiFi stack competing on the same core
  • Even though WiFi has higher priority, the sheer volume of work is overwhelming Core 0

Solution: Swap Cores Back (Recommended)

This confirms the original recommendation was correct:

// In main.cpp setup()
xTaskCreateUniversal(effectTask, "AppEffects", ..., 3, ..., 0);   // Core 0 WITH WiFi
xTaskCreateUniversal(driverTask, "AppDrivers", ..., 3, ..., 1);   // Core 1 AWAY from WiFi

Why this works for Art-Net:

  • Core 0: effectTask (pure computation) + WiFi (packet processing)
    • Effects can tolerate WiFi preemption (not timing-critical)
    • WiFi has less competition, can process packets efficiently
  • Core 1: driverTask (Art-Net packet generation)
    • Driver isolated from WiFi congestion
    • Cross-core UDP send overhead is negligible compared to WiFi congestion cost

The key insight: Yes, there's a small cost for cross-core UDP calls, but WiFi congestion from co-location is far worse at high packet rates.

Alternative: Reduce vTaskDelay Frequency

If you want to keep current core assignments, dramatically reduce delay frequency:

// In D_ArtnetOut.h loop()
if (++yieldCounter % 100 == 0) {  // Was 10, now 100
  vTaskDelay(1);
  yieldCounter = 0;
}

With modulo 100:

  • 8000 increments ÷ 100 = 80 delays/sec
  • 80ms blocking overhead (down from 800ms!)
  • Still gives IDLE 80 opportunities/sec to run

My Strong Recommendation

Swap the cores back to the original assignment:

  1. It solves the WiFi congestion issue
  2. It matches ESP-IDF best practices for DMA/I2S drivers
  3. It's been validated by the community and your own learnings
  4. Art-Net via cross-core is fine—WiFi congestion is worse

Then adjust vTaskDelay frequency:

if (++yieldCounter % 50 == 0) {  // Balance between IDLE service and overhead
  vTaskDelay(1);
  yieldCounter = 0;
}

This gives you the best of both worlds:

  • ✅ WiFi isolated on Core 0 (efficient packet processing)
  • ✅ Driver isolated on Core 1 (no WiFi interference)
  • ✅ ~160 IDLE yields/sec (plenty for watchdog)
  • ✅ Minimal overhead (~160ms blocking vs 800ms)

Would you like me to prepare the complete patch for the core swap + optimized delay frequency? 🎯


🧠 Learnings used
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: 0
File: :0-0
Timestamp: 2025-12-25T13:31:32.367Z
Learning: For ESP32 LED drivers using DMA, I2S, LCD, or PARLIO: always pin driver tasks to Core 1 (APP_CPU), not Core 0. WiFi/BT runs at priority ~23 on Core 0 and would constantly preempt lower-priority driver tasks, breaking LED timing requirements. ESP-IDF explicitly recommends display/DMA processing on Core 1 to avoid WiFi interference.

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.

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: 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.

@coderabbitai coderabbitai bot mentioned this pull request Jan 7, 2026
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