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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ If you like projectMM, give it a ⭐️, fork it, or open an issue or pull reque

🎨 **Plug in, open a browser, see lights**: a live 3D preview of every effect, modifier, and layout, controllable from the same tab. The interface renders any module from its declared controls, so adding a module needs zero UI code.

🌗 **MoonBase, the second boot image (4 MB boards)**: instead of spending half a small flash on a second firmware copy, a ~750 KB maintenance image sits in the factory slot and installs updates into one large app slot, one click in the UI covers the whole reboot-install-reboot cycle, and a power cut mid-update lands back in MoonBase, never in a half-written app. See [architecture.md § MoonBase](docs/architecture.md#moonbase-the-second-boot-image-4-mb-boards).
🌗 **MoonBase, the second boot image**: instead of spending half the flash on a second firmware copy, a ~750 KB maintenance image sits in the factory slot and installs updates into one large app slot, one click in the UI covers the whole reboot-install-reboot cycle, and a power cut mid-update lands back in MoonBase, never in a half-written app. Forced on a 4 MB board, which has room for one application and not two, and chosen on the larger ones, where the freed slot goes to the filesystem instead. See [architecture.md § MoonBase](docs/architecture.md#moonbase-the-second-boot-image).

⚡ **Flash from your browser in seconds**: the web installer picks your device, flashes the matching firmware, and hands WiFi credentials to the device over USB via Improv. No serial monitor, no recompile.

Expand Down Expand Up @@ -179,7 +179,7 @@ Specific people whose work directly shaped parts of projectMM. We study their th
- **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 color/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.
- **[FPP](https://github.com/FalconChristmas/fpp) (Falcon Player)**: the show player that drives LED panel receiver cards from a Raspberry Pi. Seeing an FPP rig feed a wall of HUB75 panels is what prompted [PanelCardDriver](docs/moonmodules/light/drivers.md#panelcard): if a Linux host can send those frames, so can a board that is already rendering them, which removes the host from the installation entirely. FPP is the inspiration, and the reference point for what good looks like here: it sustains 50 fps.
- **[Tasmota](https://github.com/arendst/Tasmota) and Mathieu Carbou's [MycilaSafeBoot](https://github.com/mathieucarbou/MycilaSafeBoot)**: the safeboot pattern behind [MoonBase](docs/architecture.md#moonbase-the-second-boot-image-4-mb-boards): replacing a small board's second OTA slot with a minimal boot image that installs into one large app slot. Tasmota proved the scheme at scale; MycilaSafeBoot distilled it to a standalone image and set the size bar. MoonBase is our from-scratch minimal take, written directly against ESP-IDF.
- **[Tasmota](https://github.com/arendst/Tasmota) and Mathieu Carbou's [MycilaSafeBoot](https://github.com/mathieucarbou/MycilaSafeBoot)**: the safeboot pattern behind [MoonBase](docs/architecture.md#moonbase-the-second-boot-image): replacing a small board's second OTA slot with a minimal boot image that installs into one large app slot. Tasmota proved the scheme at scale; MycilaSafeBoot distilled it to a standalone image and set the size bar. MoonBase is our from-scratch minimal take, written directly against ESP-IDF.
- **Damian Schneider ([dedehai](https://github.com/DedeHai))**: author of the WLED Particle System, whose emitters, forces and walls over one shared pool are the shape our [particle kernel](docs/moonmodules/light/power-functions.md#particles) and the scripted `pool` / `emit` / `step` builtins follow, in our own fixed-point implementation.
- **wladi ([myhome-control](https://shop.myhome-control.de))**: designer of the [MHC-WLED ESP32-P4 shield](https://shop.myhome-control.de/en/ABC-WLED-ESP32-P4-shield/HW10027), and the source of the hardware and the pinout details that got its **line-in audio** working in [AudioService](docs/moonmodules/core/moxygen/AudioService.md): the onboard PCM1808 I2S ADC (WS 26 / SD 33 / SCK 32 / MCLK 36), the PCM1808's stereo wiring, and its `FMT` format-select jumper (open = I2S/Philips, our default; tie to 3V3 for left-justified), which is what confirmed the standard-I2S path the ADC needs.

Expand Down
18 changes: 18 additions & 0 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ calling it sound, so it is renamed rather than left as the odd one out.
A restored config maps the old name to the new one and carries its value. On a device upgraded in
place the control returns to its default (off); switch it back on where you had it.

### AudioVolume is gone

**Action: pick another effect.** Affects any device with an AudioVolume effect on a layer.

It drew one bar from the audio level, which every audio-reactive effect does as a side effect of
what it actually draws. There is no successor to map it onto, so a restored config carrying an
`AudioVolumeEffect` node finds no such type and the layer comes up without it. `GEQ` is the nearest
thing if a literal meter is what you want.

### The Firmware card describes one image at a time

**Action: none.** Affects nothing a user has set: every control involved is read-only.

`firmwarePartition` is now `partition`, and `update_pct` is gone (an install's progress belongs in
the overlay the UI raises while it runs, not in a row that sits at zero for the life of a device
that is not mid-install). Where a device carries two images, a new `image` control selects whether
those rows describe the running app or MoonBase in the factory slot.

### Noise2D is gone; Noise renders it

**Action: re-set one control.** Affects any device with a Noise2D effect on a layer.
Expand Down
24 changes: 23 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,28 @@ network, a stronger power-fail story than dual-OTA's. A failed install deliberat
MoonBase, visibly, rather than silently reverting to the old app; the way back is its explicit
"Boot the app" action, which only boots an image that validates.

**Updating MoonBase itself** runs the same cycle backwards: the app writes the factory slot while
running from `ota_0`, exactly as MoonBase writes the app slot while running from factory. Neither
image can rewrite the partition it executes from, so each installs the other and the app is the
only thing that can repair a broken recovery image. Without it a bad MoonBase means a cable, which
is the failure MoonBase exists to prevent.

Two things make that safe enough to offer. `esp_ota_*` refuses a factory partition, so this is a
raw `esp_partition_erase_range` + `esp_partition_write`, which also forfeits the validation
`esp_ota_end` performs: `esp_image_verify` replaces it after the write. And because a 4 MB board
has nowhere to stage 743 KB before erasing, the image streams straight in, so everything that can
reject it is decided from its FIRST CHUNK, before a byte is erased: the image magic, the chip id
(one MoonBase per chip, one paste apart, and a checksum does not catch a swap), and the descriptor
naming `projectMM-moonbase` rather than the app. Those rules live in `src/core/FirmwareImage.h` so
a host test can drive them. What remains is a window, during the write, in which the device holds
no recovery image; the app keeps running throughout, so the answer to a failure is to retry.

Each image reports its version from the app descriptor IDF puts in every binary, `PROJECT_VER`
being set to the same computed version for both, so the app can read the factory partition's
version without booting it and say when the two were built apart. A device that cannot name its
own recovery image cannot be diagnosed: two boards that looked identical, one of which could not
install firmware, took a bisect of the git log to tell apart.

MoonBase is a standalone ESP-IDF project (`moonbase/`, ~750 KB against an 896 KB slot) sharing
no sources with the app, the deliberate trade for an image that must stay small and, once
working, hardly change. `moondeck/build/build_esp32.py` builds it alongside every variant that opts in
Expand Down Expand Up @@ -324,7 +346,7 @@ The defining line is the **data relationship, not the connector**: *does the mod

Services are **user-add/deletable children of the `Services` container** — the core-domain twin of the light pipeline's `Effects`/`Drivers`: a top-level container holding user-added children of one role. 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 (`Services` declares `acceptsChildRoles("service")`). Fixed device infrastructure (identity, network, the inspection tools Tasks/I2cScan) lives under **System** instead, wired by code, not user-added — that is the System/Services split. Direction is per-module, not a role: a service may read (gyro), write (relay), or both, so one `Service` 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/services.md` (enforced by `check_specs.py`). Most poll in `tick20ms`/`tick1s`; the exception is a service whose data an effect consumes *every frame*: [AudioService](moonmodules/core/moxygen/AudioService.md) reads + analyses its I²S microphone in `tick()` 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 service's data** via the shared-struct pull pattern from [§ Data exchange](#data-exchange-between-modules), no new mechanism: the service 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: AudioService produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioVolumeEffect](moonmodules/light/effects.md) and [AudioSpectrumEffect](moonmodules/light/effects.md) consume. It reaches the frame through a static `AudioService::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 `release()`, so add/remove in any order returns either the live frame or a static silent one, never null. A service that only *displays* its readings (the gyro today) skips the consumer side entirely.
**An effect reads a service's data** via the shared-struct pull pattern from [§ Data exchange](#data-exchange-between-modules), no new mechanism: the service 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: AudioService produces an `AudioFrame` (level + 16-band spectrum + peak) that [AudioSpectrumEffect](moonmodules/light/effects.md) and the other audio effects consume. It reaches the frame through a static `AudioService::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 `release()`, so add/remove in any order returns either the live frame or a static silent one, never null. A service that only *displays* its readings (the gyro today) skips the consumer side entirely.

## Multi-device runtime

Expand Down
Binary file removed docs/assets/light/effects/AudioVolumeEffect.gif
Binary file not shown.
Binary file removed docs/assets/light/effects/AudioVolumeEffect.png
Binary file not shown.
20 changes: 13 additions & 7 deletions docs/backlog/backlog-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ declared rather than for a buffer to fill, and to time out on stall rather than
RISC-V coprocessor-context save on INTERRUPT ENTRY. That is a symptom of something faulting inside
an ISR context rather than a bug in the kernel itself, and the P4 is the only RISC-V target with a
coprocessor, which is why no other board shows it. The prior art at
[Plan-20260718](../history/plans/archive/Plan-20260718%20-%20MoonI80%20lapping-v2%20clock-oracle%20ring%20(shipped).md)
`Plan-20260718 - MoonI80 lapping-v2 clock-oracle ring` (in the plans archive)
is a DIFFERENT cause with the same panic name (an ISR reading PSRAM while a flash write disabled
the cache, fixed with a `spi_flash_cache_enabled()` defer guard) and is worth re-reading first:
the same shape on another ISR would present exactly like this.
Expand All @@ -153,7 +153,7 @@ DevicesModule discovers via **passive UDP presence** (UDP 65506) feeding a [`Dev
- **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.

Full design + the reasoned transport split: [Plan-20260629 UDP device discovery + mDNS advertise-only (shipped)](../history/plans/archive/Plan-20260629%20-%20UDP%20device%20discovery%20%2B%20mDNS%20advertise-only%20%28shipped%29.md).
Full design + the reasoned transport split: `Plan-20260629 - UDP device discovery + mDNS advertise-only` (in the plans archive).

## MoonBase follow-ups

Expand Down Expand Up @@ -1073,14 +1073,20 @@ They are **not** CI failures (CI is Debug) and each one inspected so far is a fa
Not done with the multi-destination/tab-UI merge because 17 warnings across four core files is its own change, not a tail on someone else's.


## MoonLive core/platform layering + JIT sdkconfig scoping (CodeRabbit #29, 4 findings)
## MoonLive core/platform layering + JIT sdkconfig scoping (CodeRabbit #29, 3 findings left)

Four 🟠 Major boundary findings from the PR #29 review are real but each is its own scoped change, not a tail on the ring branch. The Critical sibling (a `cpl<3` overflow guard in the MoonLive effect's `tick`) landed with the branch it was found on; these four are backlogged:
Four 🟠 Major boundary findings from the PR #29 review are real but each is its own scoped change, not a tail on the ring branch. The Critical sibling (a `cpl<3` overflow guard in the MoonLive effect's `tick`) landed with the branch it was found on.

**One of the four is CLOSED (2026-09-07):** the scenario now uses `PreviewDriver`, the in-process
sink, instead of `NetworkSendDriver`. Its `tick_us` half was reviewed and DISMISSED rather than
fixed: a `measure` step asserts nothing, it records, and that recording is what feeds repo-health's
per-commit performance trend. A reviewer reading the file could not see that; deleting the
baselines would have blinded the trend to fix nothing. Recorded here so it is not re-raised.

The three that remain:

- **Core includes platform, compiled core in `mm_core`.** `src/core/moonlive/MoonLive.cpp` `#include`s `platform/platform.h` and calls the exec-memory API directly, and the root `CMakeLists.txt` compiles `MoonLive.cpp`/`MoonLiveCompiler.cpp` into `mm_core` and links `mm_core → mm_platform` — violating the header-only-core / no-platform-includes contract both files declare. The runtime exec-memory placement layer wants a core-neutral injected interface (or to move out of `src/core`), so the compiled/platform-dependent surface sits behind `mm_platform` and `mm_core` stays INTERFACE-only. These two are one change (same boundary).
- **W^X disabled in the board default.** `esp32/sdkconfig.defaults.esp32s3-n16r8` turns off `CONFIG_ESP_SYSTEM_MEMPROT_FEATURE` and enables `CONFIG_HEAP_HAS_EXEC_HEAP` for *every* build on that board, even with no MoonLive effect installed. The JIT genuinely needs a writable-then-executable heap, but that belongs in a dedicated MoonLive/JIT opt-in overlay or an explicit build profile, not the board default — so a stock build keeps memory protection on.
- **A scenario rides timing + network.** `test/scenarios/light/scenario_modifier_chain.json` carries `tick_us` baselines (host-performance dependent) and routes a modifier-chain-composition test through `NetworkSendDriver` (pulls network-path behavior into a test that is not about the network). It wants an in-process sink and structural assertions so it stays hermetic, per the `test/**` "no timing or network dependence" rule.

## MoonI80 prime-only ring: no stall backstop (sibling-path gap)

**Found:** 👾 Reviewer, pre-commit on the whole-frame stall fix (2026-07-22).
Expand Down Expand Up @@ -1342,7 +1348,7 @@ Lower risk than the RGMII case (six pins rather than twelve, and nothing of ours

## Input transports: foot pedals, USB game controllers, and MoonLive at the pins (2026-09-01)

`ButtonService` shipped with the [GPIO seam](../history/plans/Plan-20260901%20-%20Input%20services%20and%20the%20GPIO%20seam.md)
`ButtonService` shipped with the [GPIO seam](../history/plans/Plan-20260901%20-%20Input%20mapping%20and%20scripted%20sensors.md)
(`gpioInputBegin` / `gpioRead` / `gpioWrite`). It names a target as `Module.control` and writes it
through `Scheduler::setControl`, so a press and an OSC message are indistinguishable downstream.
Three follow-ups build on that seam rather than beside it.
Expand Down
Loading
Loading