Real-time NYC subway arrival display. A Convex backend stores users, their tracked stops (with direction labels), and station names — fetches live MTA GTFS-RT data on demand, and serves enriched arrivals to a Raspberry Pi e-ink display.
The display refreshes every 15 seconds using partial refresh (flicker-free), with a full panel refresh every 5 minutes to clear ghosting.
┌─────────────────────────────────────────────────────────────────────────┐
│ Hey Daniel WED, MAR 18, 2026 8:42 AM │
├═════════════════════════════════════════════════════════════════════════╡
│ Eastern Pkwy-Brooklyn Museum │
│ [2] Manhattan 3min · 11min │
│ [3] Manhattan 9min · 17min │
│ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - │
│ Franklin Av-Medgar Evers College │
│ [4] Manhattan 6min · 19min │
│ [5] Manhattan 8min · 21min │
│ UPDATED 8:42:01 AM │
├═════════════════════════════════════════════════════════════════════════╡
█ 11°C PARTLY CLOUDY H 13° / L 7° Wind 13 km/h NW █
└─────────────────────────────────────────────────────────────────────────┘
The Pi is a pure renderer — all display metadata (station names, direction labels) lives in the Convex backend.
Hardware
- Raspberry Pi (any model with GPIO — tested on Pi 4)
- Waveshare 7.5" e-ink display V2 (800×480)
Accounts
- Convex — free tier is enough
On the Pi
- Python 3.11+
- Pillow (
pip install Pillow) - Waveshare e-Paper Python library (see Pi setup)
convex/
schema.ts users, stops (with directions), stations tables
stops.ts add/remove/list stops
stations.ts upsert + seed from bundled JSON
arrivals.ts GTFS-RT fetch + enriched response
http.ts GET /arrivals endpoint
crons.ts (empty — no scheduled jobs)
data/
stations.json 992 MTA subway stops, bundled at build time
display/
display.py Raspberry Pi display script
npm install
npm run dev # first run: prompts you to create a Convex projectThis generates your deployment URL (e.g. https://happy-animal-123.convex.site) and saves it to .env.local.
To deploy to production:
npm run deployStation names come from the 992-entry convex/data/stations.json bundled in the repo (sourced from MTA GTFS static data). Seed them once after deploy:
npx convex run stations:syncStationsnpx convex run users:create '{"userId":"your-uuid","alias":"Your Name"}'Save the returned _id — you'll need it as CONVEX_USER_ID.
Find your directional stop IDs using the MTA stop ID reference, then add each one with its direction labels:
npx convex run stops:add '{
"userId": "<_id>",
"stopId": "238N",
"ordering": 0,
"directions": [
{"line": "2", "label": "Manhattan"},
{"line": "3", "label": "Manhattan"}
]
}'directions controls which lines and labels are shown per stop on the display. ordering (integer, ascending) controls the order stops appear on screen.
To reorder stops:
npx convex run stops:patchOrdering '{"stopId": "238N", "ordering": 0}'To update directions:
npx convex run stops:patchDirections '{"stopId": "238N", "directions": [{"line": "2", "label": "Manhattan"}]}'To disable a stop (removes it from the display without deleting it), set disabled: true on the stop document in the Convex dashboard.
GET https://<deployment>.convex.site/arrivals?userId=<convex_user_id>
Returns the user's alias and up to 6 arrivals per enabled stop, sorted by ordering, enriched with station name and direction labels:
{
"alias": "Daniel",
"stops": [
{
"stopId": "238N",
"name": "Eastern Pkwy-Brooklyn Museum",
"directions": [
{"line": "2", "label": "Manhattan"},
{"line": "3", "label": "Manhattan"}
],
"arrivals": [
{"route": "2", "direction": "N", "arrivalTime": 1234567890, "minutesAway": 3},
{"route": "3", "direction": "N", "arrivalTime": 1234567950, "minutesAway": 9}
]
}
]
}All other functions are internal and cannot be called directly.
pip install epaper
# or clone and install manually:
# https://github.com/waveshareteam/e-PaperThe display script expects the library at:
/usr/local/lib/python3.13/dist-packages/epaper/e-Paper/RaspberryPi_JetsonNano/python/lib
If yours is in a different location, update the sys.path.insert line at the top of display/display.py.
pip install Pillowgit clone https://github.com/PirosB3/mta-convex.git ~/mta-convexCreate /etc/systemd/system/mta-display.service:
[Unit]
Description=MTA Transit E-Ink Display
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/mta-convex
ExecStart=/usr/bin/python3 -u /root/mta-convex/display/display.py
Restart=on-failure
RestartSec=10
StandardOutput=append:/root/mta-display.log
StandardError=append:/root/mta-display.log
Environment=PYTHONUNBUFFERED=1
Environment=CONVEX_ARRIVALS_URL=https://<your-deployment>.convex.site/arrivals
Environment=CONVEX_USER_ID=<your-convex-user-id>
# Optional: set lat/lon for weather (defaults to Brooklyn)
# Environment=WEATHER_LAT=40.68
# Environment=WEATHER_LON=-73.94
[Install]
WantedBy=multi-user.targetEnable and start:
systemctl daemon-reload
systemctl enable mta-display
systemctl start mta-displayCheck it's running:
systemctl status mta-display
tail -f /root/mta-display.logA local post-push git hook ships in .git/hooks/post-push. When you push to main, it SSHes to the Pi, runs git pull, and restarts the service automatically.
The hook uses root@mta-display.local — adjust PI, REPO_DIR, and SERVICE at the top of the file if your setup differs.
Stop IDs follow the MTA GTFS convention: {station_id}{direction} where direction is N (uptown/Manhattan-bound) or S (downtown/outer-borough-bound). Examples:
| Stop ID | Station | Lines |
|---|---|---|
238N |
Eastern Pkwy-Brooklyn Museum | 2/3 → Manhattan |
239N |
Franklin Av-Medgar Evers College | 4/5 → Manhattan |
D25N |
7 Av (Park Slope) | B/Q → Manhattan |
F20N |
Carroll St | F/G → Manhattan |
A27N |
Hoyt-Schermerhorn | A/C/G → Manhattan |
All 992 directional stops are in convex/data/stations.json.
convex/data/stations.json (bundled, 992 stops)
│
▼ npx convex run stations:syncStations (once)
│
Convex DB (users + stops w/ directions + stations w/ names)
│
▼
Convex HTTP Action (GET /arrivals)
│
├─ looks up stop directions + station names from DB
├─ fetches MTA GTFS-RT protobuf feeds in parallel (free, no API key)
├─ decodes with gtfs-realtime-bindings
└─ returns {alias, stops: [{stopId, name, directions, arrivals}]}
│
▼
Raspberry Pi (polls every 15s)
│
├─ fetches live weather from Open-Meteo (free, no key, every 5 min)
├─ renders 800×480 grayscale image with Pillow
├─ partial refresh (no flicker) every 15s
└─ full panel refresh every 5 min to clear ghosting
The Pi script has no local configuration — all station metadata is owned by the backend. Weather is fetched live in °C; set WEATHER_LAT/WEATHER_LON for your location.