Expand Glitch operations hub, cabinet authoring, deployment, and RFID support

Operator hub
- Introduce Escape Gallery branding and Glitch: the Dark Machine room views
- Add cabinet illustrations, Pac-Man marquee/gameplay artwork, and waiting states
- Add device feedback panels with RFID readings, input/output states, and stale status
- Add logged operator hardware interventions with request deduplication

Room and cabinet authoring
- Add a visual custom-PCB connection editor with per-cabinet device inventory
- Support device IDs, bindings, wiring notes, input polarity, and validation
- Add physical-room label/dimension toggles and editable doorway annotations
- Preserve doorway changes through undo, save, and revert
- Expand cabinet profiles, room layout, and Glitch scenario resources

Scenarios and gameplay
- Add per-assignment game configuration and runtime feature validation
- Add timed test wins and Frogger individual-home objectives
- Add the three-cabinet timed/score/Frogger test scenario
- Add Cabinet 1 Pac-Man RFID credits, credit sound, and CREDIT 0/1 display
- Require physical Start after an accepted scan and prevent duplicate credits

Pi hardware and deployment
- Add GPIO feedback sampling and a separate multiplexed RFID helper
- Validate reader pin ownership and input/output conflicts
- Add SSH cabinet startup and Pi desktop-session service support
- Add master packaging, site setup, SSH enrollment, and staged cabinet releases
- Verify deployment manifests and support explicit activation/status/log commands

Documentation and verification
- Consolidate the root README around the current architecture and setup workflow
- Add the complete master/Pi/RFID export and deployment walkthrough
- Document network isolation, credentials, card identification, and rollback
- Add PCB, room-editor, feedback, credit, deployment, and controller tests
- Isolate protocol tests from designer-edited cabinet configurations

Known limitations
- RFID helper compilation and physical Pi commissioning remain unverified
- Fleet provisioning does not yet configure RFID-only installations completely
- Broader Pac-Man tests retain a ghost-capture assertion failure
- Staged exports must be rebuilt and checked before deploying runtime 2.3
This commit is contained in:
Jeff Luke committed 2026-10-17 14:53:54 -07:00
1 parent 705ec31e0b
commit c1b2195834
90 files changed
+4969 -456

No files matched your search

+170 -8
View File
@@ -3,7 +3,9 @@
import argparse
import hmac
import json
import math
import os
import re
from pathlib import Path
import sqlite3
import time
@@ -12,6 +14,42 @@ 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
@@ -25,9 +63,13 @@ class Room:
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"]})
@@ -47,8 +89,42 @@ class Room:
def snapshot(self):
self.expire()
return dict(self.state, online=self.online(), cabinet=self.cabinet_id,
version=self.version, gpio=self.gpio,
last_seen=self.last_seen, protocol=2)
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"]:
@@ -65,7 +141,7 @@ class Room:
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"))
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
@@ -82,6 +158,8 @@ class Room:
reward="not requested", commands=[], seen=[])
self.command(action)
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")
@@ -103,7 +181,9 @@ class Room:
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")
@@ -170,7 +250,8 @@ class Room:
class Fleet:
"""Editor catalog plus independently acknowledged cabinet sessions."""
def __init__(self, database, catalog, clock=time.time):
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"]}
@@ -187,7 +268,7 @@ class Fleet:
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)
return dict(first, cabinets=cabinets, catalog=self.catalog, hub=self.hub)
def validate_assignment(self, item):
cabinet, game = item.get("cabinet"), item.get("game")
@@ -195,6 +276,39 @@ class Fleet:
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, {}))
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"): 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"):
@@ -210,7 +324,13 @@ class Fleet:
def operator(self, action, data=None):
data = data or {}
if action == "prepare_scenario":
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")
@@ -256,10 +376,47 @@ class Fleet:
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"), scenario=scenario)
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")
@@ -363,6 +520,7 @@ def main():
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", "")
@@ -375,7 +533,11 @@ def main():
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"))
room = Fleet(Path(args.database), catalog)
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()