Ad Code

Responsive Advertisement

Ticker

6/recent/ticker-posts

MTA SUBWAY BUS TIME DISPLAY

 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

  • ComponentSpecificationApproximate 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

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             ────────► GND

2.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-C3

3. 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 IDLocationDirection
  • R43N
  • 77 St (4th Ave)
  • Toward Forest Hills
  • R41N
  • 59 St (4th Ave)
  • Toward Forest Hills

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

PhaseFree Heap
  • Boot (before WiFi)
  • ~310KB
  • After WiFi connect
  • ~210KB
  • During subway drain
  • ~200KB
  • During tail parse
  • ~135KB
  • After free()
  • ~203KB
  • During bus JSON parse
  • ~154KB
  • Steady state (idle)
  • ~203KB

7.2 Timing Performance

OperationDuration
  • WiFi connect
  • 2–5 seconds
  • NTP sync
  • 1–3 seconds
  • Subway fetch + parse
  • 8–15 seconds
  • Bus fetch + parse
  • 2–4 seconds
  • Page draw (full)
  • ~1.5 seconds
  • Footer update
  • ~50ms
  • Display SPI clock
  • 40MHz

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: drainTarget self-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

  1. Go to https://api.mta.info
  2. Register for a free developer account
  3. Your API key works for both bus and subway endpoints
  4. Note your key — you will paste it into the code

Step 2 — Find Your Bus Stop IDs

  1. Go to https://bustime.mta.info
  2. Search for your stop
  3. The stop ID appears in the URL as a number
  4. Prefix it with MTA_ — example: stop 302881 → MTA_302881

Step 3 — Find Your Subway Stop ID

  1. Download the MTA GTFS static feed from https://api.mta.info
  2. Open stops.txt and search for your station name
  3. The stop_id column gives you the ID (example: R43)
  4. Add N for northbound or S for 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

  1. Download Arduino IDE 2.x from arduino.cc
  2. In Preferences → Additional Board URLs add:

https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
  1. Install esp32 board package via Board Manager
  2. Install ArduinoJson library 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

  1. Select board: ESP32C3 Dev Module
  2. CPU Frequency: 160MHz
  3. Flash Mode: QIO 80MHz
  4. Upload Speed: 921600
  5. Select your COM port
  6. Click Upload
  7. 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:

SymptomCauseFix
  • Sub HTTP:-1
  • WiFi timeout to MTA server
  • Automatic retry — wait for next cycle
  • P1 N=0 repeatedly
  • drainTarget too high
  • System auto-adjusts — wait 2–3 cycles
  • Bus badge text blank
  • First fetch before bus data loads
  • Normal — shows on next page rotation
  • 4294568s in footer
  • uint32 overflow in countdown
  • Ensure lastBus2Refresh=millis() not millis()+offset
  • Display all white
  • SPI wiring issue
  • Check GPIO 0,1,2,3,4 connections
  • Display upside down
  • MADCTL register wrong
  • Verify writeData(0x68) after writeCmd(0x36)
  • WiFi fails
  • Wrong credentials
  • Double-check ssid/password, 2.4GHz only
  • NTP FAILED
  • DNS issue
  • Add WiFi.setAutoReconnect(true) before connect

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

  1. MTA Developer Resources — https://api.mta.info
  2. GTFS Realtime Reference — https://gtfs.org/realtime/reference
  3. Protocol Buffers Encoding — https://protobuf.dev/programming-guides/encoding
  4. ILI9488 Datasheet — Ilitek, Rev 1.0
  5. ESP32-C3 Technical Reference Manual — Espressif Systems, 2022
  6. SIRI (Service Interface for Real Time Information) Standard — CEN/TS 15531


This project is open source. All code is available for personal and educational use. MTA API usage is subject to MTA developer terms of service. This project is not affiliated with or endorsed by the Metropolitan Transportation Authority.

Post a Comment

0 Comments