Displaying Tube Times
Why I built this
Going into my second year of Univeristy, I will need to commute everyday via the Tube. From personal experience, Apps like Google Maps and City Mapper often give times that are not entirely accurate. Upon researching why this happens, I learnt that these apps base their times on TFL given schedules making the timings prone to mistakes. As we all know TFL and delays are like two peas in a pod and so inspired by this I decided to make a more accurate, simple display board for Tube Timings at my local Tube stop.
How my design differs from more common apps
My design will utilise an Arduino Nano ESP32 board which will be capable of retrieving real time tube data from the TFL API over WiFi. This will be more accurate and dynamic (similar to the physical boards at tube stations) than apps.
Initial design
The concept I had in mind was simply that the WIFI (and API) connects to the ESP32 and then I would wire the ESP32 to display the Tube times from West Finchley. Initially my plan was also to use a button to switch between southbound and Northbound trains.
I first (with the help of GPT) designed a code to connect the ESP32 to my local wifi and used an API key to access TFL's Unite API. The code then finds the relevant trains (southbound From West Finchley) from the API. I learnt here that the TFL API is in a JSON format which required a library to loop through.
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <LiquidCrystal.h>
LiquidCrystal lcd(12, 11, 5, 4, 3, 2);
const char* ssid = "MY WIFI";
const char* password = "MY WIFI PASSWORD";
const char* subscriptionKey = "API KEY"; // Replace with your real key
const char* stopPointId = "940GZZLUWFN"; // West Finchley id - replace with your stop id
void setup() {
lcd.begin(16, 2);
Serial.begin(115200);
delay(3000);
Serial.println("Starting up...");
delay(2000);
Serial.print("Connecting to ");
Serial.println(ssid);
WiFi.begin(ssid, password);
unsigned long startAttemptTime = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < 10000) {
Serial.print(".");
delay(500);
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nā
Connected!");
Serial.print("IP: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("\nā Connection failed.");
Serial.print("Status code: ");
Serial.println(WiFi.status());
}
}
void loop(){
lcd.clear();
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
String url = "https://api.tfl.gov.uk/Line/northern/Arrivals/" + String(stopPointId) + "?subscription-key=" + subscriptionKey;
http.begin(url);
// http.setInsecure(); // Was told to uncomment I got SSL cert errors
int httpCode = http.GET();
if (httpCode > 0) {
String payload = http.getString();
// Allocate memory and parse JSON
const size_t capacity = 8192;
DynamicJsonDocument doc(capacity);
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("ā JSON parsing failed: ");
Serial.println(error.f_str());
return;
}
// Loop through trains
for (JsonObject train : doc.as<JsonArray>()) {
const char* direction = train["direction"];
if (String(direction) == "inbound") { // inbound = southbound
const char* dest = train["destinationName"];
const char* branch = train["towards"];
int secs = train["timeToStation"];
// Shorten destination
String shortDest = "";
if (String(dest).startsWith("Battersea")) {
shortDest = "BP";
} else if (String(dest).startsWith("Morden")) {
shortDest = "Morden";
} else {
shortDest = dest;
}
// Condense branch info
String branchStr = String(branch);
branchStr.replace(" via Bank", ":Bnk");
branchStr.replace(" via CX", ":CX");
// Output
Serial.printf("%s - %d min\n", branchStr.c_str(), secs / 60);
String lcdLine = String(branchStr) + " - " + String(secs / 60) + " min";
lcd.setCursor(0,0);
lcd.print(lcdLine);
}
}
} else {
Serial.printf("ā GET failed: %s\n", http.errorToString(httpCode).c_str());
}
http.end();
}
delay(30000); // Refresh every 30 seconds
}
Upon testing the tube times were successfully displayed on the serial monitor. Here, I learnt to introduce delays between lines to ensure that the serial monitor could open successfully before displaying anything.

The Problem
The first problem I ran into was a power problem. The Arduino Nano ESP32 runs at 3.3V but the LCD1602 Module I was using required 5V input. This mismatch was easily solved by including a power module with a 9V battery to power the LCD screen. However this was just the small problem with an easy fix. Upon trying to display the tube times onto my LCD 1602, the screen would light up but no time would be displayed. It took a while to find an answer but the logic levels between the LCD and the ESP32 differed too much to send a signal to the LCD. E.g. The ESP32 defines HIGH as 3.3V but at least 4.2 V is needed for the LCD to register something. This mismatch means nothing can be displayed onto the LCD.

The Solution
The two main ways I found to solve my problem was to either use a bi - directional logic level shifter or simply using a 3.3V display instead. I chose to then use a 0.91" I2C OLed display which required some different code. Admittedly, a lot of AI generated code was used as I had no previous experience using any of the libraries required to loop through APIs and run animations. My strategy was to feed the logic into GPT rather than simply asking it to generate code for me.
I first began by wiring my OLed and Nano Esp32 as shown below. Please note that the modelling software I used (Wowki online Arduino simulator) did not have 0.91" 3.3V OLed screens and Nano Esp32 boards as components so the diagram below uses an Esp32 board and a 5V OLed. It was here I learnt that pins 4 and 5 are usually the I2C communication pins (this will come in play in my code and I will discuss something that halted my progress a lot).


Updated Code
With the adoption of a new display, the code neeeded to be updated. The general idea of the code was to
1- Connect to API and get South Bound train times 2- Parse data to display the train times in a concise manner 3- Play animation to signal new train times 4- Refresh and repeat
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <vector>
#include "three_carriages.h" // header file for train animation
// WiFi + TfL API Info
const char* ssid = "WiFi ID"; // replace with your own WiFi Name
const char* password = "WiFi Password"; // replace with your WiFi password
const char* subscriptionKey = "API key"; // replace with your API key
const char* stopPointId = "940GZZLUWFN"; // Replace with your Station ID
// OLED setup
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
void setup() {
Serial.begin(115200);
delay(1000);
Wire.begin();
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("ā OLED init failed"));
for (;;);
}
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Starting...");
display.display();
// Dots will print on OLed until WiFi successfully connected
WiFi.begin(ssid, password);
Serial.println("Connecting to WiFi...");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nā
WiFi connected!");
display.clearDisplay();
display.setCursor(0, 0);
display.println("WiFi OK");
display.display();
delay(1000);
}
// API request generated
void loop() {
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
String url = "https://api.tfl.gov.uk/Line/northern/Arrivals/" + String(stopPointId) + "?subscription-key=" + subscriptionKey;
http.begin(url);
int httpCode = http.GET();
if (httpCode > 0) {
String payload = http.getString();
const size_t capacity = 8192;
DynamicJsonDocument doc(capacity);
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("ā JSON parse error: ");
Serial.println(error.f_str());
http.end();
delay(30000);
return;
}
// 1: Collect all inbound trains
std::vector<JsonObject> inboundTrains;
for (JsonObject train : doc.as<JsonArray>()) {
if (strcmp(train["direction"].as<const char*>(), "inbound") == 0) {
inboundTrains.push_back(train);
}
}
// 2: Sort by arrival time
std::sort(inboundTrains.begin(), inboundTrains.end(), [](JsonObject a, JsonObject b) {
return a["timeToStation"].as<int>() < b["timeToStation"].as<int>();
});
// 3: Clear and scroll the animation ---
display.clearDisplay();
for (int x = -trainWidth; x <= SCREEN_WIDTH; x++) {
display.clearDisplay(); // Full clear for smooth motion
display.drawBitmap(x, 16, trainBitmap, trainWidth, trainHeight, WHITE);
display.display();
delay(5);
}
// 4: Display up to 2 new trains
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
int y = 0;
int count = 0;
for (JsonObject train : inboundTrains) {
if (count >= 2) break;
String destStr = String((const char*)train["destinationName"]);
String towardsStr = String((const char*)train["towards"]);
int secs = train["timeToStation"];
int mins = secs / 60;
// Destination short code
String shortDest;
if (destStr.startsWith("Battersea")) {
shortDest = "BP";
} else if (destStr.startsWith("Morden")) {
shortDest = "Mord";
} else if (destStr.startsWith("Kennington")) {
shortDest = "Ken";
} else {
shortDest = destStr.substring(0, 4);
}
// Branch short code
String shortBranch = "?";
if (towardsStr.indexOf("via Bank") != -1) {
shortBranch = "BK";
} else if (towardsStr.indexOf("via Charing Cross") != -1) {
shortBranch = "CX";
} else if (destStr.startsWith("Battersea")) {
shortBranch = "CX";
}
// Compose and print line
String line = shortDest + " " + shortBranch + "-" + String(mins) + "m";
Serial.println(line);
display.setCursor(0, y);
display.println(line);
y += 32;
count++;
}
display.display();
} else {
Serial.printf("ā GET failed: %s\n", http.errorToString(httpCode).c_str());
}
http.end();
}
delay(30000); // Refresh every 30 seconds
}
Admittedly, a lot of the code used was made with the help of GPT such as the majority of the Animation scroll and parsing of the JSON file from the API request.
I spent a long time figuring out the pins for I2C communcation on the board. I was told the that pins A4 and A5 are the I2C communication pins (which is true) but my original code used Wire.begin(GPIO for A4, GPIO for A5) which led to nothing being displayed. I soon found out that passing no arguments through Wire.begin() would simply pass the default I2C communication pins through Wire.begin(). However since I did pass arguments through the function, the I2C communcation was not initialised properly leading to not switching on. I soon found out that it is only necessary to pass arguments in the function for older boards. When left empty, the board uses the default I2C pins.
Results

