Let me tell you about the time I made a desk-mounted departure board with just a few bits of hardware, seven hundred and fifty lines of MicroPython and four thousand pixels of London.
The parts
Nothing exotic, and nothing that needed soldering either, Hurray!
- Two 64×32 P3 RGB LED matrices, which looks pretty close to the panel used in the real dot-matrix boards.
- An Interstate 75 RGB LED Matrix Driver to let the LEDs know what to do.
My bus stop is a 4 minute walk away and the buses are every 15 minutes (it used to be 20 minutes, but I complained a lot and they put on extra buses). Worst of all: it doesn’t have a departure board at the stop.
All right, all right; I’m making excuses. What I mean to say is I’m a bit of a transport nerd and I really liked these departure boards that you can buy nowadays. Most of them seem to cost a fortune, so I thought: Hey, I’m a programmer! I can learn Python! I can just make one myself!
So I hatched a plan. First of all I got the pieces from PiHut. In total this came to about £60 I think. There’s definitely not much to it.
It runs on a Pimoroni Interstate 75 W driving a 128×32 HUB75 panel — the same kind of module used in the cheap end of commercial signage. The whole program is MicroPython, five files, and it fits on the board’s flash with room to spare. There is no Pi, no Node, no Docker, no home server. It boots in about four seconds and it has been running since I built a shoddy wooden case for it.

The data is the easy part
TfL’s Unified API is really good. One GET request gets you every arrival at a stop:
https://api.tfl.gov.uk/StopPoint/490013722E/Arrivals
That 490013722E is a NaPTAN code — the national identifier for a single stopping point. The trailing letter is the stop letter you see on the pole itself.
Two things about the response are worth knowing before you write any code against it. First, the arrivals come back unsorted. Not reverse-sorted, not sorted by line — genuinely arbitrary. You have to sort by timeToStation yourself or your board will confidently tell you the 401 is next when it is forty minutes away. Second, timeToStation is in seconds, and anything under sixty is what the real boards call “Due”:
def _mins(seconds):
if seconds < 60:
return 'Due'
return str(seconds // 60) + 'm'
Getting that one detail right is most of what makes the sign feel authentic rather than like a web page someone pointed at a screen.
Four thousand pixels is not very many
This is where the actual work is. The panel is 128 pixels wide and 32 tall. The bitmap font is 8 pixels high, so with sensible leading you get three rows of text — at y-offsets 2, 12 and 23 — and that is the entire information budget.
The layout I settled on gives each row a fixed anatomy: route number pinned left in a 16-pixel column, minutes pinned right, destination filling whatever is left. On a 128-pixel panel that leaves roughly 90 pixels for the destination, which is about fourteen characters. “Thamesmead Town Centre” is twenty-two. So the destination has to move.
The masking trick
Here is the problem that ate an evening. The graphics library has no clipping region. If you draw scrolling text at a negative x-offset, it happily renders straight over the route number sitting at x=0. Every solution I reached for first — a sub-surface, a viewport, blitting a wider buffer — either doesn’t exist in PicoGraphics or doesn’t fit in RAM.
The answer was to draw the moving text first, across the whole width. Then paint black rectangles over the two zones that are supposed to be fixed. Then redraw the fixed text on top of the black.
# Draw scrolling destination first.
graphics.set_pen(AMBER)
graphics.text(dest, x_line - dest_offset, ROW1_Y, 65535, 1)
# Paint black over the line-name and time zones, then redraw on top.
graphics.set_pen(BLACK)
graphics.rectangle(0, 0, x_line, 10)
graphics.rectangle(WIDTH - mins_w - 2, 0, mins_w + 2, 10)
graphics.set_pen(BRIGHT)
graphics.text(line_str, 0, ROW1_Y, WIDTH, 1)
Three cheap operations per frame, no allocation, and the destination slides cleanly underneath a route number that never flickers. It is a hack in the sense that a real graphics stack would give you set_clip(). It is not a hack in the sense that it works and costs nothing.
Two kinds of scrolling
The board scrolls two things and they want completely different behaviour, which took me a while to notice.
The bottom ticker is a marquee. It never stops, it wraps around, you read it whenever you happen to look up. One counter, incremented once per frame, reset at the end.
The top row’s destination is not a marquee, because the top row is the thing you actually need to read, and a name that never stops moving is a name you have to wait for. So it does pause–scroll–pause: hold still for two seconds, slide left just far enough to reveal the tail, hold still again, snap back.
total = DEST_PAUSE * 2 + dest_max
dest_count = (dest_count + 1) % total
if dest_count < DEST_PAUSE:
dest_offset = 0
elif dest_count < DEST_PAUSE + dest_max:
dest_offset = dest_count - DEST_PAUSE
else:
dest_offset = dest_max
Note dest_max: it is computed as exactly the number of pixels the text overflows by, so short destinations never move at all. “Abbey Wood” just sits there. Only the long ones travel, and only as far as they need to. That single decision is the difference between a sign that looks designed and a sign that looks like a demo.
Memory is the real constraint
A microcontroller running MicroPython has a heap measured in tens of kilobytes. The arrivals response for a busy stop is tens of kilobytes of JSON. You can see the collision coming.
The fix is to hold nothing you don’t draw. Each arrival object comes back with about thirty fields — vehicle IDs, operator names, timestamps, direction, towards, bearing. The board uses three. So the response gets stripped the moment it’s parsed, and the fat original is dropped on the floor:
_KEEP = ('lineName', 'destinationName', 'timeToStation')
slim = [{k: a[k] for k in _KEEP if k in a} for a in data]
_cache = sorted(slim, key=lambda x: x.get('timeToStation', 99999))
gc.collect()
With explicit gc.collect() calls either side of every HTTP request. On CPython that would be a code smell; here it is the difference between a board that runs for months and one that dies overnight with a MemoryError nobody is awake to see.
On top of that sits a sixty-second TTL cache, timed with time.ticks_diff() rather than plain subtraction, because ticks_ms() wraps around and naive arithmetic on it produces a board that freezes once every few weeks for reasons you will never reproduce.
A settings page with no web framework
The stop ID was hardcoded for about a day before that got annoying. What I wanted was to be able to point my phone at the board and change the stop — without reflashing, without a laptop, without a cloud account.
So the board serves its own settings page to any old web browser that can connect to it. The board actually reveals it’s own IP address when it’s first turned on for a few seconds, so when you are on the same network you simply go to that address and you’ll find a nice webpage that allows you to:
- Select a new stop from ANY TfL bus stop in London.
- Change LED brightness
- Change refresh interval
This took a little bit of messing about to get right as the weird nature of the bus stop parent > child IDs meant I had to build a UI to handle these occasions.
Some technical stuff…
The constraint is that the animation loop must not stutter, and there is no room for asyncio or a threaded server. The solution is a socket in non-blocking mode, polled once per frame:
_server.setblocking(False)
# ...once per frame, inside the render loop:
try:
conn, _ = _server.accept()
except OSError:
return None # nothing waiting — carry on drawing
That’s the whole architecture. accept() raising OSError is the “no connection pending” signal, so the exception handler is the fast path, running twenty times a second forever. When a request does arrive it’s handled inline; the animation drops a couple of frames while the page renders and nobody has ever noticed.
Settings are written straight to config.json on flash and applied live — new brightness, new refresh interval, new stop — with one small trick to force an immediate refetch rather than waiting out the current interval:
last_refresh = now - refresh_ms # triggers a fresh fetch next tick
Hand-rolling the form parsing and URL decoding felt like penance at first, but it is about forty lines total and it means the entire web stack is import socket.
The stop search rabbit hole
Typing a NaPTAN code into a phone is nobody’s idea of a good time, so the settings page got a search box backed by StopPoint/Search. Which is where London’s stop hierarchy bites.
Search for “Abbey Wood” and you get back a mix of two different kinds of thing: actual stopping points, and parent stops — clusters and hubs that contain a dozen children, each a different pole pointing a different way. Request arrivals for a parent and you get everything in all directions, which is useless.
There’s no clean flag for this in the search response, but there is a reliable pattern in the identifiers: individual stops carry the stop letter as a trailing character, parents end in a digit. So:
def _is_hub(stop_id):
return bool(stop_id) and stop_id[-1].isdigit()
A heuristic, plainly. But it lets the UI make the right offer: results that look like real stops get a Use button, results that look like parents get a Stops → button that drills into the children so you can pick the specific pole. Two taps from “abbey wood” to the right side of the road.
Making it survive being ignored
A wall-mounted sign has one hard requirement: nobody should ever have to think about it again. That means assuming every layer will fail and deciding in advance what happens when it does.
- Hardware watchdog.
WDT(timeout=8000), fed at the bottom of every loop iteration. If anything wedges for eight seconds — a hung socket, a driver deadlock — the chip resets itself. This is the backstop that turns “it froze while we were away” into a four-second blink. - The loop can’t die. The whole body is wrapped in
try/except, and the handler’s last resort ismachine.reset(). An unhandled exception is a reboot, not a dark panel. - Network failures are non-events. A failed fetch keeps showing the last known arrivals — slightly stale times beat a blank screen — and Wi-Fi is re-checked every sixty seconds, with a list of SSIDs tried in order.
- It tells you where it is. On boot the board shows its own IP address for three seconds before switching to departures. No router admin page, no mDNS gamble, no port scanning.
That last one is the smallest feature in the codebase and the one I’d fight hardest to keep.
What I’d tell you before you build one
Design the constraint first. Every good decision here came from taking 128×32 seriously rather than trying to escape it. Three rows, fixed anatomy, motion only where the text genuinely overflows.
Steal the vernacular. “Due” instead of “0m”, amber on black, route number left, minutes right. The board is legible from across the room because it copies the conventions of a thing people already know how to read.
Fail towards the last good state. On a device with no keyboard, the correct answer to almost every error is “keep showing what you had, and try again in a minute.”
The future?
I actually have a pretty good idea where to take this project next: The TfL API contains data for everything! Not only buses, but Tube, trams, Overground, Crossrail (still refusing to acknowledge the name change)… So I’d like to make it possible, through my web config app, to choose any station on the network, and get a nice departure board. Maybe some of them would have a slightly different look to mimic their real-life counterparts?
Looking further ahead, but still related, I’d like to get my hands on a bigger LED board and create an even larger display! The thing is… I’d still like them to look authentic.
It has also inspired me to get to work on an aircraft fly-over LED info board!