A Raspberry Pi-powered e-ink display showing real-time subway arrival times, Citi Bike availability, and weather forecasts. Perfect for mounting on your wall to check train times and weather before heading out.
Full Post here.
- Real-time subway arrival times (NYCT GTFS feeds — no API key)
- Current Citi Bike availability for a station (GBFS feeds — no API key)
- Current weather and hourly/daily forecast (Open-Meteo — no API key)
- BirdNET-Pi observation and collage screens fetched over SSH from a remote SQLite database
- Debug mode with automatic image preview
- Native e-ink display support on Raspberry Pi
Left: mounted on the wall in a laser-cut mat & frame. Right: a render of the display output.
- Raspberry Pi 4b+
- SD Card, power supply, (optionally keyboard, mouse, hdmi cord, etc.)
- Waveshare 9.7inch E-Ink display HAT for Raspberry Pi
- Optional MPR121 capacitive touch breakout for screen switching
(For the frame and mounting, see Physical Build below.)
- Figure out how you're going to connect to the Raspberry Pi
- Install UV
- Enable the SPI interface
- Attach the e-ink display to the Raspberry Pi
git clone https://github.com/SamBroner/subway-e-ink-tracker.git
cd subway-e-ink-tracker
uv sync- Install uv (if not already installed)
- Install dependencies:
uv sync
- Set up your environment file:
cp config/.env.template config/.env # then edit config/.env with your station IDs and preferences
All configuration lives in config/.env (gitignored — your personal values stay
local). Copy config/.env.template and fill it in:
| Variable | Required | Description |
|---|---|---|
STATION_ID |
yes | MTA station ID for arrivals (e.g. F20S) |
TRAIN_LINE_1, TRAIN_LINE_2 |
yes | Train lines to monitor (e.g. F, G) |
CITIBIKE_STATION_ID |
yes | Citi Bike station UUID (see below) |
CITIBIKE_STATION_NAME |
yes | Display name for the bike station |
WEATHER_LAT, WEATHER_LON |
no | Coordinates (defaults to NYC center) |
BIRDNET_SSH_HOST |
no | SSH host alias for the BirdNET-Pi sensor (defaults to birdnet) |
BIRDNET_DB_PATH |
no | Remote BirdNET-Pi SQLite path (defaults to ~/BirdNET-Pi/scripts/birds.db) |
BIRD_WINDOW_HOURS |
no | Observation summary window for the bird screen (defaults to 24) |
BIRD_RESULT_LIMIT |
no | Max grouped species returned by the bird feed (defaults to 15) |
BIRD_UPDATE_SECONDS |
no | Bird feed refresh interval (defaults to 900) |
BIRD_ASSET_DIR |
no | Local bird illustration directory |
BIRD_MOCK_DATA |
no | Local mock bird result JSON for debug rendering |
BIRD_USE_MOCK_DATA |
no | true makes the bird service read BIRD_MOCK_DATA instead of SSH |
TOUCH_ENABLED |
no | true enables optional MPR121 capacitive touch screen switching |
TOUCH_CHANNEL |
no | MPR121 electrode index to poll (defaults to 0) |
TOUCH_I2C_ADDRESS |
no | MPR121 I2C address (defaults to 0x5a) |
DEBUG |
no | true saves a render to debug_output/ instead of driving the display |
DEBUG_FRAME_HISTORY |
no | true also saves timestamped debug frames and debug_output/frame_manifest.csv |
QUIET_MODE |
no | true suppresses console output |
Values provided in the shell take precedence over config/.env, which is useful
for smoke tests such as DEBUG=true QUIET_MODE=false uv run runner.py.
Find your Citi Bike station's UUID and name in the GBFS feed: https://gbfs.citibikenyc.com/gbfs/en/station_information.json
The app can use one MPR121 capacitive touch electrode to advance screens. Wire the breakout to Raspberry Pi I2C bus 1:
| Wire | Pi connection |
|---|---|
| red | pin 1 / 3.3V |
| blue | pin 3 / SDA |
| yellow | pin 5 / SCL |
| black | pin 6 / GND |
| brass button | MPR121 E0 |
Enable it with:
TOUCH_ENABLED=true
TOUCH_CHANNEL=0
TOUCH_I2C_ADDRESS=0x5asudo i2cdetect -y 1 should show 5a.
If DEBUG=true in your environment:
- Images will be saved to
debug_output/current_display.png - If
DEBUG_FRAME_HISTORY=true, timestamped frames are also saved todebug_output/frames/with timing metadata indebug_output/frame_manifest.csv - Your system's default image viewer will automatically open and update with each refresh
- The image viewer will refresh automatically when new data arrives
If DEBUG=false:
- On Raspberry Pi: The e-ink display will update
- On other platforms: An error will be raised (e-ink display only works on Raspberry Pi)
To run:
uv run runner.pyScreen switching cycles through:
transit
bird-collage
bird-collage-named
birds
bird-profile
The display reads best when it's framed like a piece of art rather than left as a bare panel. The key is a laser-cut mat: a precise cutout window holds the e-ink panel square and aligned behind the glass, and gives it a clean, finished border.
- Frame: I used this natural cherry gallery frame.
- Mat: a custom laser-cut mat sized to the display's active area, also from American Frame. This is what makes the mount look intentional and keeps everything aligned.
See the full write-up for the framing and mounting technique, including how the Pi and ribbon cable tuck behind.
The unit tests for the Citi Bike module use the stdlib unittest
(no extra dependencies). Run them from the repo root:
uv run python -m unittest tests.test_citibike_service(The other scripts under tests/ are manual Raspberry Pi hardware checks —
SPI/GPIO and the e-ink display — and only run on the Pi.)
- CairoSVG is used to convert SVGs to PNGs for the display.
- On mac, you may need to manually compile Cairo: https://stackoverflow.com/questions/36225410/installing-cairo-and-pycairo-mac-osx
Figuring out the right display mode was annoying. The full spec is here.
- Consider checking if the wait time still makes sense and then refresh. E.g. It's 11am. Train Arrives at 11:04 and there's no update. When time turns to 11:01, even if no update, refresh.
- Fix hourly weather... seems like it's only 100% or zero?
- IT8951 library by GregDMeyer: https://github.com/GregDMeyer/IT8951
To have the display start automatically on boot, create a systemd service:
[Unit]
Description=Subway E-Ink Display Service
After=network.target
[Service]
Type=simple
User=<your-username>
WorkingDirectory=/path/to/repo
ExecStart=/path/to/uv run runner.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetThen manage it with:
sudo systemctl restart subway-eink.service
sudo systemctl stop subway-eink.service.
├── runner.py # main loop: fetch data, render, update display
├── utils.py # icon rendering + shared helpers
├── config/
│ ├── config.py # all configuration + display geometry
│ └── .env.template # copy to config/.env and fill in
├── services/
│ ├── subway_service.py # MTA arrivals
│ ├── citibike_service.py # Citi Bike availability
│ ├── weather_service.py # Open-Meteo weather
│ └── weather_codes.py # WMO weather code sets
├── ui/
│ ├── display.py # e-ink / debug display driver
│ ├── layout.py # screen layout + drawing
│ └── fonts.py # font loading
├── assets/
│ ├── fonts/ # Font.ttc
│ ├── bitmaps/ # display test bitmaps
│ └── icons/ # weather + UI (bike, bolt) SVG icons
└── tests/ # unit tests + Pi hardware checks