An analog clock whose hands show the time until the next downtown 1 train arrives at 125th Street (NYC MTA). Runs on a Raspberry Pi Zero 2 W.
The software polls the MTA's public GTFS-Realtime feed every 15 seconds, parses the protobuf response, and extracts arrival predictions for the southbound 1 train at 125th St (GTFS stop 116S). The minutes until each of the next three arrivals are mapped to positions on the clock face and driven by three independent stepper motors — one per hand.
Countdown mapping. 12 o'clock represents a train arriving now. A hand for a train m minutes away sits m minutes before 12 — i.e. counterclockwise from the top, exactly like reading a normal clock backwards from the hour:
| Minutes to train | Hand position |
|---|---|
| 0 (arriving) | 12 o'clock |
| 1 | the "59" mark (just before 12) |
| 15 | 9 o'clock |
| 30 | 6 o'clock |
| 55 (capped) | 1 o'clock |
As a train approaches, its hand sweeps clockwise up toward 12. Trains beyond CLOCK_MAX_MINUTES (55) are pegged at the 1 o'clock cap so a far-out train never wraps past 12 and collides with the "arriving now" position.
- Hand 1 (bottom) — soonest train
- Hand 2 (middle) — second train
- Hand 3 (top) — third train
This is a Python translation of the SubwayTimeService Go project, stripped of AWS infrastructure (Lambda, DynamoDB, API Gateway) and adapted to run locally on the Pi.
analog_clock/
config.py # Station, route, feed URL, polling, GPIO pins, geometry
mta_feed.py # Fetches + parses GTFS-Realtime protobuf from MTA
subway_times.py # Caching layer, computes minutes-to-arrival
clock_controller.py # Maps minutes -> hand angle, drives the 3 steppers
stepper.py # StepperHand: half-stepping, homing, position tracking
main.py # Entry point — poll loop
requirements.txt # Python dependencies
# Hardware bring-up / diagnostics (run on the Pi):
test_motor.py # Spin one motor a full revolution each way
test_hall.py # Live readout of one hall sensor
test_hall_live.py # Live readout of all three hall sensors at once
When MOTORS_ENABLED = False (the default, for laptop development), clock_controller
just logs what each hand would do, so the whole app runs anywhere without GPIO.
Set it to True on the Pi to drive the motors.
# Install uv if you don't have it:
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv
source .venv/bin/activate
uv pip install -r requirements.txt
uv run main.pyNo API key is needed — the MTA GTFS-Realtime endpoint is public.
Flash Raspberry Pi OS Lite (64-bit) onto a microSD card using Raspberry Pi Imager. In the imager's settings:
- Enable SSH
- Set your Wi-Fi credentials
- Set a hostname (e.g.,
subwayclock.local)
ssh pi@subwayclock.local
# Update system
sudo apt update && sudo apt upgrade -y
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Clone or copy your code to the Pi
# Option A: git clone
# Option B: scp -r analog_clock/ pi@subwayclock.local:~/analog_clock/
cd ~/analog_clock
uv venv
source .venv/bin/activate
uv pip install -r requirements.txtuv run main.pyYou should see log output like:
2026-06-29 23:38:30 [__main__] INFO: Analog subway clock starting
2026-06-29 23:38:30 [mta_feed] INFO: 1 train at 125th St arriving Mon Jun 29 23:48:25 EDT (9.8 min)
2026-06-29 23:38:30 [__main__] INFO: Next 3 trains (min): ['9.8', '21.0', '34.5']
With MOTORS_ENABLED = True, you'll also see the hands home on startup and then
each move to its train's position:
[clock_controller] INFO: Homing 3 hands sequentially...
[stepper] INFO: [hand1] home found (magnet zone 152 steps, centered, offset +0)
...
[clock_controller] INFO: [hand1] 9.8 min -> -58.8°
Create a service file:
sudo nano /etc/systemd/system/subwayclock.service[Unit]
Description=Analog Subway Clock
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/analog_clock
ExecStart=/home/pi/analog_clock/.venv/bin/python main.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetsudo systemctl enable subwayclock
sudo systemctl start subwayclock
# Check logs:
journalctl -u subwayclock -fThree 28BYJ-48 stepper motors (one per hand), each driven by a ULN2003 board, plus three hall-effect sensors for homing. Everything runs at 5V/3.3V directly off the Pi — no external driver chips.
Each ULN2003 board: + → 5V, − → GND, IN1–IN4 → four GPIOs.
Each hall sensor: VCC → 3.3V (not 5V — keeps the signal pin within the
GPIO's safe range), GND → GND, OUT → one GPIO.
| Hand | Position | IN1 | IN2 | IN3 | IN4 | Hall OUT |
|---|---|---|---|---|---|---|
| 1 | bottom | GPIO6 | GPIO13 | GPIO19 | GPIO26 | GPIO4 |
| 2 | middle | GPIO12 | GPIO16 | GPIO20 | GPIO21 | GPIO5 |
| 3 | top | GPIO17 | GPIO27 | GPIO22 | GPIO23 | GPIO24 |
All pins are defined in config.py (the HANDS list) — change them there if you
rewire. Use the breadboard power rails to fan out 5V / 3.3V / GND, since the Pi
header doesn't have enough power pins for three boards plus three sensors. All
grounds must be common.
Power note. All three boards share the Pi's 5V pin. To stay within budget, the firmware moves and homes one motor at a time (~250 mA peak). Don't drive all three simultaneously on this power setup, or the Pi can brown out. For simultaneous motion, feed the boards from an external 5V supply (grounds still tied to the Pi).
Each hand carries a magnet; a fixed sensor in the top plate detects it at the
12 o'clock position. On startup StepperHand.home():
- Sweeps until it finds the magnet's trigger zone (an arc, not a point).
- Measures the zone width and parks at its center — the most repeatable
reference — then applies an optional per-hand
home_offset_stepsfudge to land exactly on 12. Budget is two full revolutions, so a wide zone or a worst-case start position can never run out.
HALL_ACTIVE_LEVEL in config.py is the level the sensors read with a magnet
present. These modules are active-HIGH (read 1 with a magnet, idle 0).
STEPS_PER_REV = 8192— measured, not assumed. On this hardware, 4096 half-steps produced only 180° of output rotation, so a full revolution is 8192. Every minutes→angle conversion depends on this; homing is sensor-based and unaffected. (~22.8 steps per degree.)home_offset_steps(per hand inHANDS) nudges that hand clockwise after homing to correct a magnet that isn't exactly at 12. The top hand uses ~140; the others 0. Tune by eye.MINUTES_PER_REV = 60,CLOCK_MAX_MINUTES = 55— the countdown mapping (see How It Works).
Stop the service first so it isn't holding the GPIO: sudo systemctl stop subwayclock.
.venv/bin/python test_motor.py 1 # spin each motor: 1, 2, 3
.venv/bin/python test_hall_live.py # wave a magnet at each sensor, watch it flipThen set MOTORS_ENABLED = True in config.py, run .venv/bin/python main.py
to watch it home and track trains, and finally sudo systemctl start subwayclock.
gpiozero + lgpio come from apt, not pip (the lgpio wheel needs swig to
compile and fails under uv):
sudo apt install -y python3-gpiozero python3-lgpioThe venv must be able to see them, so create it with system site-packages:
uv venv --system-site-packages(For an existing venv: set include-system-site-packages = true in
.venv/pyvenv.cfg.)
gpiozerodocs: https://gpiozero.readthedocs.io/- MTA GTFS-Realtime reference: https://api.mta.info/
- Raspberry Pi GPIO pinout: https://pinout.xyz/