

Discover more from Tristan’s Substack
The time has come for us to put our pieces together and get something running! As a quick refresh, we now have:
A way to display text on our panels
A way to connect to the internet to get train data
A way to store the train data we want to show on the panels
All we need is the connecting code to get everything working together. This is actually pretty simple to get to a functional stage. Still, the true magic comes in the quality-of-life features we can add once we’ve validated that all the pieces are functioning and communicating as intended. Let’s get that started!
The While Loop
Keeping things Constant
The beating heart of our entire code is this While
loop. After initialization, every moment of time is spent somewhere in this while loop. To that end, we’re going to use a While True
loop to keep in a perpetual loop state; we’ll add each function we need to run into the loop, then set the appropriate interval or checks for that action to run. This allows us to really customize what happens within the loop while still making it easy to keep the code running and the sign still working!
The first thing we want to do is set up a couple of constants we’ll be using:
loop_counter
: this isn’t technically necessary, but it can be helpful for determining how long the code has been looping and troubleshootingTime checks: the core way we check if functions should run is through. time checks. You could do this through the loop_counter instead, but I was willing to sacrifice the simplicity to know exactly when we last executed each major function. This time check ensures that everything doesn’t run every loop, but at a frequency we control.
I’ll admit that neither of these is totally necessary right now as I doubt you’ll run out of WMATA API calls, but the more we can implement basic protocols now to save us some headaches down the line the better. This is part of the careful balancing act present in any MVP: how do we build as little as possible to make sure we’re on the right path, but enough that we don’t have to rip everything out and start from scratch on the next iteration?
One final note: when we initially declare these variables, we’ll want to start loop_counter
at 1 (to indicate the first loop) and last_train_check
at None
, since we haven’t filled this variable yet. More on that later!
Building the Loop
For all the fanfare, this is actually really simple:
After establishing the constants, we start the loop.
Once in the loop, we review the check constants for each function to run.
If the conditions are met, we run the function.
If we’re happy with what we got, we reset the check constants.
We refresh the display.
We run garbage collection to keep our available memory ship shape.
We sleep the while loop for a bit.
Let’s dive into the more complex steps 2-4 to get our functions working.
Keeping Time
The way we use the check constant here is through the time.monotonic()
method. This is a really neat way to handle relative time change; we don’t care if we run our train API calls at a specific time, we care about running them at a specific cadence. time.monotonic()
is perfect for this because it basically starts a running timer; GPT-4 describes it as “a method in the time
module of Python that returns the current value of a clock that counts up at a constant rate.“ We can define a certain amount of time we want to wait between running a function, which we’ll call an interval variable, then see if that amount of time has elapsed by comparing our current time.monotonic()
to our check constant plus that interval variable. This is how it looks in the code:
# update train data (default: 15 seconds)
if last_train_check is None or time.monotonic() > last_train_check + 15:
trains = get_trains(station_code, historical_trains)
if trains:
last_train_check = time.monotonic()
# update train display component
display_manager.update_trains(trains, historical_trains)
The first check is for startup conditions where we’ve never run the loop before so our constant is None
, and the second is our comparing our current time.monotonic()
against our check constant and interval variable. If either of those conditions is met, we get fresh train data.
One key point here is around the refreshing of the check constant: if we don’t get train
objects returned from the get_trains
function, we don’t reset the last_train_check
constant. We’re not going to do anything with this right away as with this shorter interval variable it doesn’t matter, but we can come up with more versatile handling in the future if we notice we’re not getting train
objects.
Speaking of missing train
objects, let’s assume it’s been 15 seconds since we refreshed train data, so we refresh the train data again; the good part about how we’ve been designing our display functions is that they don’t trust our data collection functions, so it doesn’t matter if we get train
objects back or not–we know the display manager can handle it, so we can run it every time we run our get_trains
function.
Wrapping Things Up
Now that we have the hard part taken care of, we can do a few chores before wrapping up our loop.
Refresh our display: it doesn’t matter how many functions we run in our core
While
loop if we don’t push any of those changes to our display. Refreshing our display at the end of the loop makes 100% sure that any changes we’ve made to the user-facing side of things go live, even if we do our best to update the display through the display manager side.Collect our garbage: we’ve known from the beginning that we’re working with very limited memory on the Matrix Portal, so let’s do our part and clean up after ourselves after each loop. We’ll also print this out along with our loop iteration so we can keep an eye on long-term memory allocation.
Sleep our loop: Given that we’re using our
time.monotonic()
checks I don’t think this is completely necessary, but this put the loop cadence under our control rather than leaving it up to how fast the Portal can run through the While loop. We don’t need it to run as quickly as possible, so let’s give the Portal a break and only run the loop every 10 seconds. The core thing to remember here is that every check constant will have to be greater than the loop sleep period for that check to actually work properly. Some things we may want to run every loop, like our train functions, but that might not always be the case!
Here’s how all that looks in the code:
# refresh display
display_manager.refresh_display()
# run garbage collection
gc.collect()
# print available memory
print("Loop {} | Available memory: {} bytes".format(loop_counter, gc.mem_free()))
loop_counter+=1
time.sleep(10)
Conclusion
We built our loop! With all of this together, we should have a functioning loop where the get_trains
function is periodically called, the display_manager
updates the panels, and fresh train data is displayed. Let’s quickly revisit our MVP objective:
Create an LED sign that displays the time to arrival for incoming trains at my local Metro station.
As of this point, we can declare the mission accomplished! I think this is a reasonable v0.8, and you can see the project at that point in our v0.8 release on GitHub. However, I think we can do more–and we will! Next time, we’ll cover adding another API to really take this project to the next level on our way to hitting our v1.0 General Release!
import board | |
import gc | |
import time | |
import busio | |
from digitalio import DigitalInOut, Pull | |
import neopixel | |
from adafruit_matrixportal.matrix import Matrix | |
from adafruit_esp32spi import adafruit_esp32spi | |
from adafruit_esp32spi import adafruit_esp32spi_wifimanager | |
import json | |
import display_manager | |
# --- CONSTANTS SETUP --- | |
try: | |
from secrets import secrets | |
except ImportError: | |
print("Wifi + constants are kept in secrets.py, please add them there!") | |
raise | |
# local Metro station | |
station_code = secrets["station_code"] | |
historical_trains = [None, None] | |
# --- DISPLAY SETUP --- | |
# MATRIX DISPLAY MANAGER | |
# NOTE this width is set for 2 64x32 RGB LED Matrix panels | |
# (https://www.adafruit.com/product/2278) | |
matrix = Matrix(width=128, height=32, bit_depth=2, tile_rows=1) | |
display_manager = display_manager.display_manager(matrix.display) | |
print("display manager loaded") | |
# --- WIFI SETUP --- | |
# Initialize ESP32 Pins: | |
esp32_cs = DigitalInOut(board.ESP_CS) | |
esp32_ready = DigitalInOut(board.ESP_BUSY) | |
esp32_reset = DigitalInOut(board.ESP_RESET) | |
# Initialize wifi components | |
spi = busio.SPI(board.SCK, board.MOSI, board.MISO) | |
esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) | |
# Initialize neopixel status light | |
status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) | |
# Initialize wifi object | |
wifi = adafruit_esp32spi_wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light, attempts=5) | |
#wifi.connect() | |
gc.collect() | |
print("WiFi loaded | Available memory: {} bytes".format(gc.mem_free())) | |
# --- CLASSES --- | |
class Train: | |
def __init__(self, destination, destination_name, destination_code, minutes): | |
self.destination = destination | |
self.destination_name = destination_name | |
self.destination_code = destination_code | |
self.minutes = minutes | |
# --- API CALLS --- | |
# queries WMATA API to return an array of two Train objects | |
# input is StationCode from secrets.py, and a historical_trains array | |
def get_trains(StationCode, historical_trains): | |
try: | |
# query WMATA API with input StationCode | |
URL = 'https://api.wmata.com/StationPrediction.svc/json/GetPrediction/' | |
payload = {'api_key': secrets['wmata api key']} | |
response = wifi.get(URL + StationCode, headers=payload) | |
json_data = response.json() | |
del response | |
except Exception as e: | |
print("Failed to get data, retrying\n", e) | |
wifi.reset() | |
# set up two train directions (A station code and B station code) | |
A_train=None | |
B_train=None | |
# check trains in json response for correct destination code prefixes | |
try: | |
for item in json_data['Trains']: | |
if item['Line'] is not "RD": | |
pass | |
# if no train and destination code prefix matches, add | |
if item['DestinationCode'][0] is "A" and A_train is None: | |
A_train = Train(item['Destination'], item['DestinationName'], item['DestinationCode'], item['Min']) | |
elif item['DestinationCode'][0] is "B" and B_train is None: | |
B_train = Train(item['Destination'], item['DestinationName'], item['DestinationCode'], item['Min']) | |
# if both trains have a train object, pass | |
else: | |
pass | |
except Exception as e: | |
print ("Error accessing the WMATA API: ", e) | |
pass | |
# merge train objects into trains array | |
# NOTE: None objects accepted, handled by update_trains function in display_manager.py | |
trains=[A_train,B_train] | |
# if train objects exist in trains array, add them to historical trains | |
if A_train is not None: | |
historical_trains[0] = A_train | |
if B_train is not None: | |
historical_trains[1] = B_train | |
# print train data | |
try: | |
for item in trains: | |
print("{} {}: {}".format(item.destination_code, item.destination_name, item.minutes)) | |
except: | |
pass | |
return trains | |
# --- OPERATING LOOP ------------------------------------------ | |
loop_counter=1 | |
last_train_check=None | |
while True: | |
# update train data (default: 15 seconds) | |
if last_train_check is None or time.monotonic() > last_train_check + 15: | |
trains = get_trains(station_code, historical_trains) | |
if trains: | |
last_train_check = time.monotonic() | |
# update train display component | |
display_manager.assign_trains(trains, historical_trains) | |
else:pass | |
display_manager.refresh_display() | |
# run garbage collection | |
gc.collect() | |
# print available memory | |
print("Loop {} available memory: {} bytes".format(loop_counter, gc.mem_free())) | |
# increment loop and sleep for 10 seconds | |
loop_counter+=1 | |
time.sleep(10) |