Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7c1ce57
Add 74HCT595 shift-register expander to the parallel LED drivers (dor…
ewowi Jul 14, 2026
930fee1
Fix WiFi self-healing, a shift-encoder bug, and baseline shift mode a…
ewowi Jul 14, 2026
8639888
Add MoonI80: our own gapless i80 DMA driver, and find the real shift-…
ewowi Jul 14, 2026
a0f6bf2
Hoist the shift encoder's frame constants; fix the AP-retry dropping …
ewowi Jul 14, 2026
6040a7c
Hoist the shift encoder's transpose; free MoonI80's DC/WR pins
ewowi Jul 14, 2026
2873ec9
Loopback self-test verified on a spare '595 strand; panel-dots mapping
ewowi Jul 15, 2026
2f5f531
Add MoonI80 streaming ring: 128 lights/strand via '595 expander (from…
ewowi Jul 15, 2026
db67b0d
Fix shift-ring wedge + windowed snapshot; loopback toggles live now
ewowi Jul 15, 2026
616d34e
Add MoonI80 shift-ring reuse diagnosis + no-reuse stopgap (clean to 2…
ewowi Jul 16, 2026
0d75fdb
Rename the LED driver surface so the UI reads in plain language
ewowi Jul 16, 2026
da67edf
Drive the shift ring one light per DMA buffer (constant RAM at any le…
ewowi Jul 16, 2026
8d11d5e
Revert "Drive the shift ring one light per DMA buffer (constant RAM a…
ewowi Jul 17, 2026
270862a
Make the pin-expander ring's geometry a live control, not a constant
ewowi Jul 17, 2026
36f5563
Ring reset-tail + prime-only gate; flash last_port; MoonLive guard
ewowi Jul 18, 2026
5b0e6bc
Ring prime-only self-termination (192/strand clean); rabbit fixes
ewowi Jul 18, 2026
2d60024
Run the S3 at 240 MHz; IRAM ring-encode chain; correcting snapshot
ewowi Jul 18, 2026
d263c27
Stream 256 lights/strand: the MoonI80 clock-oracle lapping ring
ewowi Jul 18, 2026
5ab8d81
48x256 achieved: auto ring geometry, validated viability rule, instru…
ewowi Jul 18, 2026
a425a84
Dual-core ring: fork-join prime + memcpy snapshot, heap accounting, f…
ewowi Jul 19, 2026
2ca3138
Fix per-strand '595 corruption: shiftOverclock switch; pre-merge fixes
ewowi Jul 19, 2026
87b941c
Ring completion + prime barrier + WDT self-heal; expert mode; doc reorg
ewowi Jul 20, 2026
8cdb9ff
Process CodeRabbit review; record the 48x256 flash investigation
ewowi Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,8 @@ __pycache__/
# the site by moondeck/docs/mkdocs_hooks.py (via gen_api.py). Regenerated every build.
/docs/moonmodules/core/moxygen/
/docs/moonmodules/light/moxygen/

# Read-only hardware snapshots (flash/NVS dumps pulled off reverse-engineered boards).
# Root-anchored + dir-scoped, like /build/ above: a bare `.snapshots` would also swallow any
# nested path of that name anywhere in the tree.
/.snapshots/
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ The **one exception** is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's b

**The one nuance: a *rigorous* change gets a heads-up first.** "Flash freely" is the default for ordinary iteration (a code fix, a UI tweak, a normal reflash to see it run). But when the change is *rigorous* — it could leave a board in a bad state or is disruptive to recover from: erasing/repartitioning flash, a bootloader / partition-table / sdkconfig change, changing the flash-size variant, a first-flash of an untested board, a long full-erase, or anything that risks bricking or a bootloop — **say what you're about to do and why, and wait for the go-ahead.** The test is *reversibility*: a routine reflash is a keystroke to redo, so just do it; a change that could cost real recovery effort (or hardware) is worth one sentence of confirmation first. When unsure which side a change falls on, ask.

**Invite the product owner to test — then STOP and wait.** Whenever a change produces something the product owner can *see or judge* (LEDs on a bench board, a UI screen, a rendered effect, a live device behaviour), the agent's job ends at "it's running on <board/URL>, here's what to look at" — **not** at the agent's own verdict. Say what changed, where to look, and what would count as good or bad, then **stop and wait for their observation before drawing conclusions, writing them into docs, or moving to the next step.** The product owner's eyes are the measurement; serial logs and API reads are supporting evidence, not a substitute. This is the *[Agent Roles](#agent-roles)* division made concrete: the product owner tests on hardware before approving, and an agent that races ahead — reaching a conclusion, updating a doc, starting the next task — has quietly taken that decision away from them and is often *wrong* (this rule exists because it happened repeatedly: conclusions written up from a bench the product owner never got to look at). The trigger is simply *"could the product owner see this?"* — if yes, hand it over and wait. Doubly so before anything irreversible-ish (a revert, a reconfigure, a reflash) that would destroy the very state they were about to look at: leave it running.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the historical rationale out of CLAUDE.md.

Keep the current workflow rule here, but move the backward-looking explanation about prior agent behavior to docs/history/, docs/adr/, or lessons.md, then link to it if needed. As per coding guidelines, historical contrasts belong in those history-oriented documents rather than general documentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CLAUDE.md` at line 82, Keep the current product-owner testing workflow in
CLAUDE.md, but remove its historical rationale about repeated agent behavior and
decision-taking. Move that rationale to an appropriate history-oriented document
under docs/history, docs/adr, or lessons.md, and add a link from the workflow
rule only if the context remains necessary.

Source: Coding guidelines


The full gate lists per lifecycle event (commit, push, PR merge, release) live in **Lifecycle Events** below.

**Mandatory subtraction.** Periodically review and remove code and docs that no longer earn their place. If nothing can be removed, justify why. This applies to `docs/backlog/` and `docs/history/` too: both grow *and shrink*. A backlog item that ships is deleted (it lives in the code now); a history entry whose lesson has been absorbed — folded into a principle, a doc, or simply internalised — gets pruned. The permanent record is the git commits; these two folders are a working narrative layered on top, kept only as long as they still earn it. Don't treat either as append-only.
Expand Down Expand Up @@ -203,6 +205,7 @@ docs/
building.md ← how to build, flash, run for every target
testing.md ← test inventory and strategy
performance.md ← per-module timing, memory, sizeof for each platform
MIGRATING.md ← breaking-change log (newest first): what changed + the action it costs the user
backlog/ ← forward-looking: what to build next (not present-tense)
README.md ← landing page: overview of every item + index (the rest of the system links here, not into items)
backlog-core.md ← to-build list, core / infrastructure domain (+ UI)
Expand Down
15 changes: 9 additions & 6 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,17 @@ target_include_directories(mm_platform PUBLIC src/ src/platform/desktop/)
# Winsock for the desktop socket surface on Windows.
target_link_libraries(mm_platform PUBLIC $<$<PLATFORM_ID:Windows>:ws2_32>)

# Generate build_info.h from library.json (carries version, build date, board name).
add_custom_command(
OUTPUT ${CMAKE_SOURCE_DIR}/src/core/build_info.h
# Generate build_info.h from library.json + git (carries version, build id, build date, board name).
# ALWAYS out-of-date on purpose — see the same rule in esp32/main/CMakeLists.txt for why: MM_BUILD_ID
# must track the git hash on every build, and pinning this to library.json's mtime is what made the
# reported build stamp go stale, which reads as a failed flash and sends debugging at the wrong binary. The generator
# rewrites the header only when its content changes, so an unchanged tree triggers no rebuild.
add_custom_target(build_info_gen ALL
COMMAND ${UV_EXECUTABLE} run python ${CMAKE_SOURCE_DIR}/moondeck/build/generate_build_info.py
DEPENDS ${CMAKE_SOURCE_DIR}/library.json ${CMAKE_SOURCE_DIR}/moondeck/build/generate_build_info.py
COMMENT "Generating build_info.h"
BYPRODUCTS ${CMAKE_SOURCE_DIR}/src/core/build_info.h
COMMENT "Generating build_info.h (git build id)"
VERBATIM
)
add_custom_target(build_info_gen DEPENDS ${CMAKE_SOURCE_DIR}/src/core/build_info.h)

# Embed UI files as C arrays. Pass UV_EXECUTABLE through as a single value
# (no semicolons on the command line — those would either get split by `make`
Expand Down
78 changes: 78 additions & 0 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Migrating

The log of **breaking changes** — what changed between versions, and the action to take.

projectMM ships **no migration code**: the persistence layer is robust by default (an absent key keeps the control's default, a stale value clamps to the new bounds, an unknown key is ignored), which absorbs almost all schema drift with zero migration-specific code. The rare change that a robust reader *cannot* absorb is **documented here instead of migrated** — see [ADR-0013](adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md) for the decision and its rationale.

**Read this when upgrading a device that already holds persisted state.** Entries are newest first. Each says what changed and what to do; most need nothing at all, because the lost value re-populates on next use.

**Action legend** — how much work an entry costs you:

| Action | Meaning |
|---|---|
| *nothing* | Self-heals. The value re-populates on next use, or the default is correct. |
| *re-set a control* | One value resets to its default; set it again in the UI if you had changed it. |
| *re-add a module* | The module vanishes from the tree on boot; add it again and re-enter its controls. |
| *update a file* | An on-device file must be edited or replaced. |
| *erase flash* | A full flash erase is required (the heaviest — a full reconfigure follows). |

---

## Unreleased (`next-iteration`)

### `MoonLedDriver`: `forceRing` → `useRing`, and the ring's geometry is now settable (2026-07-17)

The pin-expander path selector was a three-option Select (`auto` / `ring` / `wholeFrame`) named for a *diagnostic override*. The auto-router is gone — at the size the expander exists for (48 strands × 256 lights) a whole frame never fits internal DMA RAM, so "auto" had exactly one right answer while presenting itself as a choice, and its silent fallback hid which path was actually running. What remains is the honest question, as a switch:

| Old | New |
|---|---|
| control `forceRing` (Select: `auto`/`ring`/`wholeFrame`) | `useRing` (a switch: on = ring, off = whole frame) |
| — | `ringRows` (new: lights per DMA buffer, 1..64) |
| — | `ringBufs` (new: buffers the DMA circulates, 2..32) |

**Action: re-set `useRing` if you had `forceRing` on `wholeFrame`.**

`forceRing` reads as absent → ignored, and `useRing` takes its default (**on**, the ring). A device that had explicitly selected whole-frame therefore comes up on the ring; flip `useRing` off to get it back. `ringRows`/`ringBufs` default to 16 and 12 — the geometry the driver effectively ran. (It shipped with a pool of 16, but 16 buffers never fit the S3's internal DMA heap, so the ring build failed its own fit check and the driver quietly fell back to whole-frame; 12 is what actually held. A config on the old defaults may therefore start *ringing* where it used to fall back.) They exist so the RAM / encode-overhead / interrupt-rate / lap-time trade-off can be swept on a live board rather than fixed at compile time.

### LED driver + control rename — a human-readable UI (2026-07-16)

The LED driver module types and several controls were renamed so the UI reads in plain language rather than peripheral jargon (the UI shows a control's name verbatim, so the name *is* the label).

| Old | New |
|---|---|
| module type `I80LedDriver` | `MultiPinLedDriver` |
| module type `MoonI80LedDriver` | `MoonLedDriver` |
| control `shiftRegister` | `pinExpander` |
| control `asyncTransmit` | `doubleBuffer` |
| read-only `wireUs` | `frameTime` |
| read-only `stall` (Drivers) | `renderWait` |

**Action: re-add the module, then re-set `pinExpander` / `doubleBuffer` if you had changed them.**

A device whose persisted config names the old module type loads a module type that no longer exists — the unknown type is ignored, so **the driver is absent from the tree on boot**. Re-add it (`MultiPinLedDriver` or `MoonLedDriver`) and re-enter its controls. Within a re-added driver, the two renamed *settable* controls (`pinExpander`, `doubleBuffer`) read as absent → they take their defaults (`pinExpander` off, `doubleBuffer` on); set them again if your board needs otherwise. `frameTime` and `renderWait` are read-only KPIs — nothing to restore.

`RmtLedDriver` and `ParlioLedDriver` are unchanged. The `pins` / `ledsPerPin` / `clockPin` / `latchPin` / `loopback*` controls are unchanged.

---

## Earlier

These pre-date this log and were recorded in ADR-0013's Consequences list. A device that persisted state on an older build and loads a newer one loses only the noted value, which re-populates on next use.

### Container config filenames (`LayoutGroup.json` → `Layouts.json`)

`.config/LayoutGroup.json` → `Layouts.json`, `.config/DriverGroup.json` → `Drivers.json` (container type rename). The stale files are ignored (unknown-type config isn't loaded); they linger harmlessly on disk until a flash erase.

**Action: nothing.** Lost: the container's `enabled` flag (defaults back on).

### UI last-selected module (`mm.selectedModule` → `mm_selected`)

The browser localStorage key for the UI's last-selected module.

**Action: nothing.** Lost: the remembered selection resets to the first module.

### Device-list `colour` → `color` (US-spelling rename)

The DevicesModule persisted-list key for a Hue bridge's color-capable light count (`DevicesModule::restoreList()`). A device list persisted under the old key reads the count as absent → 0.

**Action: nothing.** The cached bridge count resets to 0 until the bridge is re-heard live and re-populates it.
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,13 @@ The realization that settles it: **the persistence layer is already robust to sc

## Decision

**No migration code in the system.** Persistence stays robust-by-default (absent → default, stale → clamp, unknown → ignore), which absorbs almost all schema drift with zero migration-specific code. A breaking format change (a persisted key, config filename, wire key, or localStorage key that a *released* version wrote and a new version reads differently) is **documented, not migrated** — recorded in this ADR's Consequences list below, one line per break, so the record is present-tense prose rather than past-narrating code.
**No migration code in the system.** Persistence stays robust-by-default (absent → default, stale → clamp, unknown → ignore), which absorbs almost all schema drift with zero migration-specific code. A breaking format change (a persisted key, config filename, wire key, or localStorage key that a *released* version wrote and a new version reads differently) is **documented, not migrated** — recorded in [MIGRATING.md](../MIGRATING.md), one entry per break with the action it costs the user, so the record is present-tense prose rather than past-narrating code.

A **patching framework is explicitly deferred**, not rejected forever: it becomes the right tool post-1.0 *if* breaking format changes become frequent enough that ad-hoc losses pile up (a rough bar: >5 across a few releases) *and* users hold persisted state too valuable to re-derive. At that point build the recognizable version-stamp + ordered-patch-chain pattern (SQLite `user_version`, Rails/Room migrations), not a bespoke one. Until then, the cost of not migrating is a re-populated setting, which the robust-by-default layer already makes cheap.

## Consequences

- The two existing migrations were removed: `FilesystemModule::migrateRenamedConfigs()` (and its call + declaration) and the `lsRead` legacy-key parameter. The proposed `colour`-fallback was not added.
- **Robust-by-default is the contract**: a schema change must degrade gracefully through absent-default / clamp / ignore, never crash or wedge (the *Robust to any input* principle already guards this in tests). A change that would lose data silently is documented here instead of papered over with a fallback.
- **Known breaking changes** (a device that persisted state on an older build and loads a newer one loses only the noted value, which re-populates on next use):
- `.config/LayoutGroup.json` → `Layouts.json`, `.config/DriverGroup.json` → `Drivers.json` (container type rename). The stale files are ignored (unknown-type config isn't loaded); they linger harmlessly on disk until a flash erase. Lost: the container's `enabled` flag (defaults back on).
- localStorage `mm.selectedModule` → `mm_selected` (UI last-selected module). Lost: the remembered selection resets to the first module.
- DevicesModule persisted-list key `colour` → `color` (a Hue bridge's color-capable light count, in `DevicesModule::restoreList()`). A device list persisted under the old key reads the count as absent → 0; the cached bridge count resets to 0 until the bridge is re-heard live and re-populates it.
- **Known breaking changes are logged in [MIGRATING.md](../MIGRATING.md)** — one entry per break, newest first, each with the action it costs the user (usually none: the value re-populates on next use). That log is the home for this decision's output; it lives outside the ADR because it *grows* with every break, and an ADR is immutable (superseded, never edited).
- The lesson that generalizes: a robust reader is worth more than a migration — build the loader to tolerate drift, and most migrations never need to exist. Reach for a patching framework only when real, frequent, high-value breaks prove it earns its complexity.
Loading
Loading