Maker.io main logo

Moon Flight Tracker

46

License: See Original Project 3D Printing LCD / TFT Displays Wifi Wireless LED / Display Drivers Microcontrollers Proximity Touch ESP32 Qwiic STEMMA

Courtesy of Adafruit

Guide by Ruiz Brothers

Overview

We were inspired by the round-display flight-tracker trend and added a twist, combining a moon tracker with a flight tracker to make catching photographic shots easier.

This build takes the luck out of it. It pulls live aircraft positions for your area, computes where the moon actually is from your ZIP code, projects each plane forward along its heading, and warns you when one is about to pass close to, or straight across, the lunar disc. Two tiers of alert: NEAR when the plane and moon will share a telephoto frame, and TRANSIT when it crosses the disc itself.

It's small enough to sit on your camera's hotshoe, so it rides along with the rig you're already pointing at the sky.

camera_1

The tracker queries the adsb.fi open ADS-B network for aircraft within 15 nautical miles, then converts each plane's latitude, longitude, and altitude into the same azimuth-and-elevation coordinates it uses for the moon. The moon's position is computed on the device itself, no API, no key; from a compact lunar ephemeris accurate to roughly a third of a degree.

Each aircraft is then flown forward in software along its reported track and ground speed, in five-second steps across the next five minutes, and the smallest angular separation from the moon is recorded. When a plane's closest approach falls inside the alert thresholds and is less than 90 seconds out, the display raises a banner: cyan for a near pass, pink for a transit. Tap it and you get the flight's route and details, looked up on the fly.

tracker_2

tracker_3

Four Screens

  • RADAR: a live plan view of traffic around you, with the moon's bearing marked, alert banners, and each plane's callsign and altitude.

  • LIST: every tracked aircraft ranked by how close it will come to the moon, with predicted separation and time to closest approach. Tap any row for flight details.

  • MOON: current phase rendered to scale, illumination percentage, altitude and azimuth, and tonight's rise and set times in your local time zone.

  • LOCATION: a touch keypad for your ZIP code, plus screen rotation. Both persist across power cycles, so you set them once.

screens_4

screens_5

screens_6

screens_7

Everything runs on an Adafruit Qualia ESP32-S3 driving a 2.1" round 480×480 capacitive touch display. The S3's 16 MB of flash and 8 MB of PSRAM leave room to hold a full-color framebuffer, run the trajectory math, and talk to the network at the same time, no extra boards, no GPS module, no sensors. The only wiring is one ribbon cable and USB-C for power.

display_8

The code is CircuitPython, so every parameter worth tuning, such as search radius, alert thresholds, refresh rate, prediction window, is a plain constant at the top of code.py file that you can edit over USB with no toolchain.

This uses an online database keyed by ZIP code for the United States. Folks outside the US may want to adjust the code to use other data.

code_9

Parts

Circuit Diagram

Circuit Diagram

This provides a visual reference for wiring of the components. This diagram was created using Fritzing software.

diagram_10

Ribbon cable

Align the board and display as pictured. Make sure the copper traces are facing down.

Gently lift the latch up and insert the ribbon cable into the socket. Make sure to carefully push the ribbon evenly into the socket. Press the latch down to grip the ribbon cable, making sure the cable is evenly inserted into the socket. Gently tug on the board to insure the ribbon is fully secure and evenly held in place inside the socket.

CircuitPython

CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.

CircuitPython Quickstart

Follow this step-by-step to quickly get CircuitPython running on your board.

This microcontroller requires the latest unstable (development) release of CircuitPython. Click below to visit the downloads page on circuitpython.org for your board. Then, Browse S3 under Absolute Newest.

Download the latest version of CircuitPython for this board via circuitpython.org

Click the link above to download the latest CircuitPython UF2 file.

Save it wherever is convenient for you.

click_11

The Qualia S3 does not have a RGB status LED.

board_12

Plug your board into your computer, using a known-good data-sync cable, directly, or via an adapter if needed.

Double-click the reset button (highlighted in red above), and you will see the RGB status LED(s) turn green (highlighted in green above). If you see red, try another port, or if you're using an adapter or hub, try without the hub, or different adapter or hub.

This board does not have a NeoPixel, so you will need to just double tap the reset button.

For this board, tap reset and wait about a half a second and then tap reset again.

Some boards may not have a UF2 bootloader installed. If double-clicking does not work, follow the instructions on the "Install UF2 Bootloader" page in this guide.

If double-clicking doesn't work the first time, try again. Sometimes it can take a few tries to get the rhythm right!

A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.

You will see a new disk drive appear called TFT_S3BOOT.

Drag the adafruit_circuitpython_etc.uf2 file to TFT_S3BOOT.

drive_13

drive_14

The BOOT drive will disappear and a new disk drive called CIRCUITPY will appear.

That's it!

boot_15

Code

Flash your Qualia ESP32-S3 with the latest stable release of CircuitPython 9.x for Qualia ESP32-S3 RGB666. See the Install CircuitPython page of the Qualia guide if this is your first time.

Add the CircuitPython Libraries

This project uses several libraries from the Adafruit CircuitPython Library Bundle. Using the Project Bundle below copies them into your lib folder automatically, but for reference the project imports:

libraries_16

Upload the Code and Files

Click the Download Project Bundle button below to download the code and assets as a zip file. Copy the contents to your CIRCUITPY drive, keeping the file and folder structure the same.

Library note: touch uses the standard adafruit_cst8xx driver. Some 2.1" round panel’s report a chip ID of 0x00. Support for these landed in the library in July 2026, so use a current bundle.

No adafruit_qualia package is needed; the display is initialized directly so the pixel clock can be lowered (more on that below).

Download Project Bundle

Copy Code
# SPDX-FileCopyrightText: 2026 Pedro Ruiz for Adafruit Industries
#
# SPDX-License-Identifier: MIT
'''
LUNA TRANSIT flight tracker.
Alerts when a plane is about to cross (or pass near) the moon,
for photographing transits.
Adafruit Qualia ESP32-S3 + 2.1" round 480x480 touch TFT (product 5792).
'''
# pylint: disable=broad-except, redefined-outer-name, global-statement
# pylint: disable=too-many-locals, too-many-branches, too-many-statements
# pylint: disable=too-many-return-statements
# Screens: RAD (radar) / LIST / MOON / LOC (zip keypad)
# Data: adsb.fi open API + on-device moon ephemeris
import time
import math
import os
import ssl
import rtc
import wifi
import socketpool
import microcontroller
import board
import busio
import displayio
import terminalio
import vectorio
import dotclockframebuffer
from framebufferio import FramebufferDisplay
import adafruit_requests
import adafruit_ntp
from adafruit_display_text import label
from adafruit_display_shapes.roundrect import RoundRect
import adafruit_cst8xx
import tl021wvc02
from moon_ephem import moon_altaz_phase, phase_name

# ---------------- palette ----------------
BG = 0x0A0F16
PANEL = 0x101A26
GRID = 0x1C2836
GRID2 = 0x24344A
CYAN = 0x00E5FF
PINK = 0xFF2E88
MOONC = 0xE9E4D4
DIM = 0x5A6472
GREEN = 0x3DDC84
DARK = 0x0D1219

CX, CY = 240, 240
PX_PER_MI = 42          # 5 mi -> 210 px ring
FETCH_S = 15            # aircraft refresh
MOON_S = 60             # ephemeris refresh
LOOKAHEAD_S = 300       # transit prediction window
SEP_HIT = 0.5           # pink: plane crosses right over the moon disc
SEP_NEAR = 2.0          # cyan: close pass — moon still in a 300mm frame
NM_RADIUS = 15          # adsb.fi query radius (nm)
DEBUG_TOUCH = False     # print every tap/release to serial — enable when tuning
DISPLAY_FREQ = 8_000_000   # pclk; guide default is 16 MHz — lowered to fight
                           # ESP32-S3 RGB "screen drift" during WiFi bursts

# ---------------- state ----------------
screen = "radar"
filter_mode = "mi"      # "mi" (5 mi ring) or "mm" (300mm lens ~3.1 mi)
zip_draft = None
aircraft = []           # dicts: cs, x, y (mi E/N), alt_ft, gs_kt, track, type
best = None             # (cs, eta_s, sep_deg)
sel = None              # aircraft dict selected from LIST
list_rows = []          # (eta, sep, ac) as drawn on LIST — for tap lookup
route_cache = {}        # callsign -> (codes, names) or None
route_pending = None    # callsign awaiting route lookup (detail screen)
demo_t0 = None          # "67"+OK on keypad: demo start time (60s total)
demo_phase = -1         # 0 = pink pass, 1 = cyan pass (rebuild once per pass)
demo_pass_t0 = None     # pass clock — starts AFTER the pass's slow rebuild
demo_grp = None         # the demo plane's displayio sub-group (moved per frame)
demo_lbl = None         # banner label (ETA text updated per frame)
moon = (0.0, 0.0, 0.5, True)  # alt, az, illum, waxing
moon_rs = None          # ("HH:MM", "HH:MM") local rise/set
TZ_OFF = -5             # replaced after geocode (UTC_OFFSET env or lon/15)
loc_msg = None          # (text, color) confirmation on LOC screen
loc_msg_t = 0
last_touch = 0          # touch dispatch state (poll_touch)
last_tx = last_ty = -999
was_down = False


def nvm_get_zip():
    try:
        z = bytes(microcontroller.nvm[0:5]).decode()
        if z.isdigit():
            return z
    except Exception:
        pass
    return "10013"


def nvm_set_zip(z):
    microcontroller.nvm[0:5] = z.encode()


def nvm_get_rot():
    try:
        r = microcontroller.nvm[5]
        if r < 4:
            return r * 90
    except Exception:
        pass
    return 0


def nvm_set_rot(deg):
    microcontroller.nvm[5] = deg // 90


ZIP = nvm_get_zip()
ROT = nvm_get_rot()      # 0 / 90 / 180 / 270 — set via LOC keypad
LAT, LON = 40.72, -74.00  # replaced by geocode at boot

# ---------------- display ----------------
# Direct init (TL021WVC02 codes + timings from the Adafruit learn guide).
# The panel is scanned continuously out of PSRAM; WiFi/SSL bursts contend for
# that bandwidth and cause horizontal jitter ("screen drift") — DISPLAY_FREQ
# runs below the guide's 16 MHz to leave headroom. Raise it back toward
# 16_000_000 if your unit is stable.
displayio.release_displays()

# Init sequence lives in tl021wvc02.py (keeps this module under
# pylint's 1000-line cap and reusable by other Qualia projects).
# Not using the adafruit_qualia helper: its Round21 class bakes the panel's
# stock 16 MHz pclk into the display definition, and this project needs the
# lower DISPLAY_FREQ to suppress WiFi-burst jitter — so init stays direct.

try:
    board.I2C().deinit()
except Exception:
    pass
_i2c_init = busio.I2C(board.SCL, board.SDA)
_io_exp = dict(board.TFT_IO_EXPANDER)
# _io_exp['i2c_address'] = 0x38  # uncomment for rev B boards
dotclockframebuffer.ioexpander_send_init_sequence(
    _i2c_init, tl021wvc02.INIT_SEQUENCE, **_io_exp)
_i2c_init.deinit()

_timings = {
    "frequency": DISPLAY_FREQ,
    "width": 480, "height": 480,
    "hsync_pulse_width": 20, "hsync_front_porch": 40, "hsync_back_porch": 40,
    "vsync_pulse_width": 10, "vsync_front_porch": 40, "vsync_back_porch": 40,
    "hsync_idle_low": False, "vsync_idle_low": False, "de_idle_high": False,
    "pclk_active_high": True, "pclk_idle_high": False,
}
_fb = dotclockframebuffer.DotClockFramebuffer(**dict(board.TFT_PINS), **_timings)
display = FramebufferDisplay(_fb, auto_refresh=False)
display.rotation = ROT
root = displayio.Group()
display.root_group = root

pal = {}
def P(color):
    if color not in pal:
        p = displayio.Palette(1)
        p[0] = color
        pal[color] = p
    return pal[color]


def circle(x, y, r, color):
    return vectorio.Circle(pixel_shader=P(color), radius=r, x=x, y=y)


def ring(group, r, color):
    group.append(circle(CX, CY, r, color))
    group.append(circle(CX, CY, r - 1, BG))


def rect(x, y, w, h, color):
    return vectorio.Rectangle(pixel_shader=P(color), width=w, height=h, x=x, y=y)


def text(s, x, y, color, scale=2, anchor=(0, 0)):
    t = label.Label(terminalio.FONT, text=s, color=color, scale=scale)
    t.anchor_point = anchor
    t.anchored_position = (x, y)
    return t


# ---------------- network ----------------
print("connecting wifi…")
wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD"))
# WiFi TX bursts contend with the display's PSRAM scanout and cause the
# residual jitter — capping TX power shrinks those bursts considerably.
# Raise back toward 15–20 dBm if your AP is far and fetches start failing.
try:
    wifi.radio.tx_power = 8
    print("wifi tx_power capped at 8 dBm")
except Exception as e:
    print("tx_power cap failed:", e)
pool = socketpool.SocketPool(wifi.radio)
requests = adafruit_requests.Session(pool, ssl.create_default_context())
ntp = adafruit_ntp.NTP(pool, tz_offset=0)


def sync_time():
    """One-shot NTP -> RTC so we never block on UDP again."""
    for attempt in range(4):
        try:
            rtc.RTC().datetime = ntp.datetime
            print("time synced")
            return True
        except Exception as e:
            print("ntp retry {}: {}".format(attempt + 1, e))
            time.sleep(2)
    return False

sync_time()


def utc_now():
    return time.localtime()  # RTC holds UTC (tz_offset=0)


def geocode(z):
    global LAT, LON, TZ_OFF
    try:
        r = requests.get("https://api.zippopotam.us/us/" + z, timeout=10)
        p = r.json()["places"][0]
        r.close()
        LAT, LON = float(p["latitude"]), float(p["longitude"])
        v = os.getenv("UTC_OFFSET")
        if v is not None:
            TZ_OFF = float(v)
        else:
            TZ_OFF = fetch_utc_offset() or round(LON / 15.0)
        return True
    except Exception as e:
        print("geocode failed:", e)
        return False


def fetch_aircraft():
    """adsb.fi open data — planes within NM_RADIUS of observer."""
    out = []
    url = "https://opendata.adsb.fi/api/v2/lat/{:.4f}/lon/{:.4f}/dist/{}".format(
        LAT, LON, NM_RADIUS)
    try:
        # short timeout: this call blocks the whole loop (touch included) —
        # better to skip one 15s cycle than freeze for 10s on a slow response
        r = requests.get(url, timeout=4)
        data = r.json()
        r.close()
    except Exception as e:
        print("adsb fetch failed:", e)
        return None
    for ac in data.get("aircraft", []):
        try:
            la, lo = ac["lat"], ac["lon"]
            alt = ac.get("alt_baro", 0)
            if alt == "ground" or not isinstance(alt, (int, float)) or alt < 500:
                continue
            dx = (lo - LON) * 69.17 * math.cos(math.radians(LAT))  # mi east
            dy = (la - LAT) * 69.17                                # mi north
            out.append({
                "cs": (ac.get("flight") or ac.get("hex", "?")).strip(),
                "x": dx, "y": dy, "alt": alt,
                "gs": ac.get("gs", 0) or 0,
                "track": ac.get("track", 0) or 0,
                "type": ac.get("t", "?") or "?",
            })
        except (KeyError, TypeError):
            continue
    return out


def fetch_route(cs):
    """Route lookup via adsbdb.com (free, no key) — better coverage than
    adsb.lol's routeset. Cached per callsign. Returns ("MCO-SRQ", [names]) or None."""
    if cs in route_cache:
        return route_cache[cs]
    info = None
    try:
        r = requests.get("https://api.adsbdb.com/v0/callsign/" + cs, timeout=10)
        status = r.status_code
        d = r.json()
        r.close()
        if status == 200:
            fr = d.get("response", {}).get("flightroute")
            if fr:
                o, dst = fr.get("origin", {}), fr.get("destination", {})
                codes = "{}-{}".format(o.get("iata_code", "?"), dst.get("iata_code", "?"))
                info = (codes, [o.get("name", "?"), dst.get("name", "?")])
        elif status == 404:
            print("route: not in adsbdb for", cs)  # genuine no-data → cache None
        else:
            print("route lookup HTTP", status)
            return None
    except Exception as e:
        print("route lookup failed:", e)
        return None  # don't cache failures — retry on next tap
    route_cache[cs] = info
    return info


def fetch_utc_offset():
    """DST-aware UTC offset for LAT/LON via timeapi.io (free, no key).
    Returns hours (e.g. -4.0 for EDT) or None on failure."""
    try:
        r = requests.get(
            "https://timeapi.io/api/TimeZone/coordinate"
            "?latitude={:.4f}&longitude={:.4f}".format(LAT, LON),
            timeout=10)
        d = r.json()
        r.close()
        off = d["currentUtcOffset"]["seconds"] / 3600.0
        print("tz: {} (UTC{:+.1f})".format(d.get("timeZone", "?"), off))
        return off
    except Exception as e:
        print("tz lookup failed:", e)
        return None


def fmt_local(ts):
    lt = time.localtime(int(ts + TZ_OFF * 3600))
    h, ap = lt.tm_hour % 12, ("AM" if lt.tm_hour < 12 else "PM")
    return "{}:{:02d}{}".format(h if h else 12, lt.tm_min, ap)


def compute_rise_set():
    """Scan the next 24h in 10-min steps for horizon crossings (interpolated)."""
    t = time.time()
    prev = moon_altaz_phase(time.localtime(t), LAT, LON)[0]
    rise = sett = None
    step = 600
    for _ in range(145):
        t += step
        alt = moon_altaz_phase(time.localtime(t), LAT, LON)[0]
        if rise is None and prev <= 0 < alt:
            rise = t - step + step * (-prev) / (alt - prev)
        if sett is None and prev > 0 >= alt:
            sett = t - step + step * prev / (prev - alt)
        if rise is not None and sett is not None:
            break
        prev = alt
    return (fmt_local(rise) if rise else "--:--",
            fmt_local(sett) if sett else "--:--")


# ---------------- geometry ----------------
def az_el(x_mi, y_mi, alt_ft):
    gd = math.sqrt(x_mi * x_mi + y_mi * y_mi) + 1e-6
    el = math.degrees(math.atan2(alt_ft / 5280.0, gd))
    az = math.degrees(math.atan2(x_mi, y_mi)) % 360
    return az, el


def ang_sep(az1, el1, az2, el2):
    a1, e1, a2, e2 = map(math.radians, (az1, el1, az2, el2))
    c = (math.sin(e1) * math.sin(e2)
         + math.cos(e1) * math.cos(e2) * math.cos(a1 - a2))
    return math.degrees(math.acos(max(-1, min(1, c))))


def predict(ac, m_az, m_el):
    """Return (eta_s, min_sep_deg). Always evaluates the CURRENT position
    (t=0), so aircraft with missing/low ground speed — common for GA
    transponders on adsb.fi — still register their live separation instead
    of being silently excluded from the alert logic."""
    az, el = az_el(ac["x"], ac["y"], ac["alt"])
    best_t, best_sep = 0, ang_sep(az, el, m_az, m_el)
    if ac["gs"] >= 40:
        v = ac["gs"] * 1.15078 / 3600.0  # mi/s
        tr = math.radians(ac["track"])
        vx, vy = v * math.sin(tr), v * math.cos(tr)
        for t in range(5, LOOKAHEAD_S + 1, 5):
            az, el = az_el(ac["x"] + vx * t, ac["y"] + vy * t, ac["alt"])
            s = ang_sep(az, el, m_az, m_el)
            if s < best_sep:
                best_sep, best_t = s, t
    return best_t, best_sep


# ---------------- screens ----------------
NAVS = ("RAD", "LIST", "MOON", "LOC")
KEYS = ("1", "2", "3", "4", "5", "6", "7", "8", "9", "<", "0", "OK")


def outlined(g, x, y, w, h, fill, border, r=6):
    """Rounded, 1px-outlined panel — matches the mockup's bordered boxes.
    Needs adafruit_display_shapes in /lib."""
    g.append(RoundRect(x, y, w, h, r, fill=fill, outline=border))


def nav_group(active):
    g = displayio.Group()
    total = 4 * 56 + 3 * 6
    x0 = CX - total // 2
    for i, n in enumerate(NAVS):
        x = x0 + i * 62
        on = n == active
        outlined(g, x, 398, 56, 44, PANEL if on else DARK, CYAN if on else GRID, 10)
        g.append(text(n, x + 28, 420, CYAN if on else DIM, 1, (0.5, 0.5)))
    # edge-cycle hints (match the tap zones)
    g.append(text("<", 16, 240, DIM, 2, (0, 0.5)))
    g.append(text(">", 464, 240, DIM, 2, (1, 0.5)))
    return g


def moon_icon(g, x, y, r, illum, waxing):
    if illum < 0.06:
        # new moon: dotted outline so the position still reads on screen
        for i in range(24):
            a = math.radians(i * 15)
            g.append(circle(int(x + math.sin(a) * r), int(y - math.cos(a) * r), 2, MOONC))
        return
    g.append(circle(x, y, r, MOONC))
    # shadow disc slides across (fully off-disc at full moon); flips for waning
    off = int((illum * 2.1 - 0.1) * r)
    g.append(circle(x - off if waxing else x + off, y, r + 2, BG))


def build_radar():
    g = displayio.Group()
    g.append(rect(0, 0, 480, 480, BG))
    # alert bezel ring FIRST — ring() fills its inside with BG, so drawing
    # it last wiped the whole radar (moon + planes vanished under it)
    if best and best[2] < SEP_NEAR and best[1] <= 90:
        acol = PINK if best[2] < SEP_HIT else CYAN
        ring(g, 238, acol)
        ring(g, 236, acol)
    # largest ring FIRST — ring() fills its inside with BG, so smaller-first
    # gets painted over (the bug that hid the inner circles)
    ring(g, 210, GRID2)          # 5 mi
    ring(g, 130, GRID)           # 3.1 mi — 300mm capture zone
    ring(g, 70, GRID)
    g.append(circle(CX, CY, 3, CYAN))
    g.append(text("N", CX, 14, DIM, 2, (0.5, 0)))
    g.append(text("5MI", 40, 232, 0x2C3A4C, 1, (0, 0.5)))
    g.append(text("3.1", 118, 232, 0x2C3A4C, 1, (0, 0.5)))

    m_alt, m_az, illum, waxing = moon
    # moon centered — leaves the whole ring free for traffic;
    # the label carries the REAL alt/az for pointing the camera
    mx, my = CX, CY
    if m_alt > 0:
        # accent halo ring around the moon (mockup: 2px accent, inset -8)
        g.append(circle(mx, my, 49, 0xB3215F))
        g.append(circle(mx, my, 47, BG))
        moon_icon(g, mx, my, 40, illum, waxing)
        g.append(text("MOON {:.0f}/{:.0f}".format(m_alt, m_az), mx, my + 48, 0xB7AE93, 1, (0.5, 0)))
        # dotted bearing line at the moon's REAL azimuth — planes crossing
        # THIS line are transit candidates; screen-center proximity means nothing
        ar = math.radians(m_az)
        for rr in range(60, 206, 16):
            g.append(circle(int(CX + math.sin(ar) * rr), int(CY - math.cos(ar) * rr), 2, 0x63203F))
    else:
        g.append(text("MOON BELOW HORIZON", CX, 60, DIM, 1, (0.5, 0)))

    limit = 5.0 if filter_mode == "mi" else 3.1
    global demo_grp, demo_lbl
    for ac in aircraft:
        d = math.sqrt(ac["x"] ** 2 + ac["y"] ** 2)
        if d > limit:
            continue
        px = int(CX + ac["x"] * PX_PER_MI)
        py = int(CY - ac["y"] * PX_PER_MI)
        is_t = best and best[0] == ac["cs"] and best[2] < SEP_NEAR
        col = PINK if (is_t and best[2] < SEP_HIT) else CYAN
        tr = math.radians(ac["track"])
        pts = []
        for a, r in ((0, 10), (140, 9), (180, 4), (220, 9)):
            aa = tr + math.radians(a)
            pts.append((int(math.sin(aa) * r), int(-math.cos(aa) * r)))
        # blip + labels live in a sub-group at (px,py) so the demo driver can
        # slide the whole thing per-frame without a full screen rebuild
        sub = displayio.Group(x=px, y=py)
        sub.append(vectorio.Polygon(pixel_shader=P(col), points=pts, x=0, y=0))
        sub.append(text(ac["cs"], 12, -10, col, 1))
        sub.append(text("{:d}".format(int(ac["alt"])), 12, 2, DIM, 1))
        if "sep" in ac:
            sub.append(text("{:.1f}d".format(ac["sep"]), 12, 14, DIM, 1))
        if is_t:
            sub.append(text("T-{}:{:02d}".format(best[1] // 60, best[1] % 60), 12, 26, col, 1))
        g.append(sub)
        if ac["cs"] == "DEMO67":
            demo_grp = sub

    # filter chip — cyan pill, matches mockup (radius 22, cyan border/text)
    outlined(g, CX - 85, 40, 170, 36, PANEL, CYAN, 18)
    lbl = "RANGE 5 MI" if filter_mode == "mi" else "300MM 3.1 MI"
    g.append(text(lbl, CX, 58, CYAN, 2, (0.5, 0.5)))

    # closest-candidate readout — always visible so a near-miss is
    # distinguishable from a dead alert pipeline
    if best:
        g.append(text("CLOSEST {} {:.1f}D T-{}:{:02d}".format(
            best[0], best[2], best[1] // 60, best[1] % 60),
            CX, 378, DIM, 1, (0.5, 0)))
    elif moon[0] <= 5:
        g.append(text("MOON TOO LOW - ALERTS OFF", CX, 378, DIM, 1, (0.5, 0)))

    # alert banner — pink = over the disc, cyan = close pass (bezel ring
    # itself is drawn first, at the bottom of the stack)
    if best and best[2] < SEP_NEAR and best[1] <= 90:
        acol = PINK if best[2] < SEP_HIT else CYAN
        word = "TRANSIT" if best[2] < SEP_HIT else "NEAR"
        g.append(rect(CX - 140, 90, 280, 34, acol))
        lbl = text("{} {}:{:02d} {}".format(word, best[1] // 60, best[1] % 60, best[0]),
                   CX, 107, BG, 2, (0.5, 0.5))
        g.append(lbl)
        if best[0] == "DEMO67":
            demo_lbl = lbl
    g.append(nav_group("RAD"))
    return g


def build_list():
    global list_rows
    g = displayio.Group()
    g.append(rect(0, 0, 480, 480, BG))
    g.append(text("UPCOMING PASSES", CX, 66, CYAN, 2, (0.5, 0)))
    g.append(text("SEP IN DEG FROM MOON CENTER - TAP FOR INFO", CX, 92, DIM, 1, (0.5, 0)))
    m_alt, m_az = moon[0], moon[1]
    rows = []
    for ac in aircraft:
        p = predict(ac, m_az, m_alt)
        if p:
            rows.append((p[0], p[1], ac))
    rows.sort(key=lambda r: r[1])
    list_rows = rows[:6]
    y = 124
    for eta, sep, ac in list_rows:
        col = PINK if sep < SEP_HIT else (CYAN if sep < SEP_NEAR else MOONC)
        outlined(g, 100, y, 280, 40, PANEL, col if sep < SEP_NEAR else GRID, 6)
        g.append(text(ac["cs"], 112, y + 20, col, 2, (0, 0.5)))
        g.append(text(ac["type"], 218, y + 20, DIM, 1, (0, 0.5)))
        g.append(text("{:.1f}d".format(sep), 306, y + 20, DIM, 2, (1, 0.5)))
        g.append(text("{}:{:02d}".format(eta // 60, eta % 60), 368, y + 20, col, 2, (1, 0.5)))
        y += 46
    if not rows:
        g.append(text("NO CANDIDATES IN RANGE", CX, 220, DIM, 2, (0.5, 0)))
    g.append(nav_group("LIST"))
    return g


def build_detail():
    global route_pending
    g = displayio.Group()
    g.append(rect(0, 0, 480, 480, BG))
    if not sel:
        g.append(text("NO FLIGHT SELECTED", CX, 200, DIM, 2, (0.5, 0)))
        g.append(nav_group("LIST"))
        return g
    eta, sep, ac = sel
    hot = sep < SEP_HIT
    col = PINK if hot else (CYAN if sep < SEP_NEAR else MOONC)
    g.append(text(ac["cs"], CX, 70, col, 3, (0.5, 0)))
    # position in list, e.g. 2/6 — the edge arrows page through these
    for k, row in enumerate(list_rows):
        if row[2]["cs"] == ac["cs"]:
            g.append(text("{}/{}".format(k + 1, len(list_rows)), CX, 30, DIM, 1, (0.5, 0)))
            break
    g.append(text(ac["type"], CX, 112, DIM, 2, (0.5, 0)))
    if ac["cs"] in route_cache:
        route = route_cache[ac["cs"]]
        if route:
            g.append(text(route[0].replace("-", " > "), CX, 150, MOONC, 3, (0.5, 0)))
            y = 192
            for name in route[1]:
                g.append(text(name[:36], CX, y, DIM, 1, (0.5, 0)))
                y += 16
        else:
            g.append(text("NO ROUTE FILED", CX, 156, DIM, 2, (0.5, 0)))
    else:
        # paint INSTANTLY with a loading dial; the (blocking) route lookup
        # happens in the main loop right after this frame is on screen
        for i in range(10):
            a = math.radians(i * 36)
            dc = CYAN if i < 3 else GRID2
            g.append(circle(int(CX + math.sin(a) * 22), int(160 - math.cos(a) * 22), 3, dc))
        g.append(text("ROUTE LOOKUP...", CX, 192, DIM, 1, (0.5, 0)))
        route_pending = ac["cs"]
    d = math.sqrt(ac["x"] ** 2 + ac["y"] ** 2)
    g.append(text("ALT {}   GS {:.0f} KT".format(ac["alt"], ac["gs"]), CX, 240, CYAN, 2, (0.5, 0)))
    g.append(text("DIST {:.1f} MI   TRK {:.0f}".format(d, ac["track"]), CX, 270, CYAN, 2, (0.5, 0)))
    g.append(text("SEP {:.1f} DEG   ETA {}:{:02d}".format(
        sep, eta // 60, eta % 60), CX, 300, col, 2, (0.5, 0)))
    g.append(nav_group("LIST"))
    return g


def build_moon():
    g = displayio.Group()
    g.append(rect(0, 0, 480, 480, BG))
    m_alt, m_az, illum, waxing = moon
    moon_icon(g, CX, 150, 85, illum, waxing)
    g.append(text(phase_name(illum, waxing), CX, 254, MOONC, 2, (0.5, 0)))
    g.append(text("{:.0f}% ILLUMINATED".format(illum * 100), CX, 282, PINK, 2, (0.5, 0)))
    g.append(text("ALT {:.0f}   AZ {:.0f}".format(m_alt, m_az), CX, 316, CYAN, 2, (0.5, 0)))
    if moon_rs:
        g.append(text("RISE {}   SET {}".format(moon_rs[0], moon_rs[1]),
                      CX, 346, MOONC, 2, (0.5, 0)))
    g.append(text("ZIP {}  LAT {:.2f} LON {:.2f}".format(ZIP, LAT, LON), CX, 378, DIM, 1, (0.5, 0)))
    g.append(nav_group("MOON"))
    return g


def build_loc():
    g = displayio.Group()
    g.append(rect(0, 0, 480, 480, BG))
    g.append(text("ZIP LOCATION", CX, 30, CYAN, 2, (0.5, 0)))
    g.append(text("5 DIGITS = ZIP / 90 180 270 = ROTATE", CX, 58, DIM, 1, (0.5, 0)))
    shown = ZIP if zip_draft is None else (zip_draft + "_" * 5)[:5]
    outlined(g, CX - 80, 78, 160, 40, PANEL, GRID2, 6)
    g.append(text(shown, CX, 98, MOONC, 3, (0.5, 0.5)))
    for i, k in enumerate(KEYS):
        col_i, row_i = i % 3, i // 3
        x, y = CX - 105 + col_i * 74, 134 + row_i * 62
        bg = 0x12261E if k == "OK" else PANEL
        fg = GREEN if k == "OK" else (PINK if k == "<" else MOONC)
        outlined(g, x, y, 66, 52, bg, GRID2, 8)
        g.append(text(k, x + 33, y + 26, fg, 2, (0.5, 0.5)))
    if loc_msg and time.monotonic() - loc_msg_t < 4:
        g.append(text(loc_msg[0], CX, 382, loc_msg[1], 1, (0.5, 0)))
    g.append(nav_group("LOC"))
    return g


BUILDERS = {"radar": build_radar, "list": build_list, "moon": build_moon,
            "loc": build_loc, "detail": build_detail}

# ---------------- touch init ----------------
# Stock driver. Requires adafruit_cst8xx with the merged fix that accepts
# this panel batch's 0x00 chip ID (bundles after 2026-07-14); it also
# disables the controller's auto-sleep so the first tap isn't swallowed.
try:
    touch = adafruit_cst8xx.Adafruit_CST8XX(board.I2C())
    print("touch: CST8XX ok")
except Exception as e:
    print("touch init failed:", e)
    touch = None


def show():
    global demo_grp, demo_lbl
    demo_grp = demo_lbl = None  # build_radar reassigns them when relevant
    while len(root):
        root.pop()
    root.append(BUILDERS[screen]())
    display.refresh()


# ---------------- touch ----------------
def rotate_touch(x, y):
    """Map physical touch coords to logical (rotated) display coords.
    If left/right feel swapped at 90/270 on your unit, swap those two cases."""
    if ROT == 90:
        return y, 479 - x
    if ROT == 180:
        return 479 - x, 479 - y
    if ROT == 270:
        return 479 - y, x
    return x, y


def handle_touch(x, y, repeat=False):
    global screen, filter_mode, zip_draft, ZIP, loc_msg, loc_msg_t, ROT, sel, route_pending, demo_t0
    # nav bar — nearest-chip mapping, no dead gaps between chips
    if 388 <= y <= 452:
        total = 4 * 56 + 3 * 6
        x0 = CX - total // 2
        if x0 <= x <= x0 + total:
            i = max(0, min(3, (x - x0) // 62))
            screen = ("radar", "list", "moon", "loc")[i]
            zip_draft = None
            return True
    # far left / right edge zones. On DETAIL they page through the flight
    # list (past either end exits back to LIST); elsewhere they cycle screens.
    # Generous: the CST8xx reports near-bezel taps compressed inward — a
    # finger on the chevron at x=464 can report as low as ~382.
    if x <= 118 or x >= 385:
        d = 1 if x >= 385 else -1
        if screen == "detail" and sel:
            idx = -1
            for k, row in enumerate(list_rows):
                if row[2]["cs"] == sel[2]["cs"]:
                    idx = k
                    break
            j = idx + d
            if idx >= 0 and 0 <= j < len(list_rows):
                sel = list_rows[j]
            else:
                screen = "list"  # walked off either end (or flight left range)
            return True
        order = ("radar", "list", "moon", "loc")
        i = order.index(screen) if screen in order else 1
        screen = order[(i + d) % 4]
        zip_draft = None
        return True
    if screen == "list" and 124 <= y < 124 + 6 * 46:
        i = (y - 124) // 46  # no dead gaps — nearest row wins
        if i < len(list_rows):
            sel = list_rows[i]
            screen = "detail"
            return True
    if screen == "radar" and 26 <= y <= 90 and CX - 105 <= x <= CX + 105:
        filter_mode = "mm" if filter_mode == "mi" else "mi"
        return True
    if screen == "loc" and 122 <= y <= 392 and 120 <= x <= 364:
        if repeat:
            return False  # held finger must not enter the same digit twice
        # nearest-key mapping, no dead gaps — taps report ~25px low near the
        # bottom of the round panel, so a strict per-key box misses row 4
        col = max(0, min(2, (x - 135) // 74))
        row = max(0, min(3, (y - 134) // 62))
        k = KEYS[row * 3 + col]
        if k == "OK":
            if zip_draft == "67":
                # demo mode: 30s TRANSIT (pink) + 30s NEAR (cyan)
                # passes with a fake plane — for filming/testing
                demo_t0 = time.monotonic()
                screen = "radar"
                zip_draft = None
                return True
            if zip_draft in ("0", "90", "180", "270"):
                ROT = int(zip_draft)
                display.rotation = ROT
                nvm_set_rot(ROT)
                loc_msg = ("ROTATION {} SAVED".format(ROT), GREEN)
                loc_msg_t = time.monotonic()
                zip_draft = None
                return True
            if zip_draft and len(zip_draft) == 5:
                if geocode(zip_draft):
                    ZIP = zip_draft
                    nvm_set_zip(ZIP)
                    loc_msg = ("SAVED {}  {:.2f} {:.2f}".format(ZIP, LAT, LON), GREEN)
                else:
                    loc_msg = ("ZIP LOOKUP FAILED - RETRY", PINK)
                zip_draft = None
            else:
                loc_msg = ("ENTER 5 DIGITS", PINK)
            loc_msg_t = time.monotonic()
        elif k == "<":
            zip_draft = (zip_draft or "")[:-1]
        elif zip_draft and len(zip_draft) >= 5:
            # full draft: say so instead of silently ignoring digits
            loc_msg = ("5 DIGITS MAX - OK OR <", PINK)
            loc_msg_t = time.monotonic()
        else:
            zip_draft = (zip_draft or "") + k
        return True
    return False


# ---------------- main ----------------
def poll_touch():
    """One touch poll + tap dispatch. Called every loop iteration AND
    immediately after every blocking operation (network fetch, ephemeris
    scan, screen rebuild) — the CST8xx has no event buffer, so the sooner
    we read after a freeze, the more of a quick tap we can still catch."""
    global was_down, last_touch, last_tx, last_ty
    if not touch:
        return
    now = time.monotonic()
    try:
        pts = touch.touches
    except Exception:
        pts = []
    if pts:
        tx, ty = rotate_touch(pts[0]["x"], pts[0]["y"])
        moved = abs(tx - last_tx) + abs(ty - last_ty) > 40
        if not was_down or moved or now - last_touch > 0.6:
            is_repeat = was_down and not moved
            was_down = True
            last_touch = now
            last_tx, last_ty = tx, ty
            hit = handle_touch(tx, ty, is_repeat)
            if DEBUG_TOUCH:
                print("tap raw=({},{}) rot=({},{}) screen={} rep={} hit={}".format(
                    pts[0]["x"], pts[0]["y"], tx, ty, screen, is_repeat, hit))
            if hit:
                show()
                poll_touch()  # catch a finger held through the rebuild
    elif was_down:
        was_down = False
        if DEBUG_TOUCH:
            print("release")


geocode(ZIP)
last_fetch = last_moon = last_rs = -9999
last_demo_frame = 0
show()

while True:
    now = time.monotonic()
    # pending route lookup: the detail screen painted its loading dial first,
    # then we fetch here (blocking) and repaint with the result
    if route_pending:
        cs = route_pending
        route_pending = None
        if fetch_route(cs) is None and cs not in route_cache:
            route_cache[cs] = None  # lookup failed: show NO ROUTE, allow retry via re-tap
            fail = True
        else:
            fail = False
        if screen == "detail":
            show()
        if fail:
            del route_cache[cs]
        poll_touch()

    # defer blocking work (network, ephemeris scans) while the user is
    # interacting — the CST8xx has no buffer, so a tap during a blocked
    # loop is lost forever. Touch gets a 2s priority window.
    interacting = now - last_touch < 2.0

    # demo mode: fake plane crosses the display W>E — first pass dead over
    # the moon (pink TRANSIT), second pass offset (cyan NEAR). Each pass's
    # 30s clock starts only after its slow full rebuild, so the plane's
    # motion is visible from the left edge onward.
    if demo_t0 is not None and now - last_demo_frame > 0.25:
        last_demo_frame = now
        if demo_phase < 0:
            demo_phase = 0
            demo_pass_t0 = None
        p = 0.0 if demo_pass_t0 is None else (now - demo_pass_t0) / 30.0
        if p >= 1.0:
            if demo_phase == 0:
                demo_phase = 1
                demo_pass_t0 = None
                p = 0.0
            else:
                demo_t0 = None
                demo_phase = -1
                aircraft = []
                best = None
                last_fetch = -9999  # resume real data immediately
                show()
        if demo_t0 is not None:
            hit = demo_phase == 0
            fake = {"cs": "DEMO67", "x": -3.0 + 6.0 * p,
                    "y": 0.0 if hit else 0.9,
                    "alt": 3500, "gs": 140, "track": 90, "type": "B738",
                    "sep": 0.2 if hit else 1.5, "eta": int(30 - p * 30)}
            aircraft = [fake]
            best = ("DEMO67", fake["eta"], fake["sep"])
            if screen == "radar" and demo_pass_t0 is not None and demo_grp:
                # incremental frame: slide the plane group + tick the banner
                # ETA — no full rebuild, so this runs at several fps
                demo_grp.x = int(CX + fake["x"] * PX_PER_MI)
                if demo_lbl:
                    word = "TRANSIT" if hit else "NEAR"
                    demo_lbl.text = "{} 0:{:02d} DEMO67".format(word, fake["eta"])
                display.refresh()
            else:
                if screen in ("radar", "list"):
                    show()  # full rebuild once per pass (ring color, banner)
                if demo_pass_t0 is None:
                    demo_pass_t0 = time.monotonic()  # clock starts NOW
        poll_touch()

    if not interacting and now - last_moon > MOON_S:
        try:
            moon = moon_altaz_phase(utc_now(), LAT, LON)
        except Exception as e:
            print("moon calc failed:", e)
        last_moon = now
        poll_touch()

    if not interacting and now - last_rs > 1800:
        try:
            moon_rs = compute_rise_set()
        except Exception as e:
            print("rise/set failed:", e)
        last_rs = now
        poll_touch()

    if not interacting and demo_t0 is None and now - last_fetch > FETCH_S:
        planes = fetch_aircraft()
        poll_touch()  # fetch is the longest freeze — read touch before drawing
        if planes is not None:
            aircraft = planes
            m_alt, m_az = moon[0], moon[1]
            best = None
            if m_alt > 5:
                for ac in aircraft:
                    ac["eta"], ac["sep"] = predict(ac, m_az, m_alt)
                    if best is None or ac["sep"] < best[2]:
                        best = (ac["cs"], ac["eta"], ac["sep"])
        last_fetch = now
        if screen in ("radar", "list"):
            show()
        poll_touch()

    if screen == "loc" and loc_msg and now - loc_msg_t >= 4:
        loc_msg = None
        show()

    # tap detection lives in poll_touch() — see notes there. No sleep here:
    # the loop is paced by the touch I2C transaction itself (~1-2 ms), and
    # any added delay is pure tap latency.
    poll_touch()

View on GitHub

Add your network credentials to settings.toml:

Copy Code
CIRCUITPY_WIFI_SSID = "your-ssid"
CIRCUITPY_WIFI_PASSWORD = "your-password"
# optional — pin the rise/set timezone manually (hours from UTC):
# UTC_OFFSET = "-4"

Normally you don't need UTC_OFFSET — the device looks up a DST-aware offset for your ZIP code automatically.

Everything runs on free, keyless APIs: aircraft from adsb.fi open data, routes from adsbdb.com, ZIP geocoding from zippopotam.us, and time zone from timeapi.io. The moon's position is computed on the device itself.

Code Walkthrough

The Knobs

The constants at the top of code.py allow changes for one's preferences:

Download File

Copy Code
FETCH_S = 15          # aircraft refresh
LOOKAHEAD_S = 300     # transit prediction window
SEP_HIT = 0.5         # pink: plane crosses right over the moon disc
SEP_NEAR = 2.0        # cyan: close pass — moon still in a 300mm frame
NM_RADIUS = 15        # adsb.fi query radius (nm)
DISPLAY_FREQ = 8_000_000  # pixel clock — see display section

The two separation thresholds are camera-driven. The moon is about half a degree across, so a predicted miss under 0.5° means the plane crosses the disc itself — that's the pink TRANSIT alert. Under 2° the plane and moon still fit a 300 mm telephoto frame together — the cyan NEAR alert.

Display Initialization and the Pixel Clock

The round TFT is an RGB dot-clock panel: the ESP32-S3 continuously scans the framebuffer out of PSRAM. WiFi and TLS traffic contend for that same bandwidth, which can show up as horizontal jitter during fetches. The code fights this two ways:

  • DISPLAY_FREQ runs the pixel clock at 8 MHz instead of the usual 16 MHz, leaving PSRAM headroom. If your unit is stable, you may consider raising it back toward 16 MHz.

  • WiFi transmit power is capped at 8 dBm. If your access point is far away and fetches time out, raise it in code.py.

Because the pixel clock is a custom value, the panel is initialized directly — tl021wvc02.py holds the init sequence and code.py passes its own timings — rather than through the adafruit_qualia helper, which uses the stock 16 MHz timing.

Predicting a Transit

Every 15 seconds, the tracker fetches all aircraft within 15 nautical miles and converts each one's position and barometric altitude into an azimuth/elevation pair — the same coordinates the moon uses. predict() then steps the plane forward along its track and ground speed in 5-second increments for the next 5 minutes, computing the angular separation from the moon at each step:

Download File

Copy Code
def predict(ac, m_az, m_el):
    az, el = az_el(ac["x"], ac["y"], ac["alt"])
    best_t, best_sep = 0, ang_sep(az, el, m_az, m_el)
    if ac["gs"] >= 40:
        v = ac["gs"] * 1.15078 / 3600.0  # mi/s
        tr = math.radians(ac["track"])
        vx, vy = v * math.sin(tr), v * math.cos(tr)
        for t in range(5, LOOKAHEAD_S + 1, 5):
            az, el = az_el(ac["x"] + vx * t, ac["y"] + vy * t, ac["alt"])
            s = ang_sep(az, el, m_az, m_el)
            if s < best_sep:
                best_sep, best_t = s, t
    return best_t, best_sep

The current position (t=0) is always evaluated first, so general-aviation transponders that report no ground speed still register their live separation. The plane with the smallest minimum separation becomes the alert candidate; the banner fires when its ETA drops under 90 seconds. Alerts are gated off while the moon is below 5° altitude — too much atmosphere, and the geometry gets unreliable near the horizon.

The Moon, Computed On-Device

moon_ephem.py implements a truncated lunar ephemeris: the moon's ecliptic position from its principal periodic terms, converted to azimuth/altitude for your location, good to about 0.3° — a third of the NEAR threshold, plenty for framing a shot. It also returns illumination fraction and waxing/waning, which drive the MOON screen's phase render. Rise and set times are found by scanning the next 24 hours in 10-minute steps for horizon crossings, then interpolating. Time comes from NTP once at boot and lives in the real-time clock (RTC) after that.

Screens and Touch

Each screen (RAD, LIST, MOON, LOC, plus a flight DETAIL view) is a builder function that returns a fresh displayio.Group; show() swaps it in and refreshes. The display runs with auto_refresh=False, so nothing repaints mid-build.

Tap targets use nearest-neighbor mapping instead of strict bounding boxes — the CST8xx reports taps near the round bezel compressed inward, so dead gaps between buttons would eat presses. The keypad, nav chips, and list rows all snap to the closest target. If you rotate the screen (the keypad's 90/180/270 + OK), rotate_touch() remaps coordinates to match, and the setting persists in NVM across power cycles — as does your ZIP code.

The Main Loop: Touch Comes First

The CST8xx touch controller has no event buffer — a tap that lands entirely inside a blocking network call is gone by the time the code looks. The main loop is organized around that constraint:

  • poll_touch() runs every loop iteration and immediately after every blocking operation (fetch, ephemeris scan, screen rebuild).

  • Any touch opens a 2-second priority window during which all blocking work (network fetches, rise/set scans) is deferred — so navigating around the UI stays responsive.

  • The aircraft fetch uses a short 4-second timeout: better to skip one 15-second cycle than freeze the UI for 10 on a slow response.

  • The flight-detail screen paints instantly with a loading dial, and the (blocking) route lookup happens on the next loop pass, after the frame is already on screen.

Demo Mode

Enter 67 on the LOC keypad and tap OK: a fake plane (callsign DEMO67) crosses the radar twice — 30 seconds dead over the moon triggering the pink TRANSIT alert, then 30 seconds offset for the cyan NEAR alert — before live data resumes. Handy for filming the project or verifying your build end-to-end without waiting for real traffic. The demo animates incrementally (sliding the plane's display group rather than rebuilding the screen), which is also a good template if you want to add your own animations.

Troubleshooting

  • Horizontal jitter during fetches — your panel may tolerate more pixel clock than mine, or less. Lower DISPLAY_FREQ further, or reduce WiFi TX power.

  • Fetches timing out — raise wifi.radio.tx_power back toward 15–20 dBm.

  • Touch not responding — make sure your adafruit_cst8xx is from a bundle dated after July 14, 2026 (the 0x00 chip-ID fix).

  • Left/right taps swapped when rotated — swap the 90 and 270 cases in rotate_touch().

  • Tap logging — set DEBUG_TOUCH = True to print every tap and release to the serial console.

3D Printing

3MF files for 3D printing are oriented and ready to print on FDM machines using PLA filament. Original design source files may be downloaded using the links below.

3d_17

Moon Flight Tracker STLs

Edit Design

The dropdown on the Fusion 360 site allows you to pick your preferred 3D file format like STEP, STL, etc.

Slice with settings for PLA material

The parts were sliced using BambuStudio using the slice settings below.

  • PLA filament 220c extruder

  • 0.2 layer height

  • 10% gyroid infill

  • 200mm/s print speed

  • Tree Supports

  • 60 C heated bed

slice_18

Design Source Files

The project assembly was designed in Fusion 360. Once opened in Fusion 360, It can be exported in different formats like STEP, STL and more.

Electronic components like Adafruit's boards, displays, connectors and more can be downloaded from the Adafruit CAD parts GitHub Repo.

files_19

Assembly

Place Display

Align the ribbon cable to the cutouts inside the case.

place_20

Insert Mount Plate

The mounting plate snap-fits over the display at an angle to hold the display in place, with the stand offs facing up.

Gently bend the plate over the reset stem and then over the side tabs inside the case.

plate_21

Attach ribbon cable

Gently open the ribbon latch on the Qualia board and align the ribbon cable to the port.

cable_22

Mount Qualia

Use M2.5x6mm screws to mount the Qualia board to the standoffs on the mounting plate.

mount_23

Attach Lid

First align two of the snap fits to the side tabs inside the case. Once the two walls are "locked-in", press fit the last tab to secure the lid.

lid_24

Check USB port

Verify the USB port opening fits a USB cable. The mounting plate can rotate to adjust the USB port to the middle of the opening.

port_25

Tripod screw

Use a flat tool to secure a 3/8" to 1/4" Adapter Screw to the thread on the lid.

tripod_26

tripod_27

Use

Using the Moon Flight Tracker

First Boot

Plug in USB-C power. The tracker connects to WiFi, syncs the clock over NTP, and lands on the RADAR screen. Before it can predict anything, it needs to know where you are — so set your ZIP code first:

  1. Tap LOC in the navigation strip.

  2. Enter your five-digit ZIP code on the keypad. DEL backspaces.

  3. Tap OK. The tracker looks up your coordinates and time zone, then returns to the radar.

Your ZIP is saved to non-volatile memory, so it survives unplugging — you only do this once, or when you travel.

boot_28

Getting Around

The chips at the bottom of every screen switch view: RAD, LIST, MOON, LOC. Taps register on press, and the nearest chip to your finger wins — so you don't have to be precise near the curved edge of the display.

RADAR

A plan view of the sky around you, refreshed every 15 seconds. You're at the center; the rings mark distance out to the search radius. Each aircraft appears as a triangle pointed along its heading, labeled with callsign and altitude. A moon marker sits on the rim at the moon's current bearing, so you can see at a glance which side of the sky to face.

When a plane is on track to pass close to the moon it lights up and an alert banner drops from the top of the screen with the callsign and a countdown. Tap the banner — or any plane — for its details.

radar_29

LIST

Every tracked aircraft, sorted by how close it will come to the moon — best candidate at the top. Each row shows callsign, the predicted minimum separation in degrees, and how many seconds until that closest approach. Rows are color-coded to match the alert tiers. Tap a row to open flight details.

list_30

FLIGHT DETAIL

Callsign, aircraft type, origin and destination airports, altitude, speed, and heading, plus the predicted separation and time. Route information is looked up when you open the screen, so it appears a moment after the rest — a dial spins while it loads. Not every aircraft is in the route database; private and general-aviation flights often show the callsign only. Tap anywhere to go back.

flight_31

MOON

Tonight's moon: the current phase drawn to scale, illumination percentage, whether it's waxing or waning, its altitude above the horizon and compass azimuth, and local rise and set times. Negative altitude means the moon is below the horizon — the tracker holds off on alerts until it climbs above 5°, since the geometry near the horizon isn't reliable enough to be worth chasing.

moon_32

LOCATION

Beyond the ZIP keypad, this screen sets screen rotation. Enter 90, 180, or 270 and tap OK to rotate the display — useful depending on which way the ribbon cable exits your mount. Touch coordinates rotate with it, and the setting persists across power cycles. Enter 0 to go back to upright.

location_33

Reading the Alerts

Alerts are about angular separation — how far apart the plane and the moon will look in the sky, not how far apart they are in miles. The moon is about half a degree wide, which sets both thresholds:

  • NEAR (cyan) — closest approach under 2°. The plane won't cross the disc, but both fit comfortably in a 300 mm telephoto frame. Worth shooting.

  • RANSIT (pink) — closest approach under 0.5°. The plane crosses the lunar disc itself. This is the shot.

alerts_34

The banner fires when the predicted closest approach is less than 90 seconds away and counts down to it. Predictions assume the aircraft holds its current heading, speed, and altitude — a plane in a turn or a climb will drift from the forecast, so treat the countdown as a cue to be ready, not a shutter trigger.

Demo Mode

Enter 67 on the LOC keypad and tap OK. A simulated aircraft, callsign DEMO67, crosses the radar twice: first straight over the moon to fire the pink TRANSIT alert, then offset to fire the cyan NEAR alert. It runs about a minute, then live data resumes. Use it to confirm your build works, to learn the alert flow, or to film your project on a cloudy night.

demo_35

Shooting the Shot

  • Mount the tracker to your hotshoe so alerts are in your peripheral vision while you're at the viewfinder.

  • Frame and focus early. A transit lasts a fraction of a second — there's no time to hunt for focus. Pre-focus on the moon, switch to manual, and leave it.

  • Expose for the moon, not the sky. It's a sunlit rock; roughly 1/250 s at f/8, ISO 200 is a starting point. The plane will silhouette.

  • Shoot bursts. Start the burst a couple of seconds before the countdown hits zero and hold through it.

  • Watch the LIST screen while you wait — it shows candidates minutes out, well before any banner fires.

  • Best odds come from a moon between 10° and 40° altitude near a flight path — low enough that aircraft are still at cruise altitudes in your line of sight, high enough to be clear of haze.

If Something Looks Off

  • No planes at all — ADS-B coverage varies by area, and rural regions can be thin. Check the serial console for fetch errors and try raising NM_RADIUS in code.py.

  • Rise and set times look wrong — the time zone comes from your ZIP lookup. Re-enter your ZIP or set UTC_OFFSET in settings.toml.

  • Alerts never fire — check the MOON screen. Below 5° altitude, alerts are suppressed by design.

  • Taps land in the wrong place after rotating — see the rotation note on the Code page.

Mfr Part # 5800
EVAL BOARD FOR ESP32-S3
Adafruit Industries LLC
Mfr Part # 5792
GRAPHIC DISPLAY TFT RGB 2.1"
Adafruit Industries LLC
Mfr Part # 5153
CABLE A PLUG TO C PLUG 3.28'
Adafruit Industries LLC
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.