./beatled.sh — CLI Reference

beatled.sh is the single entry point for building, running, testing, flashing, and deploying every piece of the system. It wraps cmake, ninja, npm, xcodebuild, the Pico SDK, esptool, mkcert, Docker, and ssh behind a small group/subcommand grammar so day-to-day work doesn’t need a cheat sheet for each underlying tool.

./beatled.sh <group> <subcommand> [action] [options]

Every command level accepts -h / --help (or help). Run e.g. ./beatled.sh server -h to see the same content as below without leaving the terminal.

Quick orientation

GroupWhat it touches
clientReact frontend (client/) and the SwiftUI app shared by iOS + macOS (ios/)
controllerEmbedded firmware at controller/ (5 ports: pico, pico_freertos, posix, posix_freertos, esp32)
serverC++ beat server (server/)
testCatch2 / Vitest test runs across the components
buildCross-component builds without running anything
cleanWipe build artefacts
docsServe this Jekyll site locally
certsGenerate locally-trusted dev certificates

client

./beatled.sh client <surface> <action>
SurfaceActionBehaviour
reactdevVite dev server with HMR; proxies /api to https://127.0.0.1:8443
reactbuildProduction build → client/dist/
iosbuildxcodebuild for the iOS Simulator
iossimBuild + boot the iOS Simulator and launch the app
macosbuildxcodebuild Beatled.app for macOS
macosstartBuild and launch the macOS app

The iOS and macOS targets share a single Xcode project at ios/Beatled.xcodeproj and the same Swift sources (the ios/ directory name is historical). react dev needs the server running — start it with server start --start-http in another terminal.

controller

./beatled.sh controller <port> <action>
PortActionsOutput
picobuild, flashcontroller/build-pico/.../pico_w_beatled.uf2
pico-freertosbuild, flashcontroller/build-pico-freertos/.../pico_w_beatled.uf2
posixbuildNative simulator (Metal LED renderer on macOS); execs the binary
freertos-simbuildNative simulator + FreeRTOS POSIX kernel; execs the binary
esp32-freertosbuild, flash, monitorESP-IDF binary; flash chains esptool + idf.py monitor

The flash action on the Pico ports copies the .uf2 to /Volumes/RPI-RP2/ after the build, so the board needs to be in BOOTSEL mode (hold the button while plugging USB in). The ESP32 flash uses the existing serial connection from ESP32_PORT.

WiFi config (.env.wifi)

The WiFi networks live in one file, controller/.env.wifi, shared by every hardware firmware build and the Raspberry Pi host’s wifi-fallback service (see server deploy). Set them once:

cp controller/.env.wifi.template controller/.env.wifi
$EDITOR controller/.env.wifi

WIFI_SSID/WIFI_PASSWORD is the primary network. Up to three optional upstream fallbacks — WIFI_SSID_2/WIFI_PASSWORD_2 through _4 — are tried in order if it fails. Finally, HOTSPOT_SSID/HOTSPOT_PASSWORD is the Pi’s self-hosted fallback network: the controller joins it as a last resort, and the Pi host broadcasts it (see wifi-fallback below) — so it is defined once and never duplicated as an upstream entry. The controller cycles the whole list and keeps retrying until one joins. Leave a slot blank to skip it. These apply to the pico, pico-freertos, and esp32-freertos ports (the simulators have no radio). Quote any value containing spaces (WIFI_SSID="My Home Network").

Firmware config (.env.pico / .env.esp32)

The hardware ports bake a few more values into the firmware at build time — the server’s hostname or IP, the pixel count, and the GPIO pin. They live in per-device .env.* files inside controller/:

cp controller/.env.pico.template  controller/.env.pico
cp controller/.env.esp32.template controller/.env.esp32
$EDITOR controller/.env.pico

The relevant subcommand sources .env.wifi first, then the per-device file (so a device file can override a shared WiFi value). All .env.* shapes are gitignored at the repo root.

server

./beatled.sh server <subcommand> [options]

Subcommands

SubcommandBehaviour
buildcmake + ninja under server/build/ (vcpkg toolchain)
start [opts]Build if needed, then run beat_server in the foreground
deploy <user> <host>Build → tarball → scp → systemd reload over SSH; rollback on failure

Service flags

Service flags pick which sub-services the binary brings up.

FlagDefaultNotes
--start-httpoffHTTPS API + static client on --http-port (default 8443)
--start-udpoffUDP server on --udp-port (default 9090) — HELLO / TIME / TEMPO requests
--start-broadcastoffPer-beat tempo dispatcher + PROGRAM push (see --broadcast-mode)

Script-handled flags (consumed by beatled.sh, not passed to the binary)

FlagDefaultNotes
--no-clientoffSkip the client dependency. By default server start builds client/dist if it’s missing and serves it as the static root (so GET / returns the UI instead of a 404). Pass --no-client when the UI is served elsewhere — e.g. client react dev.

The React bundle is a build dependency of server start: the server serves client/dist as its static root, so a missing bundle makes GET / 404 (Chrome shows ERR_EMPTY_RESPONSE). server start builds it on demand unless --no-client is given.

Server config

FlagDefaultDescription
-a ADDRESSlocalhost (script overrides to 0.0.0.0)Listen address
-p, --http-port PORT8443HTTP(S) port
-u, --udp-port PORT9090UDP request port
-n, --thread-pool-size N2asio worker threads
-r, --root-dir PATHclient/distStatic-file root
--certs-dir PATHserver/certsTLS cert / key / DH parameters
--no-tlsoffServe plain HTTP — development only (emits SPDLOG_WARN)
--cors-origin URLdisabledSingle-origin CORS allowance
--api-token TOKENdisabledRequire Authorization: Bearer <token> on state-changing calls
--log-level LEVELinfospdlog verbosity. One of trace, debug, info, warn, err, critical, off. Falls back to the BEATLED_LOG_LEVEL env var when the flag is absent on the CLI.

Broadcaster config (only with --start-broadcast)

FlagDefaultDescription
--broadcast-mode {unicast,subnet,limited}unicastSee below
-c, --m_broadcasting-address ADDR255.255.255.255Destination for subnet / limited modes
-b, --broadcasting-port PORT8765UDP destination port
--program-refresh-ms MS200PROGRAM background refresh period in ms. On-change pushes are also sent twice ~50 ms apart for Wi-Fi loss insurance; lower this if controllers that miss both copies need to catch up faster.

QoS / diagnostics (protocol v4)

FlagDefaultDescription
--status-probe-ms MS5000STATUS probe period in ms; 0 disables. The server unicasts a STATUS_REQUEST to every registered client at this cadence. The response carries a fresh server-controlled RTT plus the same beatled_qos_block_t that piggy-backs on TEMPO_REQUEST. Surfaced via /api/qos.
--qos-skew-warn-us US5000Fleet skew (max-min controller offset in µs) at which /api/qos.health flips from ok to warn. The React Fleet QoS pip turns amber.
--qos-skew-fail-us US20000Fleet skew at which the pip turns red (fail). A non-zero intercore-drop or time-sync outlier total anywhere in the fleet also forces red, regardless of skew.
ModeDestinationNotes
unicasteach registered client’s last-known endpointDefault — per-client OWD compensation, best for ≤10 controllers on Wi-Fi
subnet--m_broadcasting-address (e.g. 192.168.1.255)One packet per beat, no per-client compensation
limited255.255.255.255Frequently dropped by consumer Wi-Fi APs

Deployment

server deploy <user> <host> tarballs the cross-compiled aarch64 build (build rpi artefacts), copies it to the Pi via scp, installs or reloads the systemd unit, and runs an /api/health probe; on failure it restores the previous backup. See the Deployment runbook.

scripts/deploy/install-service.sh (run once on the Pi) also installs a wifi-fallback systemd unit. It reads the same controller/.env.wifi and, on boot, brings up each upstream WIFI_SSID[_2..4] in turn, falling back to the hotspot profile HOTSPOT_CON (default beatled-hotspot) if none connect. That hotspot is an AP-mode profile broadcasting HOTSPOT_SSID — the same network the controllers join as their last resort — so the host broadcasts it rather than listing it as an upstream network. The upstream networks are activated by NetworkManager profile name, so a profile must already exist whose name matches each SSID — create them once with nmcli dev wifi connect "<SSID>".

install-service.sh also installs an avahi-alias unit, which publishes an extra mDNS name (MDNS_ALIAS, default beatled.local) via avahi-publish -a -R, so the Pi answers to that name in addition to <hostname>.local. The -R (no reverse PTR) is required: the host’s primary name already owns the address’s PTR, so publishing another would collide. Generate the server certificate with both names (./beatled.sh certs beatled.local raspberrypi.local) so HTTPS validates under either.

test, build, clean

./beatled.sh test  [server|client|pico|all]
./beatled.sh build [server|client|pico|pico-freertos|rpi|all]
./beatled.sh clean [server|client|pico|pico-freertos|esp32|all]
  • test all is the default; it runs the server’s Catch2 binaries (including test_api_gates + test_state_manager), then npm test in client/, then the POSIX-port firmware tests under controller/build/tests/posix/.
  • build all is the default; it does the server, client, pico, and pico-freertos targets. The rpi target is opt-in (it requires Docker for the aarch64 cross-compile).
  • clean all wipes every component’s build directory. Pico / FreeRTOS variants each have two build directories (POSIX simulator vs. hardware .uf2); both are cleaned.

docs

./beatled.sh docs [-- jekyll options]

Serves the Jekyll site at http://127.0.0.1:4000/beatled/. Any options after -- are passed through to bundle exec jekyll serve; common examples: --livereload, --port 4001.

certs

./beatled.sh certs [domain ...]

Generates a locally-trusted TLS cert (cert + key + DH parameters) under server/certs/ using mkcert. The cert chains to the local mkcert root CA, which the server iOS + Mac clients also need installed (mkcert -install).

Examples:

./beatled.sh certs                              # default: beatled.test
./beatled.sh certs beatled.local
./beatled.sh certs beatled.local 192.168.1.100  # multi-SAN

Environment variables

Most callers don’t need to set these — the defaults are wired for the in-tree layout — but they’re useful when the standard layout doesn’t match (sibling clones, alternate vcpkg root, different ESP32 board).

VariableDefaultUsed by
VCPKG_DIR~/coding/external/vcpkgServer build
PICO_DIR<repo>/controllerAll controller subcommands
WIFI_SSID[_2..4](required for hardware ports)Pico W / ESP32 build (baked into .uf2); Pi host wifi-fallback. Shared via .env.wifi
WIFI_PASSWORD[_2..4](required for hardware ports)Pico W / ESP32 build. Shared via .env.wifi
HOTSPOT_SSID / HOTSPOT_PASSWORD(optional)Pi’s fallback hotspot. Firmware joins it last; the Pi host broadcasts it. Shared via .env.wifi
HOTSPOT_CONbeatled-hotspotPi host only: AP-mode NetworkManager profile that broadcasts HOTSPOT_SSID
BEATLED_SERVER_NAME(required for hardware ports)Pico W / ESP32 build (hostname or IP)
NUM_PIXELS(required)Pixel count baked into firmware
WS2812_PIN0GPIO data pin
ESP32_TARGETesp32s3idf.py set-target value
ESP32_PORT/dev/cu.usbmodem*esptool / monitor serial device
BEATLED_API_TOKEN(none)Fallback API token when --api-token is omitted on the CLI
BEATLED_LOG_LEVELinfoServer log level (trace/debug/info/warn/err/critical/off); used when --log-level is omitted

WIFI_SSID/WIFI_PASSWORD (and the _2..4 fallbacks) are set once in controller/.env.wifi; BEATLED_SERVER_NAME, NUM_PIXELS, and WS2812_PIN live in the per-device controller/.env.pico and friends — rather than exported manually. The build wrapper sources .env.wifi first and then the right per-device file for each port automatically.

Common workflows

First-time setup (dev machine, macOS)

git clone https://github.com/oost/beatled.git
cd beatled
git submodule update --init --recursive
scripts/git-hooks/install.sh              # pre-commit hooks (clang-format, shellcheck)
./beatled.sh certs                  # local TLS for beatled.test / mkcert root
./beatled.sh server start --start-http --start-udp --start-broadcast
# in another terminal:
./beatled.sh client react dev

Iterate on the firmware (POSIX simulator, no hardware)

cp controller/.env.wifi.template controller/.env.wifi
cp controller/.env.pico.template controller/.env.pico
$EDITOR controller/.env.wifi                # WIFI_SSID / WIFI_PASSWORD (shared)
$EDITOR controller/.env.pico                # BEATLED_SERVER_NAME, NUM_PIXELS
./beatled.sh controller posix build   # builds + execs the simulator
./beatled.sh test pico                # POSIX-port unit + integration tests

Flash a real Pico W

# Once: fill .env.wifi (WIFI_SSID / WIFI_PASSWORD) + .env.pico (BEATLED_SERVER_NAME)
# Hold BOOTSEL while plugging USB so /Volumes/RPI-RP2/ mounts.
./beatled.sh controller pico flash
# or the FreeRTOS port:
./beatled.sh controller pico-freertos flash

Deploy to a Raspberry Pi

./beatled.sh certs beatled.local           # first time only
./beatled.sh build rpi                     # Docker cross-compile to out/
./beatled.sh server deploy pi beatled.local

The deploy script copies certs to the Pi, scps the tarball, installs or reloads beat-server.service, and probes /api/health. On failure it restores the previous backup. See Deployment for the long version.

Cut a release commit

./beatled.sh test all                      # server + client + firmware POSIX
./beatled.sh build all                     # check every component still links
git status                                       # spot-check
git commit …

Escape hatches

  • Skip the pre-commit hooks for a single commit: BEATLED_SKIP_HOOKS=1 git commit …. Don’t make a habit of it.
  • Point at a sibling firmware checkout: PICO_DIR=/path/to/other/firmware ./beatled.sh controller posix build.
  • Pass through to the underlying Jekyll server: anything after -- reaches jekyll serve directly, e.g. ./beatled.sh docs -- --livereload --port 4001.