diff --git a/.github/workflows/container-test.yml b/.github/workflows/container-test.yml new file mode 100644 index 00000000..30c20f7a --- /dev/null +++ b/.github/workflows/container-test.yml @@ -0,0 +1,182 @@ +# TEMPORARY: a full end-to-end rehearsal of a release, as a PRERELEASE. +# +# Why this exists. The real release workflow cannot be rehearsed from a branch: when its tag +# resolves to `latest` it force-pushes that tag and deletes the `latest` release, which the web +# installer and the OTA update badge both read. Running it from a feature branch would hand every +# updating user a branch build. +# +# So this does the whole thing under a different name: build the Linux desktop package, build a +# container image from that exact .deb, push the image, pull it back and prove it serves, then +# attach the artifacts to a PRERELEASE. Publicly visible, so the person who asked for the container +# can test it, and reachable by no device: the stable update check reads `/releases/latest` (newest +# non-prerelease) and the dev channel reads `/releases/tags/latest` (a specific tag), so neither +# sees a prerelease under its own tag. Delete it when you are done. +# +# DELETE THIS FILE once release.yml's publish-container job has run for real on main. It exists to +# de-risk that first run, not to become a second way of releasing. +# +# It never touches the `latest` tag or any vX.Y.Z tag. +name: container test (temporary, prerelease) + +# PUSH on this one branch, plus manual. The push trigger is what makes it runnable at all: a +# `workflow_dispatch` workflow is addressable only once its file sits on the DEFAULT branch, because +# GitHub resolves the name you dispatch against main, so dispatching this from its own branch fails +# with a 404 and no Run button appears. A push trigger has no such requirement, and this is how +# `moonbase-test-release.yml` was run for the MoonBase test releases. +# +# Scoped to `docker-container` so it cannot follow the code onto main: this file is deleted once +# release.yml's publish-container job has run for real, and until then the branch name IS the guard. +# `workflow_dispatch` stays for a re-run without an empty commit, and starts working once main has +# the file. +on: + push: + branches: + - docker-container + workflow_dispatch: + +jobs: + container-test: + runs-on: ubuntu-latest + permissions: + contents: write # the prerelease and its own `container-test` tag; it moves no other + packages: write # push the image to ghcr.io + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + # compute_version.py counts commits since the last v* tag, as release.yml does. + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v3 + + # The same packaging step release.yml's build-linux runs, so the .deb under test is built the + # way a released one is rather than by a path that exists only here. + # + # `--tag latest`, NOT `--tag container-test`: compute_version maps `latest` to the rolling + # prerelease channel and treats every other tag as stable, carrying it through as the version + # verbatim. So a made-up tag becomes a made-up version, and dpkg-deb rejects it outright, since + # a Debian version must start with a digit. `latest` yields the real `-dev.` a rolling + # release builds, which is also the truer rehearsal. What isolates this run is the IMAGE tag + # and the RELEASE tag, both `container-test` and both set below. + - name: Build + package Linux x64 + id: pkg + run: | + set -euo pipefail + V=$(uv run python moondeck/build/compute_version.py --tag latest) + echo "version=$V" >> "$GITHUB_OUTPUT" + uv run moondeck/ci/package_desktop.py --version "$V" + ls -la dist/ + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Lowercase, because a registry path must be and the org is not. + - name: Resolve image name + id: img + env: + REPO: ${{ github.repository }} + run: | + set -euo pipefail + echo "name=ghcr.io/$(echo "$REPO" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + # Byte-for-byte the Dockerfile release.yml writes: a rehearsal of a different file would + # prove nothing about the one that ships. + - name: Write the release Dockerfile + run: | + set -euo pipefail + deb=$(ls dist/projectmm_*_amd64.deb | head -1) + test -n "$deb" + mkdir -p ctx && cp "$deb" ctx/projectmm.deb + cat > ctx/Dockerfile <<'DOCKERFILE' + FROM debian:trixie-slim AS fetch + COPY projectmm.deb /tmp/projectmm.deb + RUN dpkg-deb -x /tmp/projectmm.deb /rootfs + FROM gcr.io/distroless/cc-debian13 + COPY --from=fetch /rootfs/usr/bin/projectMM /usr/bin/projectMM + ENV XDG_DATA_HOME=/data + VOLUME /data + EXPOSE 8080 + ENTRYPOINT ["/usr/bin/projectMM"] + DOCKERFILE + + - uses: docker/build-push-action@v6 + with: + context: ctx + platforms: linux/amd64 + push: true + # `container-test` only: never `latest`, never a version tag. This image is a rehearsal + # artifact and must not be mistaken for a release. + tags: ${{ steps.img.outputs.name }}:container-test + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.description=TEMPORARY container-publishing rehearsal, not a release + + # The point of the whole workflow: pull what was pushed and prove it SERVES, rather than + # trusting that a successful push means a working image. + - name: Pull it back and check it answers + id: verify + run: | + set -euo pipefail + img="${{ steps.img.outputs.name }}:container-test" + docker rmi "$img" 2>/dev/null || true # force a real pull, not the local cache + docker pull "$img" + docker run -d --name mmtest -p 8080:8080 -v mmtest-data:/data "$img" + ok="" + for i in $(seq 1 30); do + if curl -fsS -m 3 http://localhost:8080/api/modules/System >/dev/null 2>&1; then + ok="yes"; echo "UI answered after ~${i}s"; break + fi + sleep 1 + done + test -n "$ok" || { echo "the image never served"; docker logs mmtest; exit 1; } + name=$(curl -fsS -m 5 http://localhost:8080/api/modules/System \ + | python3 -c 'import sys,json; d=json.load(sys.stdin); v={c["name"]:c.get("value") for c in d["controls"]}; print(v.get("deviceName"), v.get("mac"))') + echo " identity: $name" + echo "identity=$name" >> "$GITHUB_OUTPUT" + # The identity must have been generated and stored: that is what makes an instance + # distinguishable and what survives an upgrade. + docker run --rm -v mmtest-data:/data alpine cat /data/projectMM/.config/identity + docker rm -f mmtest + + # A PRERELEASE carrying the same assets a real one would, so the whole path is exercised and + # anyone can try it: a draft would be invisible to people without write access, and the point + # of this rehearsal is that the person who asked for the container can test it too. + # + # Safe because of WHAT the update checks read. The stable channel fetches + # `/releases/latest`, which the GitHub API defines as the newest NON-prerelease, and the dev + # channel fetches `/releases/tags/latest`, a specific tag. Neither enumerates releases, so a + # prerelease under its own tag reaches no device. It does appear on the Releases page, which + # is the visibility we want. + - name: Prerelease with the artifacts + uses: softprops/action-gh-release@v2 + with: + tag_name: container-test + name: "container test ${{ steps.pkg.outputs.version }} (rehearsal, delete me)" + draft: false + prerelease: true + make_latest: "false" + fail_on_unmatched_files: true + files: | + dist/projectMM-linux-x64-*.tar.gz + dist/projectmm_*_amd64.deb + body: | + **Rehearsal, not a release.** Produced by `.github/workflows/container-test.yml` to + prove the container path before it ships in `release.yml`. It is marked a prerelease so + no device update check reaches it. Delete it when done. + + The image is public, so anyone can run it without a GitHub login. + + Container image, pulled back and verified serving in the same run: + + ``` + docker run -d -p 8081:8080 -v projectmm:/data \ + ${{ steps.img.outputs.name }}:container-test + ``` + + Reported identity on first start: `${{ steps.verify.outputs.identity }}` + (generated and stored in the volume, so it survives an upgrade). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddee7279..2d34436a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -422,6 +422,10 @@ jobs: # the `main` ref, so a tag push can't deploy Pages itself). outputs: tag: ${{ steps.tag.outputs.tag }} + # The resolved semver, so the container job can tag its image with the same string the + # binary reports. Computed here already for the manifests; exposed rather than recomputed, + # because two computations are two chances to disagree about what this release is called. + version: ${{ steps.ver.outputs.version }} # No `environment: github-pages` here: that environment's protection rule # only allows `main`, so binding asset-publishing to it made every vX.Y.Z # tag run fail BEFORE any step ran β€” including the asset upload β€” leaving @@ -588,6 +592,93 @@ jobs: dist/projectMM-*-setup.exe dist/projectmm_*.deb + # Publish the container image to the GitHub Container Registry, one per release. + # + # It runs AFTER `release` and pulls the .deb from that job's artifacts rather than downloading it + # from the API: the release the image describes is the one that just built, so taking the package + # from the same run is what makes the image and the binary provably the same build. It also means + # the image exists the moment the release does, with no window where `latest` points at the + # previous version. + # + # amd64 only, because the .deb is. An arm64 image needs an arm64 Linux build in `build-linux` + # first, at which point this job gains a `platforms:` line and nothing else changes. + publish-container: + if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + needs: [release] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # push to ghcr.io; GITHUB_TOKEN carries it, no secret to manage + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # The same .deb the release published, from the same run. + - uses: actions/download-artifact@v4 + with: + name: desktop-linux + path: dist + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Lowercase, because a registry path must be and the org is not: `MoonModules/projectMM` + # would be rejected where `moonmodules/projectmm` is accepted. + - name: Resolve image name + id: img + env: + REPO: ${{ github.repository }} + run: | + set -euo pipefail + echo "name=ghcr.io/$(echo "$REPO" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + # A build context of just the .deb, so the image installs THIS run's package instead of + # resolving one from the API the way a local `docker build` does. Written here rather than + # kept as a second Dockerfile in the repo: it is four lines, and two Dockerfiles that must + # agree about the runtime is exactly the duplication that drifts. + - name: Write the release Dockerfile + run: | + set -euo pipefail + deb=$(ls dist/projectmm_*_amd64.deb | head -1) + test -n "$deb" + mkdir -p ctx && cp "$deb" ctx/projectmm.deb + cat > ctx/Dockerfile <<'DOCKERFILE' + FROM debian:trixie-slim AS fetch + COPY projectmm.deb /tmp/projectmm.deb + RUN dpkg-deb -x /tmp/projectmm.deb /rootfs + # debian13, NOT debian12: the binary is built on ubuntu-24.04 (glibc 2.39) and needs + # glibc >= 2.38, where bookworm ships 2.36 and it dies at startup. See ../../Dockerfile. + FROM gcr.io/distroless/cc-debian13 + COPY --from=fetch /rootfs/usr/bin/projectMM /usr/bin/projectMM + ENV XDG_DATA_HOME=/data + VOLUME /data + EXPOSE 8080 + ENTRYPOINT ["/usr/bin/projectMM"] + DOCKERFILE + + # Two tags: the exact version, which never moves, and `latest`, which follows this workflow's + # own notion of latest (the rolling prerelease from main, or a tagged release). A user pins + # one or tracks the other. + - uses: docker/build-push-action@v6 + with: + context: ctx + platforms: linux/amd64 + push: true + tags: | + ${{ steps.img.outputs.name }}:${{ needs.release.outputs.version }} + ${{ steps.img.outputs.name }}:latest + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ needs.release.outputs.version }} + org.opencontainers.image.description=Drive large LED installations and DMX fixtures + org.opencontainers.image.licenses=GPL-3.0 + # Deploy the web installer to GitHub Pages. Separate from `release` because # the `github-pages` environment's protection rule only allows `main` β€” so # this job is gated to main and carries the environment, while `release` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..1f12d65c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,85 @@ +# projectMM as a container: the desktop firmware, which is the whole system without an ESP32. +# +# The desktop build is not a simulator. It runs the same effect pipeline, the same web UI and the +# same driver stack as a board, and it drives real fixtures over Art-Net, DDP and E1.31, so a +# container is a complete live installation for anyone whose fixtures are on the network. +# +# It INSTALLS the released .deb rather than building from source, deliberately. The release already +# produces that package; building here would be a second build path to keep working, and the two +# would drift. The image is packaging, not a build. +# +# docker build -t projectmm . # the rolling prerelease (default) +# docker build --build-arg RELEASE=stable -t projectmm . # the newest stable release +# docker build --build-arg RELEASE=v4.0.0 -t projectmm . # a specific tag +# docker run --rm -p 8080:8080 -v projectmm:/data projectmm +# +# Then open http://localhost:8080/. +# +# **Ports.** 8080 is the web UI, and the only port needed to try it out. Driving fixtures is +# OUTBOUND: Art-Net on UDP 6454, DDP on 4048, E1.31/sACN on 5568. Those are L3 and reach a unicast +# fixture address through ordinary bridge networking. +# +# **When L2 matters.** mDNS discovery (finding boards, being found by them) is multicast and does +# not cross a bridge network, and Art-Net's broadcast mode has the same problem. For those, attach +# the container to the host's network directly (`--network host`, or an L2 CNI on Kubernetes). +# Unicast output needs none of it. NOT verified on a Linux host yet: on macOS and Windows, Docker +# Desktop runs a Linux VM, so `--network host` joins the VM rather than the machine's LAN and the +# question cannot be answered there. +# +# **Capabilities.** None. It binds 8080 as an ordinary process and needs no added capability. +# +# **amd64 only.** The release ships no arm64 LINUX binary (macOS arm64 is a different target), so +# an arm64 image needs an arm64 build in the release pipeline first, not a change here. + +# --- stage 1: fetch the release and unpack it ------------------------------------------------- +# A full Debian image, used only to resolve and extract the .deb. None of it reaches the result. +FROM debian:trixie-slim AS fetch + +# WHICH release to install, and the default is the ROLLING PRERELEASE, matching what the installer +# page offers rather than the last tagged version: projectMM ships from `main` continuously, so a +# tagged release can be months behind what a board would be flashed with, and an image that lagged +# the firmware would be the wrong thing to test against. +# +# `latest` here is a real git TAG carrying that rolling build, not GitHub's "latest release" idea. +# `stable` is the special value asking for GitHub's newest NON-prerelease, and anything else is +# taken as a literal tag. The two words genuinely differ, which is why both exist. +ARG RELEASE=latest +ARG REPO=MoonModules/projectMM + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && if [ "$RELEASE" = "stable" ]; then \ + api="https://api.github.com/repos/${REPO}/releases/latest"; \ + else \ + api="https://api.github.com/repos/${REPO}/releases/tags/${RELEASE}"; \ + fi \ + && url=$(curl -fsSL "$api" | grep -o 'https://[^"]*_amd64\.deb' | head -1) \ + && test -n "$url" || { echo "no amd64 .deb in release ${RELEASE}" >&2; exit 1; } \ + && curl -fsSL -o /tmp/projectmm.deb "$url" \ + && dpkg-deb -x /tmp/projectmm.deb /rootfs + +# --- stage 2: the image that ships ------------------------------------------------------------ +# Distroless: the binary plus its four shared libraries, with no shell and no package manager, so +# the attack surface is the application rather than a distribution. `ldd` on the release binary +# lists exactly libstdc++, libm, libgcc_s and libc, which is the whole reason this fits: nothing +# else has to come along. 45 MB against 140 MB for the full-Debian form. +# +# **debian13, NOT debian12**, and this is load-bearing. The release is built on ubuntu-24.04 +# (glibc 2.39), so the binary requires glibc >= 2.38. The debian12/bookworm images ship 2.36, where +# it installs cleanly and then dies at startup with "GLIBC_2.38 not found" from libc and libm. +# Verified both ways on the bench. If the release ever moves to an older builder, this can too. +FROM gcr.io/distroless/cc-debian13 + +COPY --from=fetch /rootfs/usr/bin/projectMM /usr/bin/projectMM + +# WHERE THE CONFIG LIVES, and why this line is required rather than a convenience. The desktop +# build resolves its data directory from the environment (platform_desktop.cpp, userDataDir): on +# Linux XDG_DATA_HOME first, then HOME/.local/share. A container has NEITHER, and the function then +# returns empty, so without this the app has nowhere defined to write. Setting it explicitly also +# gives the volume one documented path instead of a guess: config lands in /data/projectMM/.config. +ENV XDG_DATA_HOME=/data +VOLUME /data + +EXPOSE 8080 + +ENTRYPOINT ["/usr/bin/projectMM"] diff --git a/README.md b/README.md index 5d92e9e3..c00c18d5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Drive large LED installations and DMX fixtures. One source tree drives ESP32, Te πŸ“¦ **Release + downloads:** [latest release](https://github.com/MoonModules/projectMM/releases/latest) +🐳 **No hardware handy?** `docker run -p 8081:8080 -v projectmm:/data ghcr.io/moonmodules/projectmm:latest` runs the whole system in a container: the same UI and effect pipeline, driving fixtures over Art-Net, DDP or E1.31. See [building.md Β§ Docker](docs/building.md#docker). + πŸ› οΈ **Building / hacking on it?** [MoonDeck](moondeck/MoonDeck.md), our browser-based dev console (build Β· flash Β· test Β· live device discovery), comes in the repo. Open Chrome or Edge, plug in your device, and you'll see lights in under a minute. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..4e2973da --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +# projectMM in a container: the desktop firmware, which is the whole system without an ESP32. +# +# docker compose up -d start it, in the background +# docker compose logs -f watch it +# docker compose down stop it (the volume, and your config, survive) +# +# Then open http://localhost:8081/. +# +# Everything worth changing is one line, marked CHANGE ME. + +services: + projectmm: + build: . + # The released binary is amd64 only, so this line is REQUIRED on an arm64 host (an Apple-silicon + # Mac, an ARM server). Without it compose builds for the host's own architecture and the + # container dies with "rosetta error: failed to open elf" the moment it starts. It is harmless + # on an amd64 host, where it is what would have happened anyway. It goes when the release grows + # an arm64 Linux build. + platform: linux/amd64 + # Or, once images are published, drop `build:` and use one of these instead: + # image: ghcr.io/moonmodules/projectmm:latest # the rolling prerelease + # image: ghcr.io/moonmodules/projectmm:4.0.0 # a fixed version + container_name: projectmm + + ports: + # CHANGE ME: the port YOU open in the browser is the left one. 8081 rather than 8080 so a + # container never fights a projectMM already installed on the machine. The right side is the + # port inside the container and does not change: containers do not share a port space, so + # several instances can all listen on 8080 internally with different left-hand numbers. + - "8081:8080" + + volumes: + # Config, presets and scripts. Everything the app writes lands in /data/projectMM, so this one + # mount is the whole of its state: remove it and a restart comes up factory-fresh. + - projectmm-data:/data + + # The container has no browser to open, and an unknown argument is a hard error rather than + # something ignored, so this is the one flag worth passing by default. The other is --port, + # which only matters under host networking (see the bottom of this file). + command: ["--no-browser"] + + # NAMING an instance happens in the UI (System > deviceName), not on the command line: it is + # persisted state, so it lives in the volume above and survives a restart. It is not a hostname + # either, because the desktop build advertises no mDNS. Instances address each other by IP, + # which is what the network drivers take. + + restart: unless-stopped + + # CPU is the real limit when running SEVERAL instances, and the reason is worth knowing: the + # desktop build renders as fast as the machine allows (measured at ~83,000 fps in a container), + # because nothing paces it the way an LED refresh paces a board. One instance therefore takes a + # whole core, and three take three. Uncomment to cap it; 0.5 is ample for output over the + # network, where the wire rate is what actually matters. + # deploy: + # resources: + # limits: + # cpus: "0.5" + + # For DISCOVERY and BROADCAST output, replace the `ports:` block above with host networking: + # + # network_mode: host + # + # Unicast Art-Net (UDP 6454), DDP (4048) and E1.31 (5568) reach a fixture perfectly well through + # the ordinary port mapping above; it is broadcast and multicast that a bridge network does not + # carry. With host networking there is no port mapping, so add `--port 8081` to `command:` above to + # keep clear of anything already on 8080. + +volumes: + projectmm-data: diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 882da853..442e5b47 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -15,6 +15,45 @@ timing test here uses: feed the packet, advance the clock explicitly, assert. Ra on PR #96 and deliberately not taken in that pass: it is pre-existing rather than part of that diff, and swapping a test's transport is a change that wants its own verification. +## The desktop build advertises no mDNS (2026-09-06) + +`mdnsInit` on the desktop platform is a stub returning false, so a desktop instance is invisible to +discovery: it neither announces itself nor answers `.local`. On a board mDNS is what makes a +device findable without knowing its address. + +Nice-to-have rather than blocking, because instances address each other by IP and that is what the +network drivers take (`NetworkSendDriver`'s `ips` list). What it costs today is convenience: a +container on a NAS has to be looked up rather than named, and a fleet of them cannot be enumerated +the way a fleet of boards can. + +**What it costs:** a platform implementation per OS, which is the awkward part. macOS has Bonjour +built in, Linux would want Avahi (a dependency the container deliberately does not have), and +Windows has its own. A small self-contained responder is the alternative, since the announce side is +a handful of multicast records and we do not need the resolver half. + +Worth revisiting if instance-to-instance sync grows: discovery is the difference between configuring +a fleet by address and having it assemble itself. + +## The desktop build renders as fast as the machine allows (2026-09-06) + +Nothing paces the desktop render loop. On a board the LED refresh does; on desktop the loop runs +flat out, measured at 83,000 fps in a container and ~110% of a core per instance. Memory is not the +constraint (about 5 MB), so the practical ceiling on running several instances is one per core. + +That matters now the desktop build ships as a container: an array of instances is a plausible +deployment (a fleet under test, several zones of a show), and today each one costs a whole core for +frames nobody consumes. Nothing downstream benefits from more than the output rate: Art-Net, DDP and +E1.31 all clock at their own wire rate, and the preview socket is throttled separately. + +**What it costs:** a target-fps control on the desktop platform, defaulting to something like 120, +with the loop sleeping the remainder of each frame. The tick path already measures its own duration +(`tickTimeUs`), so the sleep is a subtraction rather than new measurement. Roughly the shape of the +`--port` flag: a desktop-only concern, invisible on a board. + +**Also worth knowing:** it would cut power draw on a single instance too, which matters for anyone +running projectMM permanently on a NAS or a Pi. + + ## Distribution ### OTA upload refuses a normal client: the body must arrive within ~50 ms (2026-09-02) @@ -1439,3 +1478,33 @@ the rows should be. Until then a list is user-populated, which is the honest behavior: the device knows the pin, the user knows what the button should do. + +## A desktop build reports firmware "unknown", so the update UI guesses from the browser (2026-09-06) + +Spotted on the container's Firmware card, which showed **Device: macOS arm64** while the image is +Linux amd64. It was not reading the device: `desktopKeyForThisHost()` (`src/ui/install-picker.js`) +and `desktopAssetPrefix()` (`src/ui/app.js`) both sniff `navigator.platform`, which describes the +machine holding the BROWSER. The container makes it visible because there the two always differ, but +the same card lies on any desktop instance opened from another machine. + +The download it offers is the failure mode worth naming: on a Mac the picker serves +`projectMM-macos-arm64-*.dmg`, a real file that installs and runs perfectly, and updates the wrong +computer. Nothing errors, and the device stays on its old build. + +**Root cause is one missing build flag.** `MM_FIRMWARE_NAME` already exists for exactly this +(`build_info.h`, surfaced as the `firmware` control): `build_esp32.py` passes the variant key, and +the desktop packaging passes nothing, so every published desktop binary falls through to the +`"unknown"` default. `build_info.h:43` even records the assumption that made this safe, "local +builds aren't published" β€” the container is a published desktop build, which is what retired it. + +**What it would take:** have `package_desktop.py` pass +`-DMM_FIRMWARE_NAME="desktop--"` for the target it packages (the keys the picker's +`DESKTOP_LABEL` map already lists), then delete both browser-sniffing functions and read the +`firmware` control instead. `deviceFirmwareInfo()`'s `isDesktop` test (`firmware === "unknown"`) +needs the same treatment, since a named desktop build no longer matches it: test the +`desktop-` prefix. Local unflagged builds keep reporting `unknown`, where a guess is still better +than nothing. + +Worth doing with the OTA path rather than alone: a desktop cannot install its own archive anyway, so +the honest end state may be a card that names the platform and links the release rather than +offering a Download button that only ever means "fetch a file and swap it by hand". diff --git a/docs/building.md b/docs/building.md index 91bf4a73..18f93c64 100644 --- a/docs/building.md +++ b/docs/building.md @@ -102,6 +102,57 @@ Every host needs [uv](https://docs.astral.sh/uv/), CMake 3.20+, and a C++20 comp Build and test from a **Developer PowerShell for VS 2022** (Start Menu β†’ "x64 Native Tools…") so `cl.exe` and the SDK paths are on `PATH`. The default CMake generator on Windows is Visual Studio multi-config, so `projectMM.exe` lands at `build/windows/Release/projectMM.exe` and `mm_scenarios.exe` at `build/windows/test/Release/`. `build_desktop.py` and `run_scenario.py` look in both the `Release/` subdir and the build root, so Ninja (single-config) also works if preferred. +### Docker + +The desktop build runs in a container, which is the whole system without an ESP32: same effect +pipeline, same web UI, same driver stack, driving real fixtures over Art-Net, DDP and E1.31. + +```sh +docker compose up -d # then open http://localhost:8081/ +docker compose logs -f +docker compose down # stops it; the volume, and your config, survive +``` + +Or from the published image, one per release: + +```sh +docker run -d --name projectmm -p 8081:8080 -v projectmm:/data \ + ghcr.io/moonmodules/projectmm:latest +``` + +`:latest` follows the rolling prerelease, the same build the installer page offers; a version tag +like `:4.0.0` pins one. Images are published by the release workflow from the same `.deb` that +release ships, so the image and the binary are the same build. + +**Upgrading preserves everything.** The image holds only the binary and all state lives in the +volume, so `docker compose pull && docker compose up -d` keeps settings, presets, scripts and the +device's identity. Only `docker compose down -v` wipes it, and only a mounted volume is preserved +at all: a bare `docker run` with no `-v` loses its state when the container goes. + +| | | +|---|---| +| **Config** | `/data/projectMM/.config/` in the volume, `XDG_DATA_HOME=/data` | +| **Identity** | `/data/projectMM/.config/identity`, generated on first run | +| **Logs** | stdout, so `docker logs` | +| **UI** | container port 8080; the compose file publishes it on 8081 so it never fights a native install | +| **Output** | Art-Net UDP 6454, DDP 4048, E1.31 5568, all outbound | +| **Capabilities** | none; it binds its port as an ordinary process | + +**When host networking is needed.** Unicast output to a fixture works over ordinary bridge +networking. Discovery and the broadcast or multicast output modes do not cross a bridge, so those +want `network_mode: host` (or an L2 CNI on Kubernetes). With host networking there is no port +mapping, so pass `--port 8081` in `command:` to stay clear of anything already on 8080. + +**Several instances** run side by side with no conflict: each container has its own port space, so +they all listen on 8080 internally with different published ports, and each generates its own +identity so they are distinguishable on the network. CPU is the practical limit rather than memory +(measured at ~5 MB and about one core each, since the desktop build renders as fast as it is +allowed); cap it with `cpus:` in compose when running a fleet. + +**amd64 only** for now: the release ships no arm64 Linux binary. On an Apple-silicon Mac or an ARM +server the compose file's `platform: linux/amd64` runs it under emulation, which works but is +slower than native. + ## ESP32 The ESP32 target uses ESP-IDF directly, not the Arduino framework. diff --git a/src/core/moonlive/moonlive_lower.h b/src/core/moonlive/moonlive_lower.h index dc811dec..2f04b159 100644 --- a/src/core/moonlive/moonlive_lower.h +++ b/src/core/moonlive/moonlive_lower.h @@ -141,7 +141,11 @@ size_t lowerWith(IrProgram& ir, uint8_t* out, size_t cap, const RegBudget* squee // function whether or not it returns early, because the label is what closeFn binds; unused // ones bind and emit nothing. Not shared with tooDeep: that one only exists when the script // calls, and a return needs an exit either way. - LabelId fnExit[kMaxIrEntries]; + // Zero-initialized, though only the first `fnCount` entries are ever read: GCC 14 cannot see + // that bound across the lambdas below and reports a maybe-uninitialized use under -Werror, + // where GCC 16 and clang stay quiet. A compiler-version difference is not worth an exception, + // and the initializer costs nothing on a cold path. + LabelId fnExit[kMaxIrEntries] = {}; for (uint8_t f = 0; f < ir.fnCount; f++) fnExit[f] = a.newLabel(); // Which function is being emitted, so a Ret knows which exit is its own. -1 until the first // function opens: a function-less program (the hand-built IR the codegen tests use) has no diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 2f7ff402..0cc699de 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -8,6 +8,8 @@ #include #include #include +#include // the stored identity is generated once, see getMacAddress +#include #include #include #ifndef _WIN32 @@ -449,12 +451,6 @@ size_t totalInternalHeap() { return 0; // Not meaningful on desktop } -void getMacAddress(uint8_t mac[6]) { - // Stable fake MAC for desktop (consistent deviceName across runs) - mac[0] = 0xDE; mac[1] = 0xAD; mac[2] = 0xBE; - mac[3] = 0xEF; mac[4] = 0xCA; mac[5] = 0xFE; -} - const char* macString() { static char buf[18] = {}; if (buf[0] == 0) { @@ -604,6 +600,7 @@ std::filesystem::path defaultRoot() { std::filesystem::path fsRoot_{defaultRoot()}; + // Map "/.config/foo.json" β†’ "/.config/foo.json". Strips leading '/'s, normalizes // the result, and rejects paths that escape fsRoot_ (e.g. "../../etc/passwd"). Returns // an empty path on rejection; callers already treat empty/nonexistent as failure. @@ -1050,6 +1047,85 @@ bool winDescForPcapName(const MIB_IF_TABLE2* table, const char* pcapName, char* #endif // _WIN32 } // namespace +void getMacAddress(uint8_t mac[6]) { + // A STORED identity, generated once and kept beside the config. This is systemd's machine-id + // pattern (freedesktop.org/software/systemd/man/machine-id): try for something stable, else + // generate randomly, then SAVE it so it never moves again. + // + // It matters because the MAC is an identity, not a diagnostic: `deviceName` defaults to + // MM-XXXX from it, and the MQTT topic prefix and Home Assistant `unique_id` are derived from + // it too. A hardcoded value made every desktop instance `MM-CAFE` on one topic, so two + // desktops or a handful of containers were indistinguishable and fought over the same MQTT + // entity. Home Assistant's own requirement is that a unique_id survive container recreation, + // which is exactly what storing it achieves and what reading a host NIC does not: this Mac + // lists an internal management interface (anpi1) before its real one, and containers sharing a + // bridge can present related addresses. + // + // Locally-administered and unicast (first octet 0x02): the IEEE range set aside for addresses + // that are not vendor-assigned, so this can never collide with real hardware. + // + // EXISTING installs keep their identity. A tree with no identity file is seeded with the old + // hardcoded value rather than a fresh one, so an upgrade does not silently rename the device or + // move its MQTT topics; only a genuinely new instance gets a new address. + // Cached per ROOT rather than once per process: fsSetRoot can move the config (tests do it + // between cases), and an identity cached from a previous root would then describe the wrong + // install. Comparing the path is cheap next to re-reading the file every tick. + static std::filesystem::path resolvedFor; + static uint8_t cached[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE}; + if (resolvedFor != fsRoot_) { + resolvedFor = fsRoot_; + for (int i = 0; i < 6; i++) cached[i] = 0; + cached[0] = 0xDE; cached[1] = 0xAD; cached[2] = 0xBE; + cached[3] = 0xEF; cached[4] = 0xCA; cached[5] = 0xFE; + std::error_code ec; + // fsRoot_, not defaultRoot(): the root is settable (fsSetRoot, which tests and a + // relocated install both use), so the identity must follow the config it belongs to + // rather than the process's working directory. + const std::filesystem::path file = fsRoot_ / ".config" / "identity"; + bool loaded = false; + if (std::ifstream in(file); in) { + unsigned b[6] = {}; + if (in >> std::hex >> b[0] >> b[1] >> b[2] >> b[3] >> b[4] >> b[5]) { + bool sane = true; + for (int i = 0; i < 6; i++) if (b[i] > 0xFF) sane = false; + if (sane) { for (int i = 0; i < 6; i++) cached[i] = static_cast(b[i]); loaded = true; } + } + } + if (!loaded) { + // No stored identity, so this is either a fresh install or one that predates the + // identity file. They are told apart by whether the tree already holds CONFIG: a + // pre-existing install keeps the historic address, so an upgrade never renames a + // device or moves its MQTT topics, while a new one gets its own. + // + // Reading it here is safe precisely because this runs during SystemModule::setup(), + // which Scheduler::setup() calls BEFORE the config load (Scheduler.cpp): a fresh tree + // genuinely has no config yet at this instant. It writes some moments later, which is + // why the answer is decided once and stored rather than re-derived. + bool existing = false; + if (std::filesystem::is_directory(fsRoot_ / ".config", ec) && !ec) { + for (const auto& e : std::filesystem::directory_iterator(fsRoot_ / ".config", ec)) { + if (e.path().extension() == ".json") { existing = true; break; } + } + } + if (!existing) { + std::random_device rd; + for (int i = 0; i < 6; i++) cached[i] = static_cast(rd() & 0xFF); + cached[0] = static_cast((cached[0] & 0xFC) | 0x02); // locally administered, unicast + } + std::filesystem::create_directories(file.parent_path(), ec); + if (std::ofstream out(file); out) { + char line[24]; + std::snprintf(line, sizeof(line), "%02X %02X %02X %02X %02X %02X", + cached[0], cached[1], cached[2], cached[3], cached[4], cached[5]); + out << line << "\n"; + } + // A write failure is not fatal: the address is still valid for this run, and the next + // start will try again. A read-only mount then behaves like the old constant did. + } + } + for (int i = 0; i < 6; i++) mac[i] = cached[i]; +} + // Open a raw L2 socket on `ifName` so a host build drives panels for real β€” the deployment a Pi or // a mini-PC covers, and the same code path the ESP32 takes. Linux uses AF_PACKET, macOS BPF; both // need root (or CAP_NET_RAW), so an ordinary test run simply stays in capture mode. diff --git a/test/unit/core/unit_MqttModule.cpp b/test/unit/core/unit_MqttModule.cpp index e218361f..db503c50 100644 --- a/test/unit/core/unit_MqttModule.cpp +++ b/test/unit/core/unit_MqttModule.cpp @@ -51,10 +51,35 @@ struct FakeDrivers : public MoonModule { // Build a scheduler with FakeDrivers + a SystemModule + an MqttModule, run setup so // Scheduler::instance() is live and controls are bound. The topic prefix is STABLE + MAC-derived -// (projectMM/), NOT from deviceName β€” so it's rename-proof. On desktop the fake MAC is -// DE:AD:BE:EF:CA:FE (platform_desktop.cpp), so last-6 = "efcafe". +// (projectMM/), NOT from deviceName, so it is rename-proof. +// +// DERIVED here rather than written out, because the desktop MAC is a per-install stored identity +// (platform_desktop.cpp, getMacAddress): a literal would pin whatever this machine happens to +// generate, and it is the rename-proof DERIVATION these cases exist to check. +/// The last six MAC hex digits alone, which is what the Home Assistant discovery topic and its +/// unique_id are built from. +inline const char* macId() { + static char buf[16] = {}; + if (!buf[0]) { + uint8_t mac[6] = {}; + mm::platform::getMacAddress(mac); + std::snprintf(buf, sizeof(buf), "%02x%02x%02x", mac[3], mac[4], mac[5]); + } + return buf; +} + +inline const char* macPrefix() { + static char buf[32] = {}; + if (!buf[0]) { + uint8_t mac[6] = {}; + mm::platform::getMacAddress(mac); + std::snprintf(buf, sizeof(buf), "projectMM/%02x%02x%02x", mac[3], mac[4], mac[5]); + } + return buf; +} + struct Rig { - static constexpr const char* kPrefix = "projectMM/efcafe"; // desktop fake MAC last-6 + const char* const kPrefix = macPrefix(); Scheduler scheduler; FakeDrivers* drivers = new FakeDrivers(); SystemModule* system = new SystemModule(); @@ -213,19 +238,19 @@ TEST_CASE("MqttModule: CONNACK publishes a retained HA discovery config") { REQUIRE(len > 0); // The captured stream must contain the discovery topic + the key config fields. std::string sent(reinterpret_cast(cap), len); - CHECK(sent.find("homeassistant/light/projectMM_efcafe/config") != std::string::npos); + CHECK(sent.find(std::string("homeassistant/light/projectMM_") + macId() + "/config") != std::string::npos); CHECK(sent.find("\"schema\":\"json\"") != std::string::npos); - CHECK(sent.find("\"uniq_id\":\"projectMM_efcafe\"") != std::string::npos); - CHECK(sent.find("projectMM/efcafe/ha/set") != std::string::npos); // cmd_t - CHECK(sent.find("projectMM/efcafe/ha/state") != std::string::npos); // stat_t - CHECK(sent.find("projectMM/efcafe/status") != std::string::npos); // avty_t + CHECK(sent.find(std::string("\"uniq_id\":\"projectMM_") + macId() + "\"") != std::string::npos); + CHECK(sent.find(std::string("projectMM/") + macId() + "/ha/set") != std::string::npos); // cmd_t + CHECK(sent.find(std::string("projectMM/") + macId() + "/ha/state") != std::string::npos); // stat_t + CHECK(sent.find(std::string("projectMM/") + macId() + "/status") != std::string::npos); // avty_t CHECK(sent.find("online") != std::string::npos); // the retained availability publish // The discovery config AND the availability publish must carry the RETAIN bit (bit 0 of the PUBLISH // fixed header) β€” a late-joining HA reads the retained config/state, so dropping retain breaks it. - const int cfgFlags = publishFlagsForTopic(cap, len, "homeassistant/light/projectMM_efcafe/config"); + const int cfgFlags = publishFlagsForTopic(cap, len, (std::string("homeassistant/light/projectMM_") + macId() + "/config").c_str()); REQUIRE(cfgFlags >= 0); CHECK((cfgFlags & 0x01) == 0x01); // discovery config retained - const int avtyFlags = publishFlagsForTopic(cap, len, "projectMM/efcafe/status"); + const int avtyFlags = publishFlagsForTopic(cap, len, (std::string("projectMM/") + macId() + "/status").c_str()); REQUIRE(avtyFlags >= 0); CHECK((avtyFlags & 0x01) == 0x01); // availability "online" retained } @@ -269,7 +294,7 @@ TEST_CASE("MqttModule: a PUBLISH split across feeds still routes (fragment reass r.drivers->on = true; uint8_t buf[128]; const char* payload = "false"; - const size_t n = buildMqttPublish("projectMM/efcafe/on/set", reinterpret_cast(payload), + const size_t n = buildMqttPublish((std::string("projectMM/") + macId() + "/on/set").c_str(), reinterpret_cast(payload), std::strlen(payload), buf, sizeof(buf)); REQUIRE(n > 0); // Feed one byte at a time β€” the parser holds partial state until the packet completes. diff --git a/test/unit/core/unit_SystemModule.cpp b/test/unit/core/unit_SystemModule.cpp index 5600cc24..9c9fb065 100644 --- a/test/unit/core/unit_SystemModule.cpp +++ b/test/unit/core/unit_SystemModule.cpp @@ -19,16 +19,29 @@ class CountingChild : public mm::MoonModule { }; } // namespace -// On the desktop platform (MAC DE:AD:BE:EF:CA:FE), the auto-generated device name is "MM-CAFE" (last two MAC bytes). +/// The derived name is "MM-" plus the last two MAC bytes in hex, whatever those bytes are. +/// +/// Pinned by SHAPE rather than against one literal: the desktop MAC is a stored per-install +/// identity now (platform_desktop.cpp, getMacAddress), so a fresh install generates its own and +/// asserting "MM-CAFE" would pin the old hardcoded constant rather than the derivation. +bool looksLikeMacName(const char* name) { + uint8_t mac[6] = {}; + mm::platform::getMacAddress(mac); + char expect[8] = {}; + std::snprintf(expect, sizeof(expect), "MM-%02X%02X", mac[4], mac[5]); + return std::strcmp(name, expect) == 0; +} + +// The auto-generated device name is "MM-" plus the last two MAC bytes (see looksLikeMacName). TEST_CASE("SystemModule MAC-to-deviceName") { // Desktop platform returns MAC DE:AD:BE:EF:CA:FE - // deviceName should be MM-CAFE (last two bytes) + // deviceName follows the MAC, whatever this install's stored identity is mm::SystemModule sys; sys.setup(); - CHECK(std::strcmp(sys.deviceName(), "MM-CAFE") == 0); + CHECK(looksLikeMacName(sys.deviceName())); } -// deviceName is bound as a Text control to the MAC-derived default ("MM-CAFE" on the desktop platform). +// deviceName is bound as a Text control to the MAC-derived default (see looksLikeMacName). TEST_CASE("SystemModule deviceName control") { mm::SystemModule sys; sys.setup(); @@ -38,7 +51,7 @@ TEST_CASE("SystemModule deviceName control") { for (uint8_t i = 0; i < sys.controls().count(); i++) { if (std::strcmp(sys.controls()[i].name, "deviceName") == 0) { CHECK(sys.controls()[i].type == mm::ControlType::Text); - CHECK(std::strcmp(static_cast(sys.controls()[i].ptr), "MM-CAFE") == 0); + CHECK(looksLikeMacName(static_cast(sys.controls()[i].ptr))); found = true; } } @@ -84,7 +97,7 @@ TEST_CASE("SystemModule falls back to the MAC name when deviceName is all-invali sys.defineControls(); writeDeviceName(sys, "!@#$"); sys.tick1s(); - CHECK(std::strcmp(sys.deviceName(), "MM-CAFE") == 0); // desktop MAC fallback + CHECK(looksLikeMacName(sys.deviceName())); // the MAC-derived fallback } // An already-valid name is left untouched (idempotent) β€” a normal user name survives.