"""Local-only Glitch lab orchestration; never contacts a Pi or Odin.""" import argparse import json import os from pathlib import Path import shutil import socket import subprocess import sys import time import urllib.error import urllib.request import uuid ROOT = Path(__file__).resolve().parent BASE = "http://127.0.0.1:18766" TOOLS = ("cargo", "flutter", "nats-server") PORTS = (14222, 18766, 18767) def preflight(): missing = [] for name in TOOLS: path = shutil.which(name) print(f"{name}: {path or 'MISSING - install and add to PATH'}") if not path: missing.append(name) if missing: print("See README.md prerequisites. Nothing was started.") return False return True def run_checked(args, cwd=ROOT): # Flutter ships as a batch file on Windows; cmd is used solely for its fixed CLI. if os.name == "nt" and str(args[0]).lower().endswith((".bat", ".cmd")): args = ["cmd.exe", "/d", "/c", *args] subprocess.run(args, cwd=cwd, check=True) def api(path, body=None, base=BASE): data = None if body is None else json.dumps(body).encode() request = urllib.request.Request(base + path, data=data, headers={"Content-Type": "application/json"}) with urllib.request.urlopen(request, timeout=5) as response: return json.load(response) def command(action, session, command_id=None): return {"id": command_id or str(uuid.uuid4()), "session": session, "expires_ms": int(time.time() * 1000) + 10000, "action": action} def smoke(): state = api("/state")["state"] assert state["simulated"] is True, "Refusing to test a real cabinet" session = state["session"] def send(action): return api("/command", command(action, session)) assert send("reset")["accepted"] assert not send("start")["accepted"], "Start must require power and credit" power = command("power_on", session) assert api("/command", power)["state"]["phase"] == "booting" assert api("/command", power)["duplicate"], "Duplicate command must be acknowledged" assert not send("scan")["accepted"], "Boot-period scans must not grant credit" deadline = time.monotonic() + 6 while time.monotonic() < deadline: state = api("/state")["state"] if state["phase"] == "ready": break time.sleep(0.1) assert state["phase"] == "ready" and state["credits"] == 0 assert not send("start")["accepted"] scan = command("scan", session) assert api("/command", scan)["state"]["credits"] == 1 replay = api("/command", scan) assert replay["duplicate"] and replay["state"]["scans"] == 1 assert send("start")["state"]["phase"] == "playing" assert send("win")["state"]["phase"] == "won" assert not api("/command", command("reset", "stale-session"))["accepted"] expired = command("reset", session) expired["expires_ms"] = 1 assert not api("/command", expired)["accepted"] assert send("reset")["state"]["phase"] == "powered_off" print("PASS: real HTTP -> Rust -> NATS -> MessagePack -> simulated cabinet flow") def build(): run_checked([shutil.which("cargo"), "test"]) run_checked([shutil.which("cargo"), "build"]) flutter = shutil.which("flutter") run_checked([flutter, "pub", "get"], ROOT / "gm") run_checked([flutter, "analyze"], ROOT / "gm") run_checked([flutter, "test"], ROOT / "gm") run_checked([flutter, "build", "web", "--release"], ROOT / "gm") def start(godot=False, room=False, speech=False, lan=False, speech_output="browser", speech_backend="chatterbox"): if speech: import teddy_speech if not room: raise RuntimeError("--speech requires start-room") if speech_backend == "chatterbox": from chatterbox_client import REFERENCE if not REFERENCE.exists(): raise RuntimeError("Missing Teddy Chatterbox reference; see TEDDY_TRAINING.md") if speech_backend == "piper" and (not teddy_speech.python_path().exists() or not teddy_speech.MODEL.exists()): raise RuntimeError("Install the lab voice first: py -3 lab.py setup-speech") # Fail before launching anything if another process owns a lab port. ports = (14232, 18776, 18777) if room else PORTS check_ports = (*ports, 18768) if godot else ports if speech: check_ports = (*check_ports, 18778) for port in check_ports: with socket.socket() as sock: sock.bind(("127.0.0.1", port)) binary = ROOT / "target" / "debug" / ("glitch-odin-lab.exe" if os.name == "nt" else "glitch-odin-lab") if not binary.exists() or not (ROOT / "gm/build/web/index.html").exists(): raise RuntimeError("Build the lab first: python lab.py build") processes = [] def spawn(args): flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 process = subprocess.Popen(args, cwd=ROOT, creationflags=flags) processes.append(process) return process try: broker = spawn([shutil.which("nats-server"), "-c", str(ROOT / ("room-nats.conf" if room else "nats.conf"))]) deadline = time.monotonic() + 5 while True: if broker.poll() is not None: raise RuntimeError("NATS exited; see its output above") try: with socket.create_connection(("127.0.0.1", ports[0]), timeout=0.2): break except OSError: if time.monotonic() >= deadline: raise RuntimeError("NATS did not become ready") time.sleep(0.1) if room: spawn([str(binary), "room"]) elif godot: executable = ROOT.parents[1] / "Godot_v4.7.2-stable_win64_console.exe" godot_path = os.environ.get("GLITCH_LAB_GODOT") or str(executable) if not Path(godot_path).is_file(): raise RuntimeError("Set GLITCH_LAB_GODOT to the Godot executable path") spawn([godot_path, "--path", str(ROOT.parents[1]), "--windowed", "res://tests/odin_lab/cabinet.tscn", "--", "--glitch-lab", "--lab-inject-rfid"]) spawn([str(binary), "godot"]) else: spawn([str(binary), "cabinet"]) if not room: spawn([str(binary), "controller"]) if room: spawn([sys.executable, str(ROOT / "room_web.py"), "--bind", "0.0.0.0" if lan else "127.0.0.1"]) else: spawn([sys.executable, "-m", "http.server", str(ports[2]), "--bind", "127.0.0.1", "--directory", str(ROOT / "gm/build/web")]) deadline = time.monotonic() + 10 while True: if any(p.poll() is not None for p in processes): raise RuntimeError("A lab process exited during startup") try: api("/room/state" if room else "/state", base=f"http://127.0.0.1:{ports[1]}") break except (OSError, ValueError): if time.monotonic() >= deadline: raise RuntimeError("Controller/cabinet did not become ready") time.sleep(0.2) if speech: spawn([sys.executable, str(ROOT / "teddy_speech.py"), "follow", "--backend", speech_backend, "--output-device", speech_output]) deadline = time.monotonic() + 180 while True: if any(p.poll() is not None for p in processes): raise RuntimeError("Teddy speech failed to start") try: health = api("/health", base="http://127.0.0.1:18778") if health.get("status") == "error": raise RuntimeError(health.get("message", "Teddy failed to load")) if health.get("status") != "starting": break if time.monotonic() >= deadline: raise RuntimeError("Teddy model loading timed out; see .speech/chatterbox.log") time.sleep(.2) except (OSError, ValueError): if time.monotonic() >= deadline: raise RuntimeError("Teddy speech did not become ready") time.sleep(0.2) url = f"http://127.0.0.1:{ports[2]}/" + ("?room=1" if room else "") print(f"READY: {url} - press Ctrl+C to stop all lab processes", flush=True) if lan: print("LAN DEMO: open http://:18777/?room=1 on your phone. Shared simulation controls; enable browser audio on each listening device.", flush=True) while True: if any(p.poll() is not None for p in processes): raise RuntimeError("A lab process exited; stopping this lab") time.sleep(0.5) finally: for process in reversed(processes): if process.poll() is None: process.terminate() for process in processes: try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait() def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("mode", choices=("preflight", "build", "start", "start-godot", "start-room", "setup-speech", "smoke")) parser.add_argument("--speech", action="store_true", help="Play Teddy cues using Chatterbox (default) or Piper") parser.add_argument("--lan", action="store_true", help="Expose only the room demo webpage/gateway to the LAN") parser.add_argument("--speech-backend", choices=("chatterbox", "piper"), default="chatterbox") parser.add_argument("--speech-output", choices=("browser","local"), default="browser") args = parser.parse_args() mode = args.mode if args.lan and mode != "start-room": parser.error("--lan is only supported with start-room") if args.speech and mode != "start-room": parser.error("--speech is only supported with start-room") if mode == "setup-speech": import teddy_speech teddy_speech.setup() return 0 if mode == "smoke": smoke() return 0 if not preflight(): return 1 if mode == "build": build() elif mode in ("start", "start-godot", "start-room"): start(godot=mode == "start-godot", room=mode == "start-room", speech=args.speech, lan=args.lan, speech_output=args.speech_output, speech_backend=args.speech_backend) return 0 if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: print("\nLab stopped.") except Exception as error: print(f"ERROR: {error}", file=sys.stderr) sys.exit(1)