Shipping a New Tidbyt App: LAX Departures

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.

Departures Board from Qantas First Lounge at Sydney 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

App Architecture Diagram

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:

LAX Departures JSON View

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.

GIF of LAX Departures App

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!

Bookshelf angle 1

Bookshelf angle 2

Tidbyt screen showing LAX departures

Tidbyt on table with departure screen 3

Tidbyt mounted by bookshelf

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),
    )