This project assumes you'll modify it. It's MIT-licensed, the art pipeline needs no code changes, and everything domain-specific lives in a dozen small files. The README covers installing and running the stock sign; this page is the map for changing it: the dev loops, where each behavior lives, and the traps that cost the author real time.
Two sibling checkouts — the app, and the ScrollKit LED-matrix library it runs on:
git clone https://github.com/czei/themeparkwaits
git clone https://github.com/czei/scrollkit
cd themeparkwaits
python3 -m pip install pygame pillow pytestThe app runs unchanged on desktop in ScrollKit's pygame simulator — real screens, real themeparks.wiki data:
# Live 64×32 preview window
PYTHONPATH="../scrollkit/src:src" python -m src.themeparkwaits --dev
# Same, throttled to the board's real frame rate
SCROLLKIT_HW_SIM=1 PYTHONPATH="../scrollkit/src:src" python -m src.themeparkwaits --dev
# Domain tests (mocked HTTP)
pytest tests/Path note: examples elsewhere in this repo (docs, tool docstrings) write the
library path as ../ScrollKit Library/src — the author's checkout name. Same thing;
substitute wherever your scrollkit checkout lives. python -m src.themeparkwaits --dev
finds either sibling name on its own.
Two desktop traps (SCROLLKIT_NOTES.md is the full gotcha ledger):
- Never
import displayioorimport wifibare in app code — PyPI packages by those names shadow the platform modules. If the simulator behaves strangely, runpip uninstall wifi displayio. - Simulator-green is not device-green. RAM limits, native-type quirks, and
animation start-time stalls only show on the board (watch serial for
ANIM-*-FAIL).
scripts/deploy.sh --dry-run # show exactly what would copy
scripts/deploy.sh # deploy to a USB-connected boardFour things trip people up:
- deploy.sh ships
git archive HEAD, not your working tree. Uncommitted edits silently don't deploy. Commit first. - The board decides who may write the flash. Hold the DOWN button while
powering on to make the drive Mac-writable (deploy mode). A normal boot makes it
read-only to your Mac — a
cpfailing withRead-only file systemmeans reboot holding DOWN. Holding UP at boot is a factory reset (deletes WiFi credentials and settings); hold it for a full second, because it now demands a sustained press. Logic lives inboot.py. Both buttons enable the internal pull-up and debounce: the board has no external pull-ups, so a bare read is undefined — the UP check was doing exactly that until 2026-08, which made every boot a coin flip on wiping the device and itserror_log. - The library comes from the sibling checkout, rsynced by deploy.sh. Point
SCROLLKIT_SRCat your scrollkit package if it isn't at../ScrollKit Library/src/scrollkit;MPY=1deploys it compiled (half the flash, worse tracebacks). - Watch serial or you're flying blind:
screen /dev/tty.usbmodem* 115200, orpython tools/dev_serial_capture.py(reconnects across resets).
The config page is http://themeparkwaits.local/ on the device; the simulator serves
the same page on http://localhost:8080/.
The fully-paved path. docs/ride-intro-images.md takes an
ASCII-art sketch to an on-device BMP plus its manifest.json line;
docs/ride-intro-animations.md adds motion with one
registry line. Icons are keyed by ride UUID in src/images/rides/manifest.json, so any
image can serve any ride — drawing your own family into the Jungle Cruise boat is a
design file, a manifest line, and a spec line. Preview everything with
tools/intro_preview.py (see the README).
src/ui/ride_screen_content.py is the dual-zone ride screen (scrolling name, 2× wait
number, intro phases); src/ui/tpw_display.py has the scaled digit drawing;
src/ui/reveal_splash.py is the boot reveal. The classic trap: draw_text y is the
text baseline, not the top — y=0 clips the glyph off the panel. On a
microcontroller the render loop can't allocate: reuse a Label and set .text only on
change, move .x to scroll, keep bit_depth=4.
src/ui/content_builder.py turns fetched parks into the display queue: sort, group,
filter, the vacation countdown, the attribution screen. A new kind of screen — say,
an animated interstitial between rides — is a DisplayContent subclass appended here
(reveal_splash.py is a good template). There's no data-driven extension point for
custom screens yet; it's a code change.
A new setting is two edits: its default (and bool_keys entry, if boolean) in
src/settings_schema.py, and a form field + handler in src/web/config_server.py. It
persists to settings.json on the board.
src/api/theme_park_service.py fetches and parses the plain-HTTP relay, not
themeparks.wiki: trimmed static snapshots at data_base_url (default
http://www.themeparkhallofshame.com/device), regenerated every five minutes from a
collector that polls the wiki. src/models/ is the domain (parks, rides, vacation).
The relay is not a convenience, it's the fix for the TLS-SRAM exhaustion that used to
wedge the fleet — the device now does no routine TLS at all, so keep new fetches on
http: and read docs/relay.md before adding a network call. The
constraints that shaped the rest: free RAM is scarce, so parks fetch sequentially
with a gc.collect() between, drained into one reused 16 KB buffer that is also the
device-side size cap on a relay payload; HTTP is synchronous and freezes the display,
so always paint a status frame before a blocking call.
The display abstraction, effects and transitions, WiFi (including the setup portal), HTTP, the settings engine, and the web-server core are all scrollkit. If the defect is there, fix it there — this repo is only the theme-park domain. (Scrollkit still ships an OTA client. This app stopped calling it in 3.5.21; see below.)
Signs used to update themselves from this repo's live branch, which meant a modified
board could silently eat a stock release. That path was removed in 3.5.21 along with
src/ota_glue.py: a full install is 762 KB against a ~400-500 KB TLS budget before the
internal-SRAM exhaustion bites, so only a delta at fresh boot could ever have fit, and
every 9.x box needed a USB bootstrap regardless. docs/relay.md has the
arithmetic; RELEASING.md describes the retired flow and is kept only in
case the ESP-IDF leak is fixed upstream.
So your build stays on the board until you replace it. Ship it the same two ways
everyone else does: scripts/deploy.sh over USB while you're iterating, or
scripts/make_usb_zip.sh for a zip someone else can drop onto CIRCUITPY (no flags by
design, and its REQUIRED list is a hard gate, so add to that list when you add a device
file). Keep settings.json in place and the board keeps its parks, colors, and WiFi
across the swap; delete it and the sign comes back in first-run setup.
- docs/README.md — which docs are how-to guides and which are engineering investigation ledgers.
- SCROLLKIT_NOTES.md — the numbered ledger of hard-won platform gotchas (CircuitPython, TLS, RAM, sockets). Read it before fighting the device.
specs/— spec-kit design history for the major features: the why behind the current shape, not a how-to.designs/— the editable ASCII source for every shipped icon.