This project is a little side project I worked on this summer. I wanted to make a little CTA departure board for my girlfriend based on the ones you see at bus stops and L stops in Chicago. This write-up will explain how I created it, as well as explain some of the decisions I made for the project! If you are interested in creating your own CTA tracker (or similar transit tracker for other agencies) you can follow along with this write-up. !!TODO: QUICK GUIDE!!
For this project, I used an esp-32 board from Waveshare with a built-in LCD display. !!TODO: link and hardware info!!
This project was written in Rust using esp-idf-sys, which provides Rust bindings to Espressif’s IoT Development Framework (IDF) SDK. I chose Rust for a few reasons:
- Low-level control:
Rust is great because it does allow you to write low level code that talks directly to memory. For an embedded project like this, other languages would not necessarily provide the control over memory needed to do MMIO (like for writing to the builtin screen)
- Reliability:
With Rust I didn’t have to worry about any number of problems that could make my transit tracker unreliable, such as a buffer overflow from CTA’s API returning more data than expected.
- Ecosystem:
Rust has a number of existing packages (crates in Rust speak) which make this sort of project much easier. For example, ratatui makes it extremely easy to create a TUI on pretty much anything with a display. Additionally, Rust resources for esp-32 development are great, with lots of documentation!
- Familiarity:
Most importantly, I know Rust and enjoy writing Rust code a lot! Aside from Python, Rust has become the language I use most often, particuarly for side projects, and I wanted to see how far I could take my experience with the language!
[package]
name = "esp-cta-tracker"
version = "0.2.0"
authors = ["Aaron Rumph <aaronbrumph@gmail.com>"]
edition = "2021"
resolver = "2"
rust-version = "1.82" # 1.82 is suggested for working with ESP chips
[[bin]]
harness = false # do not use the built-in cargo test harness -> resolve rust-analyzer errors
name = "cta-tracker"
path = "src/bin/main.rs"
[dependencies]
# esp-32 and embedded dev crates
embassy-time = { version = "0.5", features = ["generic-queue-8"] }
embedded-graphics = "0.8"
embedded-svc = "0.29.0"
esp-idf-svc = { version = "0.52.1", features = ["critical-section", "embassy-sync", "embassy-time-driver"] }
# other crates
anyhow = "1" # for error handling
heapless = "0.8" # std::Vec and std::String alternatives with fixed sizes
log = "0.4" # for quick logging to computer for development
# makes it extremely easy to create a TUI on any display
# all you need to do is write a backend if necessary
# then can use ratatui's existing components to do just about anything
ratatui = { version = "0.30", default-features = false, features = ["all-widgets", "layout-cache", "std"] }
# super super easy serialization and deserialization
# maybe the single greatest library every made for any language
serde = { version = "1", default-features = false, features = ["derive"] }
serde_json = "1.0"
# bitmap image support for embedded graphics for CTA icons
tinybmp = "0.6.0"
[build-dependencies]
embuild = "0.33"
[profile.dev]
opt-level = "z"
debug = true # Symbols are nice, and they don't increase the size on Flash
[profile.release]
opt-level = "s"Next, you need to set some useful constants which will be used throughout the main loop. Because the API requests need Wifi, you need a way of connecting to a network. You also need API keys for CTA’s Train Tracker and Bus Tracker APIs. I chose to do this by having the Wifi SSID and password read in from a “wifi.env” file in the project root. I keep my API keys in a “~/.api_keys.env” file. I chose to read these in at compile time rather than run time to avoid hardcoding sensitive information into my code. In order to do so, I wrote a ‘build.rs’ file:
use std::{env, fs, path::PathBuf};
fn main() {
embuild::espidf::sysenv::output();
// This function loads the 'wifi.env' file in the project root
load_project_wifi_env();
// This function loads my API keys from ~/.api_keys.env
load_home_api_keys_env();
}
/// Loads 'WIFI_SSID' and 'WIFI_PASSWORD' env variables from 'wifi.env'
/// file in the project root directory.
fn load_project_wifi_env() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let wifi_env = manifest_dir.join("wifi.env");
println!("cargo:rerun-if-changed={}", wifi_env.display());
load_env_file(&wifi_env, &["WIFI_SSID", "WIFI_PASSWORD"]);
}
/// Loads 'CTA_BUS_API_KEY' and 'CTA_TRAIN_API_KEY' env variables from
/// '~/.api_keys.env'.
fn load_home_api_keys_env() {
/* NOTE: I choose to keep my API keys in ~/.api_keys.env.
If you keep them somewhere else you should adjust the
following code to look in the correct paths */
let home = env::var("HOME").expect("HOME is not set");
let api_env = PathBuf::from(home).join(".api_keys.env");
println!("cargo:rerun-if-changed={}", api_env.display());
println!("cargo:rerun-if-env-changed=HOME");
load_env_file(&api_env, &["CTA_BUS_API_KEY", "CTA_TRAIN_API_KEY"]);
}
/// Simple parser for **.env** files that loads any variables
/// into rustc-env variables.
fn load_env_file(path: &PathBuf, allowed_keys: &[&str]) {
let contents = fs::read_to_string(path).unwrap_or_else(|e| panic!("Couldn't read {}: {e}", path.display()));
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
panic!("invalid line in {}: {line}", path.display());
};
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
if allowed_keys.contains(&key) {
println!("cargo:rustc-env={key}={value}");
}
}
}To understand how the transit tracker works, you should first understand how the CTA’s API for train and bus departures works.
For some reason (who knows why), CTA has two seperate APIs: A Train Tracker API and a Bus Tracker API. Oddly, the two use completely different schemas for requesting data, and for the data they return. Even weirder, the train API uses HTTP while the bus API uses HTTPS. A description of each API and the schema it returns/accepts is given below.
The Bus Tracker API is available at https://www.ctabustracker.com/bustime/api/v3/. There are number of endpoints available which provide information about vehicles, routes, detours, etc. For the purposes of this project though, the relavent endpoint was the predictions endpoint.
The predictions endpoint takes the following parameters:
| Parameter | Value | Description |
|---|---|---|
| key | string (required) | API Key |
| stpid | stpid (xor `vid`) | Stop ID for the stop predictions requested for. |
| rt | list[string] (with `stpid`) | List of route ids to get predicitions at a stop for. |
| vid | list[string] (xor `stpid`) | List of vehicles IDs to get predictions for. |
| top | int (optional) | Max number of predicitons to return. |
| tmres | string (optional) | Time stamp resolution (”s” or “m”) |
| unixTime | bool (optional) | Provide timestamps in Unix Time |
The transit tracker for this project only needs to use the key and stpid parameters, however, if you would like to track a stop that is served by many routes, you may want to filter it to only get the specific routes you would like.
When you sned a request to the bus predictions API, you get the data back in the following format:
| Parameter | Value | Description |
|---|---|---|
| key | string (required) | API Key |
| stpid | stpid (xor `vid`) | Stop ID for the stop predictions requested for. |
| rt | list[string] (with `stpid`) | List of route ids to get predicitions at a stop for. |
| vid | list[string] (xor `stpid`) | List of vehicles IDs to get predictions for. |
| top | int (optional) | Max number of predicitons to return. |
| tmres | string (optional) | Time stamp resolution (”s” or “m”) |
| unixTime | bool (optional) | Provide timestamps in Unix Time |
The actual transit tracker application code is relatively simple. When the tracker starts up, a bit of setup begins, including connecting properly to the necessary hardware, setting up Wifi, and configuring the tracker (specifically, which trains or buses to track)
Before the main loop can start, there are a few things we need to setup. First, we need to import all the modules we will be using. Note that the modules, functions, etc. from cta_tracker are part of the library for this project, not an existing package, and their implementation will be shown later!
use anyhow::anyhow;
use core::ffi::c_void;
use cta_tracker::{
cta::{CtaStop, TrainRoute},
display::{
psram::PsramBuffer,
screen::{Lcd, FRAMEBUFFER_BYTES, LCD_PINS},
},
time::ChicagoClock,
tui::{TransitCardConfig, TransitTui},
};
use embedded_svc::{
http::client::Client,
wifi::{AuthMethod, ClientConfiguration, Configuration as WifiConfiguration},
};
use esp_idf_svc::{
eventloop::EspSystemEventLoop,
hal::{delay::FreeRtos, peripherals::Peripherals},
http::client::{Configuration as HttpConfig, EspHttpConnection},
nvs::EspDefaultNvsPartition,
sys,
wifi::{BlockingWifi, EspWifi},
};
use std::time::Duration;
use log::{debug, info, warn};There are some constants we need to set that will survive the lifetime of the application. First, Wifi information and API keys, which are turned into rustc env variables at compile time (see build.rs).
// Wifi information and API keys for CTA's APIs are turned into rustc env variables
const WIFI_SSID: &str = env!("WIFI_SSID");
const WIFI_PASSWORD: &str = env!("WIFI_PASSWORD");
const CTA_BUS_API_KEY: &str = env!("CTA_BUS_API_KEY");
const CTA_TRAIN_API_KEY: &str = env!("CTA_TRAIN_API_KEY");Next, we can set which stops we want the tracker to track! When I was designing this part of the tracker, there were a number of challenges that kept coming up. First, when there were no API results for the train or bus in question, there would be no route information to use to construct the departure board entry, i.e., there is no way to decide which icon to use, what the destination is, or even what the name of the route is.
I therefore decided that it was worth bundling that information with the stop’s id or its platform id, so that even without any predictions, I could still display information about the route there are no predictions for.
// These are the CTA stops which the transit tracker will get arrival times for.
// They make use of the `TrackedStop` struct, which has fields for the
// platform id/the stop id, the name of the route, the stop's name, and the route's
// headsign/destination.
const STOPS: [TrackedStop; 5] = [
TrackedStop::bus(
"14896", // the stop_id for the station
"Bus 76", // The name of the route
"Diversey / Mildred", // the name of the station
"Harlem", // destination of the route
),
TrackedStop::bus("5764", "Bus 8", "Halsted / Wellington", "79th Street"),
TrackedStop::train("30231", TrainRoute::Brown, "Brown Line", "Wellington", "Kimball"),
TrackedStop::train("30232", TrainRoute::Brown, "Brown Line", "Wellington", "Loop"),
TrackedStop::train("30256", TrainRoute::Red, "Red Line", "Belmont", "95th / Dan Ryan"),
];For most cases, this is all the setup you need in order to get arrival predicitons for the train or bus stops you want! For this project, however, I wanted to make it so that if there are Purple Line trains coming to Wellington, then it will display a Purple Line icon, with the correct route and destination names. Unfortunately, getting arrival times for the platform id “30232”, also gives arrival times for the Brown Line, with the only way to differentiate between the two being to inspect the API response. Essentially, in order to handle both Loop-bound Purple Line trains and Loop-bound Brown Line trains from Wellington, I needed to hard code both possibilites in the above format, and then read the response to decide which option to actually display.
// Notice that the platform_id/stop_id below is the same as for the Loop-bound
// Brown Line trains at Wellington
const PURPLE_TO_LOOP: TrackedStop =
TrackedStop::train("30232", TrainRoute::Purple, "Purple Line", "Wellington", "Loop");
// I also added this for the sake of making my code more readable and avoiding magic
// numbers
const PURPLE_OR_KIMBALL_SLOT: usize = 2;The next step was setting up the hardware to be able to:
- Make network requests using HTTP and HTTPS
- Store the results/predicitions in memory
- Display information on the LCD
- Keep time
This marks the start of the actual application itself (yay!), as the hardware setup and initialization will need to run everytime the application starts up.
// The main function for the application returns an `anyhow::Result`
// which makes it much easier to let the application crash in a
// reasonable way when there is an unrecoverable error.
// Especially nice is that this makes the compiler accept the
// `?` operator to propogate errors without having to explicitly
// handle every possible error type
fn main() -> anyhow::Result<()> {
// Recommended by the esp-idf-svc developers
// (see https://github.com/esp-rs/esp-idf-template/issues/71)
esp_idf_svc::sys::link_patches();
// This function is marked unsafe because it calls ffi C code.
// The point of this function is to ensure that any heap allocations
// bigger than 4kb (arbitrary choice on my part) go on PSRAM rather
// than smaller on board ram.
unsafe {
sys::heap_caps_malloc_extmem_enable(4096);
}
// Logging setup
esp_idf_svc::log::EspLogger::initialize_default();
info!("Starting transit tracker!")
// The LCD uses MMIO to draw to the screen, so here I am creating
// a handle to an abstraction for the relevant GPIO pins.
// Then, initializing a frame buffer to render to that can then
// be flushed all at once to the display.
// NOTE: framebuffer is 800 * 480 pixels * 16 bits/pixel =
// 6_144_000 bits (larger than SRAM) so needs to go on PSRAM
let mut lcd = Lcd::new(LCD_PINS)?;
let mut framebuffer = PsramBuffer::new(FRAMEBUFFER_BYTES)?;
let mut tui = TransitTui::new(framebuffer.as_mut_slice())?;
// Now can draw a startup screen while waiting for other hardware init
tui.draw_startup(&mut lcd, "Starting CTA Tracker!")
// Wifi setup
debug!("Wifi SSID: {}", WIFI_SSID);
let peripherals = Peripherals::take()?;
let sysloop = EspSystemEventLoop::take()?;
let nvs = EspDefaultNvsPartition::take()?;
// NOTE: You may need to change AuthMethod if having problems connecting to
// your Wifi. Also, you need to make sure that you have 2.4ghz wifi, because
// esp32 cannot use 5/6ghz
let wifi_config = WifiConfiguration::Client(ClientConfiguration {
ssid: WIFI_SSID.try_into().map_err(|_| anyhow!("Wi-Fi SSID invalid/too long(?)"))?,
password: WIFI_PASSWORD
.try_into()
.map_err(|_| anyhow!("Wi-Fi password invalid/is too long(?)"))?,
auth_method: AuthMethod::WPA2Personal,
..Default::default()
});
info!("Starting Wifi setup");
let mut wifi = BlockingWifi::wrap(
EspWifi::new(
peripherals.modem,
sysloop.clone(),
Some(nvs)
)?,
sysloop
)?;
wifi.set_configuration(&wifi_config)?;
wifi.start()?;
info!("Connecting to Wifi")
// Retry logic for trying to connect to Wifi since I was having some problems
// at my apartment
let mut connected = false;
for connection_attempt in 1..5 {
info!("Wifi connect attempt {attempt}");
match wifi.connect() {
Ok(_) => match wifi.wait_netif_up() {
Ok(_) => {
connected = true;
break;
}
Err(err) => {
warn!("Widi couldn't connect on attempt {attempt}: {:?}", err);
}
},
Err(err) => {
warn!("Wifi couldn't connect on attempt {attempt}: {:?}", err);
}
}
// in case cannot connect to Wifi, will disconnect fully and try again in 5 seconds
let _ = wifi.disconnect();
FreeRtos::delay_ms(5000);
}
}
// If 5 attempts to connect to Wifi fail, then will stop trying and instead
// hope for reset or fixed Wifi!
if !connected {
tui.draw_bad_wifi_msg(
&mut lcd,
"Could not connect to Wifi, reset tracker or resart wifi!",
)?;
loop {
FreeRtos::delay_ms(60_000);
}
}
info!("Connected succesfully to Wifi")
/* IP info for debugging (very useful!)
let ip_info = wifi.wifi().sta_netif().get_ip_info()?;
debug!("IP info: {:/}", ip_info);
*/
// Allocating buffer for API responses and setting up http_config
// NOTE: 16k should be more than enough, but if the tracker is crashing,
// it's likely caused by this buffer being too small!
let mut api_response_buffer = vec![0_u8; 16 * 1024];
let http_config = HttpConfig {
timeout: Some(Duration::from_secs(20)),
crt_bundle_attach: Some(attach_crt_bundle), // ffi raw pointer
..Default::default()
};
// Clock and tracked stops setup
let clock = ChicagoClock::new()?;
let tracked_stops = &STOPS;
info!("Setup complete! Starting main tracker loop");Now that all the setup is done, we have a esp32 with a working Wifi connection, a working LCD screen, and an Http client.
loop {
info!("Querying CTA APIs");
for (slot, tracked_stop) in tracked_stops.iter().enumerate() {
// http client setup
// NOTE: Each API request needs its own seperate new http_client
// obj
let http_connection = EspHttpConnection::new(&http_config)?;
let mut http_client = Client::wrap(http_connection);
match tracked_stop.stop.get_predictions(
&mut http_client,
&mut api_response_buffer,
CTA_BUS_API_KEY,
CTA_TRAIN_API_KEY,
) {
// TODO: Once TUI figured out, this is where framebuffer should
// get updated!
Ok(predictions) => {
predictions.log_predictions();
tui.update_card_from_predictions(slot, tracked_stop.card, &predictions)?;
}
Err(err) => {
warn!("API query failed: {:?}", err);
tui.update_card_error(slot, tracked_stop.card, format!("{err:?}"))?;
}
}
// have to wait a sec between API requests so that http_client can properly
// be dropped and then reused
FreeRtos::delay_ms(1_000);
}
let http_connection = EspHttpConnection::new(&http_config)?;
let mut http_client = Client::wrap(http_connection);
match PURPLE_TO_LOOP.stop.get_predictions(
&mut http_client,
&mut api_response_buffer,
CTA_BUS_API_KEY,
CTA_TRAIN_API_KEY,
) {
Ok(predictions) => {
predictions.log_predictions();
if predictions.contains_train_route(TrainRoute::Purple) {
tui.update_card_from_predictions(PURPLE_OR_KIMBALL_SLOT, PURPLE_TO_LOOP.card, &predictions)?;
}
}
Err(err) => {
warn!("Purple Line API query failed: {:?}", err);
}
}
tui.set_clock(clock.now().unwrap_or_else(|| "--:--".to_string()));
tui.redraw(&mut lcd)?;
// loop every 30 secs to update predictions
info!("Sleeping for 30 secs before refreshing times");
FreeRtos::delay_ms(30_000);
}
}