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:
1 parent
705ec31e0b
commit
c1b2195834
90 files changed
+4969
-456
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user