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
21 changes: 19 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ jobs:
# Bump the suffix when changing the IDF version to invalidate.
key: esp-idf-v6.1-dev-${{ runner.os }}-v1

# The release-channel tag burned into the binary as MM_RELEASE (shown by
# SystemModule alongside the semver). Same resolution as the release
# job's "Resolve effective tag" step — kept here because this build job
# runs before that one. workflow_dispatch tag wins; main push → latest;
# a vX.Y.Z tag push → that tag.
- name: Resolve release tag
id: tag
env:
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
set -euo pipefail
if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT"
else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi

Comment on lines +92 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Deduplicate release-tag resolution to one source of truth.

Line 92-Line 103 duplicates the tag-resolution policy already implemented in the release job. Keep this policy in one shared script/composite action to prevent drift between the tag burned into firmware and the tag used for publishing.

Based on learnings: "No duplication in code or docs; same logic in two places belongs in one shared function; same fact in two docs belongs in one place with links from elsewhere".

🤖 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 @.github/workflows/release.yml around lines 92 - 103, The "Resolve release
tag" step (id: tag) duplicates tag-resolution logic; extract the if/elif/else
logic that reads INPUT_TAG, REF_NAME and IS_MAIN into a single shared reusable
workflow or composite action that emits an output "tag", then replace this step
with a call to that reusable component (or uses: ./path/to/action) and wire its
output to the rest of the job; ensure the shared component sets the "tag" output
the same way and preserves the environment variables INPUT_TAG, REF_NAME and
IS_MAIN so callers (previously relying on step id: tag) continue to receive the
same output without duplicated logic.

- name: Build firmware
uses: espressif/esp-idf-ci-action@v1
with:
Expand All @@ -99,8 +116,8 @@ jobs:
path: 'esp32'
# We run our own builder (not the action's default `idf.py build`)
# so the sdkconfig fragments and EXCLUDE_COMPONENTS go through the
# same code path as local builds.
command: python ../scripts/build/build_esp32.py --firmware ${{ matrix.firmware }}
# same code path as local builds. --release burns the channel tag in.
command: python ../scripts/build/build_esp32.py --firmware ${{ matrix.firmware }} --release "${{ steps.tag.outputs.tag }}"

- name: Stage release artifacts
run: |
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ See `docs/architecture.md` for system design. This file contains only rules and
## Principles

- **Common patterns first.** This repo is meant to be a recognisable example of good practice across code, docs, tests, and UI — not a Frankenstein of bespoke conventions only the authors understand. Hold every decision against it, especially in core architecture and documentation. Before introducing a pattern, name a widely-used project / framework / canonical resource that uses it; if you can't, treat it as bespoke and justify the divergence in a one-line comment at the introduction site. A new contributor with general C++/web experience should recognise the pattern within 30 seconds. Bespoke choices are allowed — header-only light modules, the MoonModule lifecycle, present-tense docs — but each carries its reason at the place it's introduced.
- **Minimalism means simplicity.** Flat, simple, predictable code. Not clever abstractions. Not elegant templates. If a contributor can't understand it in 30 seconds, it's too complex. Every addition must pay for itself. Prefer removing code over adding it.
- **Data over objects.** Design around data flow, not class hierarchies.
- **Minimalism means simplicity.** Flat, simple, predictable code. Not clever abstractions. Not elegant templates. If a contributor can't understand it in 30 seconds, it's too complex.
- **Core grows slower than the domain.** Adding a domain module is expected growth: a new unit of domain capability adds lines because it adds a feature, and that's fine. The **core** (domain-neutral infrastructure — `src/core/`, the platform layer, the docs that describe them) is held to a higher bar: while the system is still being built the core does grow, but each core change should buy proportionally more than a domain addition — new infrastructure that many modules use, not a one-off. A core change is suspect until that leverage is shown. Watch the ratio — core is meant to be the lean base under a wider domain, not the other way around.
- **Default to subtraction.** The reflex on most changes — a bug fix, a review finding, a refactor — should be *can this remove or replace code, or land net-neutral?*, not *what do I add?* If a change only ever grows the line count and the doc count, that's the smell this rule exists to catch. Prefer removing code over adding it; a deletion that preserves behaviour is the best kind of change.
- **No duplication, in code or docs.** Same logic in two places belongs in one shared function; same fact in two docs belongs in one place the other links to. A comment or doc paragraph that restates what the code already says is duplication too — delete it. (Reuse a recognisable shape rather than inventing one — see *Common patterns first* above.)
- **Data over objects in the hot path.** Where speed and memory matter most, design around plain contiguous data, not an object graph: a flat buffer of elements that one stage writes and the next stage reads, following the producer/consumer data flow in [docs/architecture.md](docs/architecture.md). This is a deliberate performance choice — a contiguous buffer is cache-friendly and lets a stage do integer math straight on the array, whereas per-element objects with virtual accessors are cache-hostile and allocation-heavy, exactly what the hot-path rules forbid. The one deliberate class hierarchy is the module tree (one `MoonModule` base, shallow subclasses, a single virtual-dispatch boundary), because uniform polymorphism is what lets the UI render any module generically with zero per-module UI code. Don't add inheritance elsewhere, and don't wrap hot-path buffer data in objects.
- **Concrete first, abstract later.** Build one working feature end-to-end before extracting patterns into shared abstractions. Don't build the framework before the domain logic works.
- **Domain-neutral core.** Separate core infrastructure from the light domain as much as practical. When mixing is necessary, use domain-neutral naming so the code stays open to future separation.
- **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. Exceptions: `docs/plan.md` and `docs/history/`.
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ add_custom_target(build_info_gen DEPENDS ${CMAKE_SOURCE_DIR}/src/core/build_info
add_custom_command(
OUTPUT ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h
COMMAND ${CMAKE_COMMAND} -DUI_DIR=${CMAKE_SOURCE_DIR}/src/ui -DOUT=${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h -P ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake
DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js
DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/index.html ${CMAKE_SOURCE_DIR}/src/ui/app.js ${CMAKE_SOURCE_DIR}/src/ui/style.css ${CMAKE_SOURCE_DIR}/src/ui/install-picker.js ${CMAKE_SOURCE_DIR}/src/ui/preview3d.js ${CMAKE_SOURCE_DIR}/src/ui/moonlight-logo.png ${CMAKE_SOURCE_DIR}/src/ui/embed_ui.cmake
COMMENT "Embedding UI files"
)
add_custom_target(ui_embed DEPENDS ${CMAKE_SOURCE_DIR}/src/ui/ui_embedded.h)
Expand Down
17 changes: 12 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,20 @@ The model is **producers vs consumers**: producers generate data, consumers proc

## Data exchange between modules

When one module produces data another module reads on the hot path — the Layer pixel buffer that drivers send, the `PreviewFrame` the HTTP module broadcasts, the `Correction` table physical drivers apply — the pattern is the same throughout the codebase:
When one module produces data another module reads on the hot path, the pattern is the same throughout the codebase. Two shapes, both core-defined and domain-neutral:

**Shared-struct (pull).** The reader holds a pointer to the producer's data and reads it when it needs it.

- The **producer owns a small POD struct** as a member, overwritten in place each tick. No allocation per frame.
- A **plain-data header** declares the struct (`Buffer`, `Correction`, `PreviewFrame`). Both producer and consumer include it; neither needs to know the other's class.
- A **plain-data header** declares the struct. Both producer and consumer include it; neither needs to know the other's class.
- The producer exposes the struct via a `const`-returning getter (or a `setX(const Foo*)` setter on the consumer).
- The **consumer holds a `const Foo*`** received once at wiring time in `main.cpp`, and reads it on the hot path each frame.

No registry, no subscription, no event bus. The consumer reads the latest value when it needs it; if the producer wrote nothing this tick, the consumer sees the previous value (acceptable for the kinds of data this exchanges — frame buffers, periodic captures). Producer and consumer can run on different cores: publisher-write / readers-read with a one-frame staleness tolerance that doesn't need a lock.

Concrete examples: `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white); `PreviewDriver` writes a `PreviewFrame` that `HttpServerModule` reads for the WebSocket broadcast; `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path.
**Push through a domain-neutral sink.** When the producer should hand bytes to a generic core service rather than expose a struct, the core defines a narrow interface and the producer pushes to it. The producer owns the data and its wire format; the core sink (the interface's implementer) knows only "take these bytes and do my generic job" — it has zero knowledge of what the bytes mean or which domain produced them. `BinaryBroadcaster` (`HttpServerModule` implements it: "broadcast these bytes to all WebSocket clients") is the example — the producer side lives in the light domain (see [§ The pipeline](#the-pipeline)).

The shape extends without ceremony to any future producer/consumer pair (a sensor module owning a state struct, an effect reading it through a `const Foo*` set at wiring time). It is deliberately not pub/sub: there's one producer per data kind and the consumer explicitly wants that specific data — the registry overhead and listener-lifecycle complexity of pub/sub buy nothing.
Both shapes extend without ceremony to any future producer/consumer pair (a sensor module owning a state struct, an effect reading it through a `const Foo*` set at wiring time; or a module pushing bytes to a core sink). Neither is pub/sub: there's one producer per data kind and the consumer explicitly wants that specific data — the registry overhead and listener-lifecycle complexity of pub/sub buy nothing.

## Event triggering between modules

Expand Down Expand Up @@ -212,6 +214,11 @@ Modules in the light pipeline can be added, replaced, or removed dynamically at
└── PreviewDriver (raw buffer, no Correction) ─→ WebSocket
```

**Data flow.** The pipeline instantiates both core data-exchange shapes (see [§ Data exchange between modules](#data-exchange-between-modules)):

- *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path — each consumer holds a `const`-pointer set once at wiring time and reads it per frame.
- *Push to a core sink:* `PreviewDriver` owns the preview wire format (a one-time coordinate table + per-frame RGB point list) and pushes the bytes to a `BinaryBroadcaster` (the core HTTP server). The server broadcasts them over WebSocket without knowing they're a preview — the format and the light types stay entirely in the driver. See [PreviewDriver](moonmodules/light/drivers/PreviewDriver.md).

**Naming convention.** Capital `Layouts`, `Layers`, `Drivers` are class names (always capitalised when referring to the class). Lowercase "layouts", "layers", "drivers" is the English plural — used freely when context makes it clear. Singular "layout", "layer", "driver" is an individual instance.

## 3D from the start
Expand Down Expand Up @@ -412,7 +419,7 @@ The UI is **MoonModule-driven**. It contains no hard-coded knowledge of specific
- Controls are auto-rendered by type (slider, toggle, colour picker, text input, dropdown).
- Modules can be switched (change which effect a layer uses) and linked (assign a layout to a layer).

Adding a new MoonModule with controls needs **zero changes** to the UI files.
Adding a new MoonModule with controls needs **zero changes** to the UI files. This extends to the tree-mutation affordances: which modules accept children (and of what role) comes from each type's `acceptsChildRoles()`, and whether a module can be deleted/replaced comes from its `userEditable()` — both declared on the C++ side and reported in `/api/types` + `/api/state`. The UI hardcodes no list of "which types are containers" or "which roles are editable"; a new container type or a fixed child is a one-line C++ override.

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) — type byte `0x02`, 13-byte header `dw/dh/dd/ow/oh/od`, RGB triples), and emoji-key assignments for the chip filter (full table in [core/ui.md](moonmodules/core/ui.md)). Full UI spec: [docs/moonmodules/core/ui.md](moonmodules/core/ui.md).

Expand Down
2 changes: 1 addition & 1 deletion docs/coding-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ When a `switch (type)` outside the type's home file is legitimate: the caller ha
## File shape: header-only vs `.h` + `.cpp`

- **Light-domain modules and the `MoonModule` base: header-only.** Every effect, modifier, driver, layout, the light-domain containers (`Layouts`, `Layers`, `Drivers`, `Layer`), and the `MoonModule` base class live in a single `.h` with implementation inline. The benefit is concrete: a contributor copies `RainbowEffect.h`, edits, saves as `MyEffect.h`, registers one line in `main.cpp` — no "where does the `.cpp` go, what does CMake need" friction. The chain `RainbowEffect.h → EffectBase.h → MoonModule.h` is uniform; readers don't pivot to a different file shape at the base. When a light-domain file outgrows one concern, extract a helper into its own header (`BlendMap`, `MappingLUT`) rather than splitting to `.h` + `.cpp`. Header-only is a feature of the light domain.
- **Core service modules: `.h` + `.cpp`.** Core modules that bridge to the platform layer or implement substantial infrastructure (`HttpServerModule`, `FilesystemModule`, `NetworkModule`, `Scheduler`, `SystemModule`, `Control`) ship as a `.h` (interface) plus a `.cpp` (implementation). Three reasons that compound: (a) implementation changes recompile only the `.cpp`, not every TU that includes the header — incremental builds are 2–5× faster on the kind of edits that happen in development; (b) readers want the interface separately from the body; (c) symbol bloat and link-time stay bounded. Small core utilities that are *almost entirely declarations or inline accessors* — `types.h`, `color.h`, `version.h`, `PreviewFrame.h`, `JsonUtil.h`, `JsonSink.h`, `Sha1.h`, `Base64.h` — stay header-only. Templates (e.g. `ModuleFactory::registerType<T>`) also must stay in the header because of C++ instantiation rules; a module that's mostly template can therefore stay header-only.
- **Core service modules: `.h` + `.cpp`.** Core modules that bridge to the platform layer or implement substantial infrastructure (`HttpServerModule`, `FilesystemModule`, `NetworkModule`, `Scheduler`, `SystemModule`, `Control`) ship as a `.h` (interface) plus a `.cpp` (implementation). Three reasons that compound: (a) implementation changes recompile only the `.cpp`, not every TU that includes the header — incremental builds are 2–5× faster on the kind of edits that happen in development; (b) readers want the interface separately from the body; (c) symbol bloat and link-time stay bounded. Small core utilities that are *almost entirely declarations or inline accessors* — `types.h`, `color.h`, `version.h`, `BinaryBroadcaster.h`, `JsonUtil.h`, `JsonSink.h`, `Sha1.h`, `Base64.h` — stay header-only. Templates (e.g. `ModuleFactory::registerType<T>`) also must stay in the header because of C++ instantiation rules; a module that's mostly template can therefore stay header-only.
- **Exceptions need a one-line comment at the top of the file naming the reason.** Without a stated reason the file is expected to follow the default for its category. When in doubt: light → header-only, core → `.h` + `.cpp`.

## Override-and-chain convention
Expand Down
Loading