""" 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()