MTA SUBWAY & BUS TIME DISPLAY WITH CUSTOM MESSAGE
A Complete Technical Guide to Embedded IoT Transit Information Systems.
A Complete Technical Guide to Embedded IoT Transit Information Systems:
Abstract
This paper documents the complete design, development, and implementation of a real-time public transit information display system built on the ESP32-C3 microcontroller platform. The system fetches live bus and subway arrival data from the New York City Metropolitan Transportation Authority (MTA) public API, parses binary Protocol Buffer (protobuf) encoded GTFS-RT feeds without external libraries, and renders a multi-page animated display on a 3.5-inch ILI9488 TFT screen. The project demonstrates practical embedded systems engineering including memory-constrained HTTP streaming, binary protocol parsing, adaptive buffer management, SPI display driving without hardware abstraction libraries, and real-time data visualization. The final system operates continuously as a 24/7 wall-mounted transit display with automatic dark mode, sleep scheduling, and animated custom message pages.
1. Introduction
Public transit information systems traditionally require expensive dedicated hardware or commercial software platforms. This project demonstrates that a capable, aesthetically polished transit display can be built for under $25 in components using open-source tools and public APIs. The primary technical challenges addressed are memory management on a resource-constrained microcontroller, binary protocol parsing without dedicated libraries, reliable HTTP streaming over TLS, and smooth display rendering without a framebuffer.
1.1 Project Goals
- Display real-time arrival times for two bus stops and one subway station
- Update automatically every 30 seconds for buses and 36 seconds for subway
- Render clearly on a 480×320 color TFT display in both light and dark modes
- Operate 24/7 with automatic sleep mode during off-hours
- Show animated custom messages on configurable dates
- Fit entirely within the ESP32-C3's 400KB RAM
1.2 System Overview
┌─────────────────────────────────────────────────────────┐
│ MTA Public APIs │
│ ┌─────────────────────┐ ┌──────────────────────────┐ │
│ │ Bus Time API │ │ GTFS-RT Subway Feed │ │
│ │ (JSON/SIRI format) │ │ (Binary Protobuf) │ │
│ └──────────┬──────────┘ └────────────┬─────────────┘ │
└─────────────┼──────────────────────────┼────────────────┘
│ HTTPS/TLS │ HTTPS/TLS
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ ESP32-C3 SuperMini │
│ ┌────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ WiFi Stack │ │ HTTP Client │ │ Protobuf Parser │ │
│ └────────────┘ └──────────────┘ └─────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Display Renderer │ │
│ │ Page 0: Bus Stop 1 │ Page 1: Bus Stop 2 │ │
│ │ Page 2: Subway │ Page 3: Custom Message │ │
│ └────────────────────────────────────────────────────┘ │
│ SPI @ 40MHz │
└─────────────────────────────┬───────────────────────────┘
│
┌─────────▼──────────┐
│ ILI9488 3.5" TFT │
│ 480 × 320 pixels │
│ 18-bit color │
└────────────────────┘
2. Hardware
2.1 Components List
Component Specification Approximate Cost - Microcontroller
- ESP32-C3 SuperMini
- $3.00
- Display
- 3.5" ILI9488 TFT SPI 480×320
- $12.00
- Power supply
- USB 5V 1A
- $3.00
- Enclosure
- 3D printed or project box
- $5.00
- Wiring
- Jumper wires or PCB
- $2.00
Total ~$25.00
| Component | Specification | Approximate Cost |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Total | ~$25.00 |
2.2 Pin Connections:
ESP32-C3 SuperMini ILI9488 TFT Display───────────────── ───────────────────GPIO 4 (CS) ────────► CS (Chip Select)GPIO 2 (DC) ────────► DC (Data/Command)GPIO 3 (RST) ────────► RST (Reset)GPIO 1 (MOSI) ────────► MOSI (Master Out)GPIO 0 (SCK) ────────► SCK (Clock)GPIO 21 (BL) ────────► BL (Backlight)3.3V ────────► VCCGND ────────► GND2.3 Display Configuration
The ILI9488 is configured with specific register settings for landscape orientation:
MADCTL = 0x68 → Memory Access Control (landscape, BGR)
Color = 0x66 → 18-bit color depth (262K colors)
SPI = 40MHz → Maximum reliable speed for ESP32-C33. Software Architecture
3.1 Development Environment
- IDE: Arduino IDE 2.x
- Board: ESP32C3 Dev Module
- CPU Speed: 160MHz
- Flash Mode: QIO 80MHz 4MB
- Upload Speed: 921600 baud
- Libraries required:
- ArduinoJson v7.x
- SPI, WiFi, HTTPClient, WiFiClientSecure (built-in ESP32)
- time.h (built-in)
3.2 Code Structure:
mta_tracker.ino
│
├── Defines & Constants
│ ├── Pin definitions
│ ├── Color palette (light + dark mode)
│ └── Timing constants
│
├── Data Structures
│ ├── SubSlot — subway arrival slot
│ └── CustomMsg — animated message definition
│
├── SPI / Display Layer
│ ├── writeCmd(), writeData()
│ ├── setWindow(), pushColor()
│ ├── fillRect(), fillCircle(), drawRect()
│ └── font5x7[][] — 5×7 bitmap font in PROGMEM
│
├── Text Rendering
│ ├── drawChar() — single character with scale
│ ├── drawText() — string with anti-alias pass
│ ├── drawTextC() — centered string
│ └── drawBadgeText() — badge-safe rendering
│
├── Theme System
│ ├── Light mode colors
│ └── Dark mode colors (auto 8PM–6AM)
│
├── Emoji Animation Engine
│ ├── drawCake(), drawHeart(), drawStar()
│ ├── drawFirework(), drawSnowflake()
│ ├── drawBalloons(), drawRainbow()
│ ├── drawCat(), drawFlower(), drawHappyFace()
│ └── drawEmoji() — dispatcher
│
├── Page System
│ ├── drawPage0() — Bus Stop 1
│ ├── drawPage1() — Bus Stop 2
│ ├── drawPage2() — Subway 77 St
│ └── drawPage3() — Animated Custom Message
│
├── Protobuf Parser (custom, no library)
│ ├── pbVarint() — variable-length integer
│ ├── pbSkip() — skip unknown fields
│ ├── pbStr() — read string field
│ ├── pbSTE() — StopTimeEvent parser
│ ├── pbSTU() — StopTimeUpdate parser
│ ├── pbTU() — TripUpdate parser
│ └── parseSubBuf() — full feed parser
│
├── Data Fetching
│ ├── fetchSubwayData() — adaptive drain + parse
│ ├── fetchBusStop() — JSON with retry
│ ├── fetchBus1Data() — stop 1 wrapper
│ └── fetchBus2Data() — stop 2 wrapper
│
└── Main Loop
├── setup() — init, WiFi, NTP, first fetch
└── loop() — page rotation, refresh timing
3.3 Timing Architecture
Timeline (seconds)
0────────────────30───────────36────────────────60
│ │ │ │
│←── Bus 1 ─────►│ │ │
│ │←── Subway ──────►│ │
│ │←── Bus 2 ───────►│
│ │
│←── Page rotation every 20s ──────────────────► │
│←── Footer clock update every 1s ─────────────► │
Only one fetch runs per loop cycle — they are staggered using else if chaining to prevent two network operations from competing for heap simultaneously.4. Key Technical Challenges and Solutions
4.1 Challenge: Parsing Binary Protobuf Without a Library
The MTA subway feed uses GTFS-RT format encoded as Protocol Buffers. Standard protobuf libraries are too large for the ESP32-C3. A custom minimal parser was implemented.
Protobuf wire format fundamentals:
Each field = [tag varint] [data]
Tag varint:
bits 0-2 = wire type
bits 3+ = field number
Wire types:
0 = Varint
1 = 64-bit
2 = Length-delimited (string, bytes, embedded message)
5 = 32-bit
Custom varint decoder:
uint64_t pbVarint(const uint8_t* b, int len, int &pos){
uint64_t r=0; int s=0;
while(pos63){pos=len;break;}
}
return r;
}
GTFS-RT field mapping for R43N (77 St subway stop):
FeedMessage
└── entity[] (field 2)
└── trip_update (field 3)
├── trip (field 1)
│ ├── trip_id (field 1) → used to determine direction
│ └── route_id (field 5) → R, N, Q, W
└── stop_time_update[] (field 2)
├── stop_id (field 4) → "R43N" = 77 St northbound
├── departure (field 2)
│ └── time (field 2) → Unix timestamp
└── arrival (field 3)
└── time (field 2) → Unix timestamp fallback
4.2 Challenge: Feed Size Exceeds Available RAM
The GTFS-RT NQRW feed is approximately 105KB. The ESP32-C3 cannot allocate 105KB contiguously after WiFi and TLS stacks consume RAM. Maximum reliable allocation is ~65KB.
Solution: Adaptive Drain-Then-Parse Strategy
Analysis of the feed showed R43N trips consistently appear after byte 80,000. The solution drains the first portion of the feed unread using a tiny 256-byte stack buffer, then allocates a 65KB buffer for only the relevant tail section.
Feed bytes: 0────────────80000────────────105000
│ │ │
│ Drain (discard)│ Parse (65KB) │
│ tiny 256B buf │ heap allocated │
└────────────────┴─────────────────┘
Heap usage during fetch:
Before: ~201KB free
During drain: ~200KB free (256B stack buffer only)
During tail: ~135KB free (65KB heap buffer)
After free(): ~201KB free
Adaptive drain target algorithm:
drainTarget starts at 80000
After each successful fetch:
if N > 0 and drainTarget < 90000:
drainTarget += 1000 (nudge forward — working well)
if N = 0 and drainTarget > 70000:
drainTarget -= 3000 (back off — R43N was in drained section)
Hard floor: drainTarget never goes below 70000
(R43N is never before byte 70000 in this feed)
If drain was incomplete (stream closed early):
do NOT adjust drainTarget
4.3 Challenge: Display Rendering Without Framebuffer
The ILI9488 requires 480×320×3 = 460,800 bytes for a full framebuffer — far exceeding available RAM. All rendering is done directly to the display hardware using the SPI window command.
Window-based rendering:
void setWindow(int x0,int y0,int x1,int y1){
writeCmd(0x2A); // Column address set
writeData(x0>>8); writeData(x0&0xFF);
writeData(x1>>8); writeData(x1&0xFF);
writeCmd(0x2B); // Row address set
writeData(y0>>8); writeData(y0&0xFF);
writeData(y1>>8); writeData(y1&0xFF);
writeCmd(0x2C); // Memory write
}
Every fillRect sets a window and floods it with a single color — no pixel-by-pixel iteration needed for solid fills.
Font rendering with anti-aliasing trick:
The 5×7 bitmap font is stored in PROGMEM (flash memory) to save RAM. A two-pass rendering technique gives the appearance of smoother text:
Pass 1: Draw all pixels normally (foreground + background)
Pass 2: Draw foreground pixels shifted 1px right (skipBg=true)
Result: Slight horizontal smearing that visually thickens strokes
and reduces the pixelated appearance of small bitmap text
4.4 Challenge: Bus Badge Text Invisible on Colored Backgrounds
The standard drawText function uses the card's ETA color as background during rendering. On dark green, orange, or red cards this caused white badge text to be overwritten.
Root cause: The double-pass anti-alias rendering in drawText draws background pixels from the card color, not from MTA_BLUE, overwriting the white pixels of the previous character.
Solution: Direct pixel rendering for badge text only:
void drawBadgeText(int bx,int by,String text){
// Bypass drawText entirely
// Each pixel is either WHITE or MTA_BLUE — no card color involved
for(int ci=0;ci<n;ci++){
int idx=text[ci]-32;
for(int col=0;col<5;col++){
uint8_t bits=pgm_read_byte(&font5x7[idx][col]);
for(int row=0;row<7;row++){
uint32_t color=(bits&(1<<row)) ? WHITE : MTA_BLUE;
fillRect(cx+col*2, sy+(6-row)*2, 2,2, color);
}
}
}
}4.5 Challenge: Footer Countdown Overflow
The countdown timer showed 4294568s — clearly a uint32 overflow. This occurred because lastBus2Refresh was initialized to millis()+15000 to stagger the second bus fetch.
Root cause: When now - lastBus2Refresh is computed and lastBus2Refresh > now (due to the +15000 offset at boot), unsigned integer subtraction wraps to ~4 billion.
Solution: Never add offsets to lastRefresh variables. Instead stagger using else if with different interval thresholds:
// Instead of: lastBus2Refresh = millis() + 15000 ← WRONG
// Use:
if(now - lastSubRefresh >= SUB_MS) { fetchSubwayData(); }
else if(now - lastBus1Refresh >= BUS_MS) { fetchBus1Data(); }
else if(now - lastBus2Refresh >= BUS_MS+12000){ fetchBus2Data(); }
// Bus 2 effectively starts 12s later naturally
5. API Integration
5.1 MTA Bus Time API
Endpoint:
GET https://bustime.mta.info/api/siri/stop-monitoring.json
?key=YOUR_API_KEY
&MonitoringRef=MTA_302881
&MaximumStopVisits=3
Response structure (SIRI format):
{
"Siri": {
"ServiceDelivery": {
"StopMonitoringDelivery": [{
"MonitoredStopVisit": [{
"MonitoredVehicleJourney": {
"PublishedLineName": "MTA NYCT_B37",
"DestinationName": "ATLANTIC AV BARCLAYS CENTER via 3 AV",
"MonitoredCall": {
"ExpectedArrivalTime": "2024-05-20T14:32:00-04:00",
"Extensions": {
"Distances": {
"DistanceFromCall": 425.3
}
}
}
}
}]
}]
}
}
}
ETA calculation:
// Primary: parse ExpectedArrivalTime timestamp
int arrivalSeconds = aH*3600 + aM*60 + aS;
int nowSeconds = now.tm_hour*3600 + now.tm_min*60 + now.tm_sec;
int diff = arrivalSeconds - nowSeconds;
// Handle midnight rollover
if(diff < -43200) diff += 86400;
if(diff > 43200) diff -= 86400;
// Skip buses that already passed more than 2 minutes ago
if(diff < -120) continue;
etaMinutes = (diff <= 0) ? 0 : diff/60;
// Fallback: distance-based estimate (250 meters per minute)
etaMinutes = (int)(distanceFromCall / 250.0f);
5.2 MTA GTFS-RT Subway Feed
Endpoint:
GET https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-nqrw
Header: x-api-key: YOUR_API_KEY
Stop ID reference for this project:
| Stop ID | Location | Direction |
|---|---|---|
|
|
|
|
|
|
Direction determination from trip_id:
trip_id format: "XXXXXX_R..N" or "XXXXXX_R..S"
The character after ".." indicates direction:
N = Northbound (toward Forest Hills / Queens on R line)
S = Southbound (toward Bay Ridge / Brooklyn on R line)
Example: "096600_R..N" → Forest Hills bound
"096600_R..S" → Bay Ridge bound
6. Display Layout
6.1 Color System :
Light Mode: Dark Mode:
Page background: #F2F2F2 Page background: #0A1628
Card background: #FFFFFF Card background: #162040
Card border: #DDDDDD Card border: #2A4060
Top bar: #1A6BD4 Top bar: #0D1F3C
Footer: #EEEEEE Footer: #0D1F3C
ETA Color Coding (both modes):
0–2 min: Red (urgent)
3–5 min: Orange (soon)
6+ min: Green (comfortable)
7. Results and Performance
7.1 Memory Usage
| Phase | Free Heap |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7.2 Timing Performance
| Operation | Duration |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7.3 Reliability Observations
Over continuous 24/7 operation the system demonstrated:
- Subway data: Available ~95% of cycles. Failures occur during MTA feed maintenance windows (typically 1–4 AM) and are handled gracefully by showing "No subway service"
- Bus data: Available ~98% of cycles. Occasional HTTP 403 rate-limit errors auto-retry after 3 seconds
- Adaptive drain:
drainTargetself-tunes within 3–5 cycles after any feed size change and stabilizes - Memory: No heap fragmentation observed over 72-hour test periods
8. Step-by-Step Build Guide
Step 1 — Get an MTA API Key
- Go to
https://api.mta.info - Register for a free developer account
- Your API key works for both bus and subway endpoints
- Note your key — you will paste it into the code
Step 2 — Find Your Bus Stop IDs
- Go to
https://bustime.mta.info - Search for your stop
- The stop ID appears in the URL as a number
- Prefix it with
MTA_— example: stop 302881 →MTA_302881
Step 3 — Find Your Subway Stop ID
- Download the MTA GTFS static feed from
https://api.mta.info - Open
stops.txtand search for your station name - The stop_id column gives you the ID (example:
R43) - Add
Nfor northbound orSfor southbound based on your direction
Step 4 — Wire the Hardware
Follow the pin table in Section 2.2. Use short jumper wires to minimize SPI signal noise. The backlight pin (GPIO 21) can be connected directly to 3.3V if you want it always on.
Step 5 — Install Arduino IDE and ESP32 Support
- Download Arduino IDE 2.x from
arduino.cc - In Preferences → Additional Board URLs add:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
- Install
esp32board package via Board Manager - Install
ArduinoJsonlibrary version 7.x via Library Manager
Step 6 — Configure the Code
Open the sketch and fill in your credentials at the top:
const char* ssid = "YourWiFiName";
const char* password = "YourWiFiPassword";
const char* apiKey = "YourMTAApiKey";
const char* stopId1 = "MTA_302881"; // Your bus stop 1
const char* stopId2 = "MTA_308206"; // Your bus stop 2
const char* targetStopN = "R43N"; // Your subway stop
const char* busStop1Label= "3 Av / 80 St"; // Display label
const char* busStop2Label= "77 St / 3 Av"; // Display label
const char* busStop1Dest = "to ATLANTIC AV";
const char* busStop2Dest = "to JACKSON HTS";
Adjust dark mode and sleep hours for your timezone:
#define DARK_HOUR_START 20 // 8 PM
#define DARK_HOUR_END 6 // 6 AM
#define SLEEP_HOUR_START 1 // 1 AM
#define SLEEP_HOUR_END 4 // 4 AM
Step 7 — Add Custom Messages
Add birthday wishes, holidays, or any special message:
CustomMsg messages[]={
// month, day, startHour, endHour, bgColor, textColor, line1, line2, line3, emoji
{true, 5,20, 0,24, 0xFF1493,WHITE, "Happy Birthday!", "Wishing you", "an amazing day!", "CAKE"},
{true,12,25, 0,24, 0x1B5E20,WHITE, "Merry Christmas!","Joy and peace","to you!", "SNOW"},
};
Available emoji: CAKE HEART STAR FIREWORK SNOW BALLOON RAINBOW CAT FLOWER HAPPY
Step 8 — Upload and Test
- Select board: ESP32C3 Dev Module
- CPU Frequency: 160MHz
- Flash Mode: QIO 80MHz
- Upload Speed: 921600
- Select your COM port
- Click Upload
- Open Serial Monitor at 115200 baud
Expected Serial output on successful boot:
=== MTA boot ===
WiFi OK: 192.168.1.xx
NTP: 2024-05-20 14:32
Sub P1 heap=201xxx drain=80000
Sub HTTP:200 heap=149xxx
Drained 80000 in 300ms
tail buf 65536 ok
Tail read 51xxx/65536 bytes
P1 N=3
N[0]R Forest Hills-71 Av 7min
N[1]R Forest Hills-71 Av 15min
N[2]R Forest Hills-71 Av 23min
Sub done heap=203xxx
Bus HTTP:200 heap=156xxx
Bus payload 1467 bytes
[0]B37 -> Atlantic Av Barclays Center 16min
9. Troubleshooting:
| Symptom | Cause | Fix |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10. Conclusion
This project successfully demonstrates that production-quality real-time information displays can be built with minimal hardware and cost. The key engineering contributions are a memory-efficient adaptive streaming parser for binary protobuf feeds, a framebuffer-free direct SPI rendering pipeline, and a self-tuning feed position algorithm that reliably extracts target data from oversized binary streams. The system has operated continuously for extended periods with high data availability and stable memory usage, validating the approach for permanent installation as a household transit display.
10.1 Future Work
- Add a second subway line (N/Q/W trains at the same stop)
- Implement service alert display from the MTA alerts feed
- Add a PIR sensor to disable the display when no one is present
- Port to a round GC9A01 display for a clock-style form factor
- Add weather data from OpenWeatherMap API as a fifth page
References
- MTA Developer Resources —
https://api.mta.info - GTFS Realtime Reference —
https://gtfs.org/realtime/reference - Protocol Buffers Encoding —
https://protobuf.dev/programming-guides/encoding - ILI9488 Datasheet — Ilitek, Rev 1.0
- ESP32-C3 Technical Reference Manual — Espressif Systems, 2022
- SIRI (Service Interface for Real Time Information) Standard — CEN/TS 15531
0 Comments