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
15 changes: 15 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "uv run \"$CLAUDE_PROJECT_DIR/moondeck/check/hook_prose.py\""
}
]
}
]
}
}
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ New behavior is pinned before it ships: a unit test for module logic, a scenario

Docs land with the code, not at merge time: the module's spec and catalog card describe what actually shipped ([coding-standards § Documentation model](docs/coding-standards.md#documentation-model)); a breaking change gets its entry in [docs/MIGRATING.md](docs/MIGRATING.md); a shipped backlog item or spec draft is deleted. The merge gate only verifies this happened.

**How the writing looks: American spelling, no em-dashes.** `color`, `serialize`, `behavior`, `analyze`; a comma, colon or full stop where an em-dash wants to go. In comments, docs, commit messages and chat replies alike. Both rules are enforced mechanically by `check_prose.py` (a write-time hook, and again at the commit gate), because they are exactly the kind of habit an author does not notice in their own prose. Full rationale: [coding-standards § Writing](docs/coding-standards.md).

### Commit

Git only with the PO in the loop: staging, committing, and pushing happen only when the PO explicitly triggers them. **The PO verifies EVERY changed file before it is committed.** That is the rule the others serve: nothing reaches history unseen. Two things follow, and both have been broken. **The trigger is the words "commit now", never a task instruction** — "fix it", "do step 4", "the build is broken", even "hotfix it on main" say what to change and nothing about recording it; finishing the work is not a prompt to commit it. And **a "commit now" covers only the files the PO has actually looked at** — touch one more, anything at all, and the tree again holds something unverified, so the go-ahead is void until they see it. Stop at a clean tree, say exactly which files changed, and wait. On main exactly as on a branch; a one-line fix exactly as a feature. What and when to commit or merge is 100% the product owner's call — never ask or propose commit timing. One combined commit per cycle (no partial commits; hygiene changes fold into the next one). Branches and commits may bundle multiple topics: not every small change gets its own commit — the pre-commit and pre-merge checks would be too much overhead.
Expand Down Expand Up @@ -107,7 +109,9 @@ Agents never commit. **Delegate the mechanical roles**: parallelizable or substa

**Sanity-check every request.** Hold it against README, this file, and architecture.md. If it conflicts, push back briefly with the specific reference; the product owner can still overrule.

**Anti-stalling.** If a build error or test failure survives 2 fix attempts: STOP. Ask, or roll back and re-approach.
**Never revert without asking.** Undoing work already done is the product owner's call, whatever prompted it: a doc that seems to contradict it, a reviewer finding, a failing check, or the agent's own second thoughts. Deleting a file, dropping a config, or backing out a change costs the thinking that went into it and may reverse a decision the PO made deliberately. State the case and wait; a written statement is a status, not a law, and only the PO knows which.

**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).

**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.

Expand Down
18 changes: 17 additions & 1 deletion docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,29 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul

## Unreleased (`next-iteration`)

### esp32-16mb moves to the MoonBase partition table (2026-08-28)

**Action: erase flash** (USB re-flash). Back up first (File Manager, or the installer's
bookmarklet on older firmware); restore after the install brings WiFi, config and scripts back.

`esp32-16mb` replaces its dual-OTA layout with
[MoonBase](architecture.md#moonbase-the-second-boot-image), the same trade the 4 MB variants
made in the entry below, taken here by choice rather than necessity: the second app slot was
idle except during an update, so the filesystem grows 7168 to 11264 KB and the device gains
MoonBase's stronger recovery story (a power cut mid-install boots MoonBase and the user retries
over the network). One app slot remains, at its full 4096 KB.

Every partition moves, so the existing filesystem volume is not where the new table looks:
without a backup, WiFi credentials, module config and scripts all re-enter through provisioning.
A partition table only changes over USB, so an OTA update leaves a device on the old layout.

### 4 MB boards move to the MoonBase partition table (2026-08-26)

**Action: erase flash** (USB re-flash). Back up first (File Manager ⤓, or the installer's
bookmarklet on older firmware); restore after the install brings WiFi, config and scripts back.

The 4 MB variants (`esp32`, `esp32-wrover`, `esp32-eth`) replace the dual-OTA layout with
[MoonBase](architecture.md#moonbase-the-second-boot-image-4-mb-boards): the app slot grows
[MoonBase](architecture.md#moonbase-the-second-boot-image): the app slot grows
1856 → 2496 KB and the filesystem 256 → 548 KB, but the filesystem moves (0x3B0000 → 0x360000),
so the existing volume is not where the new table looks; without a backup, WiFi credentials,
module config and scripts all re-enter through provisioning. A partition table only changes over USB: a device
Expand Down
22 changes: 14 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,14 +263,20 @@ Three distinct things, kept distinct in the vocabulary:

A deviceModel can run multiple firmwares (the Olimex Gateway runs both `esp32-eth` and the default `esp32`); a firmware can run on multiple deviceModels (`esp32` runs on any classic ESP32 dev kit). The `esp32s3-n16r8` firmware is S3-only and does not run on the Olimex Gateway or other classic-ESP32 hardware. The codebase reserves "deviceModel" exclusively for the physical product and "firmware" exclusively for the compiled binary.

### MoonBase: the second boot image (4 MB boards)
### MoonBase: the second boot image

A 4 MB board has room for one application, not two, so the dual-OTA layout (half the chip spent
on a second copy of the firmware) is replaced on those boards by **MoonBase**: a small,
rarely-changing image in the partition table's `factory` slot that owns the device while the
application is being replaced, a board cannot rewrite the partition it is executing from. The
app slot grows by a third in exchange. 8/16 MB boards keep dual-OTA and are untouched by any of
this.
Dual-OTA spends half the app area on a second copy of the firmware that is idle except during an
update. **MoonBase** replaces it: a small, rarely-changing image in the partition table's
`factory` slot that owns the device while the application is being replaced, since a board
cannot rewrite the partition it is executing from. One app slot then suffices, and the flash the
second slot held goes elsewhere.

A 4 MB board has no choice, having room for one application and not two; its app slot grows by a
third in exchange. On a **16 MB** board the choice is deliberate rather than forced, and the
freed 4 MB goes to the filesystem (11 MB rather than 7). Which boards use MoonBase is a
per-variant decision recorded in `moondeck/build/build_esp32.py`, not a property of flash size:
today the 4 MB classic, the S3-Zero and `esp32-16mb` do, and it may become the default
everywhere.

The update cycle: the app stages the install URL in NVS (or nothing, for a browser upload),
points the bootloader at MoonBase and reboots; MoonBase joins the network with the app's stored
Expand All @@ -285,7 +291,7 @@ MoonBase, visibly, rather than silently reverting to the old app; the way back i

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 the 4 MB variants
working, hardly change. `moondeck/build/build_esp32.py` builds it alongside every variant that opts in
and owns the flash-layout helpers every consumer uses (serial flash, mooninstaller manifests,
release preview, the QEMU image): IDF's own `flasher_args.json` knows nothing of the two-image
scheme and stages the app at the factory offset, so each of those paths applies the same
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 48 additions & 1 deletion docs/backlog/backlog-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Full design + the reasoned transport split: [Plan-20260629 — UDP device discov

## MoonBase follow-ups

MoonBase v1 ([architecture.md § MoonBase](../architecture.md#moonbase-the-second-boot-image-4-mb-boards))
MoonBase v1 ([architecture.md § MoonBase](../architecture.md#moonbase-the-second-boot-image))
ships exactly one action: install firmware (upload + URL). The name is deliberately broader than
"recovery", these are the candidate next actions, each solving something only a separate boot
image can solve. The budget rule from the partition table applies to all of them: the 896 KB slot
Expand Down Expand Up @@ -919,6 +919,53 @@ The P4 build runs at **360 MHz** because IDF's `Kconfig.cpu` caps a `SELECTS_REV

Neither ships until a rev-3 P4 can prove 400 runs clean — no untested clock config, per the same rule the S31/320 and this P4/400 bootloop both taught.

## ESP32-P4: the esp-dsp assembly FFT faults once a second task runs (workaround shipped)

**Found:** bench, 2026-08-28, bringing up HLS on the P4.

**Symptom:** a crash loop the moment the HLS encode task exists alongside the render loop —
`Guru Meditation Error: ... (Illegal instruction)` or `(Load access fault)`, the type varying
between boots, always faulting on the twiddle-table load in `dsps_fft2r_fc32_arp4.S:52`
(`flw fa0, 0(t3)`) reached from `platform::audioFft`. The LED output freezes after a few frames
and the board reboots. Present only with audio analysis running AND a second task; either alone
is stable, which is why it never surfaced before HLS.

**Cause:** the P4's hardware-loop unit has a documented silicon erratum, declared by Espressif's
own `soc_caps.h`: `SOC_CPU_HAS_HWLOOP_STATE_BUG` — "HWLOOP state doesn't go to DIRTY after
executing the last instruction of a loop". FreeRTOS saves the HWLP registers lazily and keys that
save on the DIRTY flag, so a context switch out of the FFT concludes there is nothing to save and
the loop state is lost; the resumed kernel then reads its table through a stale register. The
esp-dsp `_arp4` kernel uses `esp.lp.setup` (the hardware-loop instruction), which is why the FFT
is where it lands. IDF v6.1-rc1 carries workarounds for this erratum at two sites in
`portasm.S` (lines 217 and 795, gated `ESP32P4_REV_MIN_FULL <= 1`, which our `REV_MIN_FULL=0`
build satisfies) — **both on the RESTORE side**. The **save** site (~line 676) reads
`CSR_HWLP_STATE_REG`, skips saving unless it equals `HWLP_DIRTY_STATE`, and carries no erratum
guard at all. That is precisely what the erratum breaks: a task switched out after a loop's last
instruction reports non-DIRTY, so its hardware-loop registers are never saved, and the resumed
FFT reads its twiddle table through a stale register. Restore is patched; save is not.

**Workaround shipped:** `CONFIG_DSP_ANSI=y` in `sdkconfig.defaults.esp32p4rev1-eth`, which swaps
esp-dsp's hand-written assembly kernels for portable C. Measured cost on the bench P4: the Audio
module's tick goes from ~615 us to ~658 us, about 40 us (7%) against 3.3 ms of tick headroom.
Stability confirmed over a soak with HLS streaming: zero crashes, zero corrupt packets.

**Reported upstream:** [esp-idf#19025](https://github.com/espressif/esp-idf/issues/19025)
(2026-08-28), which names the unguarded save path. Espressif already had the symptom on file from
another reporter: [esp-dsp#119](https://github.com/espressif/esp-dsp/issues/119) hits the same
fault at the same instruction on IDF v5.5-beta1 and settles on the same `CONFIG_DSP_ANSI=y`
workaround at the same ~7% cost, and [esp-dsp#102](https://github.com/espressif/esp-dsp/issues/102)
tracks P4 hardware loops in general. So the bug is real, reproducible by others, and spans at
least v5.5-beta1 to v6.1-rc1 - but it is unfixed, and the workaround stays until it is answered.

**What is NOT proven:** the save-path gap is read from the source and matches every symptom, but
it has not been confirmed by instrumenting the switch itself (logging `CSR_HWLP_STATE_REG` on a
switch out of the FFT), nor reduced to a minimal project. Offered upstream if triage wants it. Ruled out on the bench, so nobody re-treads them: worker stack size (8K -> 16K),
core placement (worker on core 1 vs core 0), heap corruption (comprehensive heap poisoning reports
nothing), an unpinned task migrating with coprocessor state (our main task is pinned to core 0),
an FFT size mismatch (512 <= 1024 <= 4096), and a cross-task race on the single `audioFft` call
site. **To report upstream** (esp-dsp and/or esp-idf) with the repro above; the workaround stands
until it is answered, and reverting it needs a bench soak with audio + HLS together.

## Flaky unit tests: the AudioService sync suite contends on a fixed UDP port

**Found:** 2026-07-27, caught by `premerge.py`; pinned to the exact cases by looping the suite and keeping the failing logs. Fails roughly **1 run in 10**.
Expand Down
37 changes: 37 additions & 0 deletions docs/backlog/backlog-light.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,25 @@ encode-worker-stalled latch. A page refresh reportedly did NOT revive it; toggli
wake-up re-request, or per-driver lease state that only prepare() resets. Needs a reproduction
with the WS uplink logged before it can be fixed.

### HLS upscaling is cache-hostile on large walls (measured, 2026-08-28)

`HlsDriver`'s `scale` control replicates each light into a scale x scale block. Measured on the
bench P4 at 128x128: **~1 ms per frame at 1:1, ~60 ms at scale 4** (a 512x512 output). The frame
is 16x larger, so ~16 ms would be the honest cost; the extra 4x is the access pattern. The loop
walks LIGHTS and writes each block as `scale` separate short rows scattered across a 786 KB
buffer, so consecutive lights touch distant addresses and every write misses cache, where the
1:1 path writes straight through sequentially.

**Not urgent, because the default path never hits it:** auto-scale only engages on walls below
the encoder's 80-pixel floor, where the output is small by construction (a 20x10 wall becomes
160x80, 38 KB). The expensive case is a manual scale on an already-large wall, which is also
where upscaling has the least to offer.

**The fix when it earns its place:** iterate the OUTPUT rows rather than the input lights, so
writes are sequential: for each output row, walk its source row once and emit `scale` copies of
each light's colour, then `memcpy` that finished row to the remaining `scale - 1` rows of the
block. Same output, one pass through the destination in address order.

### Sprite follow-ups (draw::sprite + FlyingToasters shipped; [spec + plan](../history/plans/Plan-20260827%20-%20Sprites%20and%20flying%20toasters.md))

Deliberately deferred when sprites landed: P4 PPA acceleration behind the same `draw::sprite`
Expand All @@ -155,6 +174,24 @@ That is the arrangement `platform_desktop.cpp` uses for Npcap today: resolve the

Also note projectMM renders into a CPU buffer, so a Spout/Syphon path would upload to the GPU purely to hand off, spending the zero-copy advantage it was chosen for.

### M5Stack Tab5 as a display target — MIPI-DSI, not the H.264 path (open)

The Tab5 is an ESP32-P4 with a 1280x720 MIPI-DSI panel, and a P4 is already a supported target, so
the question is what its *screen* would show. The P4's H.264 block does not answer it: that encoder
exists to compress an incoming MIPI-CSI camera feed, and the P4 has no hardware H.264 **decoder**
at all (Espressif's own FAQ points at software decode, which will not hold 720p). Driving the panel
is the **MIPI-DSI** peripheral plus the PPA / 2D-DMA blitter, which take raw pixels and never touch
a codec. So the three things a Tab5 could be are separate pieces of work, and only the first is free:

- **An HLS source**, like any other P4: it encodes its own rendered grid and streams to a TV. Its
panel is incidental, and this needs nothing beyond the P4 HLS work itself.
- **A local wall preview or touch console** — the interesting one, and the real ask: a `platform::`
MIPI-DSI display seam plus a UI on the panel. Related to the PPA acceleration noted under sprite
follow-ups above (same 2D-DMA block), and it is a display *output* seam projectMM does not have
today; the nearest prior art is the WLED-MM-P4 world's LovyanGFX usage, which we would not vendor.
- **An HLS/video player**, showing another device's stream: blocked on the missing hardware decoder,
so not worth planning.

### Multi-card walls — does a daisy chain work today? (open, ask before building)

The ColorLight format has **no card addressing**: the destination MAC is a fixed constant and every card filters on it, so every card on a segment shows the same image. A user with six cards on a switch observed exactly that.
Expand Down
Loading
Loading