Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ The product owner commits. **Delegate the mechanical roles**: parallelizable or

**Anti-stalling.** If a build error or test failure survives 2 fix attempts: STOP. Ask, or roll back and re-approach (rolling back is itself a revert: ask).

**Desktop first, always.** Build and verify on the desktop before any ESP32 build or flash: it is
the fastest loop, and anything the desktop can prove (UI, logic, tests) is proven there rather than
through a multi-minute compile and a 60-second flash. A device build comes after the desktop is
clean, and only for what the desktop cannot show: the platform layer, timing, memory, real hardware.

**Bench boards are free test rigs.** Build and flash freely to verify work; re-probe ports first. A *rigorous* change (anything that could brick, boot-loop, or wipe a board: flash erases, boot/partition/build-config changes, a first flash of an untested board) gets a one-sentence heads-up and a go-ahead first — the test is reversibility.

**Invite the product owner to test, then STOP.** If the PO could see or judge the result, hand it over ("running on X, look at Y") and wait for their observation before concluding, documenting, or moving on. Leave the state running; don't revert, reflash, or reconfigure what they were about to look at.
Expand Down
3 changes: 2 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ file(GLOB MOONLIVE_SCRIPTS CONFIGURE_DEPENDS
${CMAKE_SOURCE_DIR}/moonlive/effects/*.mle
${CMAKE_SOURCE_DIR}/moonlive/layouts/*.mll
${CMAKE_SOURCE_DIR}/moonlive/modifiers/*.mlm
${CMAKE_SOURCE_DIR}/moonlive/services/*.mls)
${CMAKE_SOURCE_DIR}/moonlive/services/*.mls
${CMAKE_SOURCE_DIR}/moonlive/palettes/*.mlp)
# The file LIST itself is a dependency, not just each file's timestamp: DEPENDS notices an edited
# script but not a DELETED one, so removing a script left it in the catalog and the device went on
# offering a name that no longer exists upstream. The stamp is written at configure time from the
Expand Down
23 changes: 23 additions & 0 deletions docs/backlog/backlog-light.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,4 +623,27 @@ The LED-driver increments **shipped**: increment 1 (RMT/WS2812B single-strand on

**What it costs when it comes:** a small preallocated record queue the built-in writes into, drained from a housekeeping path through the existing platform output seam. The budget and the burst-spent message stay as they are; only where the bytes are written moves. Worth doing when a script is left with a print in it on a real fixture, which is the case the cap exists for.

- **ParallelLedDriver hangs in `esp_lcd_new_i80_bus` on classic ESP32** (2026-09-03). Setting any
pin list on a QuinLED Dig-Next-2 (ESP32-PICO-V3-02, IDF v6.1-rc1) resets the board:
`TG1WDT_SYS_RESET`, both CPUs stopped at the same PC, no panic and no coredump. Traced to the
call itself, which never returns: a log line immediately before `esp_lcd_new_i80_bus` prints and
the "created OK" line after it never does. RmtLedDriver on the same board is fine, so it is this
bus API rather than the chip or the wiring.

**What it is NOT**, each ruled out on the bench: the frame size (hangs at 3200 and 10112 bytes
alike, both far inside the internal-DMA budget), duplicate pins parked on WR (hangs with 8
distinct data pins), and the WR/DC pin choice (hangs on 10/11, on 21/22 and on 18/23). IDF does
declare `SOC_LCD_I80_SUPPORTED` for this target, so the driver is configured for an API the SOC
caps say exists.

**Next step:** call `esp_lcd_new_i80_bus` from a bare IDF example on the same chip and IDF pin. If
that hangs too it is upstream and belongs in an IDF issue; if it returns, the difference is in our
bus config. Until then classic-ESP32 boards use RmtLedDriver, and `ParallelLedDriver` stays
registered and selectable rather than compiled out: hiding it would remove the one path anyone can
retest with, and the driver is correct on every LCD_CAM chip.

LCD-MM cannot substitute here. It is `lcdLanes`-only by design (MoonLedDriver.h,
`lanesAvailable`) because the classic ESP32's i80 IS the I2S peripheral, which that backend does
not implement, so a chip without LCD_CAM has no second parallel route.

(The shared lane-driver scaffolding extraction — when a 3rd parallel backend lands — is tracked separately under [§ Extract shared lane-driver scaffolding](#extract-shared-lane-driver-scaffolding-when-the-3rd-parallel-backend-lands-deferred) above.)
136 changes: 136 additions & 0 deletions docs/history/plans/Plan-20260903 - MoonLive palettes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Plan: MoonLive palettes (`.mlp`)

## What this is

A palette that is CODE rather than data. A `.mlp` script fills the 16 active palette entries once
per frame, so a palette can respond to audio, drift over time, or compute from an algorithm.
A gradient stop list cannot express any of that: it is frozen the moment it is saved.

The idea is MoonLight's, and it is a good one. Their implementation is not the part to copy.

## Why our engine makes this cheap, where MoonLight's does not

MoonLight runs a scripted palette through hpwit's LiveScript in a **separate FreeRTOS task**, at
8 KB of stack per script plus an on-device compiler, which is why their live scripts are documented
as needing a PSRAM-class board. Changing sixteen colors should not cost that.

We already JIT to native code and run every binding INLINE on the render thread. A `.mlp` is the
fifth binding beside effect (`.mle`), layout (`.mll`), modifier (`.mlm`) and service (`.mls`): the
same `MoonLiveScript` member, the same `sync()` on prepare, the same file picker. The pattern is
established four times over, so this adds a role rather than a mechanism.

## Cost, which is what makes the design work

A palette is **16 entries, once per frame**. Not per light. A 16x16 grid runs an effect body 256
times a frame; a `.mlp` runs 16 iterations of a loop, whatever the rig size. So the hot-path cost is
independent of light count, which is the property that makes running it in the render path
reasonable at all.

Measure it anyway on the classic ESP32, where the margin is thinnest: the acceptance number is that
a scripted palette costs less than a scripted EFFECT on the same board, since the effect is the
thing already considered affordable.

## The shape

```c
class Fire {
byte speed = 40;

void defineControls() {
addControl("speed", speed, 1, 120); // how fast the fire breathes
}

void tick() {
for (int i = 0; i < 16; i = i + 1) {
int heat = beatsin(speed, t + i * 16, 255);
setPalEntryHSV(i, div(heat, 8), 255, heat);
}
}
}
```

- **Entry point**: `tick`, the same name an effect uses. A palette and an effect are both
per-frame producers, so the name means the same thing in both, and a script author moving between
them learns nothing new.
- **Two new builtins**: `setPalEntry(i, r, g, b)` and `setPalEntryHSV(i, h, s, v)`. Both are
MoonLight's names, kept deliberately: a MoonLight user's palette script should read as familiar.
Index bounded to 0..15, out of range ignored, so a script cannot write past the palette.
- **Everything else it already has**: `beat`, `beatsin`, `noise`, `sin`, `random16`, and crucially
`audioBand()` / `audioBeat()`. The audio vocabulary is where "a palette that reacts" actually
lands, and it costs nothing to expose because the common builtins are already shared.
- **`addControl` works**, as it does in every other binding, so a scripted palette is configurable
from its card without editing the source.

## Where it plugs in

`Palettes::active_` is already a single 48-byte global (`Palette`, 16 x RGB) that every effect
samples through `colorFromPalette()`. `setActiveDirect()` already exists. So a `.mlp` fills those
48 bytes where `fromBuiltin()` otherwise would: **no effect changes, and the sampling path does not
change at all.**

The `palette` control on `Drivers` gains the discovered `.mlp` files after the built-ins, exactly as
the effect list already appends scripted effects. One selector, one mental model, and a scripted
palette is picked the same way a built-in is.

## The three decisions, and their answers

**1. Tearing.** Today `Palettes::setActive()` runs on a control change, so it never overlaps a
frame. A per-frame script does. The script therefore fills a SCRATCH `Palette` and the 48 bytes are
assigned once at the end, so an effect samples either the old palette or the new one, never half of
each. Cheap, and it removes the whole class of problem rather than narrowing the window.

**2. Where the per-frame call sits.** Ahead of the effect pass, so every effect in the frame sees
the same palette. `Drivers::tick()` owns the palette control today, which makes it the honest owner
of the per-frame refresh too.

**3. Ordering against the perceptual curve.** A `.mlp` writes entries in LINEAR light; the CIE curve
is applied downstream in the driver's output LUT. This is already correct and needs no code, but it
belongs in the docs: a palette author who pre-compensates would double-correct, which is the same
trap the curve work just documented one layer down.

## Steps

1. **The role**: `.mlp` extension, `kPaletteExt`, `kPalettePick`, a template, and the catalog glob.
Four globs need it (CMakeLists, `catalog_scripts.py` x2, `catalog_scripts.cmake`), which is the
step that was missed when `.mls` was added, so it is called out here rather than discovered.
2. **The builtins**: `setPalEntry` / `setPalEntryHSV` in the light table, writing through a sink
installed for exactly one `run()`, the same bracket `MoonLiveModifier` uses for its coordinate
sink. A script cannot reach the palette outside its own tick.
3. **The binding**: `MoonLivePalette`, holding a `MoonLiveScript` and filling a scratch `Palette`.
4. **The wiring**: the `palette` select gains the `.mlp` files; `Drivers::tick()` runs the active
one per frame; the scratch is assigned into `Palettes::active_`.
5. **Two shipped scripts**, because a feature with no example is a feature nobody finds: one
algorithmic (a drifting fire) and one AUDIO-REACTIVE, which is the case that justifies the whole
design over a stop list.

## Tests

- Unit: a `.mlp` fills all 16 entries; an out-of-range index writes nothing; a script with no `tick`
leaves the palette untouched; a broken script leaves the LAST GOOD palette rather than black
(the same degrade-visibly rule the other bindings follow).
- Scenario: a scripted palette drives a real effect end to end, which is the integration the unit
tests cannot reach.
- Bench: the audio-reactive palette on a board with a mic, which is the only way to judge whether
the idea actually looks good.
- Cost: the per-frame tick measured on a classic ESP32 against a scripted effect on the same rig.

## Backlogged from the build

**A swatch for every picker row, not just palettes.** The shared picker learned an optional swatch
column for palettes (a row carrying `colors` paints a gradient; every other list is untouched
because it carries none). The same column could preview an EFFECT or a script, which would make the
picker readable at a glance rather than a list of names.

What to draw is the open question, and it is why this is backlogged rather than built: an effect has
no single color, and a still frame of a moving effect may be its least representative moment. The
candidates worth trying are a strip of the effect's first rendered row, its palette usage, or a
tiny animated preview once the picker can afford one. Worth prototyping against real effects before
committing to any of them, because the wrong preview is worse than no preview.

## Not in this plan

- **Crossfading between palettes.** WLED blends over a transition and it is the visible quality
difference, but it is orthogonal: it improves built-in palette CHANGES, scripted or not, and
belongs in its own change.
- **A stop-list editor.** WLED's `cpal.htm` is a good visual editor for FROZEN palettes. It solves a
different problem from this one and is worth its own decision later.
26 changes: 26 additions & 0 deletions esp32/partitions/esp32dev_8mb_moonbase.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# MoonBase on a classic ESP32 with 8 MB flash (the ESP32-PICO-V3-02's embedded part).
#
# The same trade the 4 MB and 16 MB MoonBase tables make: dual-OTA spends half the app area on a
# second copy of the firmware that sits idle except during an update, where MoonBase is a small
# factory image that owns the device while the app is replaced. One app slot is enough and the
# recovery story is stronger (a power cut mid-install boots MoonBase and the user retries over the
# network). Here that returns 3 MB, and it goes to LittleFS.
#
# NOTE a device flashed with a different table keeps it until a FULL serial flash: OTA updates the
# app, never the partition table.
# Layout (8 MB = 0x800000):
# 0x0000-0x8FFF bootloader (reserved)
# 0x9000-0xDFFF nvs ( 20 KB)
# 0xE000-0xFFFF otadata ( 8 KB)
# 0x10000-0xEFFFF moonbase ( 896 KB) -> factory recovery image
# 0xF0000-0x3EFFFF app ( 3 MB) -> ota_0
# 0x3F0000-0x7EFFFF spiffs ( 4 MB) -> LittleFS state
# 0x7F0000-0x7FFFFF coredump ( 64 KB)
#
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
moonbase, app, factory, 0x10000, 0xE0000,
app, app, ota_0, 0xF0000, 0x300000,
spiffs, data, spiffs, 0x3F0000, 0x400000,
coredump, data, coredump, 0x7F0000, 0x10000,
27 changes: 27 additions & 0 deletions esp32/sdkconfig.defaults.esp32-pico
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ESP32-PICO-V3-02: classic ESP32 (Xtensa LX6) in a SiP package with 8 MB flash and 2 MB PSRAM
# both EMBEDDED in the chip. The QuinLED Dig-Next-2 carries one.
# Append to sdkconfig.defaults + sdkconfig.defaults.eth (later fragment wins).
#
# Its own variant rather than a reuse of `esp32` or `esp32-wrover` for two reasons: the flash is
# 8 MB where the classic base sets 4, and the PSRAM must be on (the base has none) — a 4 MB image
# on this part would waste half the flash, and the wrover image's 4 MB table would too.

# The 8 MB flash and its MoonBase partition table: a factory recovery image, ONE 3 MB app slot, and
# 4 MB of LittleFS. The 4 MB base table affords only 1856 KB slots and 256 KB of filesystem.
CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="8MB"
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/esp32dev_8mb_moonbase.csv"

# PSRAM: 2 MB, embedded in the SiP. QUAD mode, and there is nothing to select — on classic-ESP32
# silicon CONFIG_SPIRAM=y IS quad (CONFIG_SPIRAM_MODE_OCT exists only on the S3), which is the same
# reason the WROVER fragment sets this one symbol and no mode.
CONFIG_SPIRAM=y

# MoonLive native codegen needs an executable heap (allocExec -> MALLOC_CAP_EXEC IRAM).
# MALLOC_CAP_EXEC is gated behind CONFIG_HEAP_HAS_EXEC_HEAP, which IDF disables whenever memory
# protection (W^X) is on. A JIT needs writable-then-executable memory, so disable memprot — the
# standard ESP32-JIT configuration, same as the WROVER and S3 PSRAM fragments; the safety story for
# scripted code is the staged bounds/watchdog checks.
CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n
CONFIG_HEAP_HAS_EXEC_HEAP=y
12 changes: 12 additions & 0 deletions moondeck/build/build_esp32.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,18 @@ def check_idf_pin(idf_path: Path) -> None:
"the larger buffers (big grids, preview) the WROVER's extra RAM allows.",
"ships": True,
},
"esp32-pico": {
"chip": "esp32",
"fragments": ["sdkconfig.defaults", "sdkconfig.defaults.eth",
"sdkconfig.defaults.esp32-pico"],
"moonbase": True, # 8 MB: factory MoonBase + one app slot (see moonbase/)
"eth_only": False,
"description": "ESP32-PICO-V3-02 (classic ESP32 SiP: 8 MB embedded flash + 2 MB "
"embedded quad PSRAM). WiFi + Ethernet, same silicon as `esp32`; its "
"own variant because the flash is 8 MB where the base assumes 4 and "
"PSRAM is on (QuinLED Dig-Next-2).",
"ships": True,
},
"esp32-eth": {
"chip": "esp32",
"fragments": ["sdkconfig.defaults", "sdkconfig.defaults.eth", "sdkconfig.defaults.moonbase-4mb"],
Expand Down
13 changes: 10 additions & 3 deletions mooninstaller/deviceModels.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@
"name": "QuinLED Dig-Next-2",
"chip": "ESP32",
"firmwares": [
"esp32"
"esp32-pico"
],
"image": "assets/deviceModels/quinled-dig-next-2.jpg",
"url": "https://quinled.info/dig-next-2/",
Expand All @@ -183,9 +183,9 @@
],
"planned": [
"Button",
"Microphone",
"Relay"
"Microphone"
],
"flashBaud": 460800,
"modules": [
{
"type": "System",
Expand All @@ -194,6 +194,13 @@
"deviceModel": "QuinLED Dig-Next-2"
}
},
{
"type": "Drivers",
"id": "Drivers",
"controls": {
"relayPins": "5,20,21,22"
}
},
{
"type": "RmtLedDriver",
"id": "RmtLed",
Expand Down
7 changes: 7 additions & 0 deletions mooninstaller/firmwares.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
"ships": true,
"description": "ESP32-WROVER (classic ESP32, 4 MB flash + 4 MB quad PSRAM) — WiFi + Ethernet. Same silicon as `esp32`; this variant enables PSRAM for the larger buffers (big grids, preview) the WROVER's extra RAM allows."
},
{
"name": "esp32-pico",
"chip": "esp32",
"eth_only": false,
"ships": true,
"description": "ESP32-PICO-V3-02 (classic ESP32 SiP: 8 MB embedded flash + 2 MB embedded quad PSRAM). WiFi + Ethernet, same silicon as `esp32`; its own variant because the flash is 8 MB where the base assumes 4 and PSRAM is on (QuinLED Dig-Next-2)."
},
{
"name": "esp32-eth",
"chip": "esp32",
Expand Down
26 changes: 26 additions & 0 deletions moonlive/palettes/beat-flash.mlp
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Beat flash: the palette sits in a deep color and jumps to white on every drum hit, so any effect
// using it pulses with the room. Decays between beats rather than snapping back.

class BeatFlash {
byte hue = 160;
byte decay = 24;
int level = 0;

string tags() { return "🎨🎶"; }

void defineControls() {
addControl("hue", hue, 0, 255); // the resting color
addControl("decay", decay, 1, 80); // how fast the flash falls away
}

void tick() {
if (audioBeat() > 0) { level = 255; }
if (level > decay) { level = level - decay; }
else { level = 0; }

for (int i = 0; i < 16; i = i + 1) {
int sat = 255 - level;
setPalEntryHSV(i, mod(hue + i * 4, 256), sat, 128 + div(level, 2));
}
}
}
20 changes: 20 additions & 0 deletions moonlive/palettes/drift.mlp
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Drift: the whole palette walks the color wheel, so every effect using it slowly changes hue.

class Drift {
byte bpm = 4;
byte spread = 16;

string tags() { return "🎨"; }

void defineControls() {
addControl("bpm", bpm, 1, 60); // how fast the wheel turns
addControl("spread", spread, 1, 64); // hue distance between entries
}

void tick() {
int base = scale(beat(bpm, t), 256);
for (int i = 0; i < 16; i = i + 1) {
setPalEntryHSV(i, base + i * spread, 255, 255);
}
}
}
33 changes: 33 additions & 0 deletions moonlive/palettes/fire.mlp
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Fire: a heat ramp from black through red and orange into white, breathing as it burns.

class Fire {
byte bpm = 12;
byte coolest = 40;

string tags() { return "🎨"; }

void defineControls() {
addControl("bpm", bpm, 1, 60); // how fast the fire breathes
addControl("coolest", coolest, 0, 128); // how dark the coldest entry falls
}

void tick() {
int breath = beatsin(bpm, t, 60);
for (int i = 0; i < 16; i = i + 1) {
int heat = div(i * 255, 15) + breath - 30;
if (heat < 0) { heat = 0; }
if (heat > 255) { heat = 255; }
if (heat < coolest) { heat = 0; }

int red = heat * 3;
if (red > 255) { red = 255; }
int green = 0;
if (heat > 85) { green = (heat - 85) * 3; }
if (green > 255) { green = 255; }
int blue = 0;
if (heat > 170) { blue = (heat - 170) * 3; }

setPalEntry(i, red, green, blue);
}
}
}
Loading
Loading