尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
A runner scans a QR code at the shop, connects Strava, picks the run they just
finished, adds up to three photos, and a receipt prints on the thermal printer.
• Web app — this repo's root. Next.js 16 on Vercel. Runs the OAuth flow,
renders the receipt, forwards it to the Pi.
• pi-server/ — Flask. Owns the printer and a durable print queue.
Built on the Group Dynamics starter — design rules in
CLAUDE.md and GROUP-D-SYSTEM.md.檢視完整 README ↗A runner scans a QR code at the shop, connects Strava, picks the run they just finished, adds up to three photos, and a receipt prints on the thermal printer.
Two apps:
OAuth + pick a run PNG over bearer-token POST
runner's phone ───────────────────────► web ──────────────────────────► pi-server/
(QR at shop) (Vercel) https://raspberrypi.…ts.net (Pi at the shop)
via Tailscale Funnel
pi-server/ — Flask. Owns the printer and a durable print queue.Built on the Group Dynamics starter — design rules in CLAUDE.md and GROUP-D-SYSTEM.md.
/control, test print. The checklist.The receipt is rendered exactly once.
src/components/receipt/ReceiptDoc.tsx lays it out as flexbox JSX, next/og
rasterises it to a 384px-wide PNG, and that one PNG is both what the runner
previews on their phone and what the Pi burns onto paper.
This is the fix for what made the photobooth receipts so painful. That build drew the receipt twice — once on a canvas in the browser, once with Pillow on the Pi — so two layout engines had to be kept in sync by hand and nothing could be trusted to fit. Here there is no second layout pass, so there is nothing to drift.
Practical consequences:
src/lib/receiptConfig.ts. Every measurement
lives there, in print pixels.src/lib/fixtures/sampleRun.ts. Refresh and you are looking at
the exact bitmap that would print.Satori does not flatten React fragments. It lays <>…</> out as its own box
in the default row direction, so <><Rule/><div/></> puts the content
beside the rule, off the 384px canvas, where it prints as blank space. Use the
<Block> element in ReceiptDoc instead.
Photos are dithered in the browser, not on the Pi. A thermal head is 1-bit,
so something must decide per pixel. Doing it on the Pi would error-diffuse
across the whole composite — including the anti-aliased edge of every glyph —
and fur up the text. So src/lib/thermal.ts reduces photos to pure black and
white at exactly print size, and the Pi only thresholds. The corollary:
never resample a dithered photo. ReceiptDoc places them at native size for
this reason.
Strava changed its developer terms on 1 June 2026, and the limits decide whether this works at an event.
This app needs an athlete's data for about thirty seconds — read one activity,
render a bitmap, queue it — and never again. So /api/print calls
POST /oauth/deauthorize as soon as the job is safely queued.
If Strava's counter tracks live connections rather than lifetime authorisations, that holds the app at one or two connections no matter how many runners come through, and the ten-athlete cap never bites.
Strava does not document which it is. Developers ask in the community hub and get no staff answer. Settle it yourself in five minutes, on the free one-athlete tier, before the first event:
curl -X POST https://www.strava.com/oauth/deauthorize -H "Authorization: Bearer <token>"Step 3 succeeding means the counter is on active connections and you are fine.
403 Limit of connected athletes exceeded means it is cumulative and you need
review.
Either way, run the parallel track: email [email protected] with your
client id to request a limit increase. Reported turnaround is days to a couple
of months. Review wants to see a working app, so build first and submit after.
The API agreement requires attributing Strava, and attributing Garmin when
the displayed data came off a Garmin device. Both are handled — see
garminAttribution in src/lib/receiptConfig.ts, which keys off the activity's
device_name. Don't remove those footer lines.
npm install
cp .env.example .env.local # fill it in
npm run dev
Nothing in .env.local is NEXT_PUBLIC_, deliberately. The Strava client
secret and the printer token stay server-side: the runner's phone talks to us,
and we talk to the Pi. (The photobooth shipped its printer token to the browser,
which meant anyone who loaded the page could drive the printer directly.)
On the Strava app at strava.com/settings/api, set Authorization Callback
Domain to your bare production host — no scheme, no path. localhost is
whitelisted separately, so development needs no extra setup.
| Route | What it is |
|---|---|
/ | QR landing. Connect Strava. |
/runs | Recent runs, with route thumbnails. |
/runs/[id] | Photo picker, live preview, print. |
/control | Operator page. Passcode-gated. |
/api/receipt | GET renders the fixture; POST renders a payload. |
sudo apt update
sudo apt install -y libusb-1.0-0
cd pi-server
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # PRINT_TOKEN must match the web app's PRINTER_TOKEN
Run it:
set -a; source .env; set +a
python3 app.py
curl -H "Authorization: Bearer $PRINT_TOKEN" http://localhost:8000/health
PRINTER_DRY_RUN=1 writes each prepared bitmap into PRINT_QUEUE_DIR instead
of sending it to USB. The whole server — queue, worker, retries, hold — is
developable on a laptop with no printer attached.
PRINT_TOKEN=devtoken PRINTER_DRY_RUN=1 python3 app.py
/etc/systemd/system/active-cultures-print.service:
[Unit]
Description=Active Cultures print server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=groupdynamics
WorkingDirectory=/home/groupdynamics/active-cultures-receipts/pi-server
EnvironmentFile=/home/groupdynamics/active-cultures-receipts/pi-server/.env
ExecStart=/home/groupdynamics/active-cultures-receipts/pi-server/.venv/bin/python3 app.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now active-cultures-print
journalctl -u active-cultures-print -f
The Pi sits behind whatever wifi it's on; the web app is on the public internet. Tailscale Funnel gives the Pi a permanent public HTTPS address with no port forwarding, no exposed IP, and no domain to buy — and the address stays the same when the Pi moves networks.
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --ssh
sudo tailscale funnel --bg 8000
tailscale funnel status # prints https://<host>.<tailnet>.ts.net
Then set PRINTER_URL to that address in the web app. Details, and the
gotchas (key expiry, DNS propagation), are in
docs/PI-RUNBOOK.md.
sqlite plus PNGs on disk, both stdlib. Jobs survive a power cut mid-event.
A job left mid-print by a crash is marked failed, not requeued — the paper may
be half-printed, and silently reprinting hands someone a duplicate. The operator
decides from /control.
hold also persists across restarts: someone who paused the printer before a
reboot does not expect a stack of receipts on the way back up.
Print the test strip from /control → Test print. It gives you a dot ruler,
horizontal and vertical rules at 1–4px, and edge markers.
PRINTER_HEAD_DOTS. Reduce it, and match HEAD_DOTS in
src/lib/receiptConfig.ts.ROUTE.stroke in receiptConfig.ts.
1px strokes do not survive thresholding.PRINT_THRESHOLD.PRINT_THRESHOLD defaults to 190, not the intuitive 128. The wordmark is
ABC Monument Grotesk Light, and at the 10–11px sizes the eyebrow and
attribution lines use, its stems are thinner than one dot — anti-aliasing puts
them around 60% grey, so a 128 threshold discards them and the small type prints
as broken fragments. Measured against the real font: 128 loses most stems, 170
is legible, 190 is clean, 210 starts blobbing shut.
Raising it is free for photos, which arrive already dithered to pure black and white and are byte-identical at any threshold from 1 to 254.
src/lib/thermal.ts is
canvas-based and has only been exercised through the type checker. It needs
one real photo through a real browser before an event.HOUSE_PHOTOS in receiptConfig.ts is empty. Drop images
into public/receipt/ and list them there to print the front door on every
receipt.