Posted on :: Updated on ::

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

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 prediction
  • operationType: 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]
  • stationName
  • destinationName
  • timestamp: Timestamp for when the prediction was inserted/modified
  • timeToStation: 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

  1. Use dotenvy to load the variables stored in a file called .env into your environment variables
  2. 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"),
        &params,
    )?;
    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_case are preferred to camelCase, so we can use serde(rename_all = "camelCase") to do this with no fuss.
  • operationType can 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"),
        &params,
    )?;
    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_TZ object. We use the new LazyLock to generate it once as it is needed. It is generally a good idea to use datetimes with time zones whenever possible.
  • In the function arrivals needs to be a slice of elements with type A. A can be anything which can convert to a reference to an arrival object by borrowing, in particular they can be Arrival or &Arrival.
  • Using the let...else construction we can skip any Arrival object 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.

1

Buckingham Palace does not have any bus stops near enough