A fresh-eyes adversarial pass over the 15 pulled commits (godrum +
keyroll + sats + planes cache + god's eye): 21 raised, 18 confirmed,
all fixed.
The instrument: .mid export no longer smuggles the seeded demo beat
(lanes export only if playing or edited off the seed — same test
zeroHasBuild uses); master ▶/■ remembers per-lane mutes; the beat
panel's bpm field is always editable like the dial (🔒 blocks the
world's hands, not the user's); ⏺ rec with the beat stopped anchors
step 0 at arm instead of the invisible free-running clock; note-length
drags speak pointer events (touch works, window-level so mid-drag
re-renders survive); the resize handle no longer eats 40% of a
sixteenth's click width; the god's eye clears a stale ISS trail after
an rAF nap or resize; the "Agent A" dev-process string is gone; the
grimoire now tells the truth about kit knobs and which voices hold
note length (manual regenerated).
The workers: world_sats — partial Celestrak fetches backfill from the
old cache instead of clobbering it, pass-detection state survives the
6-hourly refresh, the no-TLE exit naps 15 min so the supervisor can't
hammer Celestrak, and the venue default now matches world_sky's
Brisbane instead of Null Island; world_planes bounds its stale-cache
fallback to 1h so a days-old sky can't resurrect as live; world_sky's
docstring admits the lat/lon it emits. run.py retires workers that
exit 0 instead of reviving them every 10s (the sgp4-missing churn).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
206 lines
8.3 KiB
Python
206 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""A swarm of real aircraft over a patch of sky, turned into a drone.
|
|
OpenSky live states give a count (thickness), mean altitude (pitch/register),
|
|
and mean groundspeed (intensity) — the sky's traffic becomes a slow chord.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
from pythonosc.udp_client import SimpleUDPClient
|
|
|
|
NAME = "world_planes"
|
|
API = "https://opensky-network.org/api/states/all"
|
|
UA = "Godstrument/1.0 (live instrument; contact monsterrobotparty@gmail.com)"
|
|
|
|
# OpenSky anonymous budget is ~400 credits/day; a bbox query costs 2-4 credits,
|
|
# so <200 requests/day -> poll no faster than ~1 per 450s or the feed 429s out
|
|
# and freezes for the rest of the day. (Add OAuth2 creds for the 4000 tier.)
|
|
POLL_OK = 450.0 # normal poll interval (s)
|
|
POLL_429 = 600.0 # backoff poll interval after HTTP 429
|
|
REEMIT = 10.0 # re-emit last values every 10s
|
|
|
|
# --- OpenSky shared cache v1 (reader side) --------------------------------
|
|
# Path: ~/.cache/godverse/opensky-states.json, override via OPENSKY_CACHE_FILE.
|
|
# Content: the raw, unmodified OpenSky /states/all body (global, no bbox), written
|
|
# atomically by WHOEVER fetched it (GODSIGH's dev proxy is the writer). Freshness =
|
|
# file mtime. We treat the cache as fresh within 120 s; on a fresh hit we skip our
|
|
# own HTTP entirely and just filter the global states to our bbox, so a godstrument
|
|
# + GODSIGH running together spend ~one poller's quota instead of two. This worker
|
|
# only ever polls a bbox, so it never WRITES the cache (never partial data into a
|
|
# global-contract file) — it is a pure reader, and degrades to its old bbox fetch
|
|
# when no writer is around (i.e. prod).
|
|
CACHE_FILE = os.environ.get("OPENSKY_CACHE_FILE") or \
|
|
os.path.expanduser("~/.cache/godverse/opensky-states.json")
|
|
CACHE_FRESH = 120.0 # a cache within this many seconds of its mtime is "fresh"
|
|
|
|
|
|
def read_cache(max_age):
|
|
"""Return (data, age_seconds) from the shared cache, or None. If max_age is not
|
|
None, returns None when the cache is older than that (i.e. not fresh)."""
|
|
try:
|
|
mtime = os.path.getmtime(CACHE_FILE)
|
|
except OSError:
|
|
return None
|
|
age = time.time() - mtime
|
|
if max_age is not None and age > max_age:
|
|
return None
|
|
try:
|
|
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
|
return json.load(f), age
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
return None
|
|
|
|
|
|
def filter_states_bbox(data, bbox):
|
|
"""Filter a global states payload to our bbox client-side (the cache is global).
|
|
OpenSky state vectors: index 5 = longitude, index 6 = latitude."""
|
|
s, w, n, e = bbox
|
|
states = (data or {}).get("states") or []
|
|
out = []
|
|
for row in states:
|
|
if not row or len(row) < 7:
|
|
continue
|
|
lon, lat = row[5], row[6]
|
|
if lon is None or lat is None:
|
|
continue
|
|
try:
|
|
if s <= float(lat) <= n and w <= float(lon) <= e:
|
|
out.append(row)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return {"states": out}
|
|
|
|
|
|
def fetch(bbox, timeout=25):
|
|
"""Fetch OpenSky states for the bbox. Returns parsed JSON dict.
|
|
|
|
Raises urllib.error.HTTPError so the caller can detect 429.
|
|
"""
|
|
s, w, n, e = bbox
|
|
url = f"{API}?lamin={s}&lomin={w}&lamax={n}&lomax={e}"
|
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
|
|
|
|
def reduce_states(data):
|
|
"""Compute (count, mean_alt, mean_speed) from a states payload.
|
|
|
|
index 7 = geo_altitude (m, may be null), index 9 = velocity (m/s, may be null).
|
|
Nulls are ignored in the means. Returns floats; means are 0.0 when no data.
|
|
"""
|
|
states = (data or {}).get("states") or []
|
|
count = float(len(states))
|
|
alts = []
|
|
speeds = []
|
|
for row in states:
|
|
if not row:
|
|
continue
|
|
alt = row[7] if len(row) > 7 else None
|
|
spd = row[9] if len(row) > 9 else None
|
|
if alt is not None:
|
|
try:
|
|
alts.append(float(alt))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if spd is not None:
|
|
try:
|
|
speeds.append(float(spd))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
avgalt = sum(alts) / len(alts) if alts else 0.0
|
|
avgspeed = sum(speeds) / len(speeds) if speeds else 0.0
|
|
return count, avgalt, avgspeed
|
|
|
|
|
|
def parse_bbox(text):
|
|
parts = [p.strip() for p in text.split(",")]
|
|
if len(parts) != 4:
|
|
raise argparse.ArgumentTypeError('--bbox must be "s,w,n,e"')
|
|
try:
|
|
return tuple(float(p) for p in parts)
|
|
except ValueError:
|
|
raise argparse.ArgumentTypeError("--bbox values must be numbers")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="OpenSky aircraft swarm -> OSC drone")
|
|
ap.add_argument("--host", default="127.0.0.1")
|
|
ap.add_argument("--port", type=int, default=9000)
|
|
ap.add_argument("--bbox", type=parse_bbox, default=(45.0, 0.0, 52.0, 10.0),
|
|
help='Bounding box "s,w,n,e" (default Western Europe)')
|
|
args = ap.parse_args()
|
|
|
|
client = SimpleUDPClient(args.host, args.port)
|
|
poll = POLL_OK
|
|
print(f"[{NAME}] emitting /gs/planes/count,/gs/planes/avgalt,/gs/planes/avgspeed every {POLL_OK:.0f}s")
|
|
|
|
# Last known values, re-emitted every REEMIT seconds between polls.
|
|
last = None # (count, avgalt, avgspeed)
|
|
next_poll = 0.0
|
|
|
|
while True:
|
|
now = time.monotonic()
|
|
if now >= next_poll:
|
|
cached = read_cache(CACHE_FRESH) # a fresh global cache -> skip HTTP entirely
|
|
if cached is not None:
|
|
data, age = cached
|
|
last = reduce_states(filter_states_bbox(data, args.bbox))
|
|
if poll != POLL_OK:
|
|
print(f"[{NAME}] recovered; back to {POLL_OK:.0f}s poll")
|
|
poll = POLL_OK
|
|
print(f"[{NAME}] cache hit ({age:.0f}s old) -> {int(last[0])} aircraft in bbox")
|
|
else:
|
|
try:
|
|
data = fetch(args.bbox)
|
|
last = reduce_states(data)
|
|
if poll != POLL_OK:
|
|
print(f"[{NAME}] recovered; back to {POLL_OK:.0f}s poll")
|
|
poll = POLL_OK
|
|
except urllib.error.HTTPError as ex:
|
|
if ex.code == 429:
|
|
retry_after = ex.headers.get("X-Rate-Limit-Retry-After-Seconds")
|
|
try:
|
|
poll = max(POLL_429, float(retry_after))
|
|
except (TypeError, ValueError):
|
|
poll = POLL_429
|
|
print(f"[{NAME}] HTTP 429 rate limited; backing off to {poll:.0f}s")
|
|
else:
|
|
print(f"[{NAME}] HTTP {ex.code}; retrying in {poll:.0f}s")
|
|
_stale = read_cache(3600) # 429 -> a stale cache beats nothing,
|
|
if _stale is not None: # but a day-old sky beats neither
|
|
last = reduce_states(filter_states_bbox(_stale[0], args.bbox))
|
|
print(f"[{NAME}] serving stale cache ({_stale[1]:.0f}s old)")
|
|
except (urllib.error.URLError, TimeoutError, OSError,
|
|
json.JSONDecodeError, ValueError) as ex:
|
|
print(f"[{NAME}] fetch error: {ex}; retrying in {poll:.0f}s")
|
|
_stale = read_cache(3600) # network down -> fall back, bounded 1h
|
|
if _stale is not None:
|
|
last = reduce_states(filter_states_bbox(_stale[0], args.bbox))
|
|
print(f"[{NAME}] serving stale cache ({_stale[1]:.0f}s old)")
|
|
next_poll = time.monotonic() + poll
|
|
|
|
# Emit (or re-emit) the last known values.
|
|
if last is not None:
|
|
count, avgalt, avgspeed = last
|
|
try:
|
|
client.send_message("/gs/planes/count", float(count))
|
|
client.send_message("/gs/planes/avgalt", float(avgalt))
|
|
client.send_message("/gs/planes/avgspeed", float(avgspeed))
|
|
except OSError as ex:
|
|
print(f"[{NAME}] OSC send error: {ex}")
|
|
|
|
time.sleep(REEMIT)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
sys.exit(0)
|