diff --git a/CLAUDE.md b/CLAUDE.md index a53be6ca..56d5501c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/CMakeLists.txt b/CMakeLists.txt index a9648e93..160c40d5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 616b7dea..163f197c 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -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.) diff --git a/docs/history/plans/Plan-20260903 - MoonLive palettes.md b/docs/history/plans/Plan-20260903 - MoonLive palettes.md new file mode 100644 index 00000000..f848031c --- /dev/null +++ b/docs/history/plans/Plan-20260903 - MoonLive palettes.md @@ -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. diff --git a/esp32/partitions/esp32dev_8mb_moonbase.csv b/esp32/partitions/esp32dev_8mb_moonbase.csv new file mode 100644 index 00000000..6517bc2d --- /dev/null +++ b/esp32/partitions/esp32dev_8mb_moonbase.csv @@ -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, diff --git a/esp32/sdkconfig.defaults.esp32-pico b/esp32/sdkconfig.defaults.esp32-pico new file mode 100644 index 00000000..45189e2c --- /dev/null +++ b/esp32/sdkconfig.defaults.esp32-pico @@ -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 diff --git a/moondeck/build/build_esp32.py b/moondeck/build/build_esp32.py index 6d91da3f..0de13e08 100644 --- a/moondeck/build/build_esp32.py +++ b/moondeck/build/build_esp32.py @@ -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"], diff --git a/mooninstaller/deviceModels.json b/mooninstaller/deviceModels.json index 6e3fe0e2..3a9c8ee8 100644 --- a/mooninstaller/deviceModels.json +++ b/mooninstaller/deviceModels.json @@ -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/", @@ -183,9 +183,9 @@ ], "planned": [ "Button", - "Microphone", - "Relay" + "Microphone" ], + "flashBaud": 460800, "modules": [ { "type": "System", @@ -194,6 +194,13 @@ "deviceModel": "QuinLED Dig-Next-2" } }, + { + "type": "Drivers", + "id": "Drivers", + "controls": { + "relayPins": "5,20,21,22" + } + }, { "type": "RmtLedDriver", "id": "RmtLed", diff --git a/mooninstaller/firmwares.json b/mooninstaller/firmwares.json index 7fbebf37..3a892c40 100644 --- a/mooninstaller/firmwares.json +++ b/mooninstaller/firmwares.json @@ -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", diff --git a/moonlive/palettes/beat-flash.mlp b/moonlive/palettes/beat-flash.mlp new file mode 100644 index 00000000..2a13128e --- /dev/null +++ b/moonlive/palettes/beat-flash.mlp @@ -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)); + } + } +} diff --git a/moonlive/palettes/drift.mlp b/moonlive/palettes/drift.mlp new file mode 100644 index 00000000..879b888a --- /dev/null +++ b/moonlive/palettes/drift.mlp @@ -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); + } + } +} diff --git a/moonlive/palettes/fire.mlp b/moonlive/palettes/fire.mlp new file mode 100644 index 00000000..1326e1ec --- /dev/null +++ b/moonlive/palettes/fire.mlp @@ -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); + } + } +} diff --git a/moonlive/palettes/spectrum.mlp b/moonlive/palettes/spectrum.mlp new file mode 100644 index 00000000..7f00d0c1 --- /dev/null +++ b/moonlive/palettes/spectrum.mlp @@ -0,0 +1,22 @@ +// Spectrum: the palette IS the room. Each entry takes its brightness from one frequency band, so +// every effect using this palette reacts to the music without knowing anything about audio. + +class Spectrum { + byte gain = 100; + byte hue = 0; + + string tags() { return "🎨🎶"; } + + void defineControls() { + addControl("gain", gain, 10, 255); // how hard the bands drive the palette + addControl("hue", hue, 0, 255); // where in the wheel the colors sit + } + + void tick() { + for (int i = 0; i < 16; i = i + 1) { + int mag = div(audioBand(i) * gain, 100); + if (mag > 255) { mag = 255; } + setPalEntryHSV(i, hue + i * 8, 255, mag); + } + } +} diff --git a/moonlive/palettes/temperature.mlp b/moonlive/palettes/temperature.mlp new file mode 100644 index 00000000..d8bd9ebc --- /dev/null +++ b/moonlive/palettes/temperature.mlp @@ -0,0 +1,37 @@ +// Temperature: a physical color ramp from deep blue through white to amber, the way a heated +// object actually changes color. Slide `kelvin` to move the whole palette along that curve. + +class Temperature { + byte kelvin = 128; + byte spread = 90; + + string tags() { return "🎨"; } + + void defineControls() { + addControl("kelvin", kelvin, 0, 255); // cold blue at 0, warm amber at 255 + addControl("spread", spread, 0, 255); // how much of the curve the sixteen entries cover + } + + void tick() { + for (int i = 0; i < 16; i = i + 1) { + int k = kelvin + div((i - 8) * spread, 16); + if (k < 0) { k = 0; } + if (k > 255) { k = 255; } + + int red = 255; + int green = 255; + int blue = 255; + if (k < 128) { + red = 128 + k; + green = 170 + div(k, 3); + } + if (k > 128) { + blue = 255 - (k - 128) * 2; + green = 255 - div(k - 128, 3); + } + if (blue < 0) { blue = 0; } + + setPalEntry(i, red, green, blue); + } + } +} diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index c98b2f47..5e9310f6 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -1743,7 +1743,8 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { // python-wled parser requires: and floods HA's log with `MissingField: filesystem` // on every frame. Fix pinned on the bench with `sudo docker logs homeassistant`. "\"lm\":\"\",\"lip\":\"\",\"ws\":-1," - // palcount = the real built-in count (matches the palettes[] array below); fxcount + // palcount = the real palette count, built-ins PLUS the scripted tail, so it matches + // the palettes[] array below entry for entry; fxcount // stays 1 (this shim exposes one effect surface). cpal/umpal = 0 (no custom palettes). "\"fxcount\":1,\"palcount\":%u,\"cpalcount\":0,\"umpalcount\":0,\"str\":false}", // Non-zero or the WLED integration rejects the device: desktop's freeHeap() reports @@ -1751,7 +1752,7 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { pmt, static_cast(platform::freeHeap() ? platform::freeHeap() : 32768u), static_cast(platform::millis() / 1000u), - static_cast(mm::palettes::kCount)); + static_cast(mm::paletteCount())); // effects + palettes: python-wled's __pre_deserialize__ turns each array into an indexed dict. // effects stays one real entry ("Solid"): this shim drives a single Layer, so a longer effect list // would be a lie. palettes is the REAL built-in list (Palette.h paletteNames / kBuiltins) so HA's @@ -1821,7 +1822,11 @@ void HttpServerModule::applyWledState(const char* body) { if (palStart) { int pal = mm::json::parseIntStr(palStart + 6); if (pal < 0) pal = 0; - if (pal >= mm::palettes::kCount) pal = mm::palettes::kCount - 1; + // Against the FULL count, built-ins plus the scripted tail, because that is exactly the + // list served as `palettes[]` above: clamping to the built-ins rejected every scripted index + // this device had just offered, so picking one in Home Assistant silently snapped back to + // the last built-in. + if (pal >= mm::paletteCount()) pal = mm::paletteCount() - 1; char valueJson[24]; std::snprintf(valueJson, sizeof(valueJson), "{\"value\":%d}", pal); applySetControl("Drivers", "palette", valueJson); @@ -2361,6 +2366,12 @@ void HttpServerModule::serveScriptCatalog(platform::TcpConnection& conn) { emit("modifiers", moonlive::kModifierFolder, moonlive::kModifierCatalog, moonlive::kModifierCatalogDim, moonlive::kModifierCatalogTags, moonlive::kModifierCatalogCount); + emit("services", moonlive::kServiceFolder, moonlive::kServiceCatalog, + moonlive::kServiceCatalogDim, moonlive::kServiceCatalogTags, + moonlive::kServiceCatalogCount); + emit("palettes", moonlive::kPaletteFolder, moonlive::kPaletteCatalog, + moonlive::kPaletteCatalogDim, moonlive::kPaletteCatalogTags, + moonlive::kPaletteCatalogCount); sink.append("}"); sink.flush(); } diff --git a/src/light/Palette.h b/src/light/Palette.h index 178364c0..f44cbe6c 100644 --- a/src/light/Palette.h +++ b/src/light/Palette.h @@ -5,6 +5,7 @@ #include #include +#include // snprintf: the seam copies the names it is handed namespace mm { @@ -166,41 +167,52 @@ inline constexpr uint8_t kYelmag[] = {0,4,1,70, 31,55,1,30, 63,255,4,7, 95, inline constexpr uint8_t kYellowout[] = {0,0,1,255, 63,0,55,255, 127,0,255,255, 191,42,255,45, 255,255,0,0}; // A built-in is a gradient ({stops,len}) or the special "rainbow" (generated via hsvToRgb). -struct Builtin { const char* name; const uint8_t* stops; size_t len; bool rainbow; }; +/// A built-in palette: its name, its gradient, and the tags the picker filters on. +/// +/// `tags` describes what the palette LOOKS LIKE, which is the question someone scrolling sixty +/// entries is actually asking: warm or cold, one hue or many, calm or loud. It is not a category +/// system to be completed, and an untagged palette is a valid state rather than an omission: an +/// empty string simply means the palette is findable by name and swatch, as it was before. +struct Builtin { const char* name; const uint8_t* stops; size_t len; bool rainbow; + const char* tags = ""; }; #define MM_PAL(name, arr) {name, arr, sizeof(arr), false} +/// The same, with tags. Two macros rather than a default argument in one, because the table is +/// written two entries per line and a trailing string on every row would cost the readability that +/// layout exists for. +#define MM_PAL_T(name, arr, tags) {name, arr, sizeof(arr), false, tags} inline constexpr Builtin kBuiltins[] = { - {"Rainbow", nullptr, 0, true}, - MM_PAL("Party", kParty), MM_PAL("Lava", kLava), - MM_PAL("Ocean", kOceanBreeze), MM_PAL("Forest", kForest), - MM_PAL("Fierce Ice", kFierceIce), MM_PAL("Sunset", kSunset), - MM_PAL("Sunset 2", kSunset2), MM_PAL("Orange & Teal",kOrangeTeal), - MM_PAL("Aurora", kAurora), MM_PAL("Aurora 2", kAurora2), - MM_PAL("Atlantica", kAtlantica), MM_PAL("Analogous", kAnalogous), - MM_PAL("April Night", kAprilNight), MM_PAL("Aqua Flash", kAquaFlash), - MM_PAL("Autumn", kAutumn), MM_PAL("Beech", kBeech), - MM_PAL("Blink Red", kBlinkRed), MM_PAL("C9", kC9), - MM_PAL("C9 2", kC9_2), MM_PAL("C9 New", kC9New), - MM_PAL("Candy", kCandy), MM_PAL("Candy2", kCandy2), - MM_PAL("Colorfull", kColorfull), MM_PAL("Departure", kDeparture), - MM_PAL("Drywet", kDrywet), MM_PAL("Fairy Reaf", kFairyReaf), - MM_PAL("Grintage", kGrintage), MM_PAL("Hult", kHult), - MM_PAL("Hult 64", kHult64), MM_PAL("Jul", kJul), - MM_PAL("Landscape", kLandscape), MM_PAL("Light Pink", kLightPink), - MM_PAL("Lite Light", kLiteLight), MM_PAL("Magenta", kMagenta), - MM_PAL("Magred", kMagred), MM_PAL("Orangery", kOrangery), - MM_PAL("Pastel", kPastel), MM_PAL("Pink Candy", kPinkCandy), - MM_PAL("Red & Blue", kRedBlue), MM_PAL("Red Flash", kRedFlash), - MM_PAL("Red Reaf", kRedReaf), MM_PAL("Red Shift", kRedShift), - MM_PAL("Red Tide", kRedTide), MM_PAL("Retro Clown", kRetroClown), - MM_PAL("Rewhi", kRewhi), MM_PAL("Rivendell", kRivendell), - MM_PAL("Sakura", kSakura), MM_PAL("Semi Blue", kSemiBlue), - MM_PAL("Sherbet", kSherbet), MM_PAL("Splash", kSplash), - MM_PAL("Temperature", kTemperature), MM_PAL("Tertiary", kTertiary), - MM_PAL("Tiamat", kTiamat), MM_PAL("Toxy Reaf", kToxyReaf), - MM_PAL("Vintage", kVintage), MM_PAL("Yelblu Hot", kYelbluHot), - MM_PAL("Yelblu", kYelblu), MM_PAL("Yelmag", kYelmag), - MM_PAL("Yellowout", kYellowout), + {"Rainbow", nullptr, 0, true, "🌈⚡️"}, + MM_PAL_T("Party", kParty, "🌈⚡️"), MM_PAL_T("Lava", kLava, "🔥💫"), + MM_PAL_T("Ocean", kOceanBreeze, "❄️💫"), MM_PAL_T("Forest", kForest, "❄️💫"), + MM_PAL_T("Fierce Ice", kFierceIce, "❄️💫"), MM_PAL_T("Sunset", kSunset, "🔥💫"), + MM_PAL_T("Sunset 2", kSunset2, "🔥💫"), MM_PAL_T("Orange & Teal", kOrangeTeal, "🌈💫"), + MM_PAL_T("Aurora", kAurora, "❄️💫"), MM_PAL_T("Aurora 2", kAurora2, "🌈💫"), + MM_PAL_T("Atlantica", kAtlantica, "❄️💫"), MM_PAL_T("Analogous", kAnalogous, "🌈💫"), + MM_PAL_T("April Night", kAprilNight, "🌑💫"), MM_PAL_T("Aqua Flash", kAquaFlash, "❄️💫"), + MM_PAL_T("Autumn", kAutumn, "🔥💫"), MM_PAL_T("Beech", kBeech, "🌑💫"), + MM_PAL_T("Blink Red", kBlinkRed, "🔥💫"), MM_PAL_T("C9", kC9, "🌈💫"), + MM_PAL_T("C9 2", kC9_2, "💫"), MM_PAL_T("C9 New", kC9New, "💫"), + MM_PAL_T("Candy", kCandy, "💫"), MM_PAL_T("Candy2", kCandy2, "💫"), + MM_PAL_T("Colorfull", kColorfull, "💫"), MM_PAL_T("Departure", kDeparture, "💫"), + MM_PAL_T("Drywet", kDrywet, "💫"), MM_PAL_T("Fairy Reaf", kFairyReaf, "💫"), + MM_PAL_T("Grintage", kGrintage, "💫"), MM_PAL_T("Hult", kHult, "💫"), + MM_PAL_T("Hult 64", kHult64, "💫"), MM_PAL_T("Jul", kJul, "💫"), + MM_PAL_T("Landscape", kLandscape, "💫"), MM_PAL_T("Light Pink", kLightPink, "💫"), + MM_PAL_T("Lite Light", kLiteLight, "💫"), MM_PAL_T("Magenta", kMagenta, "💫"), + MM_PAL_T("Magred", kMagred, "💫"), MM_PAL_T("Orangery", kOrangery, "💫"), + MM_PAL_T("Pastel", kPastel, "💫"), MM_PAL_T("Pink Candy", kPinkCandy, "💫"), + MM_PAL_T("Red & Blue", kRedBlue, "💫"), MM_PAL_T("Red Flash", kRedFlash, "💫"), + MM_PAL_T("Red Reaf", kRedReaf, "💫"), MM_PAL_T("Red Shift", kRedShift, "💫"), + MM_PAL_T("Red Tide", kRedTide, "💫"), MM_PAL_T("Retro Clown", kRetroClown, "💫"), + MM_PAL_T("Rewhi", kRewhi, "💫"), MM_PAL_T("Rivendell", kRivendell, "💫"), + MM_PAL_T("Sakura", kSakura, "💫"), MM_PAL_T("Semi Blue", kSemiBlue, "💫"), + MM_PAL_T("Sherbet", kSherbet, "💫"), MM_PAL_T("Splash", kSplash, "💫"), + MM_PAL_T("Temperature", kTemperature, "💫"), MM_PAL_T("Tertiary", kTertiary, "💫"), + MM_PAL_T("Tiamat", kTiamat, "💫"), MM_PAL_T("Toxy Reaf", kToxyReaf, "💫"), + MM_PAL_T("Vintage", kVintage, "💫"), MM_PAL_T("Yelblu Hot", kYelbluHot, "💫"), + MM_PAL_T("Yelblu", kYelblu, "💫"), MM_PAL_T("Yelmag", kYelmag, "💫"), + MM_PAL_T("Yellowout", kYellowout, "💫"), }; #undef MM_PAL inline constexpr uint8_t kCount = sizeof(kBuiltins) / sizeof(kBuiltins[0]); @@ -344,20 +356,117 @@ class Palettes { // Emit the palette dropdown's options for a ControlType::Palette control (the PaletteOptionsFn): // one {"name":…,"colors":"rrggbb rrggbb …"} object per built-in, the colors being the 16 entries // as space-separated hex so the UI renders each option as a gradient swatch. +/// How many SCRIPTED palettes the picker offers, and their names. Set by Drivers from the catalog +/// plus whatever `.mlp` files the device carries, because Palette.h knows nothing about the +/// filesystem and must not learn: this is the same one-pointer seam `Palettes::active()` is. +/// +/// They sort FIRST in the picker, ahead of the sixty built-ins. A scripted palette is the thing a +/// user just wrote or downloaded, so it is what they are looking for; a built-in is always there. +struct LivePalettes { + static uint8_t count() { return count_; } + static const char* nameAt(uint8_t i) { return (i < count_ && names_ && names_[i]) ? names_[i] : ""; } + /// What the script declared about itself, for the picker's emoji filter. Empty when the device + /// carries a `.mlp` the catalog does not know, which is a script the user wrote: it is findable + /// by name, it just carries no chips. + static const char* tagsAt(uint8_t i) { return (i < count_ && tags_ && tags_[i]) ? tags_[i] : ""; } + + /// REFERENCES the caller's arrays; it does not copy them. + /// + /// The names live in the publisher's own member array, which outlives the seam in practice: the + /// module that owns the palette control is the one that publishes, and it republishes whenever + /// its controls are rebuilt. A copy here would cost ~640 bytes of static RAM on every board, + /// including the ones with no PSRAM, to duplicate strings that already exist. + /// + /// The contract that makes this safe: a publisher must clear the seam before its arrays go. + static void set(const char* const* names, const char* const* tags, uint8_t n) { + names_ = names; + tags_ = tags; + count_ = names ? (n > kMax ? kMax : n) : 0; + } + /// Detach, for a publisher whose storage is about to go away. + /// + /// Takes the publisher's OWN array so a departing publisher cannot unpublish somebody else's: + /// a second Drivers (a probe, a module the user added and removed, a tree rebuilt around the + /// live one) publishes and is then destroyed, and an unconditional clear() would leave the + /// running Drivers' palettes silently missing from the picker. Clearing only when the seam + /// still points at the caller's storage makes publish/clear pair per owner rather than + /// globally, which is what made this a dangling pointer in the first place: whoever released + /// last decided what the seam held. + static void clear(const char* const* names = nullptr) { + if (names && names_ != names) return; // someone else owns the seam now: leave it + names_ = nullptr; + tags_ = nullptr; + count_ = 0; + } + + /// Scripted palettes sort LAST, after the built-ins, and the position is the whole point. + /// + /// A palette selection is an INDEX: it is persisted, it rides `seg[0].pal` over the WLED API, + /// and Home Assistant's integration renders `paletteNames` positionally. So an index that means + /// a different palette tomorrow is a silent corruption of every saved setting. + /// + /// Putting the scripted ones first would do exactly that: saving one more `.mlp` shifts every + /// built-in up by one, and a stored `palette: 12` quietly becomes palette 11. Last means the + /// sixty built-in indices are fixed forever and only the scripted tail renumbers, which is the + /// same trade WLED makes by growing its custom palettes downward from 255. + static bool isLive(uint8_t pickerIndex) { return pickerIndex >= palettes::kCount; } + /// The index into the underlying array (scripted or built-in) behind a picker index. + static uint8_t sourceIndex(uint8_t pickerIndex) { + return isLive(pickerIndex) ? static_cast(pickerIndex - palettes::kCount) + : pickerIndex; + } + + /// Case-insensitive name order, for sorting the scripted names among themselves. + static int cmpName(const char* a, const char* b) { + for (; *a && *b; ++a, ++b) { + const char la = (*a >= 'A' && *a <= 'Z') ? static_cast(*a + 32) : *a; + const char lb = (*b >= 'A' && *b <= 'Z') ? static_cast(*b + 32) : *b; + if (la != lb) return la < lb ? -1 : 1; + } + return *a ? 1 : (*b ? -1 : 0); + } + + static constexpr uint8_t kMax = 16; // a device carries a handful; the picker stays readable +private: + static inline const char* const* names_ = nullptr; + static inline const char* const* tags_ = nullptr; + static inline uint8_t count_ = 0; +}; + inline void paletteOptions(JsonSink& sink) { // A NAME REQUEST rather than an options dump: core sets nameIndex to ask "what is palette N // called", because it has no palette table of its own and this function pointer is the only // channel it has into the light domain (Control.h, PaletteOptionsFn). Answering here costs one // branch and no extra descriptor field, where dumping all 60 options just to read one name out // of ~9 KB of JSON would need a buffer no ESP32 task stack can spare. + const uint8_t live = LivePalettes::count(); if (sink.nameIndex() >= 0) { const uint8_t i = static_cast(sink.nameIndex()); - if (i < palettes::kCount) sink.append(palettes::kBuiltins[i].name); + const uint8_t src = LivePalettes::sourceIndex(i); + if (LivePalettes::isLive(i)) { sink.append(LivePalettes::nameAt(src)); return; } + if (src < palettes::kCount) sink.append(palettes::kBuiltins[src].name); return; } + // The BUILT-INS first, keeping the indices every saved selection and the WLED API depend on. for (uint8_t i = 0; i < palettes::kCount; i++) { const Palette p = Palettes::fromBuiltin(i); - sink.appendf("%s{\"name\":\"%s\",\"colors\":\"", i > 0 ? "," : "", palettes::kBuiltins[i].name); + sink.appendf("%s{\"name\":\"%s\",\"tags\":\"%s\",\"colors\":\"", i > 0 ? "," : "", + palettes::kBuiltins[i].name, palettes::kBuiltins[i].tags); + for (uint8_t e = 0; e < Palette::kEntries; e++) + sink.appendf("%s%02x%02x%02x", e > 0 ? " " : "", p.entry[e].r, p.entry[e].g, p.entry[e].b); + sink.append("\"}"); + } + // Then the scripted tail, each marked with 🎨 so the list says at a glance which entries are + // code rather than a fixed gradient. A scripted palette's swatch is whatever is CURRENTLY + // active: its colors exist only once it has ticked, so any other guess would not match the wall. + for (uint8_t i = 0; i < live; i++) { + const Palette& p = *Palettes::active(); + // The name alone: the 🎨 marker rides in `tags`, which the picker renders as a chip and + // filters on. Repeating it in the name showed it twice on every scripted row. + // `live` says this row runs a script, so the UI can mark it the way it marks a scripted + // effect without inferring it from the file extension. + sink.appendf(",{\"name\":\"%s\",\"live\":true,\"tags\":\"%s\",\"colors\":\"", + LivePalettes::nameAt(i), LivePalettes::tagsAt(i)); for (uint8_t e = 0; e < Palette::kEntries; e++) sink.appendf("%s%02x%02x%02x", e > 0 ? " " : "", p.entry[e].r, p.entry[e].g, p.entry[e].b); sink.append("\"}"); @@ -371,6 +480,20 @@ inline void paletteOptions(JsonSink& sink) { inline void paletteNames(JsonSink& sink) { for (uint8_t i = 0; i < palettes::kCount; i++) sink.appendf("%s\"%s\"", i > 0 ? "," : "", palettes::kBuiltins[i].name); + // The scripted tail too, in the SAME order the picker and `seg[0].pal` use. This list is + // positional: HA renders one dropdown entry per name and sends back the index, so stopping at + // the built-ins left every scripted palette unnameable and unselectable there while the device's + // own `palette` control accepted exactly those indices. paletteCount() below is what keeps the + // WLED `palcount` field agreeing with this list. + for (uint8_t i = 0; i < LivePalettes::count(); i++) + sink.appendf(",\"%s\"", LivePalettes::nameAt(i)); +} + +/// How many entries paletteNames() writes: the built-ins plus whatever scripted palettes the device +/// currently carries. One home for the count, so the WLED shim's `palcount` cannot drift from the +/// array it describes. +inline uint8_t paletteCount() { + return static_cast(palettes::kCount + LivePalettes::count()); } } // namespace mm diff --git a/src/light/drivers/Correction.h b/src/light/drivers/Correction.h index 521c49a4..16cd8478 100644 --- a/src/light/drivers/Correction.h +++ b/src/light/drivers/Correction.h @@ -1,5 +1,7 @@ #pragma once +#include // powf: the gamma presets, cold path only + #include #include "light/ChannelRole.h" @@ -52,7 +54,44 @@ inline constexpr uint8_t kWhiteModeCount = struct Correction { static constexpr uint8_t kAbsent = 255; // color role not carried by this light - uint8_t briLut[256] = {}; // briLut[v] = (v * brightness) / 255 (scale8) + /// The perceptual curve the output LUT is filled through. + /// + /// `Cie` is the default and the standards answer: CIE 1931 lightness (CIE 15 / ISO 11664-4) + /// models how the eye responds to luminance, which is exactly what a brightness control should + /// be uniform in. hzeller's rpi-rgb-led-matrix, the reference HUB75 implementation, builds its + /// table the same way. + /// + /// `Linear` is not a fallback but a REQUIREMENT for two cases: a downstream device that applies + /// its own curve (correcting twice darkens as the square of the setting, the same reason the + /// dimmer channel is held open below), and any measurement or calibration that needs the value + /// on the wire to mean duty cycle. + /// + /// Gamma 2.2 is the DISPLAY convention (sRGB's effective exponent) and belongs to content that + /// is genuinely encoded that way. Gamma 2.8 is the stage-lighting convention: it emulates the + /// feel of a tungsten dimmer, whose flux rises as roughly the 3.4th power of voltage, rather + /// than modeling perception. Both are offered because curve choice is a legitimate preference, + /// and both are labeled for what they actually do. + enum class Curve : uint8_t { Cie = 0, Gamma22, Gamma28, Linear }; + + /// CIE 1931 lightness, inverted: a 0..255 control position to a 0..1 luminance fraction. + /// + /// The constants are load-bearing rather than tuning: 8 and 903.3 place the linear toe at + /// (6/29)^3 so the two segments meet with a continuous SLOPE, and the cube root alone has + /// infinite slope at zero, which is both numerically unstable and wrong for near-black. NOTE + /// 903.3 and 116: the widely copied LED snippets carry 902.3 and 119 from a Wikipedia typo, + /// including hzeller's own table, and the reported effect is a visibly worse low end. + static float cieLuminance(float control255) { + const float L = control255 * 100.0f / 255.0f; + return (L <= 8.0f) ? (L / 903.3f) + : ((L + 16.0f) / 116.0f) * ((L + 16.0f) / 116.0f) * ((L + 16.0f) / 116.0f); + } + + + uint8_t briLut[256] = {}; // briLut[v] = curve(v * brightness / 255) + /// Which perceptual curve rebuildBrightness fills through. A DRIVER's setting, because whether + /// a curve belongs depends on what is downstream: a panel card that corrects its own pixels + /// needs Linear here or the picture is corrected twice. + Curve curve = Curve::Cie; // Derived hot-path cache: the output-byte position of each color role. Source is // always RGB (src[0]=R, src[1]=G, src[2]=B); the offset says where in `out` that // role's byte lands. Recomputed from the role array by rebuild(); GRB by default. @@ -116,11 +155,36 @@ struct Correction { uint8_t outChannels = 3; // bytes emitted per light (= channelsPerLight of the wiring) WhiteMode whiteMode = WhiteMode::Min; // how white is synthesized from RGB (white lights only) - // Refresh just the brightness LUT (briLut[v] = v * brightness / 255). Split out so a brightness- - // only change re-scales the LUT without touching the channel offsets, and so a driver can apply - // brightness even when the role source (the preset library) isn't available yet. + // Refresh just the brightness LUT. Split out so a brightness-only change re-scales the LUT + // without touching the channel offsets, and so a driver can apply brightness even when the role + // source (the preset library) isn't available yet. + // + // ORDER, and it is the whole design: brightness is a LINEAR pre-scale and the curve is applied + // LAST. A gain only composes correctly in linear light (a 0.8 white balance applied to a curved + // value yields 0.8^2.2, not 80% of the light), and anything reasoning about physical quantities + // (current, power) has to read linear values too. Curving the brightness CONTROL as well as the + // values would correct twice: the fader then feels dead at the bottom and 50% looks like 15%. + // The slider becomes perceptually uniform on its own precisely because the curve sits after it. void rebuildBrightness(uint8_t brightness) { - for (int v = 0; v < 256; v++) briLut[v] = static_cast((v * brightness) / 255); + for (int v = 0; v < 256; v++) { + const float linear = static_cast(v) * brightness / 255.0f; // scale first + float out = linear; + switch (curve) { + case Curve::Cie: out = cieLuminance(linear) * 255.0f; break; + case Curve::Gamma22: out = powf(linear / 255.0f, 2.2f) * 255.0f; break; + case Curve::Gamma28: out = powf(linear / 255.0f, 2.8f) * 255.0f; break; + case Curve::Linear: break; + } + int q = static_cast(out + 0.5f); + // A non-zero input never lands on black. Every curve here crushes the low end into + // zero at 8 bits (CIE maps 1 and 2 to 0, gamma 2.2 maps 1..5 there), so without this a + // fade-out SNAPS off partway down and the dimmest usable settings are simply missing. + // FastLED spells the same guard as the `_video` suffix on its gamma helpers; WLED has + // no equivalent and its table does map 1 to 0. Costs the exactness of black only for + // inputs that were never black. + if (q <= 0 && v > 0 && brightness > 0) q = 1; + briLut[v] = static_cast(q > 255 ? 255 : q); + } } // Cold path: refresh the brightness LUT and DERIVE the color-role offsets from the light's @@ -202,9 +266,14 @@ struct Correction { if (slot < srcChannels) out[chan[role]] = src[slot]; }); } - uint8_t r = briLut[src[0]]; - uint8_t g = briLut[src[1]]; - uint8_t b = briLut[src[2]]; + // The white math runs on the LINEAR source, and the curve is applied to what comes OUT of + // it. min() and the Accurate subtraction are ordinary arithmetic: performed on curved + // values they no longer mean what they say, because the amount subtracted from red does not + // correspond to the light the white emitter adds back. Same rule that puts the curve last + // in the pipeline, one level down. + uint8_t r = src[0]; + uint8_t g = src[1]; + uint8_t b = src[2]; // Every synthesized emitter (white + warm-white/yellow/UV) is gated by the ONE whiteMode: // None zeroes them (never a stale value — corrected_ is reused, not re-zeroed, frame to // frame), otherwise each is a best-effort approximation from RGB. Accurate additionally @@ -222,26 +291,29 @@ struct Correction { // values BEFORE Accurate pulls white out below. Compute them here, off the pre-subtraction // r/g/b, so Accurate's `r -= w` (which only rebalances the RGB emitters) can't corrupt them. // warm white ≈ the white component (same as cold white for a warm-white-only strip). - if (offWarmWhite != kAbsent) out[offWarmWhite] = w; + if (offWarmWhite != kAbsent) out[offWarmWhite] = briLut[w]; // yellow ≈ min(R,G) (the shared red+green component). - if (offYellow != kAbsent) out[offYellow] = r < g ? r : g; + if (offYellow != kAbsent) out[offYellow] = briLut[r < g ? r : g]; // UV is out of gamut (no RGB pre-image), but it reads to the eye as deep blue/violet, so // drive it from the BLUE component that has no red/green to pair with — the violet-ish // excess `max(0, B - max(R,G))`. So UV fires on blues/purples, stays dark on warm colors. if (offUV != kAbsent) { const uint8_t rg = r > g ? r : g; - out[offUV] = b > rg ? static_cast(b - rg) : 0; + out[offUV] = briLut[b > rg ? static_cast(b - rg) : 0]; } // White last: it's the only emitter that (in Accurate) rebalances RGB, so it must run // after the stand-ins have read the pre-subtraction values. if (offWhite != kAbsent) { if (whiteMode == WhiteMode::Accurate) { r -= w; g -= w; b -= w; } // pull white out of RGB - out[offWhite] = w; + out[offWhite] = briLut[w]; } } - if (offRed != kAbsent) out[offRed] = r; - if (offGreen != kAbsent) out[offGreen] = g; - if (offBlue != kAbsent) out[offBlue] = b; + // The curve, applied ONCE, to each emitter as it is written. Everything above this line is + // linear light, which is what lets min(), the subtraction and the stand-in approximations + // mean what they say. + if (offRed != kAbsent) out[offRed] = briLut[r]; + if (offGreen != kAbsent) out[offGreen] = briLut[g]; + if (offBlue != kAbsent) out[offBlue] = briLut[b]; } }; diff --git a/src/light/drivers/DriverBase.h b/src/light/drivers/DriverBase.h index f177963c..f08ad29b 100644 --- a/src/light/drivers/DriverBase.h +++ b/src/light/drivers/DriverBase.h @@ -127,6 +127,7 @@ class DriverBase : public MoonModule { const uint8_t effective = static_cast((globalBrightness * localBrightness_) / 255); correction_.whiteMode = static_cast(whiteMode_); + correction_.curve = static_cast(curveSel_); // Resolve the referenced preset's channel-role wiring from the library into our flat // Correction (cold path). On a missing id (a deleted preset) fall back to the library default // so a driver degrades to a valid RGB output rather than crashing — Robust-to-any-input. @@ -265,6 +266,19 @@ class DriverBase : public MoonModule { uint8_t presetSel_ = 0; // the preset Select's chosen INDEX (mapped to an id in onControlChanged) uint8_t whiteMode_ = static_cast(WhiteMode::Min); // index into kWhiteModeOptions uint8_t localBrightness_ = 255; // per-driver dim, multiplied with the global brightness + /// Which perceptual curve the output LUT is built through. CIE 1931 lightness by default: it is + /// the standards answer (CIE 15 / ISO 11664-4) and models the thing actually being corrected, + /// the eye's response to luminance. hzeller's rpi-rgb-led-matrix builds its HUB75 table the same + /// way. The alternatives are offered because curve choice is a legitimate preference, and each + /// is named for what it really does rather than presented as equivalent. + uint8_t curveSel_ = 0; // index into kCurveOptions; 0 = CIE + static constexpr uint8_t kCurveCount = 4; + static constexpr const char* kCurveOptions[kCurveCount] = { + "CIE 1931", // perceptual, the standard + "gamma 2.2", // the DISPLAY convention (sRGB's effective exponent) + "gamma 2.8", // the STAGE convention: emulates a tungsten dimmer's feel + "linear" // no curve: for a downstream device that corrects its own output + }; uint8_t lastGlobalBrightness_ = 0; // last global brightness the container pushed (for self-rebuilds) // The PERSISTED reference: a preset id is a runtime handle (reassigned each boot), so what // survives a reboot is the preset NAME — stable, human-readable, and reorder-proof. A hidden @@ -278,6 +292,10 @@ class DriverBase : public MoonModule { /// every defineControls (which re-runs on a control change), so adding/renaming a preset shows up. void defineCorrectionControls() { controls_.addControl("localBrightness", localBrightness_, 0, 255); + // The perceptual curve, per driver rather than global, because whether one belongs depends + // on what is DOWNSTREAM: a panel card or fixture that corrects its own pixels needs Linear + // here, or the picture is corrected twice and darkens as the square of the setting. + controls_.addSelect("curve", curveSel_, kCurveOptions, kCurveCount); buildPresetOptions(); // fill presetOptions_ from the library, sync id/sel/ref controls_.addSelect("lightPreset", presetSel_, presetOptions_, presetOptionCount_); controls_.addSelect("whiteMode", whiteMode_, kWhiteModeOptions, kWhiteModeCount); @@ -302,7 +320,7 @@ class DriverBase : public MoonModule { /// affectsPrepare() and its correction rebuilds in onControlChanged (both handled by DriverBase). static bool isCorrectionControl(const char* name) { return std::strcmp(name, "lightPreset") == 0 || std::strcmp(name, "localBrightness") == 0 - || std::strcmp(name, "whiteMode") == 0; + || std::strcmp(name, "whiteMode") == 0 || std::strcmp(name, "curve") == 0; } private: diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index ba51437a..b202c927 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -10,6 +10,8 @@ #include "light/layers/BlendMap.h" #include "light/drivers/Correction.h" #include "light/Palette.h" // the global active palette + its select control +#include "light/moonlive/MoonLivePalette.h" // a palette computed per frame by a script +#include "light/moonlive/script_catalog.h" // the tags each factory palette declares #include "core/LightSummary.h" // the POD published for the domain-neutral WLED/MQTT consumers #include "platform/platform.h" @@ -100,6 +102,15 @@ class Drivers : public MoonModule { stopEncodeTask(); renderSplitActive_ = false; seat_.vacate(); + // Detach the scripted-palette seam for the same reason the summary seat is vacated: it + // REFERENCES this object's liveNames_/livePtrs_/liveTags_ arrays rather than copying them, + // so leaving it published points /api/state at freed memory the moment this Drivers goes. + LivePalettes::clear(livePtrs_); + // The scripted-palette instance seam is the same shape and needs the same care: it points at + // paletteScriptModule_ (a member), and Effects::tick runs it every frame. Detach it and free + // the compiled script, or the render path keeps executing code owned by a dead Drivers. + MoonLivePalette::clearActiveInstance(&paletteScriptModule_); + paletteScriptModule_.release(); MoonModule::release(); } @@ -108,7 +119,18 @@ class Drivers : public MoonModule { /// release() (a stack instance in a test, a tree torn down by its owner) would leave a thread /// dereferencing freed memory. Same dangling-static guard the summary seat uses — a destructor /// is the only place that can't be skipped. - ~Drivers() override { stopEncodeTask(); } + /// The palette seam is cleared here TOO, not only in release(): a destructor is the one place + /// that cannot be skipped, and a Drivers torn down without an explicit release() would leave + /// the seam pointing into freed member arrays. + ~Drivers() override { + stopEncodeTask(); + LivePalettes::clear(livePtrs_); + MoonLivePalette::clearActiveInstance(&paletteScriptModule_); + // Free the compiled script too, not just detach the seam: release() may never run (a stack + // instance in a test, a tree torn down by its owner) and the engine holds an exec block. + // release() is idempotent, so the ordinary path that calls both frees once. + paletteScriptModule_.release(); + } /// Stop the core-1 encode worker so a STRUCTURAL TREE MUTATION (a module replace / delete / add) can /// free tree nodes without the worker dereferencing them mid-tick. The worker ticks the driver @@ -236,6 +258,59 @@ class Drivers : public MoonModule { /// gradient into the active 16-entry palette on `onControlChanged` (cheap, off the hot path). uint8_t palette = 0; + /// Discover the `.mlp` files this device carries and publish their names to the picker. + /// + /// Cold path, on defineControls: the list changes when a user saves or deletes a script, which + /// is exactly when the controls are rebuilt anyway. The names are kept in a member array because + /// the seam holds POINTERS, so a local would dangle the moment this returned. + void refreshLivePalettes() { + liveCount_ = 0; + platform::fsList(moonlive::kScriptDir, [](const char* name, bool isDir, uint32_t, void* ctx) { + auto* self = static_cast(ctx); + if (isDir || self->liveCount_ >= LivePalettes::kMax) return; + const size_t n = std::strlen(name); + if (n < 5 || std::strcmp(name + n - 4, moonlive::kPaletteExt) != 0) return; + // A bounded copy rather than snprintf("%s"): a name longer than the slot is TRUNCATED + // to fit, which is what a picker entry wants, and GCC cannot prove the %s form fits. + char* slot = self->liveNames_[self->liveCount_]; + const size_t cap = sizeof(self->liveNames_[0]) - 1; + const size_t copy = n < cap ? n : cap; + std::memcpy(slot, name, copy); + slot[copy] = '\0'; + self->livePtrs_[self->liveCount_] = self->liveNames_[self->liveCount_]; + self->liveCount_++; + }, this); + // Alphabetical, because the picker MERGES this list with the built-ins by name and that + // merge walks both in order. An unsorted list here would interleave wrongly. + for (uint8_t i = 1; i < liveCount_; i++) + for (uint8_t j = i; j > 0 && LivePalettes::cmpName(livePtrs_[j], livePtrs_[j - 1]) < 0; j--) { + const char* tmp = livePtrs_[j]; livePtrs_[j] = livePtrs_[j - 1]; livePtrs_[j - 1] = tmp; + } + // The tags each script declared, from the catalog. A `.mlp` the catalog does not know is a + // script the user wrote: it keeps its place in the list and simply carries no chips, which + // is why the lookup falls back to an empty string rather than skipping the file. + for (uint8_t i = 0; i < liveCount_; i++) { + liveTags_[i] = ""; + for (size_t c = 0; c < moonlive::kPaletteCatalogCount; c++) + if (std::strcmp(livePtrs_[i], moonlive::kPaletteCatalog[c]) == 0) { + liveTags_[i] = moonlive::kPaletteCatalogTags[c]; + break; + } + } + } + + char liveNames_[LivePalettes::kMax][moonlive::kMaxScriptName + 1] = {}; + const char* livePtrs_[LivePalettes::kMax] = {}; + const char* liveTags_[LivePalettes::kMax] = {}; + uint8_t liveCount_ = 0; + + /// The scripted palette: a `.mlp` name, and the binding that runs it. Empty means the built-in + /// `palette` select above is in charge, which is the default and the common case. The script is + /// owned HERE because this module owns the palette, and reached for its per-frame run through + /// MoonLivePalette's static seam, since the layers sample the palette before this module ticks. + char paletteScript_[moonlive::kMaxScriptName + 1] = {}; + MoonLivePalette paletteScriptModule_; + // Two ways to wire the source Layer: // - setEffects(Effects*): bind the container; layer_ is re-resolved from // activeLayer() at every prepareTree. This makes the link self-healing — @@ -286,7 +361,28 @@ class Drivers : public MoonModule { // in LED pins (architecture.md, deviceModel owns what is wired on the product). controls_.addText("relayPins", relayPins, sizeof(relayPins)); controls_.addControl("brightness", brightness, 0, 255); - controls_.addPalette("palette", palette, mm::paletteOptions, mm::palettes::kCount); + // ONE picker for both kinds. The scripted palettes are published to the seam Palette.h reads + // and merged into the same alphabetical list, so a `.mlp` is chosen exactly like a built-in + // rather than through a second control the user has to know about. + refreshLivePalettes(); + // Sized from THIS instance's scan (liveCount_), not the seam: defineControls also runs + // before prepare() has published, and a seam-sized control would cap at the built-ins and + // reject every scripted index, so selecting a live palette silently did nothing. + controls_.addPalette("palette", palette, mm::paletteOptions, + static_cast(liveCount_ + mm::palettes::kCount)); + // The EDITOR, shown only while a scripted palette is selected: there is nothing to edit in a + // built-in gradient, and offering the pane would suggest otherwise. + // + // It is an EDITOR, not a second selector. `palette` above owns the choice; this names the + // file that choice resolved to, so the two cannot disagree. An earlier version let this + // control pick a script too, which meant a user could select `spectrum` here while `palette` + // still said something else and the card showed two answers to one question. + controls_.addFilePath("paletteScript", paletteScript_, sizeof(paletteScript_), + moonlive::kPalettePick); + controls_.setHidden(controls_.count() - 1, !LivePalettes::isLive(palette)); + controls_.setReadOnly(controls_.count() - 1, true); // the selector is `palette`, above + // And the script's own controls, which only exist while one is running. + if (LivePalettes::isLive(palette)) paletteScriptModule_.publishControls(controls_); // Only where it can DO something: a rig of LED strips has no aim to hold, so the control // would be a question about hardware the user does not have. Same add-then-setHidden shape // the renderWait field below uses. @@ -313,7 +409,34 @@ class Drivers : public MoonModule { // per-driver channel order / white / local brightness live on each driver and rebuild there. void onControlChanged(const char* controlName) override { if (std::strcmp(controlName, "palette") == 0) { - Palettes::setActive(palette); // rebuild the active 16-entry lookup (cheap, off the hot path) + if (LivePalettes::isLive(palette)) { + // A scripted palette: name its file and compile. The script then fills the entries + // every frame, so there is nothing to expand here. + std::snprintf(paletteScript_, sizeof(paletteScript_), "%s", + LivePalettes::nameAt(LivePalettes::sourceIndex(palette))); + MoonLivePalette::setActiveInstance(&paletteScriptModule_); + paletteScriptModule_.setScript(paletteScript_); + paletteScriptModule_.prepare(*this); + } else { + // A built-in: detach any script, or it would keep overwriting the gradient every + // frame and the selection would appear to do nothing. + MoonLivePalette::setActiveInstance(nullptr); + paletteScript_[0] = 0; + Palettes::setActive(LivePalettes::sourceIndex(palette)); + } + rebuildControls(); // the editor and the script's controls appear or disappear with it + return; + } + if (std::strcmp(controlName, "paletteScript") == 0) { + // Published to the seam Effects::tick reads. Only while a name is set, so clearing the + // field detaches the script rather than leaving a compiled one running unseen. + MoonLivePalette::setActiveInstance(paletteScript_[0] ? &paletteScriptModule_ : nullptr); + paletteScriptModule_.setScript(paletteScript_); + paletteScriptModule_.prepare(*this); + rebuildControls(); // the compile re-derives the script's own controls + // Clearing the name hands the palette back to the built-in select, which would otherwise + // keep whatever the script last wrote. + if (paletteScript_[0] == 0) Palettes::setActive(palette); return; } if (std::strcmp(controlName, "multicore") == 0) { @@ -487,6 +610,14 @@ class Drivers : public MoonModule { } void prepare() override { + // Discover and PUBLISH the scripted palettes. Publication lives here and not in + // defineControls() because the seam is one static slot whose last writer wins, and + // defineControls() also runs on detached instances (the /api/modules probe builds one to + // read its controls, then destroys it): such a probe would take the seam and empty it on + // its way out, and every `.mlp` vanished from the running picker. prepare() runs only on a + // module the scheduler actually mounted, which is exactly the owner the seam wants. + refreshLivePalettes(); + LivePalettes::set(livePtrs_, liveTags_, liveCount_); // Re-resolve the active Layer from the bound container so a Layer that // was cleared and rebuilt via the API is picked up here (self-healing). // setLayer() pins a Layer directly and leaves effects_ null — skip then. diff --git a/src/light/drivers/MultiPinLedDriver.h b/src/light/drivers/MultiPinLedDriver.h index ac0ace10..4920a51e 100644 --- a/src/light/drivers/MultiPinLedDriver.h +++ b/src/light/drivers/MultiPinLedDriver.h @@ -85,8 +85,22 @@ class I80Peripheral : public LedPeripheral { /// LCD_CAM driver parks both pins on a dummy GPIO for the same reason. To spend no GPIO at all, use /// MoonI80Peripheral: owning the DMA below esp_lcd, it holds DC at a constant level and routes WR only /// when a '595 needs it as SRCLK. - int8_t clockPin = 10; - int8_t dcPin = 11; + /// Per-chip, because 10/11 are free GPIOs on the S3 these were chosen on and are the FLASH bus + /// on a classic ESP32 (6-11): routing the i80 clock onto one wedges the board to a watchdog + /// reset with no panic and no coredump. `i2sLanes > 0` IS "this is the classic-ESP32 i80" (the + /// two backends are mutually exclusive per silicon), the same discriminator dmaBudgetBytes() + /// below keys on, rather than a raw CONFIG_IDF_TARGET that would put chip knowledge outside the + /// platform layer. + /// + /// 18/23 rather than the first free numbers: WR and DC are peripheral-fixed signals no WS2812 + /// strand reads, so this default only has to avoid pins a BOARD is likely to have committed. + /// 21/22 look free chip-wise and are the QuinLED Dig-Next-2's relay lines, where claiming them + /// silently switched two power channels off (LEDs dark, no error anywhere). 18/23 are plain + /// GPIOs on every classic package: no strap, no flash, no UART, and unused by the catalog's + /// boards. A board that does wire them overrides the control, and reinit() refuses a reserved + /// pin outright. + int8_t clockPin = platform::i2sLanes > 0 ? 18 : 10; + int8_t dcPin = platform::i2sLanes > 0 ? 23 : 11; // --- LedPeripheral descriptors --- @@ -166,6 +180,14 @@ class I80Peripheral : public LedPeripheral { if (dcPin < 0) return "dcPin is unset — the i80 bus needs a valid DC GPIO"; if (clockPin == dcPin) return "clockPin (WR) and dcPin are the same GPIO — they must differ"; + // Neither may sit on a pin the chip wired to flash or PSRAM: routing I/O there corrupts the + // device. The driver's own sweep covers the bus LANES, but WR only rides that list when + // there are spare lanes to park it on (a full-width 8- or 16-pin setup has none) and DC + // never does, so these two are checked here, where the pair already lives. + if (platform::gpioCapability(static_cast(clockPin)).reserved) + return "clockPin (WR) is wired to flash/PSRAM on this chip - pick another pin"; + if (platform::gpioCapability(static_cast(dcPin)).reserved) + return "dcPin is wired to flash/PSRAM on this chip - pick another pin"; // The '595 latch is a BUS LANE, so it needs its own GPIO: sharing it with WR would make the // pixel clock double as the latch (the '595 would present a byte on every shift cycle), and // sharing it with DC would latch on the command phase. Both are fatal — the bus builds, but diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h index 033a6e7b..8bb3a5ef 100644 --- a/src/light/drivers/ParallelLedDriver.h +++ b/src/light/drivers/ParallelLedDriver.h @@ -804,10 +804,10 @@ class ParallelLedDriver : public DriverBase { while (fits > 0 && frameBytesFor(static_cast(fits), outCh, slotBytes(), opp) > cap) fits--; } - std::snprintf(overCapBuf_, sizeof(overCapBuf_), + std::snprintf(statusBuf_, sizeof(statusBuf_), "too many lights per pin: %u exceeds this peripheral's %u — lower ledsPerPin", static_cast(maxLaneLights_), fits); - setStatus(overCapBuf_, Severity::Error); + setStatus(statusBuf_, Severity::Error); } bool busGaveUp() { @@ -1322,7 +1322,12 @@ class ParallelLedDriver : public DriverBase { // setStatus stores the POINTER, so the text has to outlive the call; and the message is written // once per over-capacity episode, not once per tick, since it would otherwise rewrite the status // at frame rate for as long as the count stays too high. - char overCapBuf_[96] = {}; + /// ONE buffer for every formatted status this driver reports (over-capacity, reserved bus pin). + /// A member rather than a local because setStatus keeps the POINTER and does not copy + /// (MoonModule.h), so stack storage would dangle the moment the formatting call returns. Shared + /// rather than one per message: they are mutually exclusive cold-path refusals, each formatted + /// immediately before its own setStatus, and 96 bytes per variant adds up on a classic ESP32. + char statusBuf_[96] = {}; bool overCapReported_ = false; static constexpr uint8_t kDeadFramesBeforeGiveUp = 8; // Given-up retry cadence: once given up, let one frame try every this-many ticks so a TRANSIENT stall @@ -1882,6 +1887,32 @@ class ParallelLedDriver : public DriverBase { // that chip the failing esp_lcd path can BUSY-WAIT to a watchdog reset rather than return an // error. So refuse cleanly with a clear, actionable status instead of choking the init. Budget 0 // (PSRAM-capable chips, or the streaming ring) means "no bound" → always passes. Cold path. + // REFUSE a bus pin the chip has wired to flash or PSRAM, for the same reason the DMA budget + // is pre-checked below: routing I/O onto one corrupts the device rather than failing, so + // what a user sees is a reset with no panic and no coredump, naming nothing. The platform + // already knows which pins these are (gpioCapability().reserved); this is the one path that + // was not asking. Data lanes are checked too, since the same corruption follows whichever + // bus pin lands there. + // + // This is a guard, NOT the fix for the classic-ESP32 hang (backlog-light.md): that one is + // inside esp_lcd_new_i80_bus itself and survives every legal pin choice. COLD PATH. + { + const uint16_t* bus = busPinList(); + const uint8_t width = busPinCount(); + // The bus LANES here; the peripheral's own control pins (i80's WR/DC) are checked by + // validateBusFatal, which already owns that pair and runs on the same cold path. + for (uint8_t i = 0; i < width && i < kMaxLanes; i++) { + const uint16_t pin = bus[i]; + if (pin > 48) continue; // unset/NC: nothing routed + if (!platform::gpioCapability(static_cast(pin)).reserved) continue; + std::snprintf(statusBuf_, sizeof(statusBuf_), + "GPIO %u is wired to flash/PSRAM on this chip - pick another pin", + unsigned(pin)); + setStatus(statusBuf_, Severity::Error); + deinit(); + return; + } + } if (const size_t budget = peripheral_->dmaBudgetBytes(); !frameFitsDmaBudget(frameBytes_, budget)) { // deinit() above already cleared the bus and inited_ — just report and bail. diff --git a/src/light/layers/Effects.h b/src/light/layers/Effects.h index 68ba922e..51c32979 100644 --- a/src/light/layers/Effects.h +++ b/src/light/layers/Effects.h @@ -1,6 +1,7 @@ #pragma once #include "core/MoonModule.h" +#include "light/moonlive/MoonLivePalette.h" // the per-frame scripted palette, run before the layers #include "light/layers/Layer.h" #include "light/layouts/Layouts.h" #include "platform/platform.h" @@ -49,6 +50,11 @@ class Effects : public MoonModule { /// depth (an Effect that should be ticked inside a Layer). Matches /// the role-filter precedent in setLayouts / activeLayer above. void tick() MM_NONBLOCKING override { + // The scripted palette runs FIRST, so every layer in this frame samples the same sixteen + // entries. It is owned by Drivers (which owns the palette control) and reached through a + // static seam, because Drivers ticks after the layers and a palette applied there would be + // one frame late. A no-op when no `.mlp` is named, which is the common case. + MoonLivePalette::tickActive(platform::millis()); for (uint8_t i = 0; i < childCount(); i++) { MoonModule* c = child(i); if (!c || c->role() != ModuleRole::Layer) continue; diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index 4a50576d..e864c272 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -328,6 +328,11 @@ struct FadeSink { FadeFn fn = nullptr; void* ctx = nullptr; }; using CoordFn = void (*)(void* ctx, uint32_t x, uint32_t y, uint32_t z); struct CoordSink { CoordFn fn = nullptr; void* ctx = nullptr; }; +/// Where setPalEntry writes. A PALETTE script fills sixteen entries once per frame, so the sink +/// carries an index rather than a light: it is a table, not a canvas. +using PalFn = void (*)(void* ctx, uint8_t index, uint8_t r, uint8_t g, uint8_t b); +struct PalSink { PalFn fn = nullptr; void* ctx = nullptr; }; + enum class MotionAxis : uint8_t { Pan = 0, Tilt = 1 }; using MotionFn = void (*)(void* ctx, MotionAxis axis, uint32_t index, uint8_t value); struct MotionSink { MotionFn fn = nullptr; void* ctx = nullptr; }; @@ -352,7 +357,7 @@ namespace detail { // second table would repeat the claim/release machinery for the same lifetime. struct SinkSlot { std::atomic owner{0}; AddLightSink sink; draw::Canvas canvas; AddControlSink controls; FadeSink fade; MotionSink motion; CoordSink coord; - PoolSizeSink poolSize; PoolSink pool; }; + PalSink pal; PoolSizeSink poolSize; PoolSink pool; }; /// Two slots: the render task and whichever task edits a control are the two that ever run a script /// at once. A third concurrent runner gets the overflow slot, which holds no sink — so its addLight /// calls no-op instead of writing through someone else's context. @@ -451,6 +456,22 @@ inline void setCoordSink(CoordFn fn, void* ctx) MM_NONBLOCKING { if (!fn) detail::releaseIfEmpty(s); } +/// This thread's palette sink, or an empty one. Reading does not claim a slot. +inline const PalSink& palSink() MM_NONBLOCKING { + detail::SinkSlot* s = detail::ownedSlot(false); + static constinit PalSink none{}; + return s ? s->pal : none; +} + +/// Point setPalEntry at the palette binding for one run; nullptr to detach. Installed around a +/// single tick, so a script can only ever write the palette it was invoked to fill. +inline void setPalSink(PalFn fn, void* ctx) MM_NONBLOCKING { + detail::SinkSlot* s = detail::ownedSlot(fn != nullptr); + if (!s) return; + s->pal = {fn, ctx}; + if (!fn) detail::releaseIfEmpty(s); +} + /// This thread's motion sink, or an empty one. Reading does not claim a slot, as fadeSink does not. inline const MotionSink& motionSink() MM_NONBLOCKING { detail::SinkSlot* s = detail::ownedSlot(false); @@ -617,6 +638,39 @@ inline void setDrawCanvas(const draw::Canvas& cv) MM_NONBLOCKING { /// script was writing `mod(bx + dx, width) + mod(by + dy, height) * width` at every call site. /// Out-of-range coordinates are dropped, not wrapped — a "negative" coordinate arrives as a huge /// unsigned value, and wrapping it would paint the wrong edge rather than nothing. +/// setPalEntry(i, r, g, b) - write one of the sixteen active palette entries. +/// +/// The PALETTE binding's only output, and the reason a palette can be code rather than data: an +/// entry recomputed every frame can follow audio, drift, or come out of an algorithm, none of which +/// a gradient stop list can express. +/// +/// The index is BOUNDED rather than wrapped: a script computing an index from a control could +/// otherwise write a neighbouring entry and produce a palette nobody wrote, which reads as an engine +/// fault. Out of range does nothing, which is visible in the picture and blames the script. +extern "C" inline uint32_t mm_light_setPalEntry(const uintptr_t* args, uint32_t, const uint8_t*) { + const PalSink& s = palSink(); + if (!s.fn) return 0; // no palette installed: not a palette script + const uint32_t i = uint32_t(args[0]); + if (i >= Palette::kEntries) return 0; + s.fn(s.ctx, static_cast(i), byteArg(args[1]), byteArg(args[2]), byteArg(args[3])); + return 0; +} + +/// setPalEntryHSV(i, h, s, v) - the same, in the color space a palette is usually reasoned in. +/// +/// A hue sweep is one addition per entry in HSV and a table of magic numbers in RGB, which is why +/// both exist rather than leaving a script to convert. Same name MoonLight uses, so a palette +/// written there reads here. +extern "C" inline uint32_t mm_light_setPalEntryHSV(const uintptr_t* args, uint32_t, const uint8_t*) { + const PalSink& sink = palSink(); + if (!sink.fn) return 0; + const uint32_t i = uint32_t(args[0]); + if (i >= Palette::kEntries) return 0; + const RGB c = hsvToRgb(byteArg(args[1]), byteArg(args[2]), byteArg(args[3])); + sink.fn(sink.ctx, static_cast(i), c.r, c.g, c.b); + return 0; +} + extern "C" inline uint32_t mm_light_setPaletteColor(const uintptr_t* args, uint32_t, const uint8_t*) { const draw::Canvas& cv = drawCanvas(); if (!cv.data) return 0; // no canvas installed (a layout, a modifier) @@ -1116,6 +1170,11 @@ inline const BuiltinTable& lightBuiltins() { // setPaletteColor(x, y, i, bri) → one palette-coloured pixel. The form a script should reach // for: one call, one brightness evaluation, and no buffer-layout arithmetic at the call site. t.add({"setPaletteColor", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_setPaletteColor, {}}); + // setPalEntry(i,r,g,b) / setPalEntryHSV(i,h,s,v) -> write one of the sixteen ACTIVE palette + // entries. A palette script's only output; a no-op in every other role, where no sink is + // installed, so an effect calling it changes nothing rather than corrupting the palette. + t.add({"setPalEntry", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_setPalEntry, {}}); + t.add({"setPalEntryHSV", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_setPalEntryHSV, {}}); // paletteR/G/B(i, bri) → one channel each, for a script that needs the components. Kept // because setPaletteColor writes a pixel and cannot serve a script that wants the value. t.add({"paletteR", 2, /*returns*/ true, BuiltinKind::Call, &mm_light_paletteR, {}}); diff --git a/src/light/moonlive/MoonLivePalette.h b/src/light/moonlive/MoonLivePalette.h new file mode 100644 index 00000000..99b43ecb --- /dev/null +++ b/src/light/moonlive/MoonLivePalette.h @@ -0,0 +1,114 @@ +#pragma once + +#include "core/moonlive/MoonLive.h" +#include "light/moonlive/MoonLiveScript.h" +#include "light/moonlive/MoonLiveBuiltins_light.h" +#include "light/Palette.h" + +// MoonLivePalette: a PALETTE authored as a script rather than stored as data. +// +// The fifth binding, and the one that is not a shape the other four have. An effect writes a color +// per light, a layout a position, a modifier a coordinate, a service a control. A palette writes +// SIXTEEN ENTRIES, once per frame, and every effect in that frame then samples them. +// +// **Why a script and not a gradient.** A stop list is frozen the moment it is saved: it cannot +// follow the music, drift, or be computed. A palette that is code can do all three, which is the +// whole reason this exists rather than a JSON palette file. The audio builtins are already in the +// shared table, so `audioBand()` in a palette costs nothing to expose and is the case that +// justifies the design. +// +// **Cost is independent of rig size**, which is what makes running it in the render path +// reasonable. An effect body runs once per light (256 times on a 16x16 grid); a palette runs a +// sixteen-iteration loop whatever the wall is. So the per-frame cost of a scripted palette is +// closer to a single light's effect work than to a frame of it. +// +// **It fills a SCRATCH palette, not the live one.** Effects sample `Palettes::active()` on the same +// thread, between palette ticks, but a script writing the global directly would leave a half-written +// table visible for the length of its own loop. Filling a local and assigning the 48 bytes once +// means an effect sees either the previous palette or the new one, never a mixture, and it removes +// the whole class of problem rather than narrowing the window. +// +// **A broken script keeps the last good palette.** The other bindings degrade to dark, which is the +// honest answer when the script IS the picture. Here it is not: the effects still run, so dropping +// the palette to black would blame the effect for the palette's fault. The last good table stays, +// and the error goes to the status line. + +namespace mm { + +/// A palette whose sixteen entries are computed by a MoonLive script, once per frame. +class MoonLivePalette { +public: + /// Point the palette at a script. The next prepare() compiles it. + void setScript(const char* name) { script_.setName(name); } + const char* scriptName() const { return script_.name(); } + + /// Compile if the named file changed. Reports status and dynamic bytes through `owner`, the + /// same way every other binding does, so a scripted palette's errors appear on a card. + void prepare(MoonModule& owner) { + script_.sync(moonlive::effectSysVars(), owner, moonlive::lightBuiltins()); + } + + /// Publish the script's controls, so a scripted palette is configurable without editing it. + void publishControls(ControlList& controls) { script_.publishDeclaredControls(controls); } + + bool ok() const { return script_.ok(); } + + void release() { + script_.engine().free(); + script_.invalidate(); + } + + /// Run the script for this frame and install the result. Returns false when there is nothing to + /// run, so the caller can leave the built-in palette alone. + /// + /// `t` is the elapsed milliseconds every other binding is handed, so `beat` and `beatsin` mean + /// the same thing in a palette as in an effect. + bool tick(uint32_t nowMs) MM_NONBLOCKING { + if (!script_.ok()) return false; + if (!script_.engine().hasEntry(moonlive::kEntryTick)) return false; + + // Seeded from the palette currently active, so a script that writes only some entries + // leaves the rest as they were rather than showing whatever was on the stack. + Palette scratch = *Palettes::active(); + moonlive::setPalSink([](void* ctx, uint8_t i, uint8_t r, uint8_t g, uint8_t b) { + static_cast(ctx)->entry[i] = RGB{r, g, b}; + }, &scratch); + // runValue, not run(): run() refuses a call with no light buffer, which is the right + // precondition for an effect and wrong here, because a palette paints nothing. Same reason + // MoonLiveService uses it. + script_.engine().runValue(moonlive::kEntryTick, moonlive::RetType::Void, 0, + nullptr, 0, 0, nowMs); + moonlive::setPalSink(nullptr, nullptr); + + // One assignment of 48 bytes: an effect samples a whole palette or the previous one. + Palettes::setActiveDirect(scratch); + return true; + } + + /// The palette script the frame should run, or nullptr. + /// + /// A static seam, the same shape `Palettes::active()` and `AudioService::latestFrame()` use, and + /// for the same reason: the script is OWNED by Drivers (which owns the palette control), but it + /// has to run BEFORE the effects sample the palette, and Drivers ticks after them. A seam is one + /// pointer rather than a wire from the effects container to a driver container it otherwise + /// knows nothing about. + static MoonLivePalette* active() { return active_; } + static void setActiveInstance(MoonLivePalette* p) { active_ = p; } + /// Detach, for an owner whose storage is about to go away. Takes the caller's own instance so a + /// departing owner cannot unpublish somebody else's, exactly as LivePalettes::clear does: this + /// is a static pointer INTO a Drivers member, and `Effects::tick` dereferences it every frame, + /// so a Drivers released or destroyed while still published leaves the render path running a + /// script in freed memory. + static void clearActiveInstance(const MoonLivePalette* p) { if (active_ == p) active_ = nullptr; } + + /// Run the active palette script, if there is one. Called once per frame before the layers. + static void tickActive(uint32_t nowMs) MM_NONBLOCKING { + if (active_) active_->tick(nowMs); + } + +private: + moonlive::MoonLiveScript script_; + static inline MoonLivePalette* active_ = nullptr; +}; + +} // namespace mm diff --git a/src/light/moonlive/MoonLiveScriptFile.h b/src/light/moonlive/MoonLiveScriptFile.h index e9320512..caab691f 100644 --- a/src/light/moonlive/MoonLiveScriptFile.h +++ b/src/light/moonlive/MoonLiveScriptFile.h @@ -27,8 +27,8 @@ inline constexpr const char* kScriptDir = "/moonlive"; /// looks. A library you learn from has to be readable. inline constexpr const char* kFactoryScriptDir = "/.moonlive"; -/// A script's ROLE, in its file name. One language, three extensions: an effect is `.mle`, a -/// layout `.mll`, a modifier `.mlm`, a service `.mls`. +/// A script's ROLE, in its file name. One language, five extensions: an effect is `.mle`, a +/// layout `.mll`, a modifier `.mlm`, a service `.mls`, a palette `.mlp`. /// /// Stated by the author rather than derived from the script's contents. Deriving it is tempting /// (the entry point a class defines already tells the engine which moment to call), but that @@ -43,6 +43,7 @@ inline constexpr const char* kEffectExt = ".mle"; inline constexpr const char* kLayoutExt = ".mll"; inline constexpr const char* kModifierExt = ".mlm"; inline constexpr const char* kServiceExt = ".mls"; +inline constexpr const char* kPaletteExt = ".mlp"; /// What a NEW script starts out as, per role. A created file is a WORKING example rather than an /// empty one: an empty file fails to parse the moment it is made, so the first thing a new script @@ -98,14 +99,13 @@ inline constexpr const char* kServiceTemplate = // last starts at 1, the level an idle pull-up reads: starting at 0 made the first tick see a // change that had not happened and write the control before anyone touched the button. " int last = 1;\n" - " int now = 0;\n" "\n" " void defineControls() {\n" " addControl(\"pin\", pin, 0, 48);\n" " }\n" "\n" " void tick20ms() {\n" - " now = gpioRead(pin);\n" + " int now = gpioRead(pin);\n" " if (now != last) {\n" " last = now;\n" // INVERTED: the wiring is active-low (a switch to ground with a pull-up), so a pressed button @@ -118,10 +118,26 @@ inline constexpr const char* kServiceTemplate = /// What a `script` control tells the UI: where the files are, which of them to offer, and what a /// new one starts as. Borrowed by the control descriptor (addFilePath), so these live here next to /// the directory they name rather than being repeated in each binding. +inline constexpr const char* kPaletteTemplate = + "class NewPalette {\n" + " byte bpm = 20;\n" + "\n" + " void defineControls() {\n" + " addControl(\"bpm\", bpm, 1, 120); // how fast the colors move\n" + " }\n" + "\n" + " void tick() {\n" + " for (int i = 0; i < 16; i = i + 1) {\n" + " setPalEntryHSV(i, scale(beat(bpm, t), 256) + i * 16, 255, 255);\n" + " }\n" + " }\n" + "}\n"; + inline constexpr const char* kEffectPick[3] = {kScriptDir, kEffectExt, kEffectTemplate}; inline constexpr const char* kLayoutPick[3] = {kScriptDir, kLayoutExt, kLayoutTemplate}; inline constexpr const char* kModifierPick[3] = {kScriptDir, kModifierExt, kModifierTemplate}; inline constexpr const char* kServicePick[3] = {kScriptDir, kServiceExt, kServiceTemplate}; +inline constexpr const char* kPalettePick[3] = {kScriptDir, kPaletteExt, kPaletteTemplate}; /// Is `ext` one of the script extensions? One definition, beside the extensions themselves. /// @@ -132,7 +148,8 @@ inline constexpr const char* kServicePick[3] = {kScriptDir, kServiceExt, kServ inline bool isScriptExt(const char* ext) { if (!ext) return false; return std::strcmp(ext, kEffectExt) == 0 || std::strcmp(ext, kLayoutExt) == 0 || - std::strcmp(ext, kModifierExt) == 0 || std::strcmp(ext, kServiceExt) == 0; + std::strcmp(ext, kModifierExt) == 0 || std::strcmp(ext, kServiceExt) == 0 || + std::strcmp(ext, kPaletteExt) == 0; } /// The largest script the loader will read into RAM at once. Not a language limit — the buffer is @@ -257,7 +274,7 @@ inline bool compileScriptFile(MoonLive& engine, const char* name, const size_t len = std::strlen(name); const char* tail = len >= 4 ? name + len - 4 : ""; if (len < 5 || len > kMaxScriptName || !isScriptExt(tail)) { - err = "script name must end in .mle, .mll, .mlm or .mls"; return false; + err = "script name must end in .mle, .mll, .mlm, .mls or .mlp"; return false; } // The user's copy wins over the factory one of the same name: that is what makes editing a diff --git a/src/light/moonlive/catalog_scripts.cmake b/src/light/moonlive/catalog_scripts.cmake index f67a72fb..a7e5a8c2 100644 --- a/src/light/moonlive/catalog_scripts.cmake +++ b/src/light/moonlive/catalog_scripts.cmake @@ -28,7 +28,8 @@ file(GLOB SCRIPT_PATHS "${SCRIPT_DIR}/effects/*.mle" "${SCRIPT_DIR}/layouts/*.mll" "${SCRIPT_DIR}/modifiers/*.mlm" - "${SCRIPT_DIR}/services/*.mls") + "${SCRIPT_DIR}/services/*.mls" + "${SCRIPT_DIR}/palettes/*.mlp") list(SORT SCRIPT_PATHS) # deterministic output: the same input must give a byte-identical header list(LENGTH SCRIPT_PATHS SCRIPT_COUNT) diff --git a/src/light/moonlive/catalog_scripts.py b/src/light/moonlive/catalog_scripts.py index ee48e597..77a77767 100644 --- a/src/light/moonlive/catalog_scripts.py +++ b/src/light/moonlive/catalog_scripts.py @@ -16,11 +16,13 @@ # The role a script plays, from its extension. This mirrors MoonLiveScriptFile.h's kEffectExt / # kLayoutExt / kModifierExt, and it is what a picker filters on: the DEVICE keeps one flat # directory, so the extension is the only role signal once a file lands there. -ROLE_BY_EXT = {".mle": "Effect", ".mll": "Layout", ".mlm": "Modifier", ".mls": "Service"} +ROLE_BY_EXT = {".mle": "Effect", ".mll": "Layout", ".mlm": "Modifier", ".mls": "Service", + ".mlp": "Palette"} # Where each role lives in the repo. The device keeps one flat directory, so this is only ever part # of the download URL. -FOLDER_BY_ROLE = {"Effect": "effects", "Layout": "layouts", "Modifier": "modifiers", "Service": "services"} +FOLDER_BY_ROLE = {"Effect": "effects", "Layout": "layouts", "Modifier": "modifiers", "Service": "services", + "Palette": "palettes"} # What a script DECLARES about itself, read from its source. The script is the one home for this diff --git a/src/light/moonlive/script_catalog.h b/src/light/moonlive/script_catalog.h index 4f6b1841..57a51ab6 100644 --- a/src/light/moonlive/script_catalog.h +++ b/src/light/moonlive/script_catalog.h @@ -178,6 +178,35 @@ constexpr const char* kServiceCatalogTags[] = { }; constexpr const char* kServiceFolder = "services"; ///< its directory upstream -constexpr size_t kCatalogCount = 37; ///< every factory script, all roles +/// Every factory palette, by file name. They live in `moonlive/palettes/` +/// upstream and in the factory script directory on the device. +constexpr const char* kPaletteCatalog[] = { + "beat-flash.mlp", + "drift.mlp", + "fire.mlp", + "spectrum.mlp", + "temperature.mlp", +}; +constexpr size_t kPaletteCatalogCount = 5; +/// What each palette above declares about itself, in the same order. +/// A dimension of 0 means the script says nothing, so the DEVICE decides the default. +constexpr unsigned char kPaletteCatalogDim[] = { + 0, + 0, + 0, + 0, + 0, +}; +/// The emoji each declares, "" when it declares none. +constexpr const char* kPaletteCatalogTags[] = { + "🎨🎶", + "🎨", + "🎨", + "🎨🎶", + "🎨", +}; +constexpr const char* kPaletteFolder = "palettes"; ///< its directory upstream + +constexpr size_t kCatalogCount = 42; ///< every factory script, all roles } // namespace mm::moonlive diff --git a/src/ui/app.js b/src/ui/app.js index 5e859df0..431eb65e 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -2520,6 +2520,11 @@ function createControl(moduleName, moduleType, ctrl) { picker.className = "control-select fileedit-pick"; picker.dataset.mid = moduleName; picker.dataset.key = ctrl.name; + // A READ-ONLY filepath names the file something else chose, so it must not offer a + // second way to choose: the Drivers palette editor is the case, where `palette` owns the + // selection and this pane only edits what that selection resolved to. Two selectors for + // one value is how they end up disagreeing. + if (ctrl.readonly) { picker.disabled = true; picker.classList.add("is-readonly"); } // The options, as data. `fillPicker` appends option elements exactly as it did to the //