ISS Alert
Stays quiet until the space station passes near you. Then a pseudo 3D ISS slides across a twinkling starfield, hangs overhead with live distance, and drifts off the far edge as it leaves.
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
"""ISS alert: stays quiet until the space station passes near you, then counts it down.
python3 app.py [--host 127.0.0.1:8080] [--lat 52.37] [--lon 4.89] [--test]
"""
import argparse
import json
import math
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
APP = "iss-alert"
ISS_URL = "https://api.wheretheiss.at/v1/satellites/25544"
# State thresholds (km)
FAR = 1500
NEAR = 700
def parse_args():
p = argparse.ArgumentParser(description="ISS alert for BUSY Bar")
p.add_argument("--host", default="10.0.4.20")
p.add_argument("--lat", type=float, default=52.37)
p.add_argument("--lon", type=float, default=4.89)
p.add_argument("--interval", type=int, default=30, help="seconds between position checks (default: 30)")
p.add_argument("--test", action="store_true")
return p.parse_args()
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def _base(host):
host = host.replace("http://", "").replace("https://", "").rstrip("/")
return "http://" + host
def _draw(host, elements, led_notification_color=None):
"""POST /api/display/draw. Returns (status_code, body_text)."""
body = {
"application_name": APP,
"priority": 60,
"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
def _fetch_iss():
"""Fetch ISS position from wheretheiss.at. Returns dict with latitude, longitude, altitude, velocity."""
req = urllib.request.Request(ISS_URL, headers={"User-Agent": "busybar-iss-alert/1.0"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read().decode("utf-8", "ignore"))
# ---------------------------------------------------------------------------
# Haversine distance
# ---------------------------------------------------------------------------
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))
# ---------------------------------------------------------------------------
# Display elements
# ---------------------------------------------------------------------------
def _rect(el_id, x, y, w, h, color):
return {"id": str(el_id), "type": "rectangle", "x": x, "y": y, "width": w, "height": h,
"border_width": 0, "fill": "solid", "fill_colors": [color]}
# Starfield in the sky band (rows 0-7): (x, y, tier) with tier 0 = bright,
# 1 = mid, 2 = dim. Stars twinkle one tier brighter in turn on each redraw,
# and a plus-shaped glint wanders between a few fixed spots.
STARS = [(3, 6, 2), (6, 1, 1), (11, 3, 2), (15, 6, 1), (19, 1, 2), (24, 4, 0),
(29, 7, 2), (33, 0, 1), (38, 3, 2), (43, 6, 1), (47, 1, 2), (51, 5, 0),
(55, 2, 2), (59, 7, 1), (63, 0, 2), (66, 4, 1), (69, 2, 2), (70, 6, 0)]
STAR_COLORS = ["#FFFFFFFF", "#969696FF", "#505050FF"]
GLINTS = [(8, 2), (62, 5), (26, 1), (55, 3)]
def _starfield(tick=0):
els = []
for i, (x, y, tier) in enumerate(STARS):
t = max(0, tier - (1 if (i + tick) % 4 == 0 else 0))
els.append(_rect(f"star{i}", x, y, 1, 1, STAR_COLORS[t]))
gx, gy = GLINTS[tick % len(GLINTS)]
els.append(_rect("glint_h", gx - 1, gy, 3, 1, "#787878FF"))
els.append(_rect("glint_v", gx, gy - 1, 1, 3, "#787878FF"))
els.append(_rect("glint_c", gx, gy, 1, 1, "#FFFFFFFF"))
return els
def _iss_sprite(x0):
"""Pseudo-3D ISS, ~22x7 at offset x0: four slanted solar arrays with a
lit top and shaded bottom half, a truss with a drop shadow, and a module
stack with a lit side, a shadow side and cyan docking ports."""
light = "#FFC832FF"
dark = "#B46400FF"
white = "#FFFFFFFF"
gray = "#8C8C8CFF"
cyan = "#00E5FFFF"
els = []
for px in (0, 3, 15, 18):
for r in range(7):
off = (6 - r) // 3 # rows shift right toward the top: slanted panels
els.append(_rect(f"panel_{px}_{r}", x0 + px + off, r, 2, 1, light if r < 3 else dark))
els.append(_rect("truss", x0 + 2, 3, 17, 1, white)) # truss
els.append(_rect("truss_shadow", x0 + 3, 4, 15, 1, "#505050FF")) # truss drop shadow
els.append(_rect("module_lit", x0 + 9, 1, 2, 5, white)) # module stack, lit side
els.append(_rect("module_shadow", x0 + 11, 1, 1, 5, gray)) # module stack, shadow side
els.append(_rect("dock_top", x0 + 10, 0, 1, 1, cyan)) # docking port top
els.append(_rect("dock_bottom", x0 + 10, 6, 1, 1, cyan)) # docking port bottom
return els
def _iss_x(distance):
"""Slide the sprite toward the center as the ISS approaches:
1500 km = far left, 700 km or less = centered."""
frac = max(0.0, min(1.0, (FAR - distance) / float(FAR - NEAR)))
return int(2 + frac * (25 - 2))
def _iss_x_depart(distance):
"""Continue from the center off the right edge as the ISS recedes:
700 km = centered, 1500 km = fully off screen."""
frac = max(0.0, min(1.0, (distance - NEAR) / float(FAR - NEAR)))
return int(25 + frac * (72 - 25))
def _build_approach(distance, tick=0):
"""Approach: twinkling starfield, ISS sliding in from the left, one info line."""
elements = _starfield(tick)
elements += _iss_sprite(_iss_x(distance))
elements.append({
"id": "info_text",
"type": "text", "text": f"ISS {int(distance)} KM",
"x": 36, "y": 9, "font": "small",
"color": "#FFFFFFFF", "align": "top_mid",
})
return elements
def _build_depart(distance, tick=0):
"""Departing: the ISS slides on toward the right edge and off the screen."""
elements = _starfield(tick)
elements += _iss_sprite(_iss_x_depart(distance))
elements.append({
"id": "info_text",
"type": "text", "text": f"ISS {int(distance)} KM",
"x": 36, "y": 9, "font": "small",
"color": "#FFFFFFFF", "align": "top_mid",
})
return elements
def _build_overhead(distance, velocity, tick=0):
"""Overhead: twinkling starfield, ISS centered, bright cyan info line."""
elements = _starfield(tick)
elements += _iss_sprite(25)
elements.append({
"id": "info_text",
"type": "text", "text": f"OVERHEAD {int(distance)} KM",
"x": 36, "y": 9, "font": "small",
"color": "#00E5FFFF", "align": "top_mid",
})
return elements
# ---------------------------------------------------------------------------
# State classification
# ---------------------------------------------------------------------------
STATE_FAR = "far"
STATE_APPROACH = "approach"
STATE_OVERHEAD = "overhead"
STATE_DEPART = "departing"
def _classify(distance, prev_distance):
"""Determine display state from distance and whether it is shrinking."""
if distance <= NEAR:
return STATE_OVERHEAD
if distance <= FAR:
if prev_distance is None or distance < prev_distance:
return STATE_APPROACH
return STATE_DEPART
return STATE_FAR
# ---------------------------------------------------------------------------
# Test mode
# ---------------------------------------------------------------------------
def _run_test(host):
"""Simulate a pass with fake distances and the real draw logic."""
fake_distances = [1800, 1400, 1100, 850, 600, 400, 650, 900, 1600]
fake_velocity = 27600.0 # km/h, representative ISS speed
prev_distance = None
on_screen = False
led_notified = False
prev_state = None
for i, distance in enumerate(fake_distances):
state = _classify(distance, prev_distance)
if state != prev_state:
print(f"step {i+1}: distance={distance} km -> state={state}")
if state == STATE_FAR:
if on_screen:
try:
_clear(host)
on_screen = False
led_notified = False
print("ISS far away, screen released")
except Exception as e:
print(f"clear error: {e}")
elif state in (STATE_APPROACH, STATE_DEPART):
build = _build_approach if state == STATE_APPROACH else _build_depart
elements = build(distance, tick=i)
try:
status, _ = _draw(host, elements)
if status == 409:
print(f"display busy (409), continuing")
else:
on_screen = True
except Exception as e:
print(f"draw error: {e}")
elif state == STATE_OVERHEAD:
elements = _build_overhead(distance, fake_velocity, tick=i)
notify_color = None
if not led_notified:
notify_color = "#00A8FFFF"
led_notified = True
try:
status, _ = _draw(host, elements, led_notification_color=notify_color)
if status == 409:
print(f"display busy (409), continuing")
else:
on_screen = True
except Exception as e:
print(f"draw error: {e}")
prev_state = state
prev_distance = distance
time.sleep(0.5)
# Cleanup at end
if on_screen:
try:
_clear(host)
print("ISS far away, screen released")
except Exception as e:
print(f"clear error: {e}")
sys.exit(0)
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
def main():
args = parse_args()
if args.test:
_run_test(args.host)
return # _run_test calls sys.exit(0)
print(f"iss-alert -> {_base(args.host)} lat={args.lat} lon={args.lon} (Ctrl-C to stop)")
prev_distance = None
on_screen = False
led_notified = False # True after first overhead LED notification per pass
last_err_msg = None # avoid printing the same fetch error repeatedly
prev_state = None
tick = 0 # advances the star twinkle every redraw
try:
while True:
# Fetch ISS position
iss = None
try:
iss = _fetch_iss()
last_err_msg = None
except Exception as e:
msg = str(e)
if msg != last_err_msg:
print(f"fetch error (reusing previous sample): {e}")
last_err_msg = msg
if iss is not None:
distance = _haversine(args.lat, args.lon,
iss["latitude"], iss["longitude"])
velocity = iss.get("velocity", 27600.0)
else:
# No fresh data -- keep previous distance to avoid false state changes
if prev_distance is None:
time.sleep(args.interval)
continue
distance = prev_distance
velocity = 27600.0
state = _classify(distance, prev_distance)
if state != prev_state:
print(f"state change: {prev_state} -> {state} (distance={int(distance)} km)")
if state == STATE_FAR:
if on_screen:
try:
_clear(args.host)
on_screen = False
led_notified = False
print("ISS far away, screen released")
except Exception as e:
print(f"clear error: {e}")
elif state in (STATE_APPROACH, STATE_DEPART):
build = _build_approach if state == STATE_APPROACH else _build_depart
elements = build(distance, tick=tick)
try:
status, _ = _draw(args.host, elements)
if status == 409:
print("display busy (409), continuing")
else:
on_screen = True
except Exception as e:
print(f"draw error: {e}")
elif state == STATE_OVERHEAD:
elements = _build_overhead(distance, velocity, tick=tick)
notify_color = None
if not led_notified:
notify_color = "#00A8FFFF"
led_notified = True
try:
status, _ = _draw(args.host, elements, led_notification_color=notify_color)
if status == 409:
print("display busy (409), continuing")
else:
on_screen = True
except Exception as e:
print(f"draw error: {e}")
# When pass ends (drops back to far), reset so next pass notifies again
if state == STATE_FAR and prev_state in (STATE_APPROACH, STATE_OVERHEAD, STATE_DEPART):
led_notified = False
prev_state = state
prev_distance = distance
tick += 1
time.sleep(args.interval)
except KeyboardInterrupt:
print("\nstopped.")
try:
_clear(args.host)
except Exception:
pass
if __name__ == "__main__":
main()