236 lines
10 KiB
Python
236 lines
10 KiB
Python
"""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://<this-PC-Wi-Fi-IPv4>: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)
|