Godstrument/run.py
type-two ea572e8493 🏟 the stadium — the world at play (opt-in)
TheSportsDB's free tier, invited with run.py --sports and off otherwise
(a shared, rate-limited key — 30/min — so the worker is polite by
design: the day's fixtures in a few calls per hour, everything else
computed at home). sport.events_today counts the planet's fixtures,
sport.inplay is the stadium lights climbing through the evenings, and
sport.kickoff fires the instant any match begins — swelling when many
kick off together. Routed where play belongs: Netzach the Victory,
Leo's fifth house of games, Lughnasadh's funeral games, wunjo the
joy-rune, dui the joyous lake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:43:50 +10:00

230 lines
7.9 KiB
Python

"""
run.py — light the whole thing up with one command.
Spawns the hub plus a profile of workers as subprocesses, opens the visualizer
in your browser, and shuts everything down cleanly on Ctrl-C.
python run.py # 'core' profile: hub + sim + all world feeds
python run.py --profile all # + optional mic / webcam / MIDI workers
python run.py --profile world # world data only (no simulated sensors)
python run.py --profile minimal
python run.py --no-browser
Everything a worker emits shows up automatically — add your own worker to
workers/ and to a profile below.
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import time
import webbrowser
HERE = os.path.dirname(os.path.abspath(__file__))
PY = sys.executable # the venv python when launched via ./.venv/bin/python
WORLD = [
"workers/world_quakes.py",
"workers/world_weather.py",
"workers/world_air.py",
"workers/world_sun.py",
"workers/world_iss.py",
"workers/world_crypto.py",
"workers/world_fx.py",
"workers/world_planes.py",
"workers/world_wiki.py",
"workers/world_sky.py",
"workers/world_clock.py",
"workers/world_ephemeris.py",
"workers/world_almanac.py",
"workers/world_debt.py",
"workers/world_econ.py",
"workers/world_bank.py",
"workers/world_zodiac.py",
"workers/world_fire.py",
]
# workers that retarget to the venue city
LATLON_WORKERS = {"world_weather.py", "world_air.py", "world_sky.py", "replay_weather.py"}
BBOX_WORKERS = {"world_planes.py"}
def load_location(args):
"""Resolve a venue location from --city (cities.json) or explicit lat/lon/bbox."""
loc = {}
if args.city:
try:
with open(os.path.join(HERE, "cities.json")) as f:
cities = json.load(f)
except OSError:
cities = {}
key = args.city.lower().replace(" ", "")
city = cities.get(key)
if not city:
matches = [k for k in cities if k.startswith("_") is False and key in k]
city = cities.get(matches[0]) if matches else None
if city:
loc = {"lat": city["lat"], "lon": city["lon"],
"bbox": city.get("bbox"), "label": city.get("label", args.city)}
print(f" venue: {loc['label']} ({loc['lat']}, {loc['lon']})")
else:
print(f" ! unknown city '{args.city}' — using worker defaults")
if args.lat is not None and args.lon is not None:
loc["lat"], loc["lon"] = args.lat, args.lon
if args.bbox:
loc["bbox"] = [float(x) for x in args.bbox.split(",")]
return loc
def worker_args(path: str, loc: dict) -> list[str]:
name = os.path.basename(path)
extra: list[str] = []
if loc.get("lat") is not None and name in LATLON_WORKERS:
extra += ["--lat", str(loc["lat"]), "--lon", str(loc["lon"])]
if loc.get("bbox") and name in BBOX_WORKERS:
extra += ["--bbox", ",".join(str(x) for x in loc["bbox"])]
return extra
SIM = ["workers/sim_esp32.py"]
OPTIONAL = [
"workers/audio_worker.py",
"workers/vision_worker.py",
"workers/midi_worker.py",
]
REPLAY = ["workers/replay_quakes.py", "workers/replay_weather.py"]
# warp profile swaps live quakes+weather for their time-warped replay versions
_swapped = [w for w in WORLD
if os.path.basename(w) not in ("world_quakes.py", "world_weather.py")]
PROFILES = {
"minimal": SIM,
"world": WORLD,
"core": SIM + WORLD,
"all": SIM + WORLD + OPTIONAL,
"warp": SIM + REPLAY + _swapped,
}
def launch(path: str, extra: list[str] | None = None) -> subprocess.Popen | None:
full = os.path.join(HERE, path)
if not os.path.exists(full):
print(f" (skip {path} — not built yet)")
return None
return subprocess.Popen([PY, full] + (extra or []), cwd=HERE)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--profile", default="core", choices=list(PROFILES))
ap.add_argument("--config", default="config.json")
ap.add_argument("--city", help="venue city (see cities.json), e.g. melbourne")
ap.add_argument("--lat", type=float, help="override venue latitude")
ap.add_argument("--lon", type=float, help="override venue longitude")
ap.add_argument("--bbox", help='override aircraft bbox "s,w,n,e"')
ap.add_argument("--record", action="store_true",
help="also record everything to godstrument.db")
ap.add_argument("--dealgod", action="store_true",
help="also run the dealgod market worker (reads the warehouse)")
ap.add_argument("--sports", action="store_true",
help="also run the sports worker (TheSportsDB free tier — rate-limited shared key, so opt-in)")
ap.add_argument("--no-browser", action="store_true")
args = ap.parse_args()
procs: list[subprocess.Popen] = []
print("\n waking the godstrument...\n")
loc = load_location(args)
try:
with open(os.path.join(HERE, args.config)) as f:
http_port = json.load(f).get("http_port", 8088)
except OSError:
http_port = 8088
hub = subprocess.Popen([PY, os.path.join(HERE, "hub.py"),
"--config", os.path.join(HERE, args.config)],
cwd=HERE)
procs.append(hub)
time.sleep(2.0) # let the hub bind its ports
workers: list[dict] = [] # supervised: relaunched if they die mid-set
def start_worker(path, extra=None):
p = launch(path, extra)
if p:
procs.append(p)
workers.append({"path": path, "extra": extra, "proc": p, "next_ok": 0.0})
return p
for path in PROFILES[args.profile]:
start_worker(path, worker_args(path, loc))
time.sleep(0.15)
if args.dealgod:
if start_worker("workers/world_dealgod.py"):
print(" dealgod market worker -> reading the warehouse")
if args.sports:
if start_worker("workers/world_sport.py"):
print(" sports worker -> the world's fixtures (thesportsdb, opt-in)")
if args.record:
if start_worker("recorder.py"):
print(" recording -> godstrument.db")
url = f"http://localhost:{http_port}"
print(f"\n the console is live -> {url}")
print(" (Ctrl-C to put it back to sleep)\n")
if not args.no_browser:
try:
webbrowser.open(url)
except Exception:
pass
# so `kill` / systemd stop (SIGTERM) runs the finally block instead of
# orphaning the hub + ~20 workers with their ports still bound
def _on_sigterm(*_):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, _on_sigterm)
try:
while True:
if hub.poll() is not None:
print(" hub exited; shutting down.")
break
now = time.time()
for w in workers: # revive any worker that died
if w["proc"].poll() is not None and now >= w["next_ok"]:
print(f" ({os.path.basename(w['path'])} died; restarting)")
np = launch(w["path"], w["extra"])
if np:
try: # swap the dead handle out,
procs[procs.index(w["proc"])] = np # don't leak it
except ValueError:
procs.append(np)
w["proc"] = np
w["next_ok"] = now + 10.0 # rate-limit restart storms
time.sleep(0.5)
except KeyboardInterrupt:
print("\n godstrument sleeps.")
finally:
for p in procs:
try:
p.terminate()
except Exception:
pass
for p in procs:
try:
p.wait(timeout=3)
except Exception:
try:
p.kill()
except Exception:
pass
if __name__ == "__main__":
main()