A tiny "operating system" for an LED-matrix sign. A small kernel provides shared services (display, data, fonts, input) and self-contained apps plug in — each with its own lifecycle. A launcher rotates through them with smooth transitions.
Runs on two chained Adafruit 64×64 HUB75 panels (a 128×64 canvas) driven by a Raspberry Pi + Adafruit RGB Matrix HAT — and, thanks to a drop-in emulator, on your Mac with zero code changes.
Apps so far: Cafe Menu, which renders Izzy's Cafe
from the site's /izzys-cafe.json feed; Weather, current conditions from Open-Meteo;
Subway, live next-train times from the MTA's realtime feeds; and Bus, live
next-bus times from MTA Bus Time.
python3 -m venv .venv && source .venv/bin/activate
make install # or: pip install -r requirements.txt
# Easiest: the preview script (creates the venv on first run):
./preview.sh # live browser preview at http://localhost:8888
./preview.sh png # render one static frame to a PNG and open it
# Or use the make targets — live preview serves at http://localhost:8888:
make dev # full app rotation
make dev APP=weather # just one app — handy while building it
make frames APP=weather # render frames to PNGs, headless -> frames/weather/make dev/make frames are thin wrappers over python run.py (use it directly if
you prefer: python run.py --app weather, python run.py --dump-frames frames/).
Before pushing, run the green gate — byte-compiles everything and runs a hardware-free smoke test of the kernel and system layer. CI runs exactly this:
./dev/check.shThe --led-* flags mirror rpi-rgb-led-matrix, e.g.
python run.py --led-rows=64 --led-cols=64 --led-chain=2. Panel geometry defaults
live in config.yaml.
emulator_config.json tunes how the browser emulator renders. It ships configured to
simulate two chained Adafruit 64×64 3mm-pitch (P3) panels — a 128×64 canvas drawn
with the real pixel style plus a subtle glow, so previews resemble the physical sign
lit up. Panel geometry (rows/cols/chain) lives in config.yaml; this file only
controls appearance (pixel size/style/glow).
Apps live in this repo (a monorepo) behind a clean kernel/app seam. For why — and
when to revisit it — see ADR 0001; the
seam rules for app authors live in apps/__init__.py.
run.py entrypoint + CLI (--led-* flags, --dump-frames)
config.yaml matrix geometry, app rotation, dwell/transition
kernel/
display.py Mac/Pi drop-in shim + matrix construction
launcher.py rotate apps, double-buffered frame loop, transitions
app.py the App base class (the whole app contract)
loader.py discover + instantiate apps from apps/<name>/
data.py cached, stale-on-error fetching — JSON or bytes (http + file)
render.py font loading + text/scroll helpers
services.py the handle passed to each app
input.py (optional) HAT GPIO buttons -> next/prev app
apps/
cafe_menu/ the site menu, scrolling
weather/ current conditions + high/low, procedural weather icons
subway/ next trains per station, incl. a tiny GTFS-realtime reader
fonts/ drop a .ttf/.otf here for crisp text
An app implements one method:
class MyApp(App):
def refresh(self): # pull data off the render loop (optional)
...
def render(self, t): # return a PIL.Image for elapsed time t (seconds)
...mkdir apps/weatherwith anapp.pydefining oneAppsubclass and amanifest.yaml(name,dwell,refresh_interval, any config keys).- Add
weathertolauncher.rotationinconfig.yaml.
That's it — the loader discovers it, the launcher rotates to it. Fetch data via
self.services.data.get_json(url, ttl=...) — or get_bytes(...) for a binary feed,
which is how the subway app reads the MTA's protobuf. Both cache with a TTL and keep
showing the last good value if a fetch fails.
Flash an SD card and the sign does the rest. No monitor, no keyboard, no SSH.
./tools/flash.sh # interactive
./tools/flash.sh --device disk6 --ssid Home --hostname lobby-signflash.sh downloads and SHA256-verifies the latest Raspberry Pi OS Lite arm64
(cached in ~/Library/Caches), writes it to the card, and injects the HUB75 hardware
config, your WiFi credentials + SSH key, and the first-boot payload. It refuses
internal disks and makes you retype the device and its size before erasing anything.
The sign's Pi can also host Izzy's Cafe order server (the FastAPI backend behind the QR order app), exposed publicly through a Cloudflare Tunnel. One-time prep on the Mac, per tunnel:
cloudflared tunnel login
cloudflared tunnel create izzy-orders # writes ~/.cloudflared/<uuid>.json
cloudflared tunnel route dns izzy-orders orders.izzybennett.comWrite a config.yml (tunnel: <uuid>, credentials-file: /etc/cloudflared/<uuid>.json,
ingress orders.izzybennett.com → http://localhost:8000, catch-all http_status:404),
then flash with:
./tools/flash.sh --order-server \
--tunnel-cred ~/.cloudflared/<uuid>.json --tunnel-config config.ymlBootstrap installs the server under /opt/izzy-orders (its own venv, its own izzy
system user, uvicorn on loopback only) plus cloudflared, and a timer polls the order
repo every five minutes — a cheap git ls-remote when nothing changed; on a new commit
it pulls, restarts, health-checks /health, and rolls back if the server won't answer.
Signs flashed without --order-server are completely unaffected. Note the flashed card
carries the tunnel credential (like the WiFi PSK) until first boot moves it to the
rootfs — treat the card as secret. If the site's deploy needs it, set the repo variable
PUBLIC_ORDER_API=https://orders.izzybennett.com in the site repo's Actions settings.
Use a Pi 4 / 3B+ / Zero 2 W — they have solid GPIO timing.
Provision (hostname, user, SSH key, WiFi profile) → reboot → compile the matrix driver
into /opt/dizzyos/venv → install the latest release → start. Then the sign is up and
rotating apps on its own. Watch progress by SSHing in if you like, or just wait for
pixels.
While the network is down the sign draws a small red no-WiFi icon in the top-left corner, over whatever app is on screen. That's the one thing to look for if a sign comes up blank-ish or stale.
Every sign serves a settings page on the LAN:
http://<hostname>.local:8080
Opening it makes the sign display a one-time PIN for 30 seconds — type what you see
on the sign to unlock the session. That's the authorization model: you have to be in
the room. From there you can join a different WiFi network and edit the device's
config.yaml (validated on save; the sign restarts itself to apply).
On-device config lives at /etc/dizzyos/config.yaml — outside the release tree, so
it survives every update.
A timer polls for the latest GitHub release every five minutes (ETag-conditional, so
an unchanged answer is a rate-limit-free 304 — a new release reaches signs within
minutes of the tag landing). When one appears, the updater unpacks its
source tarball to /opt/dizzyos/releases/<tag>/, and atomically flips the
/opt/dizzyos/current symlink. The launcher touches a heartbeat file every frame, so
the updater can tell whether the new release actually renders — if it doesn't, the
symlink flips back, the tag is marked bad, and the sign keeps running what it had.
Releases carry no build artifacts by design (see
ADR 0002).
Run one by hand, or read the log:
sudo dizzyos-update # update if there's a newer release
sudo dizzyos-update --force # re-deploy the current tag, or retry a bad one
journalctl -u dizzyos-update- Power: two 64×64 panels can pull ~3–4A each at full white (~8A peak). Run two 5V supplies: one to the Pi, and a separate 5V 8–10A supply to the HAT's screw terminal, which feeds the panels — don't run the panels off the Pi's supply. Fuse the panel supply and keep the grounds common. (Full wiring: the build sheet.)
- Solder the HAT's address-E jumper (required for 1:32-scan 64-row panels).
- Solder the hardware-PWM jumper (GPIO4↔GPIO18) to kill flicker, then use
--led-gpio-mapping=adafruit-hat-pwm. - Stack vs. side-by-side: the panels chain into 128×64. To mount them as a tall
64×128 instead, set
pixel_mapper_config: "Rotate:90"inconfig.yaml— no code change. - Enclosure + full build:
docs/build-sheet.htmlis the step-by-step wood/acrylic build (cut list, wiring, assembly); the parametric model and its fit checks live incad/(python cad/test_fit.py).
Built on rpi-rgb-led-matrix and RGBMatrixEmulator. Architecture nods to the MLB/NHL LED scoreboards, which use the same stack.