metronome/deploy.sh
Me Here 7481f91935 PM_K-1 0.0.10: ship precompiled app.mpy (fixes boot OOM) + push .mpy over the air
The single-file app grew to ~57KB; CircuitPython compiling it at boot fragments the
RP2040 heap so badly that the fonts can't get a contiguous block (161KB free, yet a
~16KB alloc fails). Fix: precompile to app.mpy (Adafruit mpy-cross for CP 10.2.1, emits
CircuitPython mpy v6) so the device loads bytecode without compiling -> no fragmentation.

- build.sh precompiles pico-cp/app.py -> dist/app.mpy via tools/mpy-cross (gitignored
  binary); the bundle ships app.mpy (NOT app.py); serves pico-cp-app.mpy + pico-cp-app.py
  (the .py only for the editor's version regex + as readable reference).
- Loader (code.py) imports app.mpy and rolls back app.bak as .mpy.
- One-click updater now pushes the .mpy: editor base64-encodes it and sends it over the
  existing flow-controlled chunked transport (512-char = mult-of-4 chunks); the device
  base64-decodes each chunk to /app.new and verifies the CircuitPython .mpy header
  (magic 'C', v6, >=4KB) before the A/B install. Version still read from the served .py.

Verified: mpy-cross emits magic 'C'/v6; build produces a 21.8KB app.mpy; editing-logic
harness + scene render still pass; and a simulated push (base64 -> 57 chunks -> a2b_base64)
reassembles the .mpy byte-exact and passes the device's header check.

One-time recovery: delete app.py from the drive, copy app.mpy + code.py from the new zip.
After that, updates are one-click again (and can't brick: header check + A/B rollback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 14:01:57 -05:00

72 lines
4 KiB
Bash
Executable file

#!/usr/bin/env bash
# Deploy the metronome to the Caddy web root that serves
# https://metronome.varasys.io
#
# Caddy config: /var/lib/caddy/Caddyfile (metronome.varasys.io:8443 block)
# Bind-mount: /etc/containers/systemd/caddy.container
#
# The web root is bind-mounted read-only into the Caddy container and
# served by file_server, which picks up changes immediately — so a plain
# file copy is all that's needed (no container restart).
set -euo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEST_DIR="/var/lib/caddy/www/metronome"
DIST_DIR="$SRC_DIR/dist"
[[ -f "$SRC_DIR/index.html" ]] || { echo "error: $SRC_DIR/index.html not found" >&2; exit 1; }
[[ -d "$DEST_DIR" ]] || { echo "error: web root $DEST_DIR is missing — is Caddy set up?" >&2; exit 1; }
# Assemble the self-contained pages (inlines assets/ into dist/). dist/ is git-ignored.
"$SRC_DIR/build.sh"
[[ -f "$DIST_DIR/index.html" ]] || { echo "error: build did not produce $DIST_DIR/index.html" >&2; exit 1; }
# --- compute build version ---------------------------------------------------
# Formal build: clean tree on a commit tagged v<VERSION> -> "X.Y.Z"
# Dev build: anything else -> "X.Y.Z-dev.<utc-ts>.<sha>[.dirty]"
VER="$(cat "$SRC_DIR/VERSION" 2>/dev/null || echo 0.0.0)"
cd "$SRC_DIR"
if git rev-parse --git-dir >/dev/null 2>&1; then
tag="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
dirty=""; [[ -n "$(git status --porcelain 2>/dev/null)" ]] && dirty=".dirty"
if [[ "$tag" == "v$VER" && -z "$dirty" ]]; then
BUILD="$VER" # formal release
else
BUILD="$VER-dev.$(date -u +%Y%m%dT%H%M%SZ).g$(git rev-parse --short HEAD 2>/dev/null || echo nogit)$dirty"
fi
else
BUILD="$VER-dev.$(date -u +%Y%m%dT%H%M%SZ)" # not a git checkout
fi
# stamp the version into the built copy only (source stays clean)
echo "deployed v$BUILD -> $DEST_DIR"
for f in index.html editor.html player.html teacher.html stage.html micro.html showcase.html kit.html \
embed.html \
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
done
cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)"
cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download
cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle
cp "$DIST_DIR/pico-cp-app.py" "$DEST_DIR/pico-cp-app.py"; echo " pico-cp-app.py ($(stat -c '%s' "$DEST_DIR/pico-cp-app.py") bytes)" # served for version reading + reference
cp "$DIST_DIR/pico-cp-app.mpy" "$DEST_DIR/pico-cp-app.mpy"; echo " pico-cp-app.mpy ($(stat -c '%s' "$DEST_DIR/pico-cp-app.mpy") bytes)" # precompiled firmware the editor pushes (base64)
rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/)
# info-*.html are first-class pages again: each form factor has a lean widget page
# (<device>.html) + a separate spec/BOM page (info-<device>.html that embeds it).
# If real audio samples are added later (see the plan's GM-sample note),
# sync that directory too.
if [[ -d "$SRC_DIR/samples" ]]; then
rsync -a --delete "$SRC_DIR/samples/" "$DEST_DIR/samples/"
echo "synced samples/ -> $DEST_DIR/samples"
fi
# Smoke test: Caddy serves on :8443 with tls internal; resolve the host
# to localhost so SNI matches the site block.
if command -v curl >/dev/null 2>&1; then
code=$(curl -sk --resolve metronome.varasys.io:8443:127.0.0.1 \
https://metronome.varasys.io:8443/ -o /dev/null -w '%{http_code}' || echo "??")
echo "smoke test: metronome.varasys.io -> HTTP $code"
fi