Flightradar
Quiet until a plane flies over, then a split-flap board shows its callsign, aircraft type, route, and live altitude and speed. Routes are sanity-checked against real position. Live ADS-B data.
How to run
This app is a single Python file that talks directly to the BUSY Bar HTTP API: no libraries, no setup. Connect your bar via USB and run:
python app.py
Over USB the bar is always at 10.0.4.20. On Wi-Fi, pass the bar's IP
with --host <address>.
No bar at hand? Run the
BUSY Bar Emulator
and point the app at it: python app.py --host 127.0.0.1:8080.
Source code
app.py
#!/usr/bin/env python3
"""Flightradar: when a plane flies over, shows its callsign, route, altitude and speed.
python app.py # BUSY Bar over USB (always 10.0.4.20)
python app.py --host 127.0.0.1:8080 # emulator or a Wi-Fi bar
"""
import argparse
import json
import math
import signal
import sys
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
APP = "flightradar"
ADSB_URL_TEMPLATE = "https://opendata.adsb.fi/api/v3/lat/{lat}/lon/{lon}/dist/{dist}"
ADSBDB_URL_TEMPLATE = "https://api.adsbdb.com/v0/callsign/{callsign}"
# State machine constants
MIN_DWELL_S = 10
MISS_GRACE = 3
STATE_IDLE = "IDLE"
STATE_SHOWING = "SHOWING"
STATE_HOLDING = "HOLDING"
# Colors: warm cream palette
BRIGHT = "#F0F0DCFF"
DIM = "#6C6C63FF"
LED_COLOR = "#F0F0DCFF"
# Layout for the 5px-tall small font, two lines, no separator. The small-font ink
# sits ~2px lower than its nominal y on the bar, so line 1 at y=0 lands on ink rows
# 2-6 and line 2 at y=8 on rows 10-14, centring the block a little better than the
# bottom-flush y=9.
#
# Trade-off (intentional, do not "fix"): the bar plays a 1px settle animation
# whenever a text element's content changes (altitude updates every poll). That
# settle is hidden only when the ink is flush to the bottom edge (y=9), where the
# extra frame clips off-screen; at y=8 it shows as a small 1px bounce on the bottom
# line. y=8 is chosen deliberately for the better vertical centring, accepting the
# bounce. The draw API exposes no field to disable the animation.
Y_LINE1 = 0
Y_LINE2 = 8
# The device font carries a 1px right-side bearing (advance = ink + 1). Anchoring
# right-aligned text one column past the 72px width lands the ink flush to the
# right edge, matching the flush left column.
X_RIGHT = 73
# Route cache: {callsign: (expires_time, route_dict|None)}
ROUTE_CACHE = {}
ROUTE_CACHE_FOUND = float('inf') # routes found: forever
ROUTE_CACHE_NOT_FOUND = 6 * 3600 # 404: 6h TTL
ROUTE_CACHE_ERROR = 120 # network error: 120s TTL
ROUTE_CACHE_MAX_ENTRIES = 512
ROUTE_NET_SPACING = 1.0 # min seconds between adsbdb network calls
_last_route_net = 0.0 # timestamp of the last adsbdb network call
def parse_args():
p = argparse.ArgumentParser(description="Flightradar for BUSY Bar")
p.add_argument("--host", default="10.0.4.20")
p.add_argument("--lat", type=float, default=52.37, help="(default: 52.37)")
p.add_argument("--lon", type=float, default=4.89, help="(default: 4.89)")
p.add_argument("--radius", type=float, default=15, help="search/trigger radius in NM (default: 15)")
p.add_argument("--max-alt", type=int, default=40000, help="flyover altitude ceiling in ft (default: 40000)")
p.add_argument("--interval", type=float, default=5, help="seconds between polls (default: 5)")
p.add_argument("--mode", choices=["flyover", "ambient", "arrival"], default="flyover", help="screen behaviour (default: flyover)")
p.add_argument("--units", choices=["imperial", "metric"], default="imperial", help="imperial: ft + kt; metric: m/km + km/h (default: imperial)")
p.add_argument("--show-secs", type=int, default=20, help="arrival: seconds per plane (default: 20)")
p.add_argument("--hold", type=int, default=30, help="hold seconds after data lost (default: 30)")
p.add_argument("--cycle-secs", type=int, default=30, help="ambient: rotate planes after N seconds (default: 30)")
p.add_argument("--test", action="store_true")
args = p.parse_args()
# Clamp radius to 1-250 NM
args.radius = max(1.0, min(250.0, args.radius))
# Floor interval at 1.0s
args.interval = max(1.0, args.interval)
return args
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def _base(host):
host = host.replace("http://", "").replace("https://", "").rstrip("/")
return "http://" + host
def _draw(host, elements, priority=60, led_notification_color=None):
"""POST /api/display/draw. Returns (status_code, body_text)."""
body = {
"application_name": APP,
"priority": priority,
"elements": elements,
}
if led_notification_color is not None:
body["led_notification_color"] = led_notification_color
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
_base(host) + "/api/display/draw",
data=data,
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=8) as r:
return r.getcode(), r.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "ignore")
except urllib.error.URLError as e:
raise RuntimeError(f"draw failed: {e}") from e
def _clear(host):
"""DELETE /api/display/draw?application_name=..."""
qs = urllib.parse.urlencode({"application_name": APP})
req = urllib.request.Request(
_base(host) + "/api/display/draw?" + qs,
method="DELETE",
)
try:
with urllib.request.urlopen(req, timeout=8) as r:
return r.getcode()
except urllib.error.HTTPError as e:
return e.code
except urllib.error.URLError as e:
raise RuntimeError(f"clear failed: {e}") from e
# ---------------------------------------------------------------------------
# Data fetching
# ---------------------------------------------------------------------------
def _fetch_aircraft(lat, lon, radius_nm):
"""Fetch aircraft from adsb.fi. Returns list of ac dicts or raises on error."""
url = ADSB_URL_TEMPLATE.format(lat=lat, lon=lon, dist=radius_nm)
req = urllib.request.Request(url, headers={"User-Agent": "busybar-flightradar/1.0"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read().decode("utf-8", "ignore"))
return data.get("ac") or []
except urllib.error.HTTPError as e:
if e.code == 429:
raise RuntimeError("adsb.fi rate limit (429)") from e
raise RuntimeError(f"adsb.fi HTTP {e.code}") from e
except Exception as e:
raise RuntimeError(f"adsb.fi fetch: {e}") from e
def _fetch_route(callsign):
"""Fetch route from adsbdb. Returns {iata, icao, city} or raises on error."""
url = ADSBDB_URL_TEMPLATE.format(callsign=urllib.parse.quote(callsign))
req = urllib.request.Request(url, headers={"User-Agent": "busybar-flightradar/1.0"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read().decode("utf-8", "ignore"))
response = data.get("response", {})
route = response.get("flightroute", {})
origin = route.get("origin", {})
dest = route.get("destination", {})
return {
"origin_iata": origin.get("iata_code", ""),
"origin_icao": origin.get("icao_code", ""),
"origin_city": _ascii(origin.get("municipality", "")),
"origin_lat": _to_float(origin.get("latitude")),
"origin_lon": _to_float(origin.get("longitude")),
"dest_iata": dest.get("iata_code", ""),
"dest_icao": dest.get("icao_code", ""),
"dest_city": _ascii(dest.get("municipality", "")),
"dest_lat": _to_float(dest.get("latitude")),
"dest_lon": _to_float(dest.get("longitude")),
}
except urllib.error.HTTPError as e:
if e.code == 404:
raise RuntimeError("NOT_FOUND") from e
raise RuntimeError(f"adsbdb HTTP {e.code}") from e
except Exception as e:
raise RuntimeError(f"adsbdb fetch: {e}") from e
def _ascii(s):
"""Convert string to ASCII, dropping accents and unknown chars."""
return unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
def _to_float(v):
"""Parse a value to float, or None if it can't be."""
try:
return float(v)
except (TypeError, ValueError):
return None
def _haversine(lat1, lon1, lat2, lon2):
"""Great-circle distance in km between two (lat, lon) points."""
R = 6371.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return R * 2 * math.asin(math.sqrt(a))
def _bearing(lat1, lon1, lat2, lon2):
"""Initial great-circle bearing in degrees from point 1 to point 2."""
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dlam = math.radians(lon2 - lon1)
y = math.sin(dlam) * math.cos(phi2)
x = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dlam)
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
def _ang_diff(a, b):
"""Smallest absolute difference between two bearings, in [0, 180]."""
return abs((a - b + 180.0) % 360.0 - 180.0)
# Route plausibility: adsbdb matches routes by callsign against a schedule
# database, so it returns a stale/wrong route for a slice of overflying traffic.
# We reuse the airport coordinates it hands back to sanity-check the route
# against the plane's real position and heading.
ROUTE_NEAR_KM = 90.0 # within ~48 NM of an airport: terminal maneuvering
ROUTE_OVERSHOOT_KM = 150.0 # only judge "overshoot" while still this far out
ROUTE_HEADING_TOL = 80.0 # max heading-vs-bearing-to-destination difference
def _route_plausible(plane, route):
"""Return False only when a route clearly cannot belong to this plane.
Terminal traffic is trusted (a departure climbs out on its SID heading, an
arrival gets vectored), so near-airport routes, which are the reliable ones,
are never suppressed. En route, a plane heading well away from its
destination, or sitting beyond the destination from the origin, is rejected.
Missing coordinates or position mean we can't judge, so we trust the route."""
olat, olon = route.get("origin_lat"), route.get("origin_lon")
dlat, dlon = route.get("dest_lat"), route.get("dest_lon")
plat, plon = plane.get("lat"), plane.get("lon")
if None in (olat, olon, dlat, dlon, plat, plon):
return True
d_o = _haversine(plat, plon, olat, olon)
d_d = _haversine(plat, plon, dlat, dlon)
track = plane.get("track")
near_o = d_o <= ROUTE_NEAR_KM
near_d = d_d <= ROUTE_NEAR_KM
if near_o or near_d:
# Terminal traffic is normally reliable, but adsbdb sometimes returns a
# route with the wrong endpoints for a callsign whose plane is arriving
# at the airport it lists as the *origin* (or departing the one it lists
# as the *destination*). A real departure moves away from its origin and
# a real arrival moves toward its destination, so a clear contradiction
# near a single airport means the route does not belong to this plane.
if track is not None:
if near_o and not near_d and _ang_diff(track, _bearing(plat, plon, olat, olon)) < 60.0:
return False # sitting at the "origin" but flying toward it: arriving, not departing
if near_d and not near_o and _ang_diff(track, _bearing(plat, plon, dlat, dlon)) > 120.0:
return False # sitting at the "destination" but flying away: departing, not arriving
return True
if track is not None:
to_dest = _bearing(plat, plon, dlat, dlon)
if _ang_diff(track, to_dest) > ROUTE_HEADING_TOL:
return False
if d_d > ROUTE_OVERSHOOT_KM:
b_dest_orig = _bearing(dlat, dlon, olat, olon)
b_dest_plane = _bearing(dlat, dlon, plat, plon)
if _ang_diff(b_dest_orig, b_dest_plane) > 100.0:
return False
return True
# ---------------------------------------------------------------------------
# Normalization and selection
# ---------------------------------------------------------------------------
def _normalize(ac, home_lat, home_lon):
"""Filter and enrich aircraft. Returns dict or None if invalid."""
if "lat" not in ac or "lon" not in ac:
return None
alt_baro = ac.get("alt_baro")
if alt_baro == "ground":
return None
# Aircraft on or just above the ground report barometric altitude as a small
# negative number (pressure altitude below field elevation on a high-QNH day)
# rather than the "ground" string. Treat non-positive baro altitude as ground
# so taxiing / just-landed traffic is not shown as a flyover with "-200ft".
if isinstance(alt_baro, (int, float)) and not isinstance(alt_baro, bool) and alt_baro <= 0:
return None
# Compute slant range in km
horiz_km = _haversine(home_lat, home_lon, ac["lat"], ac["lon"])
alt_ft = ac.get("alt_baro")
if alt_ft is not None:
slant_km = math.hypot(horiz_km, alt_ft * 0.0003048)
else:
slant_km = horiz_km
return {
"hex": ac.get("hex", ""),
"callsign": (ac.get("flight") or "").strip().upper(),
"lat": ac["lat"],
"lon": ac["lon"],
"alt_ft": alt_ft,
"gs": ac.get("gs"),
"track": ac.get("track"),
"type": (ac.get("t") or "").strip(),
"reg": (ac.get("r") or "").strip(),
"horiz_km": horiz_km,
"slant_km": slant_km,
}
def _select(candidates, sel_state, now):
"""Pick the plane with the smallest slant range, with stickiness.
Refreshes the currently shown plane from fresh data each poll (so its
altitude/speed/position stay live), keeps it through a short miss grace when
it briefly drops out of the feed, and only switches to a different aircraft
when that one is clearly closer and the current one has been shown long
enough. sel_state = {plane, shown_at, last_seen_at, misses} or None. Returns
updated sel_state or None."""
# Refresh / age the current selection against the fresh candidate set.
if sel_state is not None:
cur_hex = sel_state["plane"]["hex"]
fresh = next((c for c in candidates if c["hex"] == cur_hex), None)
if fresh is not None:
sel_state = dict(sel_state, plane=fresh, last_seen_at=now, misses=0)
else:
misses = sel_state.get("misses", 0) + 1
if misses <= MISS_GRACE:
# Momentary drop from the feed: keep showing the last-known sample.
return dict(sel_state, misses=misses)
sel_state = None # gone for good, concede the screen
if not candidates:
return sel_state # None (idle) or conceded above
challenger = min(candidates, key=lambda p: p["slant_km"])
if sel_state is None:
# First plane to show.
return {"plane": challenger, "shown_at": now, "last_seen_at": now, "misses": 0}
current = sel_state["plane"]
# Switch only to a different aircraft that is clearly closer, after min dwell.
if (challenger["hex"] != current["hex"]
and challenger["slant_km"] < 0.8 * current["slant_km"]
and (now - sel_state["shown_at"]) >= MIN_DWELL_S):
return {"plane": challenger, "shown_at": now, "last_seen_at": now, "misses": 0}
return sel_state
def _get_route(callsign, now, fetch_fn):
"""Get a cached route or fetch it, with TTL and a network-call throttle.
Returns (route_dict or None, is_not_found). A cache hit never hits the
network. On a miss, the adsbdb call is throttled to ROUTE_NET_SPACING apart;
while throttled the route is reported as "pending" (None) without caching."""
global _last_route_net
if not callsign:
return None, False
# Prune the oldest (soonest-expiring) entry if the cache is full.
if len(ROUTE_CACHE) >= ROUTE_CACHE_MAX_ENTRIES:
oldest_key = min(ROUTE_CACHE, key=lambda k: ROUTE_CACHE[k][0], default=None)
if oldest_key is not None:
del ROUTE_CACHE[oldest_key]
# Cache hit (found or negative) still within TTL.
if callsign in ROUTE_CACHE:
expires, route_dict = ROUTE_CACHE[callsign]
if now < expires:
return route_dict, route_dict is None
del ROUTE_CACHE[callsign]
# Throttle network calls; report pending until the spacing has elapsed.
if now - _last_route_net < ROUTE_NET_SPACING:
return None, False
_last_route_net = now
try:
route_dict = fetch_fn(callsign)
ROUTE_CACHE[callsign] = (now + ROUTE_CACHE_FOUND, route_dict)
return route_dict, False
except RuntimeError as e:
if "NOT_FOUND" in str(e):
ROUTE_CACHE[callsign] = (now + ROUTE_CACHE_NOT_FOUND, None)
return None, True
# Network / transient error: short TTL so it recovers.
ROUTE_CACHE[callsign] = (now + ROUTE_CACHE_ERROR, None)
return None, False
# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------
def _fmt_alt(alt_ft, units="imperial"):
"""Format altitude for display (imperial: ft/FL, metric: m/km)."""
if alt_ft is None:
return "---"
if units == "metric":
# Always metres, never km, with a Dutch thousands separator: "10.000m".
return f"{round(alt_ft * 0.3048):,}".replace(",", ".") + "m"
if alt_ft >= 10000:
return "FL" + str(round(alt_ft / 100))
return f"{round(alt_ft)}ft"
def _fmt_speed(gs, units="imperial"):
"""Format ground speed for display (imperial: kt, metric: km/h)."""
if gs is None:
return "---"
if units == "metric":
return f"{round(gs * 1.852)}km/h"
return f"{round(gs)}kt"
def _fmt_ident(plane):
"""Format plane identity: callsign > reg > hex."""
if plane["callsign"]:
return plane["callsign"]
if plane["reg"]:
return plane["reg"]
return plane["hex"].upper()
def _fmt_route(route):
"""Format route as 'AMS>LHR', or None if unknown or a same-airport route.
adsbdb occasionally returns a flightroute whose origin and destination are the
same airport (round-trip / positioning flights, or an incomplete DB record).
"LTN>LTN" is meaningless on the bar, so treat it as unknown and let the frame
fall back to altitude/speed rather than draw a nonsensical route."""
if route is None:
return None
o_iata, o_icao = route.get("origin_iata", ""), route.get("origin_icao", "")
d_iata, d_icao = route.get("dest_iata", ""), route.get("dest_icao", "")
origin = o_iata or o_icao
dest = d_iata or d_icao
if not origin or not dest:
return None
if (o_iata and o_iata == d_iata) or (o_icao and o_icao == d_icao) or origin == dest:
return None
return f"{origin}>{dest}"
# ---------------------------------------------------------------------------
# Frame builder
# ---------------------------------------------------------------------------
def _build_frame(plane, route, tick, mode, units="imperial"):
"""Build display frame from plane data.
Returns list of element dicts."""
ident = _fmt_ident(plane)
alt_str = _fmt_alt(plane["alt_ft"], units)
speed_str = _fmt_speed(plane["gs"], units)
route_str = _fmt_route(route)
elements = []
# Line 1: callsign/ident on left, aircraft type on right (if available)
elements.append({
"id": "callsign", "type": "text", "text": ident, "x": 0, "y": Y_LINE1,
"font": "small", "color": BRIGHT, "align": "top_left",
})
if plane["type"]:
elements.append({
"id": "type", "type": "text", "text": plane["type"], "x": X_RIGHT, "y": Y_LINE1,
"font": "small", "color": DIM, "align": "top_right",
})
# Line 2: with a route, show it on the left and flap altitude<->speed on the
# right; without a route, show altitude and speed side by side.
if route_str:
left_text = route_str
right_text = alt_str if tick % 2 == 0 else speed_str
else:
left_text = alt_str
right_text = speed_str
elements.append({
"id": "l2left", "type": "text", "text": left_text, "x": 0, "y": Y_LINE2,
"font": "small", "color": BRIGHT, "align": "top_left",
})
elements.append({
"id": "l2right", "type": "text", "text": right_text, "x": X_RIGHT, "y": Y_LINE2,
"font": "small", "color": BRIGHT, "align": "top_right",
})
return elements
# ---------------------------------------------------------------------------
# State machine tick
# ---------------------------------------------------------------------------
def _tick(loop_state, aircraft, args, now, fetch_route_fn):
"""Process one poll cycle. Returns updated loop_state."""
state = loop_state["state"]
sel_state = loop_state["sel_state"]
last_draw_time = loop_state.get("last_draw_time", now)
shown_hexes = loop_state.get("shown_hexes", set()) # for arrival mode
suppressed_hexes = loop_state.get("suppressed_hexes", {}) # hex -> expiry time
last_error = loop_state.get("last_error")
failure_count = loop_state.get("failure_count", 0)
tick = loop_state.get("tick", 0)
# Prune suppressed hexes
suppressed_hexes = {h: t for h, t in suppressed_hexes.items() if t > now}
if aircraft is None:
# No fresh data
if state == STATE_SHOWING:
# Transition to HOLDING to keep showing last aircraft
print(f"step {tick}: data loss -> state=HOLDING")
loop_state["state"] = STATE_HOLDING
loop_state["holding_started_at"] = now
loop_state["failure_count"] = 0
loop_state["tick"] = tick + 1
return loop_state
if state == STATE_HOLDING:
# Check if hold period has expired
holding_duration = now - loop_state.get("holding_started_at", now)
if holding_duration > args.hold:
# Hold expired, go IDLE
if sel_state:
print(f"step {tick}: holding expired ({holding_duration:.1f}s > {args.hold}s) -> state=IDLE")
try:
_clear(args.host)
except Exception as e:
print(f"clear error: {e}")
loop_state["state"] = STATE_IDLE
loop_state["sel_state"] = None
# else stay HOLDING with last-known plane
loop_state["failure_count"] = 0
loop_state["tick"] = tick + 1
return loop_state
# IDLE: stay idle
loop_state["failure_count"] = 0
loop_state["tick"] = tick + 1
return loop_state
# Fresh data received
failure_count = 0
# Normalize aircraft
normalized = [_normalize(ac, args.lat, args.lon) for ac in aircraft]
normalized = [ac for ac in normalized if ac is not None]
# Apply mode-specific gates
if args.mode == "flyover":
gate_radius_km = args.radius * 1.852
normalized = [ac for ac in normalized if ac["horiz_km"] <= gate_radius_km and (ac["alt_ft"] is None or ac["alt_ft"] <= args.max_alt)]
elif args.mode == "arrival":
gate_radius_km = args.radius * 1.852
normalized = [ac for ac in normalized if ac["horiz_km"] <= gate_radius_km]
# ambient: no gate
# Select plane
new_sel_state = _select(normalized, sel_state, now)
cycle_ticks = max(1, round(args.cycle_secs / args.interval))
if args.mode == "ambient" and tick % cycle_ticks == 0:
# Ambient mode: cycle through planes periodically
if len(normalized) > 1 and sel_state:
idx = next((i for i, ac in enumerate(normalized) if ac["hex"] == sel_state["plane"]["hex"]), 0)
next_idx = (idx + 1) % len(normalized)
new_sel_state = {
"plane": normalized[next_idx],
"shown_at": now,
"last_seen_at": now,
}
if new_sel_state is None:
# No plane to show
if state != STATE_IDLE:
print(f"step {tick}: no candidate plane -> state=IDLE")
try:
_clear(args.host)
except Exception as e:
print(f"clear error: {e}")
loop_state["state"] = STATE_IDLE
loop_state["sel_state"] = None
loop_state["failure_count"] = failure_count
loop_state["tick"] = tick + 1
return loop_state
plane = new_sel_state["plane"]
# Enrich the selected plane's route (cached; network throttled inside).
route = None
if plane["callsign"]:
try:
route, _ = _get_route(plane["callsign"], now, fetch_route_fn)
except Exception as e:
msg = str(e)
if msg != last_error:
print(f"route fetch error: {e}")
last_error = msg
# adsbdb can hand back a stale/wrong route for the callsign; drop it when
# it doesn't fit the plane's real position/heading, so the bar shows
# altitude/speed instead of a wrong origin/destination.
if route is not None and not _route_plausible(plane, route):
msg = f"route {_fmt_route(route)} implausible for {_fmt_ident(plane)}, hiding"
if msg != last_error:
print(msg)
last_error = msg
route = None
# Check arrival mode suppression
plane_hex = plane["hex"]
if args.mode == "arrival":
if plane_hex in suppressed_hexes:
# Still suppressed, don't draw
loop_state["state"] = STATE_HOLDING
loop_state["sel_state"] = new_sel_state
loop_state["holding_started_at"] = now
loop_state["failure_count"] = failure_count
loop_state["suppressed_hexes"] = suppressed_hexes
loop_state["tick"] = tick + 1
return loop_state
if plane_hex not in shown_hexes:
# First time seeing this hex, show it
shown_hexes.add(plane_hex)
suppressed_hexes[plane_hex] = now + 15 * 60 # 15min suppression
# Draw the frame
frame = _build_frame(plane, route, tick, args.mode, args.units)
led_color = None
if state != STATE_SHOWING or sel_state["plane"]["hex"] != plane_hex:
# Newly showing or switched: fire LED
if args.mode != "ambient":
led_color = LED_COLOR
if state != STATE_SHOWING:
print(f"step {tick}: showing plane {_fmt_ident(plane)} -> state=SHOWING")
else:
print(f"step {tick}: switched to {_fmt_ident(plane)} -> state=SHOWING")
priority = 50 if args.mode == "ambient" else 60
try:
status, _ = _draw(args.host, frame, priority=priority, led_notification_color=led_color)
if status == 409:
msg = "display busy (409)"
if msg != last_error:
print(f"{msg}, continuing")
last_error = msg
else:
state = STATE_SHOWING
last_draw_time = now
except Exception as e:
msg = str(e)
if msg != last_error:
print(f"draw error: {e}")
last_error = msg
loop_state["state"] = state
loop_state["sel_state"] = new_sel_state
loop_state["last_draw_time"] = last_draw_time
loop_state["failure_count"] = failure_count
loop_state["last_error"] = last_error
loop_state["shown_hexes"] = shown_hexes
loop_state["suppressed_hexes"] = suppressed_hexes
loop_state["tick"] = tick + 1
return loop_state
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
def run_loop(args, fetch_aircraft_fn, fetch_route_fn, sleep_fn):
"""Main loop: fetch aircraft, select, draw, repeat."""
loop_state = {
"state": STATE_IDLE,
"sel_state": None,
"last_draw_time": time.time(),
"shown_hexes": set(),
"suppressed_hexes": {},
"last_error": None,
"failure_count": 0,
"tick": 0,
}
last_aircraft = []
last_aircraft_count = 0
while True:
now = time.time()
# Fetch aircraft
aircraft = None
try:
aircraft = fetch_aircraft_fn(args.lat, args.lon, args.radius)
last_aircraft = aircraft
last_aircraft_count = 0
except RuntimeError as e:
msg = str(e)
if "rate limit" in msg.lower():
# Back off on rate limit
print(f"rate limit, backing off")
sleep_fn(min(60, args.interval * (2 ** last_aircraft_count)))
last_aircraft_count += 1
aircraft = last_aircraft if last_aircraft_count <= 5 else None
else:
# Other error: keep last aircraft once
last_aircraft_count += 1
aircraft = last_aircraft if last_aircraft_count <= 5 else None
# Process one tick
loop_state = _tick(loop_state, aircraft, args, now, fetch_route_fn)
sleep_fn(args.interval)
def main():
args = parse_args()
# Install SIGTERM handler for Apps-tab Stop button
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
if args.test:
_run_test(args)
return
print(f"flightradar -> {_base(args.host)} lat={args.lat} lon={args.lon} mode={args.mode} (Ctrl-C to stop)")
try:
run_loop(args, _fetch_aircraft, _fetch_route, time.sleep)
except (KeyboardInterrupt, SystemExit):
pass
finally:
try:
_clear(args.host)
except Exception:
pass
# ---------------------------------------------------------------------------
# Test mode
# ---------------------------------------------------------------------------
class _TestState:
"""Container for test state."""
def __init__(self):
self.step = 0
def _run_test(args):
"""Run test with scripted aircraft data."""
test_state = _TestState()
def fake_fetch_aircraft_fn(lat, lon, radius_nm):
"""Simulate aircraft observations over multiple polls."""
step = test_state.step
test_state.step += 1
if step == 0:
# Empty: transition to IDLE
return []
elif step == 1:
# KLM1234 low and close, TRA5150 similar distance, a ground plane, blank-callsign
# Should show KLM1234 and fire LED
return [
{
"hex": "ABCD0001",
"flight": "KLM1234",
"lat": lat + 0.01,
"lon": lon + 0.01,
"alt_baro": 2400,
"gs": 180,
"track": 45,
"t": "B738",
"r": "PH-BGC",
},
{
"hex": "ABCD0002",
"flight": "TRA5150",
"lat": lat + 0.011,
"lon": lon + 0.011,
"alt_baro": 2500,
"gs": 185,
"track": 46,
"t": "A320",
"r": "N123AB",
},
{
"hex": "ABCD0003",
"flight": "GND001",
"lat": lat + 0.02,
"lon": lon + 0.02,
"alt_baro": "ground",
"gs": 10,
"track": 0,
"t": "C172",
"r": "PH-XYZ",
},
{
"hex": "ABCD0004",
"flight": "",
"lat": lat + 0.005,
"lon": lon + 0.005,
"alt_baro": 5000,
"gs": 200,
"track": 90,
"t": "B787",
"r": "N999ZZ",
},
]
elif step == 2:
# Same KLM1234, no TRA5150 (test near-tie hysteresis keeps KLM)
return [
{
"hex": "ABCD0001",
"flight": "KLM1234",
"lat": lat + 0.0101,
"lon": lon + 0.0101,
"alt_baro": 2410,
"gs": 180,
"track": 45,
"t": "B738",
"r": "PH-BGC",
},
]
elif step == 3:
# Missing from poll (test HOLDING with miss grace)
return []
elif step == 4:
# Back: KLM1234 reappears (test miss grace recovery)
return [
{
"hex": "ABCD0001",
"flight": "KLM1234",
"lat": lat + 0.0102,
"lon": lon + 0.0102,
"alt_baro": 2420,
"gs": 180,
"track": 45,
"t": "B738",
"r": "PH-BGC",
},
]
elif step == 5:
# Go far: triggers IDLE (alt becomes too high or horiz distance too far)
return [
{
"hex": "ABCD0001",
"flight": "KLM1234",
"lat": lat + 0.5,
"lon": lon + 0.5,
"alt_baro": 35000,
"gs": 450,
"track": 90,
"t": "B738",
"r": "PH-BGC",
},
]
elif step <= 8:
# A few empty polls: miss grace expires and the screen clears -> IDLE
return []
else:
# Script exhausted: end the test cleanly
raise SystemExit
def fake_fetch_route_fn(callsign):
"""Fake route lookups with caching."""
if callsign == "KLM1234":
return {
"origin_iata": "AMS",
"origin_icao": "EHAM",
"origin_city": "Amsterdam",
"dest_iata": "LHR",
"dest_icao": "EGLL",
"dest_city": "London",
}
elif callsign == "TRA5150":
raise RuntimeError("NOT_FOUND")
else:
raise RuntimeError("unknown route")
print(f"flightradar test -> {_base(args.host)} lat={args.lat} lon={args.lon} mode={args.mode}")
# Demonstrate route lookup + negative caching with spaced fake timestamps.
global _last_route_net
_last_route_net = 0.0
ROUTE_CACHE.clear()
print("route cache:")
r, nf = _get_route("KLM1234", 1000.0, fake_fetch_route_fn)
print(f" KLM1234 -> {_fmt_route(r)}")
r, nf = _get_route("KLM1234", 1000.2, fake_fetch_route_fn)
print(f" KLM1234 (cache hit) -> {_fmt_route(r)}")
r, nf = _get_route("TRA5150", 1002.0, fake_fetch_route_fn)
print(f" TRA5150 -> {_fmt_route(r)} not_found={nf}")
r, nf = _get_route("TRA5150", 1002.2, fake_fetch_route_fn)
print(f" TRA5150 (neg cache) -> {_fmt_route(r)} not_found={nf}")
_last_route_net = 0.0
ROUTE_CACHE.clear()
# Route plausibility: reject stale/wrong adsbdb routes that don't fit the
# plane's real position/heading; trust terminal traffic and correct routes.
print("route plausibility:")
AMS, FRA, BEG = (52.31, 4.76), (50.03, 8.57), (44.82, 20.29)
DUB, LGW, SPU = (53.42, -6.27), (51.15, -0.18), (43.54, 16.30)
AGP = (36.68, -4.49)
def _rt(o, d):
return {"origin_lat": o[0], "origin_lon": o[1],
"dest_lat": d[0], "dest_lon": d[1]}
plaus_cases = [
("overshoot BEG>FRA over AMS", dict(lat=52.4, lon=4.9, track=130, alt_ft=37000), _rt(BEG, FRA), False),
("wrong-way LGW>DUB heading east", dict(lat=52.4, lon=4.9, track=90, alt_ft=32000), _rt(LGW, DUB), False),
("correct FRA>AMS en route", dict(lat=51.0, lon=6.0, track=320, alt_ft=20000), _rt(FRA, AMS), True),
("terminal departure AMS>SPU", dict(lat=52.4, lon=4.75, track=2, alt_ft=4000), _rt(AMS, SPU), True),
# adsbdb labels this arrival AMS>AGP; the plane sits near AMS but flies
# toward it (arriving), so the wrong-endpoint route must be rejected.
("wrong-endpoint AMS>AGP while arriving AMS", dict(lat=52.37, lon=5.52, track=236, alt_ft=8775), _rt(AMS, AGP), False),
("no coords -> trusted", dict(lat=52.4, lon=4.9, track=90, alt_ft=30000), {}, True),
]
for name, plane, rte, expected in plaus_cases:
got = _route_plausible(plane, rte)
mark = "ok" if got == expected else "FAIL"
print(f" [{mark}] {name}: plausible={got} (expected {expected})")
assert got == expected, f"plausibility case failed: {name}"
# Route formatting: a same-airport route (round-trip / positioning flight, or
# bad adsbdb data) is hidden so the bar shows altitude/speed instead of "LTN>LTN".
print("route formatting:")
fmt_cases = [
("normal route", _fmt_route({"origin_iata": "AMS", "dest_iata": "LHR"}), "AMS>LHR"),
("same iata (LTN>LTN)", _fmt_route({"origin_iata": "LTN", "dest_iata": "LTN"}), None),
("same icao (EGGW>EGGW)", _fmt_route({"origin_icao": "EGGW", "dest_icao": "EGGW"}), None),
("icao fallback, different airports", _fmt_route({"origin_icao": "EHAM", "dest_icao": "EGLL"}), "EHAM>EGLL"),
("missing destination", _fmt_route({"origin_iata": "AMS"}), None),
]
for name, got, expected in fmt_cases:
mark = "ok" if got == expected else "FAIL"
print(f" [{mark}] {name}: {got} (expected {expected})")
assert got == expected, f"route format case failed: {name}"
# Ground filter: "ground" string and non-positive baro altitude are dropped;
# a genuinely airborne low plane is kept.
print("ground filter:")
ground_cases = [
("string ground", {"lat": 52.4, "lon": 4.8, "alt_baro": "ground"}, True),
("negative baro (-200ft)", {"lat": 52.4, "lon": 4.8, "alt_baro": -200}, True),
("zero baro", {"lat": 52.4, "lon": 4.8, "alt_baro": 0}, True),
("low but airborne (300ft)", {"lat": 52.4, "lon": 4.8, "alt_baro": 300}, False),
]
for name, ac, should_drop in ground_cases:
dropped = _normalize(ac, 52.37, 4.89) is None
mark = "ok" if dropped == should_drop else "FAIL"
print(f" [{mark}] {name}: dropped={dropped} (expected {should_drop})")
assert dropped == should_drop, f"ground case failed: {name}"
# Units: imperial (ft/kt, FL above 10k) vs metric (m/km, km/h).
print("units formatting:")
unit_cases = [
("imperial low alt", _fmt_alt(2400, "imperial"), "2400ft"),
("imperial flight level", _fmt_alt(37000, "imperial"), "FL370"),
("metric low alt", _fmt_alt(2400, "metric"), "732m"),
("metric high alt (always m)", _fmt_alt(37000, "metric"), "11.278m"),
("metric thousands sep", _fmt_alt(32808, "metric"), "10.000m"),
("imperial speed", _fmt_speed(450, "imperial"), "450kt"),
("metric speed", _fmt_speed(450, "metric"), "833km/h"),
]
for name, got, expected in unit_cases:
mark = "ok" if got == expected else "FAIL"
print(f" [{mark}] {name}: {got} (expected {expected})")
assert got == expected, f"units case failed: {name}"
try:
run_loop(args, fake_fetch_aircraft_fn, fake_fetch_route_fn, lambda s: time.sleep(0.6))
except (KeyboardInterrupt, SystemExit):
pass
finally:
try:
_clear(args.host)
except Exception:
pass
if __name__ == "__main__":
main()