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: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@
**`test_host --ui` is never a gate.** The UI scenario runs drive a real browser against a running device and are what the documentation clips are recorded from, so they cost minutes and skip wholesale without a desktop and a Playwright browser. They run on request only, never as part of pre-commit, pre-merge or pre-release, and the `src/ui` trigger above means `--js` alone.


**`check_docgen` is a ratchet.** Errors are resolved before a commit, and warnings may only fall: the committed `docs/reference/metrics/docgen.md` is the number to beat, on the total and on every rule. It fails a run that raises either. Per rule as well as per total, because a total hides one rule paying for another, and because the cheapest way to satisfy a width rule is to split a line, which raises the block count and fixes nothing. A rule whose own limit changed is the one case to say so in the commit.

Check warning on line 211 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"CLAUDE.md","range":{"start":{"line":211,"column":238},"end":{"line":211,"column":241}}},"severity":"INFO","code":{"value":"projectMM.SentenceLength"}}

**A file with warnings is left better than it was found.** Holding the line is the floor, not the goal: the report is meant to shrink, and it only does so if each change spends a little effort on the warnings in the files it already touches. Reasonable effort, in the spirit of principle 5: the ones a reader would agree with, not a rewrite of every comment in the file. What resists is left with its count unchanged rather than forced, since a comment split to satisfy a width rule is the move the per-rule ratchet exists to refuse. Files the change never opened are a sweep of their own.

Check warning on line 213 in CLAUDE.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"CLAUDE.md","range":{"start":{"line":213,"column":60},"end":{"line":213,"column":67}}},"severity":"INFO","code":{"value":"projectMM.SentenceLength"}}

Three checks earn their place for a reason worth knowing. **Repo health** is the only place the creeping numbers are visible: flash and DRAM per target, binary size, the tick matrix, line counts, complexity warnings. Its diff belongs in the commit and its deltas in the commit message. It runs when the code changes rather than on every commit, because its timings drift with the host: on a docs-only diff it records a regression that nothing in the diff caused. **The no-backend build** catches a helper left unused outside its guard, fatal under GCC while clang stays silent. **ESP32 firmware fresh** compares the binary against every source in a tenth of a second and catches the edit that was never compiled; compile for real after an sdkconfig or toolchain change. The [provisioning path](moondeck/MoonDeck.md#improv_smoke_test) is the five files MoonDeck names.


Expand Down
Binary file added docs/assets/light/drivers/ParallelLedDriver.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/light/drivers/RmtLedDriver.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion docs/contributing/documentation-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ Two scales below a page. **A module** has exactly one reference page written and
- **A page is read start to finish by one reader**, a **user** or a **developer**. A user brings no coding and no hardware knowledge beyond plugging in a board; a developer brings C++, embedded and this codebase's shape. Where a page serves both, lead with the user and put the depth lower down.
- **The headings are the page's table of contents, and they read top to bottom.** A few lines say what the page is and one paragraph says how it is laid out. The sections then follow in the order a reader needs them. A title that makes sense only after reading the body is the order being wrong.
- **One tone of voice, everywhere: factual, no nonsense.** State what is true and what to do, addressing the reader as "you". Leave out enthusiasm, apology, and how we felt building it. Only the assumed knowledge changes between pages, never the voice.
- **No sentence whose job is tone.** Every sentence carries a fact the reader needs. Three shapes to cut: a second person used for effect rather than instruction, a flourish before any information arrives, and a rhetorical question the page then answers. Vale checks spelling, sentence length and weasel words, so this one is the writer's judgement and the reviewer's check.
- **Follow the [principles](../CLAUDE.md#principles).** Three bear on documentation directly:
- **Minimalism**: every fact has one home; history lives in git.
- **Present tense only.** "No X anymore" narrates a removal, which is history. Describe the path that exists today.
Expand Down Expand Up @@ -258,7 +259,7 @@ The first five cut and the last adds, deliberately: the result is a short line o

**Depth is homed rather than forbidden.** One line is room to say why. Past that the reasoning belongs in an `@moreinfo` appendix, with an `@xref` back from the line that raised the question. Both kinds of file have one. A header's sits on its class or file lead, and an implementation file's on its own file lead, which 212 headers already carry. A larger cap for implementation files was tried and removed, because it homed depth inline. The reasoning then sits beside one call rather than where a reader goes looking for it.

**A finding in a header is an error; one in an implementation file is a warning.** This follows the same line. A header's comments are the published page, so a defect there ships, while a `.cpp` publishes nothing and its comments are a note to the next reader. Both are counted and both are reported, because a warning nobody sees is a warning nobody fixes. The split stages the sweep rather than ranking the two kinds of comment, so it goes and everything blocks once the warning column reaches zero. That is how Vale's own config promotes a page to error as the sweep finishes it.
**A finding in a header is an error; one in an implementation file is a warning, with one rule in each direction.** No-hard-wrap blocks in both kinds of file: the tree is at zero findings, so there is nothing left to stage. The line-length cap warns in both, because it is new and its findings are lines nobody wrote wrongly. A staged rule joins the others as the tree meets it. A header's comments are the published page, so a defect there ships, while a `.cpp` publishes nothing and its comments are a note to the next reader. Both are counted and both are reported, because a warning nobody sees is a warning nobody fixes. The split stages the sweep rather than ranking the two kinds of comment, so it goes and everything blocks once the warning column reaches zero. That is how Vale's own config promotes a page to error as the sweep finishes it.

Enforced by [`check_docgen.py`](../moondeck/check/check_docgen.py) over every header under `src/`, the vendored ones excepted.

Expand Down
11 changes: 2 additions & 9 deletions docs/contributing/principles-and-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,8 @@ title: Principles & process
---

<!-- The rules live in CLAUDE.md at the repo root, where every Claude Code session
auto-loads them and where GitHub renders them. This page embeds that file rather
than restating it, so the site and the working rulebook are the same text (one
home, per the no-duplication rule); pymdownx.snippets resolves the path from the
repo root via base_path.
auto-loads them and where GitHub renders them. This page embeds that file rather than restating it, so the site and the working rulebook are the same text (one home, per the no-duplication rule); pymdownx.snippets resolves the path from the repo root via base_path.

The `title:` above names the page in the nav and the browser tab. The embedded
text opens with its own `# CLAUDE.md` heading, which is the right name at the repo
root but not in a docs menu — front matter overrides it without editing the source
file. The doc links inside the embed are rebased at build time (docs/x.md → x.html);
see _rebase_repo_root_doc_links in moondeck/docs/mkdocs_hooks.py. -->
The `title:` above names the page in the nav and the browser tab. The embedded text opens with its own `# CLAUDE.md` heading, which is the right name at the repo root but not in a docs menu — front matter overrides it without editing the source file. The doc links inside the embed are rebased at build time (docs/x.md → x.html); see _rebase_repo_root_doc_links in moondeck/docs/mkdocs_hooks.py. -->

--8<-- "CLAUDE.md"
59 changes: 11 additions & 48 deletions docs/explanation/architecture/moonbase.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,65 +28,28 @@

## What it replaces

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

Check warning on line 31 in docs/explanation/architecture/moonbase.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"docs/explanation/architecture/moonbase.md","range":{"start":{"line":31,"column":108},"end":{"line":31,"column":116}}},"severity":"INFO","code":{"value":"projectMM.SentenceLength"}}

A 4 MB board has no choice, having room for one application and not two, and 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.
A 4 MB board has no choice, having room for one application and not two, and 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` rather than a property of flash size. Today the 4 MB classic, the
S3-Zero and `esp32-16mb` use it, and it may become the default everywhere.
Which boards use MoonBase is a per-variant decision recorded in `moondeck/build/build_esp32.py` rather than a property of flash size. Today the 4 MB classic, the S3-Zero and `esp32-16mb` use it, and it may become the default everywhere.

## The update cycle

The update cycle runs in three moves. 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 credentials, falling back to an AP at 4.3.2.1, and installs into the single app
slot, either from the staged URL unattended or from an upload. Then it reboots back. The UI covers the whole cycle with one
"updating firmware" overlay, telling the two images apart via `GET /moonbase` (MoonBase answers
with its live status; the app 404s it). Pointing the bootloader at a factory partition *erases*
otadata, so a power cut anywhere mid-install boots MoonBase and the user retries over the
network, a stronger power-fail story than dual-OTA's. A failed install deliberately stays in
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.
The update cycle runs in three moves. 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 credentials, falling back to an AP at 4.3.2.1, and installs into the single app slot, either from the staged URL unattended or from an upload. Then it reboots back. The UI covers the whole cycle with one "updating firmware" overlay, telling the two images apart via `GET /moonbase` (MoonBase answers with its live status; the app 404s it). Pointing the bootloader at a factory partition *erases* otadata, so a power cut anywhere mid-install boots MoonBase and the user retries over the network, a stronger power-fail story than dual-OTA's. A failed install deliberately stays in 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.

Check warning on line 39 in docs/explanation/architecture/moonbase.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"docs/explanation/architecture/moonbase.md","range":{"start":{"line":39,"column":747},"end":{"line":39,"column":748}}},"severity":"INFO","code":{"value":"projectMM.SentenceLength"}}

Check warning on line 39 in docs/explanation/architecture/moonbase.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"docs/explanation/architecture/moonbase.md","range":{"start":{"line":39,"column":547},"end":{"line":39,"column":555}}},"severity":"INFO","code":{"value":"projectMM.SentenceLength"}}

Check warning on line 39 in docs/explanation/architecture/moonbase.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"docs/explanation/architecture/moonbase.md","range":{"start":{"line":39,"column":158},"end":{"line":39,"column":166}}},"severity":"INFO","code":{"value":"projectMM.SentenceLength"}}

## Updating MoonBase itself

**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. A 4 MB board also has nowhere to stage 743 KB before erasing, so the image streams straight in.
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. A 4 MB board also has nowhere to stage 743 KB before erasing, so the image streams straight in.
Everything that can reject it is therefore decided from its FIRST CHUNK, before a byte is erased:
the image magic, the chip id, and the descriptor naming `projectMM-moonbase` rather than the app.
The chip id matters because there is one MoonBase per chip, one paste apart, and a checksum does
not catch a swap. Those rules live in `src/core/util/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.
The chip id matters because there is one MoonBase per chip, one paste apart, and a checksum does not catch a swap. Those rules live in `src/core/util/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.

## Telling the two images apart

Each image reports its version from the app descriptor IDF puts in every binary, with
`PROJECT_VER` set to the same computed version for both. The app can therefore 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, and owns the
flash-layout helpers every consumer uses: serial flash, mooninstaller manifests, release preview
and 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 correction from one
place. Prior art: Tasmota's safeboot scheme and
[MycilaSafeBoot](https://github.com/mathieucarbou/MycilaSafeBoot) proved the single-slot +
recovery-image pattern; MoonBase is our from-scratch, minimal take on it.
Each image reports its version from the app descriptor IDF puts in every binary, with `PROJECT_VER` set to the same computed version for both. The app can therefore 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.

Check warning on line 53 in docs/explanation/architecture/moonbase.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"docs/explanation/architecture/moonbase.md","range":{"start":{"line":53,"column":262},"end":{"line":53,"column":263}}},"severity":"INFO","code":{"value":"projectMM.SentenceLength"}}

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, and owns the flash-layout helpers every consumer uses: serial flash, mooninstaller manifests, release preview and 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 correction from one place. Prior art: Tasmota's safeboot scheme and [MycilaSafeBoot](https://github.com/mathieucarbou/MycilaSafeBoot) proved the single-slot + recovery-image pattern; MoonBase is our from-scratch, minimal take on it.

Check warning on line 55 in docs/explanation/architecture/moonbase.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"docs/explanation/architecture/moonbase.md","range":{"start":{"line":55,"column":1},"end":{"line":55,"column":9}}},"severity":"INFO","code":{"value":"projectMM.SentenceLength"}}
14 changes: 3 additions & 11 deletions docs/explanation/architecture/mooncore.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,6 @@
**Platform boundary (hard rule).** All `#ifdef`, `#if defined`, platform-specific `#include`s, and hardware API calls live exclusively in `src/platform/`. Everything outside `src/platform/` compiles on every target without modification. Compile-time platform branching uses `if constexpr` on `platform_config.h` flags, never a preprocessor `#ifdef`. The boundary is enforced by [`moondeck/check/check_platform_boundary.py`](../../moondeck/check/check_platform_boundary.py), a commit gate (see [CLAUDE.md § The Process](../../CLAUDE.md#the-process)).

**The desktop build runs everything (hard rule).** Every module, effect and driver in the repo
links and runs on the host, the platform layer has no silicon behind the call. Where a
peripheral is absent the host *emulates* it rather than declaring itself incapable: the parallel
WS2812 buses are backed by heap buffers, `lcdLanes` / `parlioLanes` / `rmtTxChannels` report a
real chip's counts, and `hasLcdCam` is true. Code excluded from the host binary is code that cannot be unit-tested and cannot be seen by any
AST-based check. It only ever runs where it is hardest to debug, which is what the LED drivers
were until they were linked here.

A capability flag therefore answers *"can this build exercise the path?"*, not *"is this real
hardware?"*. Where a flag must mean the latter (`hasLcdCam` gating the pin expander), that is a
deliberate, commented exception. Timing, wire protocol and pin state are NOT emulated: they need
silicon, and faking them would let a self-test report on hardware it never touched.
links and runs on the host, the platform layer has no silicon behind the call. Where a peripheral is absent the host *emulates* it rather than declaring itself incapable: the parallel WS2812 buses are backed by heap buffers, `lcdLanes` / `parlioLanes` / `rmtTxChannels` report a real chip's counts, and `hasLcdCam` is true. Code excluded from the host binary is code that cannot be unit-tested and cannot be seen by any AST-based check. It only ever runs where it is hardest to debug, which is what the LED drivers were until they were linked here.

Check warning on line 114 in docs/explanation/architecture/mooncore.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma. Raw Output: {"message":"[projectMM.SentenceLength] Sentence runs past 30 words; it is probably two thoughts joined by a comma.","location":{"path":"docs/explanation/architecture/mooncore.md","range":{"start":{"line":114,"column":80},"end":{"line":114,"column":85}}},"severity":"INFO","code":{"value":"projectMM.SentenceLength"}}

A capability flag therefore answers *"can this build exercise the path?"*, not *"is this real hardware?"*. Where a flag must mean the latter (`hasLcdCam` gating the pin expander), that is a deliberate, commented exception. Timing, wire protocol and pin state are NOT emulated: they need silicon, and faking them would let a self-test report on hardware it never touched.
Loading
Loading