Skip to content

Repository files navigation

Split-Flap Display

A retro split-flap (Solari board) web display with real-time updates over WebSocket. Runs as a single Docker container — one command on a Raspberry Pi, plug the Pi into a monitor over HDMI, and you have a wall display.

Two display modes, switchable from the setup screen or the API:

  • Word Clock — QLOCKTWO-style word clock
  • Info Split-Flap — animated split-flap board showing the date + 24h clock

Architecture

One container, one port. Express serves the REST API, the WebSocket, and the built React client together — so there's nothing else to run.

                    ┌─────────────────────────────────────────┐
                    │  Docker container (port 3001 → host 80)   │
   browser ───────▶ │                                           │
   (kiosk / phone)  │   Express ──┬── GET /            static   │
        │           │             │   React client (Vite build) │
        │  WebSocket │             ├── /api/*          REST      │
        └───────────┼──── ws ─────┴── broadcast to all clients  │
                    │                                           │
                    │   state + settings ──▶ /data/.state.json  │
                    └────────────────────────────│──────────────┘
                                                 ▼
                                      split-flap-data volume
                                   (survives restarts & rebuilds)

Settings (mode, theme, sound, language) and the last message are pushed to every connected client over WebSocket and persisted to a Docker volume, so the display returns in the mode it was last set to after a reboot.


Quick start (Raspberry Pi)

You need Docker + Docker Compose on the Pi. The Pi pulls a prebuilt image from GHCR — no building on the Pi.

git clone https://github.com/jeffstrout/split-flap.git
cd split-flap
cp .env.example .env                 # optional — defaults work as-is
docker compose pull && docker compose up -d

Then open http://<pi-ip> from any browser on your network. The Pi itself can be the display: plug it into a monitor over HDMI and launch Chromium in kiosk mode (see KIOSK.md).

Setting up a Pi from a fresh OS install? Follow the step-by-step PI-SETUP.md runbook (Docker install, swap, and Chromium kiosk autostart).

The container has restart: unless-stopped, so it comes back automatically after a reboot or power loss.

Automatic updates

The compose file also runs Watchtower, which checks GHCR for a newer image every ~20 minutes and updates the display in place when one appears — so a push to main reaches the wall hands-off. Your mode/theme/sound/message state lives on a Docker volume and survives updates.

  • See what's running: GET /api/version (also shown at the bottom of /setup) reports the commit + build time.
  • Pin / roll back: set IMAGE_TAG=sha-<short> in .env, then docker compose pull && docker compose up -d. Set back to latest to resume auto-updates.
  • Change cadence: WATCHTOWER_POLL_INTERVAL (seconds) in .env.
  • Prefer manual updates? Drop the watchtower service and just run docker compose pull && docker compose up -d when you want the latest, or put it on a cron/systemd timer.
  • Kiosk showing a Pi's own HDMI display? An update refreshes server data over the WebSocket immediately, but the browser keeps running the page it already loaded — so client code changes (animations, the /setup UI) only appear after the page reloads. Reboot the Pi, or sudo systemctl restart getty@tty1, to reload the kiosk. (See PI-SETUP.md.)

Configuration

All optional — copy .env.example to .env and edit. With no .env, the defaults below apply.

Variable Default Purpose
HOST_PORT 80 Host port the display + API are served on. Port 80 means the URL needs no port; override if the host already has something there
TZ UTC Timezone for the clock, IANA name (e.g. America/Chicago). Set this or the clock shows UTC
DEFAULT_MODE qlock Boot mode: qlock (word clock) or flip (split-flap)
PERSIST_FILE /data/.state.json State file on the volume; off to disable
IMAGE_TAG latest GHCR image tag to run; pin to sha-<short> to freeze/rollback
WATCHTOWER_POLL_INTERVAL 1200 Seconds between auto-update checks (~20 min)
FLIP_SPEED 5 Flip-animation speed multiplier (higher = faster). Baked in at build time, so it only applies to local builds (docker-compose.build.yml); the published image bakes 5
FLIP_ANIMATE true Whether letters animate (flip). Set false for instant updates — no flip, no per-row/char stagger, no sound; the whole screen changes at once. Build-time only; the published image bakes false

Changing the mode/theme/sound from the setup screen (http://<pi-ip>/setup) applies to all displays instantly and persists across restarts.

Port 80 needs no extra privilege

Publishing port 80 does not require running the container as root or adding CAP_NET_BIND_SERVICE. The host side of a Docker port publish is bound by dockerd, which already runs as root; Express still listens on unprivileged 3001 inside the container. Only the host mapping changed.

Moving an existing install from 8080

The display used to serve on 8080. Watchtower will not make this change for you — it recreates the container from the config the running container already has, and never re-reads docker-compose.yml. So an existing Pi keeps serving 8080 until you pull and re-up:

cd ~/split-flap && git pull
docker compose up -d          # re-reads the compose file; republishes on 80
curl http://localhost/api/health

If this Pi is also the kiosk, update the autostart URL in ~/.bash_profile from http://localhost:8080 to http://localhost in the same pass (see PI-SETUP.md), then reboot — otherwise the wall display lands on an error page at the next reload. To cut over without any gap, publish both ports for the transition by adding a second mapping (- "8080:3001") before dropping it later.


Raspberry Pi performance notes

Any recent Pi runs the server easily — the only heavy part is Chromium rendering the flip animation, and that scales with the model.

Note: the published image ships with the flip animation disabled (FLIP_ANIMATE=false) — letters snap instantly, which is trivial to render on any Pi, so the notes below only matter if you re-enable it (FLIP_ANIMATE=true) in a local build.

  • Pi 4B / 5 — the recommended choice. They drive the full-board split-flap animation GPU-accelerated (VideoCore VI does Chromium's GL ES under Wayland) at the default speed, with cores to spare. No tuning needed; just keep the kiosk's GPU on (see PI-SETUP.md / KIOSK.md).
  • Word-clock modes are smooth on any Pi (no flip animation — just letter highlighting).
  • Pi 3B+ — works, with caveats. Full-board split-flap transitions animate up to ~192 tiles at once and stress its single Cortex-A53; expect some jank on big changes (idle info-screen ticks are fine). Its VideoCore IV can't drive GL ES under Wayland, so the kiosk renders in software (--disable-gpu) — fine for this mostly-CSS board. If you re-enable animation and big changes feel busy, build with a gentler FLIP_SPEED=1 via the build override below — or just leave animation off (the published default), which sidesteps the issue entirely.
  • The client wraps each tile in React.memo to minimize re-renders on all models.

Building the image yourself

Normally the Pi just pulls the prebuilt GHCR image. To build from source instead (e.g. to test an unpushed change, bake a custom FLIP_SPEED, or turn the animation back on with FLIP_ANIMATE=true), layer the build override — on your Mac/CI, not a 3B+ (the Vite build is memory-hungry):

FLIP_ANIMATE=true FLIP_SPEED=5 \
  docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build split-flap

If you must build on a 1 GB Pi 3B+, add swap first:

sudo dphys-swapfile swapoff
sudo sed -i 's/^CONF_SWAPSIZE=.*/CONF_SWAPSIZE=1024/' /etc/dphys-swapfile
sudo dphys-swapfile setup && sudo dphys-swapfile swapon

Run the 64-bit Raspberry Pi OS so the standard arm64 Node image is used.


Operations

docker compose logs -f                       # follow logs (app + watchtower)
docker compose pull && docker compose up -d  # update now (don't wait for the poll)
curl http://<pi-ip>/api/version              # which build is running
docker compose down                          # stop (keeps the data volume)
docker compose down -v                       # stop AND wipe persisted state

The persisted state lives in the split-flap-data volume and survives down/up and image updates — only down -v clears it.


API

Full endpoint reference is in CLAUDE.md. Common ones (replace the host; add :<port> only if you overrode HOST_PORT):

curl -X POST http://<pi-ip>/api/mode/qlock        # word clock
curl -X POST http://<pi-ip>/api/mode/flip         # split-flap board
curl -X POST http://<pi-ip>/api/message \
  -H 'Content-Type: application/json' \
  -d '{"lines":["HELLO WORLD"],"align":"center"}'
curl http://<pi-ip>/api/health                    # liveness probe

Rotating info screens

In Info Split-Flap mode the board rotates through up to 6 pushable screens (15s each) in the top rows, with the date/time line pinned to the bottom. Any process on any machine can publish to a slot (16); a slot's content expires 15 minutes after its last push and drops out of the rotation. The setup screen (/setup) shows a live, view-only preview of all 6 slots.

# Push to slot 3 (up to 7 lines; uppercased, padded to 24 chars)
curl -X POST http://<pi-ip>/api/screens/3 \
  -H 'Content-Type: application/json' \
  -d '{"lines":["SERVER A","CPU 42%","MEM 71%"],"align":"center"}'

curl http://<pi-ip>/api/screens                   # inspect all slots + TTLs
curl -X DELETE http://<pi-ip>/api/screens/3       # clear one slot

Local development (without Docker)

Runs the client and server as two processes (Vite on :3000 proxies /api and the WebSocket to the server on :3001):

./start.sh        # starts both; ./stop.sh / ./status.sh to manage

See CLAUDE.md for the full developer guide, architecture, and the DigitalOcean App Platform deployment path.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages