Intro
I have the privilege of living in London — The Big Smoke. However, where I live, I have a problem with the buses, which is that one bus line is frequent and another is rare.
Wouldn't it be nice to have a noticeboard that, at a glance, can tell you how long you would have to wait before leaving the house? After all, who wants to spend a moment longer than they need to in one of London town's infamous pea-soupers?
Yes, you can get this on your phone by navigating to the TfL website and finding your bus stop, but a physical noticeboard is more fun.
Code can be found on GitHub - london-bus-forecast.
The Plan
My plan is to program the noticeboard using Rust, which is one of the programming languages that I am familiar with, along with Python.
In terms of hardware, this is what I got:
- Raspberry Pi Zero 2 This is a tiny quad-core ARM computer with a mere 512MB of ram, but it will be enough for my purposes. The W edition has Wi-Fi, which is essential. If you are a non-solderer like me, go for one with the GPIO header pre-installed.
- Micro SD Card to use as storage
- Bits and bobs that come bundled as a starter kit, such as:
- Micro USB Power Supply
- Mini HDMI Adapter
- Heatsink/Case
- 7.5in E-Ink Display I chose this based on the size and knowing I only need a black and white display
On the software side, I would call the Transport for London (TfL) API and request information on bus arrivals for the two nearest bus stops going in either direction. With this information, I would create an image that summarises this information and then somehow display that image on the E-ink display.
This blog post focuses on the process of getting the data from TfL. Later posts will describe making an image out of the TfL data, setting up the hardware, and finally displaying the image on the e-ink display.
More specifically, we will cover:
The TfL API
The TfL API defines a collection of resources that provide information about public transport in London. What we are interested in is the "Arrival Predictions" endpoint, which gives a list the predicted bus arrivals for a specified bus stop and is described in the API Documentation.
To access the information, you do an HTTP GET request on the URL https://api.tfl.gov.uk/StopPoint/{id}/Arrivals, but substitute {id} with the desired stop ID.
For example, click this link to see the arrival predictions for the Kensington Palace bus stop towards Knightsbridge/South Kensington.
Finding Your Stop ID
The easiest way to find a stop ID is by searching for your stop here TfL website. In the page URL, you will find the stop ID. From now on, I will assume I live in Kensington Palace and want to catch the bus from there1.
The pages for the nearby bus stops are
https://tfl.gov.uk/bus/stop/490011761M/kensington-palace:https://tfl.gov.uk/bus/stop/490011761N/kensington-palace
So the IDs are 490011761M and 490011761N
The API Key
An API key is optional, but will increase the request limit from 50 to 500 per minute. It is free, but you need to sign up. Go to Products: List - Transport for London - API and select the 500 per minute product. Choose a subscription name and click subscribe. Your ID can be viewed in your profile: User: Profile - Transport for London - API.
The API key is added as a query parameter to the Arrivals URI.
What Information do we get?
You can see for yourself the data you get back by clicking for example this link: 490011761M. The data comes in JSON format: a well-known and popular text format for transmitting data.
The API Documentation lays out the format.
The response is an array (i.e. a list) of objects representing the predicted arrival of a bus. Such an object is of type Tfl.Api.Presentation.Entities.Prediction. Scrolling down, you can see a table showing all the members of a prediction object. Here are the ones I ended up using:
id: The identifier for the predictionoperationType: The type of the operation (1: is new or has been updated, 2: should be deleted from any client cache) [Note: This means we should filter out 2's]lineId: Unique identifier for the Line [Note: this is just the bus line number]stationNamedestinationNametimestamp: Timestamp for when the prediction was inserted/modifiedtimeToStation: Prediction of the Time to station in seconds [Note: This is what we need!]
Getting started: Project Setup to Printing Out the Arrivals
Before we get stuck into making the image we send to the E-ink display, lets start by getting the information we need from the API and processing it properly.
As mentioned above, we are using Rust, so lets start by creating our project:
cargo new london-bus-forecast
Next add a few libraries (called crates in Rust).
Reqwest is a simple Rust HTTP client. We will use it to make the GET requests to the TfL API server and retrieve the information. We are using the async version, which needs the Tokio async runtime to work, but lets us fire off requests for 2 bus stops concurrently.
cargo add reqwest --features json
cargo add tokio@1 --features full
The response we get is JSON, which we read into a Rust data structure using serde_json. Jiff is for parsing the timestamp.
cargo add serde --features derive
cargo add serde_json
cargo add serde_repr
cargo add jiff --features serde
Anyhow is a crate that provides an easier way to handle errors in Rust applications.
cargo add anyhow
Dotenvy loads our variables from a .env file.
cargo add dotenvy
The .env file
So far, I have mentioned 3 secrets: pieces of private information that should not be included in the codebase. The first is the TfL API key. Even though the TfL API is free of charge, you should still keep its key secret.
The other 2 secrets are the IDs of the two bus stops you are tracking. You do not want to dox yourself by leaking your nearest bus stops.
The approach we take is to use a .env file with a crate such as dotenvy. It works like this
- Use
dotenvyto load the variables stored in a file called.envinto your environment variables - Load the secrets from the environment variables
Here is what our .env file could look like, using our example bus stops and a made-up TfL API key.
STOP1=490011761M
STOP2=490011761N
TFL_KEY=0123456789abcdef0123456789abcdef
This should be added to your .gitignore to stop you from committing and publishing the secrets.
Making Our First Requests
You can put the following in your main.rs file. If you have your .env file populated, it will print out the response JSONs
use std::borrow::Cow;
use reqwest::Url;
use reqwest::header::{ACCEPT, USER_AGENT};
use tokio::join;
async fn get_arrivals(
client: &reqwest::Client,
stop_id: &str,
tfl_key: Option<&str>,
) -> anyhow::Result<String> {
// if not specified we will try to get the key from the environment variable
let tfl_key = tfl_key
.map(Cow::Borrowed)
.or_else(|| std::env::var("TFL_KEY").map(Cow::Owned).ok());
let params: Vec<_> = tfl_key.map(|s| ("app_key", s)).into_iter().collect();
// build the request url
let stop_arrivals = Url::parse_with_params(
&format!("https://api.tfl.gov.uk/StopPoint/{stop_id}/Arrivals"),
¶ms,
)?;
let response = client
.get(stop_arrivals)
.header(USER_AGENT, "Rust-test-agent")
.header(ACCEPT, "application/json")
.header("X-Powered-By", "Rust")
.send()
.await?;
let text = response.text().await?;
Ok(text)
}
fn main() -> anyhow::Result<()> {
dotenvy::dotenv()?;
let stop_id = std::env::var("STOP1")?;
let stop_id2 = std::env::var("STOP2")?;
let tfl_key = std::env::var("TFL_KEY").ok();
let client = reqwest::Client::new();
// a bit of async code which should let us send 2 requests at once
let rt = tokio::runtime::Runtime::new().unwrap();
let (result1, result2) = rt.block_on(async {
let arrivals = get_arrivals(&client, &stop_id, tfl_key.as_deref());
let arrivals2 = get_arrivals(&client, &stop_id2, tfl_key.as_deref());
join!(arrivals, arrivals2)
});
let arrivals = result1?;
let arrivals2 = result2?;
println!("{arrivals}");
println!("{arrivals2}");
Ok(())
}
You will see a wall of text, from which we need to extract the relevant information.
Taming the wild data
We actually know a lot about the format of the data we get about the arrivals, because it's described in the API documentation. We can represent the structure of each arrival object as a struct, which is a bit like an object in Python, but the fields are always named in advance.
Furthermore, simply by annotating with #[derive(serde::Deserialize)], we can use serde_json to convert JSON into instances of our struct. This will handle the validation and type conversion for us.
So lets declare the struct.
use serde::Deserialize;
use serde_repr::Deserialize_repr;
#[derive(Deserialize_repr, PartialEq, Eq, Debug, Clone, Copy, Default)]
#[repr(u8)]
pub enum OperationType {
// is new or has been updated
#[default]
New = 1,
// should be deleted from any client cache
Stale = 2,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Arrival {
///The identifier for the prediction
pub id: Option<String>,
///The type of the operation (1: is new or has been updated, 2: should be deleted from any client cache)
pub operation_type: Option<OperationType>,
///Station name
pub station_name: Option<String>,
/// Unique identifier for the Line
pub line_id: Option<String>,
///Name of the destination
pub destination_name: Option<String>,
///Timestamp for when the prediction was inserted/modified (source column drives what objects are broadcast on each iteration)
pub timestamp: Option<jiff::Timestamp>,
/// Prediction of the Time to station in seconds
pub time_to_station: Option<i64>,
/// Routing information or other descriptive text about the path of the vehicle towards the destination
pub towards: Option<String>,
}
Some remarks:
- In Rust, struct members in
snake_caseare preferred tocamelCase, so we can useserde(rename_all = "camelCase")to do this with no fuss. operationTypecan only be 1 or 2 per the documentation, so we use an enum to represent this.- The triple slash comments become the field documentation. I just copied them from the website.
To help me write this struct definition, I took the JSON Schema provided by the API and used quicktype to convert it to Rust, omitting any fields I don't need.
Now lets rewrite the get_arrivals function:
async fn get_arrivals(
client: &reqwest::Client,
stop_id: &str,
tfl_key: Option<&str>,
) -> anyhow::Result<Vec<Arrival>> {
// if not specified we will try to get the key from the environment variable
let tfl_key = tfl_key
.map(Cow::Borrowed)
.or_else(|| std::env::var("TFL_KEY").map(Cow::Owned).ok());
let params: Vec<_> = tfl_key.map(|s| ("app_key", s)).into_iter().collect();
// build the request url
let stop_arrivals = Url::parse_with_params(
&format!("https://api.tfl.gov.uk/StopPoint/{stop_id}/Arrivals"),
¶ms,
)?;
let response = client
.get(stop_arrivals)
.header(USER_AGENT, "Rust-test-agent")
.header(ACCEPT, "application/json")
.header("X-Powered-By", "Rust")
.send()
.await?;
let arrival = response.json().await?;
Ok(arrival)
}
What's the difference?
- The successful return type is
Vec<Arrival>, meaning a vector/array/list of Arrival objects instead of an unstrcutured string response. - Instead of
.text()we use.json() - Renamed a variable
That's it!
The json method tries to deserialise the response as JSON. Deserialise to what? Rust looks at the function return type and knows it needs to make a Vec<Arrival>.
For the main function, you only need to change the print lines to add :?:
println!("{arrivals:?}");
println!("{arrivals2:?}");
Printing the arrivals nicely
Now we have our arrival data in an easy format, lets come up with a way to display it nicely in the terminal.
// Just generate this once
static LONDON_TZ: std::sync::LazyLock<TimeZone> =
std::sync::LazyLock::new(|| TimeZone::get("Europe/London").expect("Invalid timezone"));
fn display_arrivals<A: Borrow<Arrival>>(arrivals: &[A]) -> anyhow::Result<()> {
let station_name = arrivals
.iter()
.find_map(|a| a.borrow().station_name.as_ref())
.ok_or_else(|| anyhow!("No station name."))?;
let last = arrivals
.iter()
.filter_map(|a| {
let a: &Arrival = a.borrow();
a.borrow()
.timestamp
.map(|ts| ts.to_zoned(LONDON_TZ.clone()))
})
.max()
.ok_or_else(|| anyhow!("No time."))?;
println!("Station Name: {station_name}");
for (i, arrival) in arrivals.iter().enumerate() {
let arrival: &Arrival = arrival.borrow();
if arrival.operation_type.unwrap_or_default() == OperationType::Stale {
continue;
}
let (Some(destination_name), Some(tts), Some(line_name), Some(timestamp)) = (
arrival.destination_name.as_ref(),
arrival.time_to_station,
arrival.line_name.as_ref(),
arrival.timestamp,
) else {
continue;
};
let (min, sec) = (tts / 60, tts % 60);
println!(
"Arrival {i}: {line_name} to {destination_name} arriving in {min}m {sec}s at {timestamp}",
);
}
println!("Last updated: {last}");
Ok(())
}
Remarks:
- The first part generates the
LONDON_TZobject. We use the newLazyLockto generate it once as it is needed. It is generally a good idea to use datetimes with time zones whenever possible. - In the function
arrivalsneeds to be a slice of elements with typeA.Acan be anything which can convert to a reference to an arrival object by borrowing, in particular they can beArrivalor&Arrival. - Using the
let...elseconstruction we can skip anyArrivalobject where the key fields are missing. In practice, I don't think this happens, but we are forced to handle this case because we specified the fields as optional, following the spec.
This is what I get:
Station Name: Kensington Palace
Arrival 0: 452 to Vauxhall arriving in 23m 50s at 2025-12-06T19:12:51.551666Z
Arrival 1: 452 to Vauxhall arriving in 2m 32s at 2025-12-06T19:12:51.551666Z
Arrival 2: 49 to Clapham Junction arriving in 6m 44s at 2025-12-06T19:12:51.551666Z
Arrival 3: 49 to Clapham Junction arriving in 10m 26s at 2025-12-06T19:12:51.551666Z
Arrival 4: 49 to Clapham Junction arriving in 2m 11s at 2025-12-06T19:12:51.551666Z
Arrival 5: 52 to Victoria arriving in 22m 10s at 2025-12-06T19:12:51.551666Z
Arrival 6: 52 to Victoria arriving in 0m 59s at 2025-12-06T19:12:51.551666Z
Arrival 7: 52 to Victoria arriving in 12m 22s at 2025-12-06T19:12:51.551666Z
Arrival 8: 52 to Victoria arriving in 5m 40s at 2025-12-06T19:12:51.551666Z
Arrival 9: 70 to South Kensington arriving in 3m 26s at 2025-12-06T19:12:51.551666Z
Arrival 10: 70 to South Kensington arriving in 23m 33s at 2025-12-06T19:12:51.551666Z
Arrival 11: 70 to South Kensington arriving in 24m 35s at 2025-12-06T19:12:51.551666Z
Arrival 12: 70 to South Kensington arriving in 27m 47s at 2025-12-06T19:12:51.551666Z
Arrival 13: 70 to South Kensington arriving in 3m 25s at 2025-12-06T19:12:51.551666Z
Arrival 14: 9 to Aldwych arriving in 17m 51s at 2025-12-06T19:12:51.551666Z
Arrival 15: 9 to Aldwych arriving in 3m 22s at 2025-12-06T19:12:51.551666Z
Arrival 16: 9 to Aldwych arriving in 25m 6s at 2025-12-06T19:12:51.551666Z
Last updated: 2025-12-06T19:12:51.551666+00:00[Europe/London]
Station Name: Kensington Palace
Arrival 0: 452 to Notting Hill Gate arriving in 13m 31s at 2025-12-06T19:12:51.5220475Z
Arrival 1: 49 to White City arriving in 8m 4s at 2025-12-06T19:12:51.5220475Z
Arrival 2: 49 to Kensington, Holland Road arriving in 1m 9s at 2025-12-06T19:12:51.5220475Z
Arrival 3: 49 to White City arriving in 20m 7s at 2025-12-06T19:12:51.5220475Z
Arrival 4: 52 to Willesden Bus Garage arriving in 27m 50s at 2025-12-06T19:12:51.5220475Z
Arrival 5: 52 to Willesden Bus Garage arriving in 28m 5s at 2025-12-06T19:12:51.5220475Z
Arrival 6: 70 to Chiswick, Business Park arriving in 19m 52s at 2025-12-06T19:12:51.5220475Z
Arrival 7: 9 to Hammersmith, Bus Station arriving in 24m 49s at 2025-12-06T19:12:51.5220475Z
Arrival 8: 9 to Hammersmith, Bus Station arriving in 22m 23s at 2025-12-06T19:12:51.5220475Z
Arrival 9: 9 to Hammersmith, Bus Station arriving in 15m 43s at 2025-12-06T19:12:51.5220475Z
Arrival 10: 9 to Hammersmith, Bus Station arriving in 17m 20s at 2025-12-06T19:12:51.5220475Z
Last updated: 2025-12-06T19:12:51.5220475+00:00[Europe/London]
Next Steps
So far, all these steps could be done on a normal desktop/laptop computer. The next step is no different: to create image that can be displayed on the e-ink noticeboard. This will be the subject of the next post. After that, we will get to grips with the hardware of the noticeboard project.
Buckingham Palace does not have any bus stops near enough