Mini Public Transit Board
First World Problems
I live in San Francisco where our coffee is strong, our alcohol is stronger, and our public transit system (Muni) is weak. OK, that’s not entirely fair, but Muni is slow and unreliable: things generally don’t follow any sort of reliable/timed schedule of arrival at any given stop. This means you have to know the time of the next bus/light rail before heading out or there’s a good chance you’ll be waiting at stops for long periods of time at the stop. There are a variety of apps you can add to your mobile device to check the current status. Some are more reliable than others: I’m guessing some may cache or go through an intermediary system that introduces further inaccuracies, because they don’t all show anything close to the same times if you look at them.
Muni also has signs at a number of stops around the city that show the upcoming arrival times coming to the stop. Presumably, these are the absolute “source of truth,” but the problem is, again, you have to get to the stop before you can ever see that.

Example Muni sign showing the next 28 bus at this stop
Wouldn’t it be cool if we could build one of these, miniaturize it, and keep it in your home? I think so! Let’s do that! First, we’ll need a platform to run it on. Presumably an Arduino would be ideal, but I had some old Raspberry Pis (original RPi model Bs!) sitting around along with some low memory SD cards. I have other newer versions and this seems like a good use for that old(er) hardware. OK, so we have an old RPi and an old SD card, we need a display. The RPi has HDMI out, which is cool, but I want a more “authentic feel” of the display. It’d be cool to get the full LED panel they have (which I found a similar one here), but I decided to stick to a simple 16×2 LCD with I2C here for only $2. Maybe I’ll do the full panel as a future project.
Hardware
Ok, so now we want to hook up the LCD panel to the RPi. Easy! I2C has pins for VCC, ground, a clock (SCL) and a data (SDA), and we just need to find the RPi GPIO pins for each. RaspberryPi Spy has a nice set of pinouts here. So we’ll hook up GND to pin 6, VCC to pin 2, SDA to pin 3 (SDA0), and SCL to pin 5 (SCL0). It looks like this when done:

I2C connection between LCD and Raspberry Pi
Control Software
OK, with that done, let’s get some software installed. I chose DietPi as my distro for this project because it’s small and lightweight, but really most distros that fit on the card would do. After installing and setting up DietPi with my wifi information, we want some real software to control the LCD and get Muni updates. The first part of that is creating something to control the LCD. There was a thread on the Raspberry Pi forums about how to do this, which Michael Horne nicely wrapped up the outcome of which on his blog here. Just copy the contents of i2c_lib.py and lcddriver.py from his blog into files named the same on the RPi. I then just created a “display.py” command to take in 2 command line arguments and display them using this library. It’s incredibly simple looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import sys import lcddriver from time import * numlines = 2 lcd = lcddriver.lcd() try: lcd.lcd_clear() for i in range (1, numlines+1): lcd.lcd_display_string(sys.argv[i], i) except: pass |
If you have more than 2 LCD lines, just change the numlines variable value. Now, running
1 |
python display.py "hello there" "world!" |
should display “hello there” on line 1 and “world!” on line 2.
Success! Our hardware and basic control software is all working. Now we just need something to tell the LCD what to display. Fortunately, Muni uses NextBus for realtime GPS tracking, so we have an easy, authoritative, RESTful official API to grab from.
Prediction Software
I looked into using a variety of Python modules to pull the feed data, but pretty much all of them seemed broken in various ways (using string matching to find stops, completely wrong documentation, code that hadn’t been updated in years, code that required downloading all routes and stops before looking up predictions, etc). The same seemed generally true of many perl modules I saw. I didn’t spend a lot of time looking into them all or trying to fix them, as the API is simple enough to use directly. We just need to know what route(s) and stop(s) we want to go to. That can be accessed by pulling up a prediction on the NextBus website and analyzing the URL. For example, by selecting the N-inbound stop at Carl & Cole to King & 4th, the URL is
http://www.nextbus.com/#!/sf-muni/N/N____I_F00/3911/5239
The first bolded bit (sf-muni) is the agency, the second bolded bit is the route (N), and the 3rd bolded bit is the stop ID (3911). The other parts of the URL are the specific route code (N___I_F00) and the destination stop ID (5239). I’ll assume we don’t care about those for this exercise. After that, the API defines that we can get multiple predictions in 1 call by accessing
1 |
http://webservices.nextbus.com/service/publicXMLFeed?command=predictionsForMultiStops&a=[agency_tag]&stops=[stop 1]&stops=[stop 2]&stops=[stop 3] |
Where each stop is in the format “route|stopid”. It gives the following example:
1 |
http://webservices.nextbus.com/service/publicXMLFeed?command=predictionsForMultiStops&a=sf-muni&stops=N|6997&stops=N|3909 |
Cool! Let’s string that together. I love perl, so here I’ve made a little perl script that pulls the predictions for stops, adds and displays the 2 routes (that I actually have time to get to) that will be the soonest.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
#!/usr/bin/perl use strict; use LWP::Simple; require LWP::UserAgent; use Data::Dumper; my $ua = LWP::UserAgent->new; $ua->agent("Mozilla/5.0"); use JSON; # 'mintime' is the minimum time to walk to a given stop. the bus must be at least that many minutes away my $buses = { 'RR' => { 'stopid' => '81a5795c1a9324feb822f4ea90fc156b', 'routeid' => 28, 'mintime' => 600 }, 'GH' => { 'stopid' => 'ci9qgwjgj01r0ixkpdont6ucm', 'routeid' => 26, 'mintime' => 300 } }; my $lcdsize = 2; my $baseurl = 'https://tracking.ridechariot.com//1/track?route_id='; my $upcoming = {}; #this will hold our upcoming buses # the following creates our URL foreach my $route (keys(%{$buses})) { my $url = $baseurl . $buses->{$route}->{'routeid'}; my $resp = $ua->get($url); # uses LWP::Simple to grab the contents of the URL die $resp->status_line if (! $resp->is_success); my $json = $resp->decoded_content; my $jsonobj = decode_json $json; # uses JSON to parse the json object my $currenttime = $jsonobj->{'data'}->{'timestamp'}; my @trips = @{$jsonobj->{'data'}->{'trips'}}; foreach my $trip (@trips) # loop through trips { next if (! $trip->{'stops'}); # if there are no predictions (e.g. bus isn't running today) then skip it foreach my $stop (@{$trip->{'stops'}}) { next if ($stop->{'id'} ne $buses->{$route}->{'stopid'}); # we add to an array in $upcoming->{route} only if we can actually walk to the bus in time my $eta = $stop->{'eta_timestamp'} - $currenttime; push @{$upcoming->{$route}}, $eta if $eta > $buses->{$route}->{'mintime'}; @{$upcoming->{$route}} = sort {$a <=> $b} @{$upcoming->{$route}}; } } } my $command = "python display.py"; my $displaylines = 0; foreach my $route ((sort { $upcoming->{$a} <=> $upcoming->{$b} } keys(%{$upcoming}))) { $command .= sprintf " \"%-4s: %s\"", $route, join(', ', map { int($_ / 60) . 'm' } (@{$upcoming->{$route}})[0,1]); last if (++$displaylines == $lcdsize); } `$command`; |
You’ll need to install the LWP::Simple and XML::Simple perl modules to make this work. And now if we run perl predictions.pl, we get something like this:
Nice! Now we’re all set. The NextBus API states:
All polling commands such as for obtaining vehicle locations should only be run at the most once every 10 seconds.
Bonus: Chariot Predictions
Because Muni is slow, unreliable, uncomfortable, and doesn’t go where some people want it to go, it’s opened up to competition in the private sector in the form of Chariot, a private bus service that generally links the slowest places to travel from to downtown / CalTrain. They don’t have an official published API, but their website seems to ping a bus tracking URL every few seconds to get current data & predictions. For example, the Richmond Racer seems to ping this URL:
So we can just set this up on a cron job once every minute and be safely in the bounds.
1 |
https://tracking.ridechariot.com//1/track?route_id=28 |
It’s pretty simple from there to see what it’s doing. A simple script can pull this data in as well.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
#!/usr/bin/perl use strict; use LWP::Simple; require LWP::UserAgent; my $ua = LWP::UserAgent->new; $ua->agent("Mozilla/5.0"); use JSON; # 'mintime' is the minimum time to walk to a given stop. the bus must be at least that many minutes away my $buses = { 'RR' => { 'stopid' => '81a5795c1a9324feb822f4ea90fc156b', 'routeid' => 28, 'mintime' => 600 }, 'GH' => { 'stopid' => 'ci9qgwjgj01r0ixkpdont6ucm', 'routeid' => 26, 'mintime' => 300 } }; my $lcdsize = 2; my $baseurl = 'https://tracking.ridechariot.com//1/track?route_id='; my $upcoming = {}; #this will hold our upcoming buses # the following creates our URL foreach my $route (keys(%{$buses})) { my $url = $baseurl . $buses->{$route}->{'routeid'}; my $resp = $ua->get($url); # uses LWP::Simple to grab the contents of the URL die $resp->status_line if (! $resp->is_success); my $json = $resp->decoded_content; my $jsonobj = decode_json $json; # uses JSON to parse the json object my @trips = @{$jsonobj->{'data'}->{'trips'}}; foreach my $trip (@trips) # loop through trips { next if (! $trip->{'stops'}); # if there are no predictions (e.g. bus isn't running today) then skip it foreach my $stop (@{$trip->{'stops'}}) { next if ($stop->{'id'} ne $route->{'stopid'}); # we add to an array in $upcoming->{route} only if we can actually walk to the bus in time push @{$upcoming->{$route}}, $stop->{'eta'} if $stop->{'eta'} > $buses->{$route}->{'mintime'}; } } } my $command = "python display.py"; foreach my $route ((sort { $upcoming->{$b} <=> $upcoming->{$a} } keys(%{$upcoming}))[0 .. ($lcdsize-1)]) { $command .= sprintf " \"%-4s: %s\"", $route, join(', ', map { int($_ / 60) . 'm' } (@{$upcoming->{$route}})[0,1]); } print $command . "\n"; `$command`; |
Because Chariot’s API uses HTTPS, you’ll also need to install the LWP::Protocol::https perl module if you don’t already have it. Now we can add this to a crontab as well (but just M-F, since those are the only days Chariot operates) and offset it by 30 seconds from the Muni prediction display. Then we get the next Muni buses for 30 seconds followed by the next Chariot buses for 30 seconds.As for packaging, I had an old box that one of my watches came in to put it into. It would be nice to wrap it into something nicer with holes cut out in specific locations, but I don’t have access to the appropriate cutting tools. Finished product: