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
76 changes: 69 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ on:
# C4701 fixes each shipped unverified because they touched only test/.
- 'test/**'
- 'esp32/**'
- 'teensy/**'
- 'moondeck/build/**'
# package_desktop.py builds and packages the three desktop jobs; without this line a
# packaging fix cannot trigger the jobs that run it (the NSIS-escape fix needed a
Expand Down Expand Up @@ -91,9 +92,8 @@ jobs:
TAG: ${{ inputs.tag || github.ref_name }}
run: uv run moondeck/ci/verify_version.py --tag "$TAG"

# The shipping firmware list, read from the generated mooninstaller/firmwares.json
# (projected from build_esp32.py's FIRMWARES dict, drift-guarded by
# check_firmwares.py). Emitted as a JSON array of {firmware, chip} so build-esp32's matrix can
# The shipping ESP32 firmware list, read from the generated mooninstaller/firmwares.json
# (projected from the firmwares.py registry, drift-guarded by check_firmwares.py). Emitted as a JSON array of {firmware, chip} so build-esp32's matrix can
# fromJSON() it: GitHub matrices can't read a file at parse time, so a job output is the
# standard bridge. This is the ONLY firmware list in CI, and the chip is the IDF target.
firmwares:
Expand All @@ -108,7 +108,7 @@ jobs:
- id: gen
run: |
set -euo pipefail
echo "list=$(jq -c '[.firmwares[] | select(.ships) | {firmware: .name, chip}]' mooninstaller/firmwares.json)" >> "$GITHUB_OUTPUT"
echo "list=$(jq -c '[.firmwares[] | select(.ships and .platform == "esp32") | {firmware: .name, chip}]' mooninstaller/firmwares.json)" >> "$GITHUB_OUTPUT"

build-esp32:
needs: [verify-version, firmwares]
Expand Down Expand Up @@ -501,12 +501,70 @@ jobs:
name: desktop-linux-arm64
path: dist/

# The Teensy 4.1 firmware, a HEX for teensy_loader_cli or PJRC's Teensy app; test.yml builds it on every PR as well.
build-teensy:
needs: verify-version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- uses: astral-sh/setup-uv@v3
- name: the Arm toolchain's version, which keys its cache
id: gcc
run: echo "version=$(sed -n 's/^GCC_VERSION = "\(.*\)"/\1/p' moondeck/build/setup_teensy.py)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ~/teensy/arm-gnu-toolchain-*
key: teensy-gcc-${{ runner.os }}-${{ steps.gcc.outputs.version }}
# Same tag and version resolution as build-esp32, so the HEX reports the version and channel its asset name carries.
- name: Resolve release tag
id: tag
shell: bash
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
- name: Compute version
id: ver
shell: bash
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
V=$(uv run python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Install the Teensy SDK
run: uv run moondeck/build/setup_teensy.py --no-loader
- name: Build the Teensy 4.1 firmwares
env:
TAG: ${{ steps.tag.outputs.tag }}
V: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist
for fw in $(jq -r '.firmwares[] | select(.ships and .platform == "teensy") | .name' mooninstaller/firmwares.json); do
uv run moondeck/build/build_teensy.py --firmware "$fw" --release "$TAG" --version "$V"
cp "build/$fw/MoonLight.hex" "dist/firmware-$fw-v$V.hex"
cp "build/$fw/MoonLight.bin" "dist/firmware-$fw-v$V.bin"
done
- uses: actions/upload-artifact@v4
with:
name: firmware-teensy41
path: dist/

release:
# Publish a release on: version tag push, main branch push, or workflow_dispatch.
# Plain branch push without matching paths is filtered by the top-level `paths:`
# so the build jobs already won't run; this condition is the belt to their braces.
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
needs: [build-esp32, build-macos-arm64, build-windows-x64, build-linux-x64, build-linux-arm64]
needs: [build-esp32, build-teensy, build-macos-arm64, build-windows-x64, build-linux-x64, build-linux-arm64]
runs-on: ubuntu-latest
permissions:
contents: write # softprops/action-gh-release needs this
Expand Down Expand Up @@ -588,7 +646,7 @@ jobs:
V=$(uv run python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"

- name: Generate ESP Web Tools manifests (release-asset URLs)
- name: Generate the installer manifests (release-asset URLs)
env:
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
Expand All @@ -603,11 +661,14 @@ jobs:
# The shipping firmware list — the same mooninstaller/firmwares.json the
# build matrix reads, so manifests and builds can't drift.
for F in $(jq -r '.firmwares[] | select(.ships) | .name' mooninstaller/firmwares.json); do
# An ESP32 manifest takes its offsets from IDF's flasher args; a Teensy one is the flat image at 0.
ARGS=()
[ -f "dist/flasher-$F.json" ] && ARGS=(--flasher-args "dist/flasher-$F.json")
uv run python moondeck/build/generate_manifest.py \
--firmware "$F" \
--version "$V" \
--release-url "$BASE" \
--flasher-args "dist/flasher-$F.json" \
"${ARGS[@]}" \
--out "dist/manifest-$F.json"
done

Expand Down Expand Up @@ -675,6 +736,7 @@ jobs:
# is a glob pattern, the action does NOT strip `#` as comment syntax.
files: |
dist/firmware-*.bin
dist/firmware-*.hex
dist/shared-*.bin
dist/partition-table-*.bin
dist/manifest-*.json
Expand Down
36 changes: 36 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ on:
- 'src/**'
- 'test/unit/**'
- 'CMakeLists.txt'
# The Teensy job below builds the firmware from these.
- 'teensy/**'
- '.github/workflows/test.yml'
push:
branches:
Expand Down Expand Up @@ -84,6 +86,40 @@ jobs:
- name: node --test
run: node --test "test/js/**/*.test.mjs"

# The Teensy 4.1 firmwares, built on every PR: a change to a platform seam the Teensy must implement fails here, not on the bench.
# The HEX images are kept as an artifact, so a PR's build can be flashed and tried before it merges.
teensy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v3
# Only the Arm toolchain is cached, the large download, keyed on its version alone: an edit elsewhere in setup_teensy.py forced a fresh download, and the small source clones fetch fresh anyway.
- name: the Arm toolchain's version, which keys its cache
id: gcc
run: echo "version=$(sed -n 's/^GCC_VERSION = "\(.*\)"/\1/p' moondeck/build/setup_teensy.py)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ~/teensy/arm-gnu-toolchain-*
key: teensy-gcc-${{ runner.os }}-${{ steps.gcc.outputs.version }}
- name: install the Teensy SDK
run: uv run moondeck/build/setup_teensy.py --no-loader
- name: build the Teensy 4.1 firmwares
run: |
set -euo pipefail
mkdir -p dist
# Every shipping Teensy firmware builds and is collected, so a second one reaches the artifact too.
for fw in $(jq -r '.firmwares[] | select(.ships and .platform == "teensy") | .name' mooninstaller/firmwares.json); do
uv run moondeck/build/build_teensy.py --firmware "$fw"
cp "build/$fw/MoonLight.hex" "dist/$fw.hex"
cp "build/$fw/MoonLight.bin" "dist/$fw.bin"
done
- uses: actions/upload-artifact@v4
with:
name: firmware-teensy
path: dist/

# Sanitizers — the safety net for the multicore render/encode split. A second thread now sits
# beside code that was single-threaded for its whole life: a shared output buffer handed between
# cores, an atomic stop flag, a try-lock over the WebSocket sender, and a child array that can be
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ On "run pre-commit": run the checks whose trigger the diff matches, report one l

**A failure is something to report, not to fix and re-run.** A second run needs the words again, as much after a failure or a fix as at any other time.

**The gates run once per commit.** A run costs minutes, so scope freezes at "run pre-commit": a request made during a run goes to the next commit. A change made after a run re-checks only itself, on the product owner's go. A fixed failure reruns that one check; any other change runs the checks its own diff triggers. A full rerun needs the word "restart". A cosmetic change can ride on the run it followed, since the next commit's gates check it again.

```mermaid
flowchart LR
pc{"<b>🧑 run pre-commit</b><br/><i>PO says the words,<br/>once per request</i>"}
Expand Down
13 changes: 13 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Security policy

## Supported versions

Fixes land on `main`, which ships as the latest pre-release, and in the next tagged release. Update to the latest release to receive them.

## Reporting a vulnerability

Report it privately through GitHub: [Report a vulnerability](https://github.com/MoonModules/MoonLight/security/advisories/new). A private report lets the fix ship before the details are public. A maintainer answers in the advisory, and the fix and the advisory are published together.

## The security model

What a device trusts, what guards it, and how to secure an installation: [Security](https://moonmodules.org/MoonLight/explanation/security/).
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/aps6404l-psram.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/mdbt53-db-40.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/pi-debug-probe.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/pico-plus-2-w.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/pico-w.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/pico2-w.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/rp2040-zero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/rp2350-zero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/sn74ahct125n.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/teensy41.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/w5500-evb-pico2.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/hardware/boards/xiao-rp2040.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/assets/hardware/teensy41-breadboard.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/contributing/documentation-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ A card is read across a row; a member comment is read beside the thing it descri
| One line of a file or class lead | 400 characters |
| A whole `///` run, lead or class comment | 2500 characters |
| A sentence in a comment | 1 line, never wrapped |
| A paragraph in a comment | 1 line while it fits the line cap |
| A paragraph in a comment | 1 line, cut to fit the line cap |
| Every public member | carries one |

**A lead's line is wider, and a run is capped as a block.** A lead is the summary that opens a header or a class. One of its lines carries what the whole thing is for: what it is, which hardware it runs on, where the shared body lives. Held to the member cap the driver headers each lost about a third of that, and what went was content. The run budget is what stops a lead sprawling instead. It also closes the cheapest way to satisfy a line cap: splitting one long line into two shorter ones leaves the text identical and every per-line rule passing.
Expand All @@ -269,7 +269,7 @@ The first five cut and the last adds, deliberately: the result is a short line o

**The appendix budget counts a SECTION, not the whole appendix.** A single total punishes a file for having several distinct topics. The cheapest way to satisfy one is to delete a section rather than tighten the prose. Ten lines per `## ` section asks each one to be disciplined and lets a file carry as many as it genuinely has. That is what an implementation file needs: the platform backends each hold a handful of separately diagnosed findings, and one shared budget could only force them out of the tree. A fenced block does not count, the same exemption the no-wrap rule makes. A protocol listing is as long as the thing it describes, and counting its lines would ask an author to delete wire format to fit a prose budget.

**No hard wrap in a comment, for the reason markdown gives.** Let the editor soft-wrap, so a one-word edit is a one-word diff rather than a reflowed paragraph. The one-line budget already forbids this on a header's member or code comment. So the rule bites where a block is allowed to be multi-line. That is the class comment, the `@moreinfo` appendix, and any multi-line run in an implementation file. A list item, a heading, a table row and a fenced block are structure rather than a wrapped sentence, and each is left alone. This one holds everywhere, because its reason is the diff rather than the page. A split sentence reflows every line it spans when a word changes, which costs a reviewer the same in either file. A paragraph goes the same way: its sentences share one line while that line fits the cap. Markdown joins them into one paragraph anyway, so the split only spends a line. Past the cap, each sentence keeps a line of its own.
**No hard wrap in a comment, for the reason markdown gives.** Let the editor soft-wrap, so a one-word edit is a one-word diff rather than a reflowed paragraph. The one-line budget already forbids this on a header's member or code comment. So the rule bites where a block is allowed to be multi-line. That is the class comment, the `@moreinfo` appendix, and any multi-line run in an implementation file. A list item, a heading, a table row and a fenced block are structure rather than a wrapped sentence, and each is left alone. This one holds everywhere, because its reason is the diff rather than the page. A split sentence reflows every line it spans when a word changes, which costs a reviewer the same in either file. A paragraph goes the same way: the sentences of one topic are one paragraph, and a paragraph is one line. Markdown joins them anyway, and a reader takes separate lines as separate topics. Past the cap the paragraph is cut until it fits, never split into sentence lines or line-sized chunks.

**Use `//` sparingly.** A comment restating what the code does is a naming failure, and the fix is a better name rather than a better sentence. What survives is the WHY a reader cannot recover from the code.

Expand Down
11 changes: 11 additions & 0 deletions docs/explanation/architecture/mooninstaller.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ The words firmware, deviceModel, board and device are defined once, in [the word
**deviceModel** is the physical hardware: chip + PCB + on-board peripherals (PHY, USB-serial, PSRAM, antenna), identified by its product name. Examples: `Olimex ESP32-Gateway Rev G`, `LOLIN D32`, `Generic ESP32 Dev`. A unit cannot identify its own deviceModel (no readable PCB ID on classic ESP32), so MoonDeck deduces it from the firmware where unambiguous (`esp32-eth*` ⇒ Olimex) and otherwise lets the user pick. It is stored on the unit as SystemModule's `deviceModel` Text control (display-only in the UI; HTTP `/api/control` writes still apply). MoonDeck mirrors the picked / deduced value to the unit via `PATCH /api/state` after each discover and after every dropdown change. The catalog of valid deviceModels lives at [mooninstaller/deviceModels.json](../../mooninstaller/deviceModels.json), shared between MoonDeck and the web installer. MoonDeck reads it for its dropdown and HTTP push over plain REST on the LAN. The web installer reads it for its picker and pushes the entry's `state` document, deviceModel included, over serial during provisioning, one document per top-level container, applied by the same engine as `PATCH /api/state` (**"Improv = REST over serial"**, the `APPLY_OP` vendor RPC; see [ImprovProvisioningModule.md](../../moonmodules/core/moxygen/ImprovProvisioningModule.md)). Pushing over serial sidesteps the mixed-content block that stops an HTTPS installer page from POSTing to an `http://` device; an already-running device is re-configured via MoonDeck on the LAN. **`SET_BOARD` carries only the board name**, and every other field ships over HTTP after WiFi association. Do not extend its wire format: that couples unrelated controls to the board-name lifecycle and hides the timing constraint. A pre-association control gets either its own vendor RPC dispatched before the credentials, or a board-specific sdkconfig fragment when the value is truly board-static.

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.
## One manifest, a writer per chip family

Every firmware's manifest has the ESP Web Tools shape, and its `chipFamily` picks the writer before anything touches the device.
An ESP32 family goes to esptool-js over Web Serial; `Teensy 4.1` goes to `teensy-writer.js`, which speaks PJRC's HalfKay bootloader protocol over WebHID, with one part, the flat image at offset 0.
Detection follows the same split: a serial port with PJRC's USB ids names itself a Teensy, with no ESP32 handshake.
A board without a serial port, held in its bootloader by its program button, is reached from a canceled port pick, the one moment it is needed.
Each next platform adds its writer here and nothing else, as the UF2 boards will.

The device UI updates a device the same two ways: it hands the release URL to the device, and a device without TLS answers that route with 501.
The browser then downloads the image from the release's copy on the installer's site, which allows a cross-origin read, and uploads it.

## Config provenance: MCU → deviceModel

Firmware-vs-deviceModel is a **two-level** model for **where a pin or setting default comes from**. The installer and MoonDeck use it so a user picks their hardware instead of hand-typing every GPIO. A default belongs at the level that *fixes* it:
Expand Down
Loading
Loading