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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Drive large LED installations and DMX lighting from ESP32, Teensy, Raspberry Pi, Windows, macOS or Linux desktop. One source tree, multiple targets.

![Web UI](docs/assets/screenshots/ui_theme.gif)
![Web UI](docs/assets/ui/ui_theme.gif)

👉 **Try it now:** flash an ESP32 straight from your browser → <https://moonmodules.org/projectMM/install/> — step-by-step in the [Getting started guide](docs/gettingstarted.md).

Expand Down Expand Up @@ -90,7 +90,7 @@ The numbers above are observations. The **contracts** projectMM commits to, what

**ESP32: flash from your browser.** Open the [web installer](https://moonmodules.org/projectMM/install/) in Chrome or Edge; it walks you through release, device and firmware selection, flashing, and network setup. The installer lists stable releases and a `latest` build (published automatically on every merge to main) carrying the newest unreleased changes.

![Installer](docs/assets/screenshots/installer.png)
![Installer](docs/assets/ui/installer.png)

**Desktop: download and run.** Grab the build for your OS from the [releases page](https://github.com/MoonModules/projectMM/releases):

Expand All @@ -113,7 +113,7 @@ uv run scripts/moondeck.py

Open `http://localhost:8420`: PC tab to build / run / test, ESP32 tab to flash, Live tab to discover devices. Full per-command reference: [scripts/MoonDeck.md](scripts/MoonDeck.md).

![Moondeck Pc](docs/assets/screenshots/moondeck_pc.png)
![Moondeck Pc](docs/assets/ui/moondeck_pc.png)

## Documentation

Expand Down Expand Up @@ -165,6 +165,7 @@ Specific people whose work directly shaped parts of projectMM. We study their th
- **[hpwit](https://github.com/hpwit) (Yves Bazin)** — the clockless I2S / RMT / Parlio LED-driver techniques and the [ESPLiveScript](https://github.com/hpwit/ESPLiveScript) live-script engine behind the LED drivers and MoonLive.
- **Christophe Gagnier ([@Moustachauve](https://github.com/Moustachauve))** — author of the native [WLED-Android](https://github.com/Moustachauve/WLED-Android) app. Its source let us reverse-engineer exactly what the WLED app reads, so projectMM devices appear in (and are controllable from) the native WLED apps.
- **The [Improv Wi-Fi](https://github.com/improv-wifi) project** — the open Improv serial provisioning standard ([sdk-cpp](https://github.com/improv-wifi/sdk-cpp) / [sdk-js](https://github.com/improv-wifi/sdk-js)) that the projectMM web installer uses to provision a freshly-flashed device over USB.
- **[FastLED](https://github.com/FastLED/FastLED)** — the canonical LED-effects library whose conventions the LED-effect world shares. projectMM links no part of FastLED, but it carries forward FastLED's recognisable *names and models* for the colour/animation primitives — `scale8`, `sin8`, the gradient-palette model (`CRGBPalette16` / `colorFromPalette`), the `beatsin8` / `inoise8` / `qadd8` family — so a contributor recognises them on sight. The implementations are projectMM's own, integer-only and hot-path-tuned for our render loop; FastLED is the prior art behind the convention, credited here and in each primitive's notes.

## Contributing

Expand Down
41 changes: 35 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ The defining line is the **data relationship, not the connector**: *does the mod

Peripherals are **user-add/deletable children of SystemModule**: the firmware is identical whether or not the hardware is wired, so the user adds the module when they solder a gyro on and removes it later, reusing the generic child add/replace/delete + persistence machinery (SystemModule declares `acceptsChildRoles("peripheral")`). Direction is per-module, not a role: a peripheral may read (gyro), write (relay), or both, so one `Peripheral` role spans the category. Each is a header-only or `.h`+`.cpp` core module under `src/core/`, reaches hardware only through a domain-neutral platform primitive (`platform::i2c*`, `platform::audioMic*`, …), and gets a spec in `docs/moonmodules/core/` (enforced by `check_specs.py`). Most poll in `loop20ms`/`loop1s`; the exception is a peripheral whose data an effect consumes *every frame*: [AudioModule](moonmodules/core/AudioModule.md) reads + analyses its I²S microphone in `loop()` because the audio effects react per render tick, and its per-tick cost (one FFT) is part of the render budget. Automatic bus-probe detection is out of scope; the manual path is the foundation.

**An effect reads a peripheral's data** via the shared-struct pull pattern from [§ Data exchange](#data-exchange-between-modules), no new mechanism: the peripheral owns a small POD struct overwritten in place each poll/tick, and the consuming effect holds a `const` pointer to it. The first concrete case is audio: AudioModule produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects/AudioVolumeEffect.md) and [AudioSpectrumEffect](moonmodules/light/effects/AudioSpectrumEffect.md) consume. It reaches the frame through a static `AudioModule::latestFrame()` rather than a boot-time setter, a small variation on the pattern, because an audio effect can be added through the UI *after* boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in `setup()` and clears the pointer in `teardown()`, so add/remove in any order returns either the live frame or a static silent one, never null. A peripheral that only *displays* its readings (the gyro today) skips the consumer side entirely.
**An effect reads a peripheral's data** via the shared-struct pull pattern from [§ Data exchange](#data-exchange-between-modules), no new mechanism: the peripheral owns a small POD struct overwritten in place each poll/tick, and the consuming effect holds a `const` pointer to it. The first concrete case is audio: AudioModule produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects/effects.md) and [AudioSpectrumEffect](moonmodules/light/effects/effects.md) consume. It reaches the frame through a static `AudioModule::latestFrame()` rather than a boot-time setter, a small variation on the pattern, because an audio effect can be added through the UI *after* boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in `setup()` and clears the pointer in `teardown()`, so add/remove in any order returns either the live frame or a static silent one, never null. A peripheral that only *displays* its readings (the gyro today) skips the consumer side entirely.

## Multi-device runtime

Expand Down Expand Up @@ -369,17 +369,32 @@ Effects know nothing about hardware, protocols, physical LED layout, or mapping.

**Speed convention.** Effects with a speed control use BPM (beats per minute). `uint8_t`, default 60 (= 1 beat per second). Human-readable, musically meaningful, DMX-compatible. The effect converts BPM to animation rate internally using elapsed millis.

### Buffer persistence — the layer does not clear each frame

The Layer's buffer **persists** frame to frame: `Layer::loop()` does not clear it before running effects. The buffer holds the previous frame until an effect overwrites or fades it, and is zeroed **once** on allocation/resize. (This is the standard LED-animation model — FastLED, WLED, and MoonLight all persist their frame buffer rather than auto-clear.) Persistence holds **between frames, not across rebuilds**: `Layer::onBuildState()` clears the buffer once after `rebuildLUT()`, so adding, replacing, or reconfiguring an effect (or a resize) starts from black, then persistence takes over frame to frame again. Each effect owns its own background:

- A **full-grid** effect (Plasma, Rainbow, Fire, Noise) writes every pixel each frame — the previous frame is simply overwritten.
- A **trail** effect calls `layer()->fadeToBlackBy(amt)` to decay the previous frame before painting its new pixels — a comet leaves a fading tail because the old pixels are still there to fade.
- A **read-prior** effect (a scroll like FreqMatrix, Game-of-Life, a blur) reads last frame's pixels via `draw::get` / `draw::blur` and acts on them — the persistence *is* its state.
- A **sparse** effect that wants a clean frame calls `draw::fill(buf, {0,0,0})` itself (e.g. RubiksCube, whose draw touches only surface voxels).

There is deliberately **no per-effect "persistence" flag**: persistence is universal, so a flag would change no framework behaviour (unlike `dimensions()`, which drives extrude). Multiple effects on one layer *interact* through the shared persistent buffer — that is a feature.

**Collected fade (`Layer::fadeToBlackBy`).** Fade is a Layer operation, not a per-effect buffer pass (MoonLight's `VirtualLayer::fadeToBlackBy`). Effects register a fade amount; the Layer keeps the **MIN** across all requesting effects (the gentlest fade wins, so the longest requested trail is honoured) and applies **one** whole-buffer pass at the start of the next frame, then resets the collected amount. So N fading effects on one layer cost a single pass, not N, and one effect's fade never darkens another's just-painted pixels mid-frame. An auto-clear would make trails and read-prior effects impossible without a shadow buffer; this model gets them for free.

### Dimensionality

Every effect declares its native dimensionality through `EffectBase::dimensions()`, returning `Dim::D1`, `Dim::D2`, or `Dim::D3` (default: "I iterate every axis the layer gives me"). The Layer uses this to **extrude** lower-dimensional output across the unused axes after each effect's `loop()`:

- **D1**: the effect writes only the row at `(y=0, z=0)`. Layer copies that row across every other y in z=0, then copies z=0 across every z.
- **D2**: the effect writes only the z=0 slice. Layer copies z=0 across every z.
- **D1**: the effect writes only the column at `(x=0, z=0)` — **1D runs along Y**. Layer copies that column across every other x in z=0, then copies z=0 across every z.
- **D2**: the effect writes only the z=0 slice (the front `(x, y)` face). Layer copies z=0 across every z.
- **D3**: the effect writes every axis itself. Extrude is a one-comparison no-op.

D1/D2 are **opt-in promises**: declaring them tells the framework it can fill the missing axes, saving the per-effect work of iterating z (or y and z). Effects that don't make that promise stay at the D3 default and iterate the whole buffer.
D1/D2 are **opt-in promises**: declaring them tells the framework it can fill the missing axes, saving the per-effect work of iterating z (or x and z). Effects that don't make that promise stay at the D3 default and iterate the whole buffer.

Hot-path cost: extrude pays one comparison and returns for the D3 case. For D1/D2 on a layer whose unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D layer) the inner loops are guarded by `depth_ > 1` / `height_ > 1` and never run. Real `memcpy` work happens only for a D1 or D2 effect on a layer with more dimensions than the effect writes: exactly the case where you wanted the framework to do the duplication.
**Why 1D runs along Y, and the unified expand rule.** A lower-D effect occupies the *low* axes and the framework expands it across the *next* axis: **1D → 2D adds columns across X** (the 1D output is the first column, duplicated rightward); **2D → 3D adds slices across Z** (the 2D front face, duplicated in depth). 1D-along-Y is the deliberate choice (shared with MoonLight): it makes a 1D effect the natural **first column** of its 2D form — write the effect once down Y, and expanding to a panel is just "repeat the column," same math, no special-casing. (The alternative, 1D-along-X, would make 1D a *row* that expands *downward* — a less natural fit, since a strip is a column and a 2D effect's columns are what you tile.) Concretely: a 1D effect draws its shape down Y, so it renders correctly on a layer whose single populated axis is Y (a `1 × N` grid, width 1, height N); on an `N × 1` grid the extrude would run the wrong way and collapse the shape to a flat line. How a physical output (a strip, a row of [Hue lights](moonmodules/light/drivers/HueDriver.md)) maps to a `1 × N` grid is a layout concern — see the layout docs.

Hot-path cost: extrude pays one comparison and returns for the D3 case. For D1/D2 on a layer whose unused axes are size 1 (a D2 effect on a 2D layer, a D1 effect on a 1D `1 × N` layer) the inner loops are guarded by `depth_ > 1` / `width_ > 1` and never run. Real `memcpy` work happens only for a D1 or D2 effect on a layer with more dimensions than the effect writes: exactly the case where you wanted the framework to do the duplication.

Each effect's `dimensions()` is a claim about which axes its loop iterates, not which axes its math could in principle vary along. A "D2 fire" can in future be promoted to D3 by adding z-aware heat propagation; until then declaring it D2 honestly describes what the loop does today.

Expand Down Expand Up @@ -520,7 +535,7 @@ How lighting uses the core [multi-device runtime](#multi-device-runtime) (discov

# Web UI

![UI overview](assets/screenshots/ui_overview.png)
![UI overview](assets/ui/ui_overview.png)

The UI is a handful of hand-maintained files: `index.html`, `app.js`, `style.css`, plus two focused ES modules `app.js` imports (`preview3d.js` for the WebGL 3D preview, `install-picker.js` shared with the web installer). No frameworks, no build tools, no npm. Served directly by the embedded HTTP server.

Expand All @@ -534,6 +549,20 @@ Adding a new MoonModule with controls needs **zero changes** to the UI files. Th

The light domain plugs into the UI at three points: a fixed top-level tree (Layouts / Layers / Drivers pinned in `main.cpp`, root reorder disabled while child reorder works via drag-and-drop), a binary WebSocket preview channel ([PreviewDriver](moonmodules/light/drivers/PreviewDriver.md): a `0x03` coordinate table sent once per LUT rebuild plus per-frame `0x02` RGB point lists, so sparse layouts preview at their real positions), and per-role emoji for the chip filter (the `ROLE_EMOJI` map in `app.js` is the single source of truth: `effect`, `driver`, …, `peripheral`). Full UI spec: [docs/moonmodules/core/ui.md](moonmodules/core/ui.md).

## Tag emoji legend

A module's chips come from three sources, rendered identically on the card and the type picker: a **role** chip (UI-derived from `role`), a **dimensional** chip (UI-derived from `dim`), and the curated **`tags()`** string (a flash literal the module returns; the UI splits it into grapheme clusters, one chip each). Role and dim are *not* repeated in `tags()` — only the categories below are. The `ROLE_EMOJI` / `DIM_EMOJI` maps in `app.js` are the single source of truth for the UI-derived chips; the legend takes [MoonLight](https://github.com/MoonModules/MoonLight)'s set as the canonical basis:

| Category | Emoji | Meaning |
|---|---|---|
| **Role** (UI-derived) | 🔥 effect · 💎 modifier · 🚥 layout · ☸️ driver · 🛰️ peripheral · 🥞 layer · ⚙️ generic | what kind of module (from `role`, via `ROLE_EMOJI`) |
| **Dimensionality** (UI-derived) | 📏 1D · 🟦 2D · 🧊 3D | native axes (from `dim`) |
| **Origin / library** (`tags()`) | 💫 MoonLight · 🐙 WLED · ⚡️ FastLED · *(projectMM-native is the default origin — an origin emoji marks a module that came from elsewhere)* | which library the module came from; the [migration](../backlog/moonlight-effect-inventory.md) files docs by this, the emoji filters by it |
| **Creator** (`tags()`) | 🦅 a named contributor (credited at the introduction site) | individual authorship credit |
| **Audio** (`tags()`) | 🔊 audio-reactive | reads `AudioModule::latestFrame()` |

`tags()` carries **only** origin + creator + audio (+ any genuinely module-specific marker); a module can carry several (e.g. `💫🦅` = MoonLight origin, a named creator). Role and dim are added by the UI, so a module never duplicates them in its string. When migrating, set each module's `tags()` from this legend so the chip set is consistent across the library.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## What we leave undesigned

Genuinely open questions, *not* the same as a 🚧 marker. A 🚧 item has a settled, committed design (two-core handover, clock sync, device-to-device light distribution) — code is written toward it; the items here are ones where the *design itself* is still open, deferred until a concrete need forces the decision:
Expand Down
Binary file added docs/assets/core/Hue device disco.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
File renamed without changes
Binary file added docs/assets/light/drivers/Hue driver.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/light/effects/StarSkyEffect.gif
Binary file added docs/assets/light/layouts/CarLightsLayout.gif
Binary file removed docs/assets/screenshots/CheckerboardEffect.gif
Diff not rendered.
Binary file removed docs/assets/screenshots/CheckerboardEffect.png
Diff not rendered.
Binary file removed docs/assets/screenshots/GameOfLifeEffect.gif
Diff not rendered.
Binary file removed docs/assets/screenshots/GameOfLifeEffect.png
Diff not rendered.
Binary file removed docs/assets/screenshots/GlowParticlesEffect.gif
Diff not rendered.
Binary file removed docs/assets/screenshots/GlowParticlesEffect.png
Diff not rendered.
Binary file removed docs/assets/screenshots/PlasmaPaletteEffect.gif
Diff not rendered.
Binary file removed docs/assets/screenshots/PlasmaPaletteEffect.png
Diff not rendered.
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
2 changes: 1 addition & 1 deletion docs/backlog/backlog-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co

DevicesModule discovers via **passive UDP presence** (UDP 65506) feeding a [`DevicePlugin`](../../src/core/DevicePlugin.h) seam (shipped: projectMM + WLED plugins). mDNS is advertise-only so projectMM appears in the native WLED apps + Home Assistant; the WLED-app interop (list + live colour + brightness control) is shipped too. What remains is *growth on the seam*, each piece additive (one plugin file, no core change):

- **More discovery plugins** — ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only — never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for.
- **More discovery plugins** — ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only — never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. (Note: Hue *control* already ships as an **output driver**, see [HueDriver](../moonmodules/light/drivers/HueDriver.md) — bulbs as effect pixels; the driver also *lists* its bridge in DevicesModule with the colour-light count. Two complementary follow-ups remain: (a) auto-fill the driver's bridge IP from discovery so the user doesn't type it (the mDNS-browse plugin above); (b) **pair once, not per driver** — pairing + the app key currently live on each HueDriver, so two drivers on one bridge pair twice. The clean end-state moves the bridge identity (IP + key + Pair button + light list) into DevicesModule and makes HueDriver a pure output that reads the paired bridge by IP — do this together with the discovery plugin, since both hinge on DevicesModule owning the bridge.)
- **The command half** — `DevicePlugin::command()` (+ per-plugin capability/auth), so projectMM can *control* a discovered foreign device, not just list it: set WLED brightness via its JSON API, a Hue resource via the bridge's authenticated CLIP API, a Tasmota via `cmnd`. Built when a control consumer exists; the discovery seam is already shaped to accept it (incl. hub plugins). This is the **multi-ecosystem selling point** — one UI controlling WLED + ESPHome + Hue. Commands split by need (the rule, not "all REST"): must-arrive config over REST; latency-critical sync over UDP (~0.5–1 ms vs REST's 10–50 ms — REST would visibly de-sync).
- **Live peer state** — a discovered peer's brightness / on-off shown in our list, refreshed by polling its REST `/json` after discovery gives the IP (discovery = UDP/mDNS, state = REST). The read-side complement to the command half.
- **Non-IP transports (board-gated, far future)** — Tasmota-MQTT / zigbee2mqtt need an MQTT client; **direct Zigbee/Thread** (S31/C6/H2 802.15.4 radio) makes projectMM the *hub itself*, driving bulbs over the mesh with no gateway — the standout differentiator, the biggest lift. Same plugin philosophy, a transport addition + board gate.
Expand Down
Loading
Loading