OpenAthlete
AGPLv3 기반 지구력 훈련 플랫폼—EU 호스팅, 투명한 로직, 자체 호스팅·전체 내보내기 지원
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기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.