- Add a four-second arcade-style boot sequence followed by the Character / Nickname introduction. - Separate cabinet power-on from RFID credit and player Start. - Add operator Power on control and cabinet power-state reporting. - Prevent repeated power commands from restarting boot or gameplay. - Ignore RFID scans received before the cabinet is ready. - Add power-state, RFID, controller, and GPIO feedback regression tests. - Expand the master walkthrough with export, packaging, file transfer, fresh-Pi setup, RFID compilation, and end-to-end testing instructions. - Correct tester hardware documentation: BCM23/J20 drives an LED; physical buttons connect through a separate I2C1 input board. - Document operator-triggered power-on as the current test workflow and retain the temporary 2.4 configuration compatibility notes. - Record successful physical testing of power-on, RFID credit, and player Start. - Add an Odin integration README describing the proposed Rust/NATS bridge, shared messages, Flutter GM interface, autopilot sequencing, hardware ownership, and Starfish SSH-management role. - Define migration milestones and information needed from the framework owner; link the integration guide from the root README. Validation: 69 controller tests, 10 GPIO tests, Pac-Man power/RFID checks, managed-cabinet lifecycle checks, and visual screen inspection.
579 lines
33 KiB
Python
579 lines
33 KiB
Python
#!/usr/bin/env python3
|
||
"""Arcade room controller and browser operator; Python standard library only."""
|
||
import argparse
|
||
import hmac
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
from pathlib import Path
|
||
import sqlite3
|
||
import time
|
||
import uuid
|
||
from urllib.parse import urlparse
|
||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||
|
||
|
||
def hub_config(path=None):
|
||
default = dict(name="Escape Gallery", room_name="Glitch: the Dark Machine", services=[])
|
||
if path is None:
|
||
return default
|
||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||
if not isinstance(data, dict):
|
||
raise ValueError("Hub configuration must be an object")
|
||
for key in ("name", "room_name"):
|
||
value = data.get(key, default[key])
|
||
if not isinstance(value, str) or not 1 <= len(value.strip()) <= 80:
|
||
raise ValueError("Invalid hub " + key)
|
||
default[key] = value.strip()
|
||
entries = data.get("services", [])
|
||
if not isinstance(entries, list) or len(entries) > 40:
|
||
raise ValueError("Hub services must be a list of up to 40 entries")
|
||
seen = set()
|
||
for entry in entries:
|
||
if not isinstance(entry, dict): raise ValueError("Invalid hub service")
|
||
ident, name, kind, url = (entry.get(k, "") for k in ("id", "name", "kind", "url"))
|
||
if not isinstance(ident, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", ident) or ident in seen:
|
||
raise ValueError("Invalid or duplicate hub service ID")
|
||
seen.add(ident)
|
||
if not isinstance(name, str) or not 1 <= len(name) <= 80 or kind not in ("room", "trivia", "service"):
|
||
raise ValueError("Invalid hub service name or kind")
|
||
if not isinstance(url, str): raise ValueError("Invalid hub service URL")
|
||
parsed = urlparse(url)
|
||
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password or parsed.fragment or parsed.query or any(c.isspace() or ord(c) < 32 for c in url) or "\\" in url:
|
||
raise ValueError("Use an HTTP(S) service URL without credentials, query or fragment")
|
||
try: parsed.port
|
||
except ValueError: raise ValueError("Invalid hub service port")
|
||
description = entry.get("description", "")
|
||
if not isinstance(description, str) or len(description) > 240: raise ValueError("Invalid service description")
|
||
default["services"].append(dict(id=ident, name=name, kind=kind, url=url, description=description))
|
||
return default
|
||
|
||
|
||
class Room:
|
||
def __init__(self, database, clock=time.time, cabinet_id="cabinet_01"):
|
||
self.clock = clock
|
||
self.cabinet_id = cabinet_id
|
||
self.db = sqlite3.connect(database, check_same_thread=False)
|
||
self.db.execute("CREATE TABLE IF NOT EXISTS room (id INTEGER PRIMARY KEY, value TEXT NOT NULL)")
|
||
self.db.execute("CREATE TABLE IF NOT EXISTS cabinet_rooms (id TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||
row = self.db.execute("SELECT value FROM cabinet_rooms WHERE id=?", (cabinet_id,)).fetchone()
|
||
if row is None and cabinet_id == "cabinet_01":
|
||
row = self.db.execute("SELECT value FROM room WHERE id=1").fetchone()
|
||
self.state = json.loads(row[0]) if row else dict(
|
||
session="", phase="idle", reward="not requested", commands=[], seen=[], history=[])
|
||
# Never carry live connectivity across a controller restart.
|
||
self.feedback = {}
|
||
self.feedback_at = 0
|
||
self.feedback_seen = []
|
||
self.last_seen = 0
|
||
self.boot = self.state.get("boot", "")
|
||
self.version = "unknown"
|
||
self.features = []
|
||
self.gpio = "unknown"
|
||
self.state.setdefault("game", "pacman")
|
||
self.state.setdefault("mappings", {"game_won": ["coin_release"]})
|
||
self.save()
|
||
|
||
def save(self):
|
||
with self.db:
|
||
self.db.execute("INSERT OR REPLACE INTO cabinet_rooms VALUES (?, ?)", (self.cabinet_id, json.dumps(self.state)))
|
||
|
||
def log(self, message):
|
||
self.state["history"].append(dict(time=self.clock(), message=message))
|
||
self.state["history"] = self.state["history"][-100:]
|
||
|
||
def online(self):
|
||
return self.last_seen > 0 and self.clock() - self.last_seen < 4
|
||
|
||
def snapshot(self):
|
||
self.expire()
|
||
return dict(self.state, online=self.online(), cabinet=self.cabinet_id,
|
||
version=self.version, gpio=self.gpio, features=self.features,
|
||
last_seen=self.last_seen, protocol=2, feedback=self.feedback_snapshot())
|
||
|
||
def feedback_snapshot(self):
|
||
result = dict(self.feedback)
|
||
age = max(0, self.clock() - self.feedback_at)
|
||
result['fresh'] = bool(result.get('fresh') and self.online() and age < 4)
|
||
result['devices'] = [dict(d, age_ms=d['age_ms'] + int(age*1000)) for d in result.get('devices', [])]
|
||
return result
|
||
|
||
def receive_feedback(self, payload):
|
||
if not isinstance(payload, dict): return
|
||
devices, events = payload.get('devices', []), payload.get('events', [])
|
||
if not isinstance(devices, list) or len(devices) > 32 or not isinstance(events, list) or len(events) > 64: return
|
||
clean = []
|
||
for d in devices:
|
||
if not isinstance(d, dict) or d.get('kind') not in ('input', 'output', 'rfid'): continue
|
||
ident = d.get('id')
|
||
if not isinstance(ident, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', ident): continue
|
||
value = d.get('value')
|
||
if value is not None and type(value) is not bool and not isinstance(value, str): continue
|
||
age = d.get('age_ms', 999999)
|
||
if type(age) not in (int, float) or not math.isfinite(age) or age < 0: continue
|
||
clean.append(dict(id=ident, name=str(d.get('name',ident))[:100], port=str(d.get('port',''))[:8], kind=d['kind'], value=value[:64] if isinstance(value,str) else value, error=str(d.get('error',''))[:120], age_ms=min(int(age),999999)))
|
||
fresh = payload.get('fresh') is True and self.gpio == 'real'
|
||
self.feedback = dict(devices=clean, fresh=fresh)
|
||
self.feedback_at = self.clock()
|
||
if not fresh: return
|
||
known = {d['id'] for d in clean}
|
||
for event in events:
|
||
if not isinstance(event, dict): continue
|
||
ident = event.get('id')
|
||
if not isinstance(ident,str) or len(ident)>100 or ident in self.feedback_seen or event.get('device') not in known: continue
|
||
self.feedback_seen.append(ident)
|
||
self.feedback_seen = self.feedback_seen[-256:]
|
||
self.log('Device '+event['device']+': '+str(event.get('error') or event.get('value'))[:120])
|
||
|
||
def expire(self):
|
||
for command in self.state["commands"]:
|
||
if command["status"] == "queued" and self.clock() > command["expires"]:
|
||
command["status"] = "unknown"
|
||
if command["type"] in ("reward", "output"):
|
||
self.state["reward" if command["type"] == "reward" else "last_output"] = "unknown — inspect hardware; not automatically retried"
|
||
else:
|
||
self.state["phase"] = "needs_prepare"
|
||
self.log(command["type"] + " acknowledgement timed out")
|
||
self.save()
|
||
|
||
def command(self, kind):
|
||
command = dict(id=str(uuid.uuid4()), session=self.state["session"], type=kind,
|
||
status="queued", expires=self.clock() + 15)
|
||
if kind == "prepare":
|
||
command.update(game=self.state["game"], variant=self.state.get("variant", "standard"), game_config=self.state.get("game_config", {}))
|
||
self.state["commands"].append(command)
|
||
self.log("Queued " + kind)
|
||
return command
|
||
|
||
def operator(self, action):
|
||
self.expire()
|
||
if not self.online(): raise ValueError("Cabinet is offline; no command sent")
|
||
if any(c["status"] == "queued" and c["type"] not in ("output", "reward") for c in self.state["commands"]):
|
||
raise ValueError("Wait for the pending command or its timeout")
|
||
if action in ("prepare", "reset"):
|
||
if action == "prepare" and self.state["phase"] not in ("idle", "won", "lost", "needs_prepare"):
|
||
raise ValueError("Reset the active session before preparing again")
|
||
self.state.update(session=str(uuid.uuid4()), phase="preparing" if action == "prepare" else "resetting",
|
||
reward="not requested", commands=[], seen=[])
|
||
self.command(action)
|
||
elif action == "power_on":
|
||
if self.state["game"] != "pacman" or "pacman_power_v1" not in self.features or self.state["phase"] != "powered_off":
|
||
raise ValueError("Power on requires a prepared, powered-off Pac-Man cabinet (2.4+)")
|
||
self.state["phase"] = "booting"
|
||
self.command("power_on")
|
||
elif action == "start":
|
||
if self.state.get("game_config", {}).get("rfid_credit_device"):
|
||
raise ValueError("Scan a card and press Start on the cabinet for this RFID scenario")
|
||
if self.state["phase"] != "ready": raise ValueError("Prepare the cabinet first")
|
||
self.state["phase"] = "starting"
|
||
self.command("start")
|
||
else:
|
||
raise ValueError("Unknown operator action")
|
||
self.save()
|
||
return self.snapshot()
|
||
|
||
def poll(self, data):
|
||
if data.get("cabinet") != self.cabinet_id or data.get("protocol") not in (1, 2):
|
||
raise ValueError("Wrong cabinet or protocol version")
|
||
boot = data.get("boot")
|
||
if not isinstance(boot, str) or not 1 <= len(boot) <= 100:
|
||
raise ValueError("Missing cabinet boot identity")
|
||
if self.boot and boot != self.boot:
|
||
self.state.update(session="", phase="needs_prepare", commands=[], seen=[], reward="not requested")
|
||
self.log("Cabinet restarted; operator must prepare a new session")
|
||
self.boot = boot
|
||
self.state["boot"] = boot
|
||
self.last_seen = self.clock()
|
||
self.version = str(data.get("version", "unknown"))[:80]
|
||
self.features = data.get("features", []) if isinstance(data.get("features", []), list) else []
|
||
self.gpio = str(data.get("gpio", "unknown"))[:100]
|
||
self.receive_feedback(data.get("feedback", {}))
|
||
self.expire()
|
||
reports = data.get("reports", [])
|
||
if not isinstance(reports, list) or len(reports) > 50: raise ValueError("Invalid reports")
|
||
for report in reports:
|
||
if not isinstance(report, dict): raise ValueError("Invalid report")
|
||
if report.get("session") != self.state["session"]: continue
|
||
if report.get("kind") == "ack":
|
||
self.ack(report)
|
||
elif report.get("kind") == "event":
|
||
self.event(report)
|
||
self.save()
|
||
return dict(ok=True, session=self.state["session"],
|
||
commands=[c for c in self.state["commands"] if c["status"] == "queued"])
|
||
|
||
def ack(self, report):
|
||
command = next((c for c in self.state["commands"] if c["id"] == report.get("id")), None)
|
||
if command is None or command["status"] != "queued": return
|
||
success = report.get("ok") is True
|
||
command["status"] = "accepted" if success else "failed"
|
||
self.log("%s: %s" % (command["type"], str(report.get("detail", command["status"]))[:200]))
|
||
if command["type"] == "output":
|
||
self.state["last_output"] = "accepted (physical state unconfirmed)" if success else "failed or uncertain"
|
||
elif command["type"] == "reward":
|
||
statuses = [c["status"] for c in self.state["commands"] if c["type"] == "reward"]
|
||
if any(s in ("failed", "unknown") for s in statuses):
|
||
self.state["reward"] = "failed or uncertain — inspect cabinet"
|
||
elif all(s == "accepted" for s in statuses):
|
||
self.state["reward"] = "GPIO accepted (physical delivery unconfirmed)"
|
||
else:
|
||
self.state["reward"] = "requested"
|
||
elif command["type"] == "power_on":
|
||
if not success: self.state["phase"] = "needs_prepare"
|
||
elif success:
|
||
self.state["phase"] = {"prepare": "ready", "reset": "waiting" if self.state.get("gate") else "idle", "start": "playing"}[command["type"]]
|
||
if command["type"] == "prepare" and self.state["game"] == "pacman" and "pacman_power_v1" in self.features:
|
||
self.state["phase"] = "powered_off" if self.state.get("game_config", {}).get("power_on_device") else "booting"
|
||
if command["type"] == "prepare" and self.state["phase"] == "ready" and self.state.pop("auto_start", False):
|
||
self.state["phase"] = "starting"
|
||
self.command("start")
|
||
if command["type"] == "prepare":
|
||
for action in self.state["mappings"].get("game_activated", []):
|
||
self.command("output")["action_id"] = action
|
||
else:
|
||
self.state["phase"] = "needs_prepare"
|
||
|
||
def event(self, report):
|
||
event_id = report.get("id")
|
||
if not isinstance(event_id, str) or not 1 <= len(event_id) <= 100: return
|
||
if event_id in self.state["seen"]: return
|
||
self.state["seen"].append(event_id)
|
||
self.state["seen"] = self.state["seen"][-500:]
|
||
name = report.get("event")
|
||
if name == "power_booting" and self.state["game"] == "pacman" and self.state["phase"] == "powered_off":
|
||
self.state["phase"] = "booting"
|
||
elif name == "power_ready" and self.state["game"] == "pacman" and self.state["phase"] == "booting":
|
||
self.state["phase"] = "ready"
|
||
if self.state.pop("auto_start", False):
|
||
self.state["phase"] = "starting"
|
||
self.command("start")
|
||
elif name == "started" and self.state["phase"] in ("ready", "starting"):
|
||
self.state["phase"] = "playing"
|
||
elif name == "won" and self.state["phase"] == "playing":
|
||
self.state["phase"] = "won"
|
||
if self.state["reward"] == "not requested" and self.state["mappings"].get("game_won"):
|
||
self.state["reward"] = "requested"
|
||
elif name == "lost" and self.state["phase"] == "playing":
|
||
self.state["phase"] = "lost"
|
||
else:
|
||
return
|
||
self.log(self.state["game"] + " " + name)
|
||
for action in self.state["mappings"].get("game_" + name, []):
|
||
command = self.command("reward" if name == "won" else "output")
|
||
command["action_id"] = action
|
||
|
||
|
||
class Fleet:
|
||
"""Editor catalog plus independently acknowledged cabinet sessions."""
|
||
def __init__(self, database, catalog, clock=time.time, hub=None):
|
||
self.hub = hub if hub is not None else hub_config()
|
||
self.catalog = catalog
|
||
self.rooms = {key: Room(database, clock, key) for key in catalog["cabinets"]}
|
||
|
||
def compatible(self, cabinet, game):
|
||
definition = self.catalog["games"][game]
|
||
capabilities = set(self.catalog["cabinets"][cabinet]["capabilities"])
|
||
alternatives = definition.get("alternatives", [])
|
||
return set(definition["requirements"]) <= capabilities and (not alternatives or any(set(a) <= capabilities for a in alternatives))
|
||
|
||
def snapshot(self):
|
||
cabinets = {}
|
||
for key, room in self.rooms.items():
|
||
cabinets[key] = dict(room.snapshot(), definition=self.catalog["cabinets"][key],
|
||
compatible_games=[g for g in self.catalog["games"] if self.compatible(key, g)])
|
||
# Retain the single-cabinet desktop UI response while the browser offers the fleet view.
|
||
first = cabinets.get("cabinet_01", next(iter(cabinets.values())))
|
||
return dict(first, cabinets=cabinets, catalog=self.catalog, hub=self.hub)
|
||
|
||
def validate_assignment(self, item):
|
||
cabinet, game = item.get("cabinet"), item.get("game")
|
||
if cabinet not in self.rooms or game not in self.catalog["games"]:
|
||
raise ValueError("Unknown cabinet or game")
|
||
if not self.compatible(cabinet, game): raise ValueError(f"{game} cannot run on {cabinet}: hardware requirements not met")
|
||
if item.get("variant", "standard") != "standard": raise ValueError("Only standard variants are supported by this runtime")
|
||
settings = item.get("game_config", {})
|
||
if not isinstance(settings, dict): raise ValueError("game_config must be an object")
|
||
limits = {"test_auto_win_seconds": (0.1, 300)}
|
||
limits.update({"galaga": {"score_to_win": (0, 1000000), "stages_to_win": (0, 100)},
|
||
"frogger": {"homes_to_win": (0, 5), "boards_to_win": (0, 99)},
|
||
"pacman": {"boards_to_win": (0, 100), "score_to_win": (0, 1000000)}}.get(game, {}))
|
||
power_device = settings.get("power_on_device")
|
||
if power_device is not None:
|
||
if game != "pacman" or not isinstance(power_device, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", power_device):
|
||
raise ValueError("Invalid power-on input device")
|
||
if not any(d.get("id") == power_device and d.get("kind") == "input" for d in self.catalog["cabinets"][cabinet].get("devices", [])):
|
||
raise ValueError("Power-on device is missing from the cabinet PCB input inventory")
|
||
if "pacman_power_v1" not in self.rooms[cabinet].features:
|
||
raise ValueError("Update the game export: power-on inputs require arcade-cabinet-2.4")
|
||
if self.rooms[cabinet].gpio != "real":
|
||
raise ValueError("Power-on inputs require real GPIO feedback")
|
||
if item.get("activate_after_win"):
|
||
raise ValueError("Choose button power-on or automatic dependency activation")
|
||
credit_device = settings.get("rfid_credit_device")
|
||
if credit_device is not None:
|
||
if game != "pacman" or not isinstance(credit_device, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", credit_device):
|
||
raise ValueError("Invalid RFID credit device")
|
||
if not any(d.get("id") == credit_device and d.get("kind") == "rfid" for d in self.catalog["cabinets"][cabinet].get("devices", [])):
|
||
raise ValueError("RFID credit device is missing from the cabinet PCB inventory")
|
||
if "rfid_credit_v1" not in self.rooms[cabinet].features:
|
||
raise ValueError("Update the game export: RFID credits require arcade-cabinet-2.3")
|
||
if self.rooms[cabinet].gpio != "real":
|
||
raise ValueError("RFID credits require real GPIO feedback")
|
||
if item.get("activate_after_win"):
|
||
raise ValueError("RFID credit scenarios require player Start, not automatic start")
|
||
if "rfid_allowed_uids" in settings:
|
||
uids = settings["rfid_allowed_uids"]
|
||
if credit_device is None or not isinstance(uids, list) or len(uids) > 100 or any(not isinstance(uid, str) or not re.fullmatch(r"[0-9A-F]{8}", uid) for uid in uids):
|
||
raise ValueError("RFID allowed UIDs must be a list of uppercase 8-digit hexadecimal IDs")
|
||
for key, value in settings.items():
|
||
if key in ("rfid_credit_device", "rfid_allowed_uids", "power_on_device"): continue
|
||
if key not in limits or type(value) not in (int, float) or not math.isfinite(value):
|
||
raise ValueError("Unsupported game objective: " + str(key))
|
||
low, high = limits[key]
|
||
if not low <= value <= high or (key != "test_auto_win_seconds" and value != int(value)):
|
||
raise ValueError("Invalid game objective: " + key)
|
||
if settings and not self.rooms[cabinet].online():
|
||
raise ValueError(f"{cabinet} is offline; check its controller URL, cabinet ID and token before preparing objectives")
|
||
if settings and "assignment_config_v1" not in self.rooms[cabinet].features:
|
||
raise ValueError(f"Update the game export on {cabinet}: connected agent {self.rooms[cabinet].version} does not advertise assignment objectives (requires arcade-cabinet-2.1)")
|
||
allowed = set(self.catalog["cabinets"][cabinet]["actions"])
|
||
for event, actions in item.get("mappings", {}).items():
|
||
if event not in ("game_activated", "game_started", "game_won", "game_lost"):
|
||
raise ValueError(f"Scenario event {event} is not implemented in managed runtime")
|
||
if not set(actions) <= allowed: raise ValueError("Scenario refers to an unknown hardware action")
|
||
room = self.rooms[cabinet]
|
||
room.expire()
|
||
if not room.online(): raise ValueError(f"{cabinet} is offline")
|
||
if room.state["phase"] not in ("idle", "won", "lost", "needs_prepare"):
|
||
raise ValueError(f"Reset {cabinet} before preparing a new assignment")
|
||
if any(c["status"] == "queued" for c in room.state["commands"]):
|
||
raise ValueError(f"{cabinet} has a pending command")
|
||
|
||
def operator(self, action, data=None):
|
||
data = data or {}
|
||
if "expected_session" in data:
|
||
cabinet = data.get("cabinet")
|
||
if cabinet not in self.rooms or self.rooms[cabinet].state["session"] != data["expected_session"]:
|
||
raise ValueError("Cabinet session changed; review its current state before sending a command")
|
||
if action == "trigger_output":
|
||
self.trigger_output(data)
|
||
elif action == "prepare_scenario":
|
||
scenario_id = data.get("scenario")
|
||
scenario = self.catalog["scenarios"].get(scenario_id)
|
||
if not scenario: raise ValueError("Unknown scenario")
|
||
assignments = scenario["assignments"]
|
||
if not assignments or len({a["cabinet"] for a in assignments}) != len(assignments):
|
||
raise ValueError("Scenario must have unique, nonempty cabinet assignments")
|
||
# Validate the whole plan before sending anything to any cabinet.
|
||
for item in assignments: self.validate_assignment(item)
|
||
by_id = {a["cabinet"]: a for a in assignments}
|
||
for item in assignments:
|
||
visited = {item["cabinet"]}
|
||
source = item.get("activate_after_win", "")
|
||
while source:
|
||
if source not in by_id: raise ValueError("Dependency cabinet must be assigned in the same scenario")
|
||
if source in visited: raise ValueError("Cabinet activation dependencies cannot form a cycle")
|
||
visited.add(source)
|
||
source = by_id[source].get("activate_after_win", "")
|
||
for item in assignments: self.prepare(item, scenario_id)
|
||
# Bind dependencies to this particular run, never an earlier win.
|
||
for item in assignments:
|
||
source = item.get("activate_after_win")
|
||
if source:
|
||
room = self.rooms[item["cabinet"]]
|
||
room.state["gate"] = dict(cabinet=source, session=self.rooms[source].state["session"])
|
||
room.log("Waiting for " + source + " to win")
|
||
room.save()
|
||
elif action == "prepare":
|
||
cabinet = data.get("cabinet", "cabinet_01")
|
||
item = dict(cabinet=cabinet, game=data.get("game", "pacman"), mappings={})
|
||
self.validate_assignment(item)
|
||
# Manual overrides do not inherit a reward from the previous scenario.
|
||
self.prepare(item, "manual override")
|
||
else:
|
||
cabinet = data.get("cabinet", "cabinet_01")
|
||
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
|
||
if action == "start" and self.rooms[cabinet].state.get("gate"):
|
||
raise ValueError("This cabinet starts automatically after its dependency wins")
|
||
self.rooms[cabinet].operator(action)
|
||
if action == "reset":
|
||
self.rooms[cabinet].state.pop("gate", None)
|
||
self.rooms[cabinet].state.pop("auto_start", None)
|
||
self.rooms[cabinet].save()
|
||
self.advance()
|
||
return self.snapshot()
|
||
|
||
def trigger_output(self, data):
|
||
cabinet, action_id = data.get("cabinet"), data.get("action_id")
|
||
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
|
||
room = self.rooms[cabinet]
|
||
request_id = data.get("request_id", "")
|
||
reason = data.get("reason", "")
|
||
if not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{16,100}", request_id):
|
||
raise ValueError("Manual hardware command requires a unique request ID")
|
||
if not isinstance(reason, str) or not 3 <= len(reason.strip()) <= 200:
|
||
raise ValueError("Enter a reason (3–200 characters) for the hardware action")
|
||
if data.get("expected_session") != room.state["session"]:
|
||
raise ValueError("Cabinet session changed; review before triggering hardware")
|
||
if action_id not in self.catalog["cabinets"][cabinet]["actions"]:
|
||
raise ValueError("Hardware action is not configured for this cabinet")
|
||
receipts = room.state.setdefault("manual_requests", {})
|
||
if request_id in receipts:
|
||
if receipts[request_id] != [action_id, room.state["session"]]:
|
||
raise ValueError("Request ID was already used for another action/session")
|
||
return # A lost HTTP reply must not cause a second physical action.
|
||
if len(receipts) >= 10000:
|
||
raise ValueError("Manual action history limit reached; contact the administrator")
|
||
room.expire()
|
||
if not room.online(): raise ValueError("Cabinet is offline; no command sent")
|
||
if room.gpio != "real": raise ValueError("Cabinet must report real GPIO mode")
|
||
if not room.state["session"] or room.state["phase"] not in ("ready", "playing", "won", "lost"):
|
||
raise ValueError("Prepare the cabinet before triggering its hardware")
|
||
if any(c["status"] == "queued" for c in room.state["commands"]):
|
||
raise ValueError("Wait for the pending command or its timeout")
|
||
command = room.command("output")
|
||
command["action_id"] = action_id
|
||
command["source"] = "operator"
|
||
room.state["last_output"] = "requested"
|
||
receipts[request_id] = [action_id, room.state["session"]]
|
||
# Refuse new requests rather than evict deduplication receipts during a session.
|
||
room.log("Operator triggered " + str(action_id) + ": " + reason.strip())
|
||
room.save()
|
||
|
||
def prepare(self, item, scenario):
|
||
room = self.rooms[item["cabinet"]]
|
||
room.state.update(game=item["game"], mappings=item.get("mappings", {}),
|
||
variant=item.get("variant", "standard"), game_config=item.get("game_config", {}), scenario=scenario)
|
||
room.state.pop("gate", None)
|
||
room.state.pop("auto_start", None)
|
||
room.operator("reset" if item.get("activate_after_win") else "prepare")
|
||
room.log("Assignment from " + scenario)
|
||
room.save()
|
||
|
||
def poll(self, data):
|
||
cabinet = data.get("cabinet")
|
||
if data.get("protocol") != 2: raise ValueError("Update cabinet export: controller requires protocol 2")
|
||
if cabinet not in self.rooms: raise ValueError("Unknown cabinet")
|
||
self.rooms[cabinet].poll(data)
|
||
self.advance()
|
||
room = self.rooms[cabinet]
|
||
return dict(ok=True, session=room.state["session"],
|
||
commands=[c for c in room.state["commands"] if c["status"] == "queued"])
|
||
|
||
def advance(self):
|
||
# Invalidate descendants when their source is reset/reassigned/restarted.
|
||
# Iterate to propagate invalidation through longer chains.
|
||
for _ in self.rooms:
|
||
for room in self.rooms.values():
|
||
gate = room.state.get("gate")
|
||
if not gate: continue
|
||
source = self.rooms.get(gate["cabinet"])
|
||
if source is None or source.state["session"] != gate["session"]:
|
||
room.state.update(session=str(uuid.uuid4()), phase="needs_prepare", commands=[], seen=[], gate=None, auto_start=False)
|
||
room.log("Dependency session ended; prepare the scenario again")
|
||
room.save()
|
||
for room in self.rooms.values():
|
||
gate = room.state.get("gate")
|
||
if not gate or room.state["phase"] != "waiting" or not room.online(): continue
|
||
source = self.rooms[gate["cabinet"]]
|
||
if source.state["phase"] == "won":
|
||
room.state.update(phase="preparing", auto_start=True)
|
||
room.command("prepare")
|
||
room.log("Activated by " + gate["cabinet"] + " win")
|
||
room.save()
|
||
|
||
|
||
def handler_for(room, operator_token, cabinet_token):
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def do_GET(self):
|
||
if self.path not in ("/", "/index.html"):
|
||
self.respond(404, {"error": "Not found"})
|
||
return
|
||
body = (Path(__file__).parent / "operator.html").read_bytes()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Frame-Options", "DENY")
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def do_POST(self):
|
||
try:
|
||
length = int(self.headers.get("Content-Length", "0"))
|
||
if not 0 <= length <= 65536: raise ValueError("Request too large")
|
||
data = json.loads(self.rfile.read(length) or b"{}")
|
||
if not isinstance(data, dict): raise ValueError("Expected object")
|
||
expected = operator_token
|
||
if self.path == "/cabinet/poll":
|
||
expected = cabinet_token.get(data.get("cabinet"), "") if isinstance(cabinet_token, dict) else cabinet_token
|
||
if not expected:
|
||
self.respond(401, {"error": "Cabinet credentials not provisioned"})
|
||
return
|
||
if not hmac.compare_digest(self.headers.get("Authorization", ""), "Bearer " + expected):
|
||
self.respond(401, {"error": "Unauthorized"})
|
||
return
|
||
origin = self.headers.get("Origin")
|
||
if origin and urlparse(origin).netloc != self.headers.get("Host"):
|
||
raise ValueError("Cross-origin requests are disabled")
|
||
if self.path == "/cabinet/poll": result = room.poll(data)
|
||
elif self.path == "/operator/state": result = room.snapshot()
|
||
elif self.path == "/operator/command":
|
||
result = room.operator(data.get("action"), data) if isinstance(room, Fleet) else room.operator(data.get("action"))
|
||
else: raise ValueError("Unknown endpoint")
|
||
self.respond(200, result)
|
||
except (ValueError, TypeError, KeyError) as exc:
|
||
self.respond(400, {"error": str(exc)})
|
||
|
||
def setup(self):
|
||
super().setup()
|
||
self.connection.settimeout(3)
|
||
|
||
def respond(self, code, data):
|
||
body = json.dumps(data).encode()
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def log_message(self, *_args): pass
|
||
return Handler
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--bind", default="127.0.0.1")
|
||
parser.add_argument("--port", type=int, default=8766)
|
||
parser.add_argument("--database", default="arcade_room.sqlite3")
|
||
parser.add_argument("--catalog", default=str(Path(__file__).with_name("catalog.json")))
|
||
parser.add_argument("--hub-config", help="Optional operations hub names and service links")
|
||
args = parser.parse_args()
|
||
operator = os.environ.get("ARCADE_OPERATOR_TOKEN", "")
|
||
cabinet = os.environ.get("ARCADE_CABINET_TOKEN", "")
|
||
try:
|
||
tokens = json.loads(os.environ.get("ARCADE_CABINET_TOKENS", "{}"))
|
||
except ValueError:
|
||
parser.error("ARCADE_CABINET_TOKENS must be a JSON object")
|
||
if not isinstance(tokens, dict): parser.error("ARCADE_CABINET_TOKENS must be a JSON object")
|
||
if cabinet: tokens.setdefault("cabinet_01", cabinet)
|
||
if not isinstance(tokens, dict) or not tokens or len(operator) < 16 or any(not isinstance(t, str) or len(t) < 16 or t == operator for t in tokens.values()) or len(set(tokens.values())) != len(tokens):
|
||
parser.error("Set an operator token and unique cabinet tokens (16+ characters); use ARCADE_CABINET_TOKEN for cabinet_01 or ARCADE_CABINET_TOKENS JSON for multiple cabinets")
|
||
catalog = json.loads(Path(args.catalog).read_text(encoding="utf-8"))
|
||
try:
|
||
hub = hub_config(args.hub_config)
|
||
except (OSError, ValueError) as exc:
|
||
parser.error(str(exc))
|
||
room = Fleet(Path(args.database), catalog, hub=hub)
|
||
server = HTTPServer((args.bind, args.port), handler_for(room, operator, tokens))
|
||
print(f"Arcade controller ready at http://{args.bind}:{args.port}/ — {len(room.rooms)} cabinets", flush=True)
|
||
try: server.serve_forever()
|
||
except KeyboardInterrupt: pass
|
||
finally:
|
||
server.server_close()
|
||
for cabinet_room in room.rooms.values(): cabinet_room.db.close()
|
||
|
||
|
||
if __name__ == "__main__": main()
|