A few weeks ago I got back in the Tidbyt-app-making business, relaunching my Planes Overhead app with a new backend. With that success still fresh in my mind, I wanted to keep the momentum going and try to finish off a project I had started a number of times before without being able to actually finish off - building a “departure board” for LAX.
The Inspiration
A few years ago I flew back to the US from Sydney and was blown away by the beautiful departures board that was mounted along the wall by their lounge.
While a bookshelf-sized smart display is never going to capture the grandeur and fun of a huge physical flipping display, this sort of old-timey board was the inspiration for what I wanted to make for my local airport.
Architecting
In prior attempts at building this app, the issue that stymied me was API rate limiting. The best datasource I could find for this sort of flight schedule data A) cost money, and B) limited the number of monthly accesses at a much lower rate than you would expect.
As such, I knew I needed to build a caching system to power my backend - something that would manage the number of queries I would make to the flight schedule API to limit my costs to the $0.99/month access tier.
Mathing it out, I figured 300 requests per month / 31 days = 9.6 req/day
, so it would be no problem to support about 8 updates a day (every three hours) with some margin for error.
Using what I learned from my last adventure building a REST API, I decided on the following architecture:
- Backend: DynamoDB table to hold the JSON blob of flight schedule data, with a 180 minute TTL
- Middleware: Lambda function which fetches and returns the cached data from DynamoDB or fetches the data from the external endpoint if the cached copy is stale
- Endpoint: REST API endpoint using API Gateway which creates a
GET
path to fetch the JSON - UI: Tidbyt app which fetches the JSON from the endpoint then shows two “panes” of three departures each
Building the App
The app follows familiar design patterns - based on my prior efforts and best practices from others working in the space - but I made a few notable implementation choices that I wanted to call out:
- Tailored my external API request to filter out codeshare, cargo, and private flights, to get as close to “true” commercial departures as possible
- Added some extra processing at the end of the Lambda function to return only the next ten departures, starting -3 minutes ago, to minimize data transfer out of AWS and simplify the JSON the Tidbyt would need to process
- Colored the flight timestamps in the Tidbyt display to serve as a visual cue for the status: grey flights have already departed, green are scheduled in the future, and red flights are canceled
After spending a bunch of time setting up my Dynamo + Lambda + API Gateway backend, I was very grateful to see that my endpoint was up and running, successfully returning the processed and simplified JSON:
With the output fit for consumption and external API rate limits taken care of, all that was left was the design of the Tidbyt UI to display the information! I had a hard time winnowing this down - I would have loved to include more information about the aircraft model especially.
But pixels are at a premium on the 64x32 display, and I eventually decided that a display of Timestamp, Carrier, and Destination for three departures at a time was the best use of the limited real estate. To increase the information density, I created a very slow animation that essentially “panes” to another set of three departures, so that the next six are shown per cycle.
pixlet render lax_departures.star --gif --magnify 10
The final step on this journey was getting the code merged into the Community Apps repository - after a couple days, the reviewers approved my PR and I was able to officially install my new app!
Photos of the LAX Departures App on Tidbyt
It’s now in my rotation alongside Planes Overhead (and weather and baseball scores and all the other good ambient datastreams that my brain craves) and has been looking darn good!
Code Reference
Lambda Function
import json
import os
import boto3
import http.client
from datetime import datetime, timedelta
dynamodb = boto3.client('dynamodb')
CACHE_KEY = "lax_departures"
TABLE_NAME = os.environ.get("DDB_TABLE", "FlightCache")
CACHE_TTL_HOURS = 3
from datetime import datetime, timedelta
from datetime import datetime, timedelta
def extract_departures(data, now_utc, limit=10):
departures = []
for flight in data.get("departures", []):
try:
sched_utc = flight["movement"]["scheduledTime"]["utc"]
sched_local = flight["movement"]["scheduledTime"]["local"]
sched_dt_utc = datetime.strptime(sched_utc, "%Y-%m-%d %H:%MZ")
sched_dt_local = datetime.strptime(sched_local[:16], "%Y-%m-%d %H:%M") # drop tz
if now_utc - timedelta(minutes=3) <= sched_dt_utc <= now_utc + timedelta(hours=3):
departures.append({
"scheduled_time": sched_dt_local.strftime("%H:%M"),
"destination": flight["movement"]["airport"]["iata"],
"number": flight["number"],
"callsign": flight.get("callSign", "Unknown"),
"status": flight.get("status"),
"airline": flight["airline"]["iata"],
"aircraft": flight.get("aircraft", {}).get("model", ""),
"codeshare_status": flight.get("codeshareStatus", "Unknown"),
"is_past": sched_dt_utc < now_utc,
"_sort_key": sched_dt_utc # store UTC datetime for sorting
})
except Exception:
continue
# Sort by UTC time
departures.sort(key=lambda x: x["_sort_key"])
# Remove _sort_key from each item before returning
for dep in departures:
dep.pop("_sort_key", None)
return departures[:limit]
def lambda_handler(event, context):
now = datetime.utcnow()
# 1. Check DynamoDB cache
try:
response = dynamodb.get_item(
TableName=TABLE_NAME,
Key={"pk": {"S": CACHE_KEY}}
)
if "Item" in response:
timestamp_str = response["Item"]["timestamp"]["S"]
raw_data_str = response["Item"]["data"]["S"]
cached_time = datetime.fromisoformat(timestamp_str)
if now - cached_time < timedelta(hours=CACHE_TTL_HOURS):
print("Using cached data.")
data = json.loads(raw_data_str)
cleaned = {
"updated": now.isoformat(),
"departures": extract_departures(data, now)
}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(cleaned)
}
except Exception as e:
print(f"Error checking cache: {e}")
# 2. Fetch from External API
print("Fetching fresh data from External API")
try:
conn = http.client.HTTPSConnection("URL")
headers = {
'x-rapidapi-key': os.environ["API_KEY"],
'x-rapidapi-host': "HOST"
}
path = (
"/flights/airports/iata/LAX"
"?offsetMinutes=-5"
"&durationMinutes=720"
"&withLeg=false"
"&direction=Departure"
"&withCancelled=true"
"&withCodeshared=false"
"&withCargo=false"
"&withPrivate=false"
"&withLocation=false"
)
conn.request("GET", path, headers=headers)
res = conn.getresponse()
raw_data = res.read()
data = json.loads(raw_data.decode("utf-8"))
# 3. Store full raw data in DynamoDB
dynamodb.put_item(
TableName=TABLE_NAME,
Item={
"pk": {"S": CACHE_KEY},
"timestamp": {"S": now.isoformat()},
"data": {"S": json.dumps(data)}
}
)
# 4. Prepare cleaned response for Tidbyt
cleaned = {
"updated": now.isoformat(),
"departures": extract_departures(data, now)
}
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(cleaned)
}
except Exception as e:
print(f"Error fetching from API: {e}")
return {
"statusCode": 500,
"body": json.dumps({"error": "Failed to fetch fresh data"})
}
Tidbyt App Code
load("encoding/json.star", "json")
load("http.star", "http")
load("render.star", "render")
# Fetch data from your deployed Lambda API
def fetch_data():
res = http.get("https://ENDPOINT.execute-api.us-east-1.amazonaws.com/lax-departures")
if res.status_code != 200:
return None
data = json.decode(res.body())
if type(data.get("departures", None)) != "list":
return None
return data["departures"][:6] # First 6 flights max
# Build one page of output
def make_page(flights, page_index, total_pages):
rows = []
# Header row with left and right components
rows.append(render.Row(children = [
render.Text("Next From LAX", font = "tb-8", color = "#fcf7c5"),
render.Text(" {}/{}".format(page_index + 1, total_pages), font = "5x8", color = "#666666"),
]))
# Add flight rows
for flight in flights:
status = flight.get("status", "").lower()
if "canceled" in status:
time_color = "#ff5555" # red
elif flight.get("is_past"):
time_color = "#888888" # gray
else:
time_color = "#a8ffb0" # light green
rows.append(
render.Row(children = [
render.Text(flight["scheduled_time"], font = "5x8", color = time_color),
render.Text(" {} {}".format(flight["airline"], flight["destination"]), font = "5x8"),
]),
)
return render.Column(children = rows)
# Main entrypoint
def main():
flights = fetch_data()
if flights == None:
return render.Root(child = render.Text("Error", font = "5x8", color = "#ff0000"))
pages = []
total_pages = (min(len(flights), 6) + 2) // 3 # ceil(len / 3)
for page_index, i in enumerate(range(0, min(len(flights), 6), 3)):
page_flights = flights[i:i + 3]
page = make_page(page_flights, page_index, total_pages)
# Repeat page N times to control timing
for _ in range(200):
pages.append(page)
return render.Root(
child = render.Sequence(children = pages),
)