Files
Jeff Luke c1b2195834 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
2026-10-17 14:53:54 -07:00

223 lines
12 KiB
Python

#!/usr/bin/env python3
"""Run the Arcade controller and/or cabinet with supervised child processes."""
import argparse
import configparser
import importlib.util
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import time
import urllib.request
import webbrowser
ROOT = Path(__file__).resolve().parents[2]
def credential(env, name, configured, label):
inherited = env.get(name, '')
selected = inherited or configured
source = 'environment variable '+name if inherited else 'launcher INI'
if selected:
print(f'{label} source: {source}', flush=True)
if inherited and configured and inherited != configured:
print(f'WARNING: {name} overrides a DIFFERENT value in the launcher INI. Clear that variable in the launching terminal to use the INI.', flush=True)
if selected and (selected[0] in "\"'" or selected[-1] in "\"'"):
raise ValueError(f'{label} has surrounding quote characters. In launcher.ini use the token without quotes; enter the same unquoted token in the browser.')
return selected
def plan(config_path, mode):
config_path = Path(config_path).resolve()
config = configparser.ConfigParser(interpolation=None)
if not config.read(config_path, encoding='utf-8-sig'):
raise ValueError(f'Configuration not found: {config_path}. Copy an example INI first.')
base = config_path.parent
def value(section, key, default=''):
return config.get(section, key, fallback=default).strip()
def path(section, key, default):
return (base / Path(os.path.expanduser(value(section, key, default)))).resolve()
def require_file(filename):
if not filename.is_file(): raise ValueError(f'File not found: {filename}')
env = dict(os.environ)
jobs = []
if mode in ('controller', 'all'):
script = ROOT / 'tools/room_controller/server.py'
catalog = path('controller', 'catalog', '../room_controller/catalog.json')
require_file(script)
require_file(script.with_name('operator.html'))
require_file(catalog)
operator = credential(env, 'ARCADE_OPERATOR_TOKEN', value('controller', 'operator_token'), 'Operator token')
tokens = json.loads(env.get('ARCADE_CABINET_TOKENS') or value('controller', 'cabinet_tokens', '{}'))
if not isinstance(tokens, dict): raise ValueError('cabinet_tokens must be a JSON object')
legacy = env.get('ARCADE_CABINET_TOKEN', '')
if legacy: tokens.setdefault('cabinet_01', legacy)
if len(operator) < 16 or not tokens 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):
raise ValueError('Configure distinct operator and cabinet tokens of at least 16 characters')
server_env = dict(env, ARCADE_OPERATOR_TOKEN=operator, ARCADE_CABINET_TOKENS=json.dumps(tokens))
server_env.pop('ARCADE_CABINET_TOKEN', None)
bind = value('controller', 'bind', '127.0.0.1')
port = config.getint('controller', 'port', fallback=8766)
if not 1 <= port <= 65535: raise ValueError('Controller port must be between 1 and 65535')
database = path('controller', 'database', 'state/arcade_room.sqlite3')
url_host = '127.0.0.1' if bind == '0.0.0.0' else bind
url = f'http://{url_host}:{port}'
jobs.append(dict(name='controller', command=[sys.executable, '-u', str(script), '--bind', bind, '--port', str(port), '--database', str(database), '--catalog', str(catalog)], env=server_env, port=(bind, port), ready=url+'/', directory=database.parent))
if value('controller', 'hub_config'):
hub_path = path('controller', 'hub_config', '')
require_file(hub_path)
jobs[-1]['command'] += ['--hub-config', str(hub_path)]
if mode in ('cabinet', 'all'):
executable = path('cabinet', 'executable', '../../ArcadeEscape.exe' if os.name == 'nt' else '../../ArcadeEscape.arm64')
require_file(executable)
if os.name != 'nt' and not os.access(executable, os.X_OK):
raise ValueError(f'Game is not executable; run chmod +x "{executable}"')
cabinet_id = value('cabinet', 'id', 'cabinet_01')
if not cabinet_id or any(c.isspace() for c in cabinet_id): raise ValueError('Invalid cabinet ID')
cabinet_env = dict(env)
room_url = env.get('ARCADE_ROOM_URL') or value('cabinet', 'url')
token = credential(env, 'ARCADE_CABINET_TOKEN', value('cabinet', 'token'), 'Cabinet token')
if mode == 'all':
room_url = room_url or url
token = token or tokens.get(cabinet_id, '')
if token != tokens.get(cabinet_id): raise ValueError('Cabinet token does not match this controller\'s token for its ID')
# Omitted values preserve the cabinet's existing user://arcade_network.cfg.
if room_url: cabinet_env['ARCADE_ROOM_URL'] = room_url
if token: cabinet_env['ARCADE_CABINET_TOKEN'] = token
real_gpio = config.getboolean('cabinet', 'gpio', fallback=False)
if real_gpio:
if not sys.platform.startswith('linux'): raise ValueError('Real GPIO requires the Linux Pi; set gpio = false on Windows')
if importlib.util.find_spec('gpiozero') is None or importlib.util.find_spec('lgpio') is None:
raise ValueError('Install Pi dependencies first: sudo apt install python3-gpiozero python3-lgpio')
pins = [int(p.strip()) for p in value('gpio', 'pins', '17').split(',') if p.strip()]
if len(set(pins)) != len(pins) or any(p < 0 or p > 27 for p in pins): raise ValueError('GPIO pins must be unique BCM numbers 0..27')
script = ROOT / 'tools/pi_gpio/pi_gpio_bridge.py'
require_file(script)
ledger = path('gpio', 'ledger', '~/.local/state/arcade_gpio/requests.sqlite3')
bridge_env = dict(env, GPIOZERO_PIN_FACTORY='lgpio')
jobs.append(dict(name='gpio', command=[sys.executable, '-u', str(script), '--controller', value('gpio', 'controller', cabinet_id+'_gpio'), '--pins', ','.join(map(str,pins)), '--ledger', str(ledger)], env=bridge_env, port=('127.0.0.1', 8765), ready='http://127.0.0.1:8765/heartbeat', directory=ledger.parent))
if value('gpio', 'devices'):
devices_path = path('gpio', 'devices', '')
require_file(devices_path)
jobs[-1]['command'] += ['--devices', str(devices_path)]
if value('gpio', 'rfid_helper'):
helper_path = path('gpio', 'rfid_helper', '')
require_file(helper_path)
jobs[-1]['command'] += ['--rfid-helper', str(helper_path)]
args = [str(executable), '--', '--managed-cabinet', '--cabinet='+cabinet_id]
if real_gpio: args.append('--gpio=real')
jobs.append(dict(name='cabinet', command=args, env=cabinet_env, port=None, ready=None, directory=executable.parent))
return jobs, path('launcher', 'logs', 'logs')
def check_ports(jobs):
for job in jobs:
if not job['port']: continue
with socket.socket() as probe:
try: probe.bind(job['port'])
except OSError as exc:
raise ValueError(f"{job['name']} port {job['port'][1]} is unavailable. Stop the existing service or use the appropriate launcher mode.") from exc
def wait_ready(job, process):
deadline = time.monotonic() + 12
while time.monotonic() < deadline:
if process.poll() is not None: raise RuntimeError(f"{job['name']} exited during startup; check its log")
try:
data = b'{}' if job['name'] == 'gpio' else None
with urllib.request.urlopen(urllib.request.Request(job['ready'], data=data), timeout=.5) as response:
if response.status == 200: return
except (OSError, ValueError): pass
time.sleep(.1)
raise RuntimeError(f"{job['name']} did not become ready; check its log")
def run(jobs, logs, open_browser=False, remote_targets=None):
check_ports(jobs)
logs.mkdir(parents=True, exist_ok=True)
children, files = [], []
try:
for job in jobs:
job['directory'].mkdir(parents=True, exist_ok=True)
output = open(logs / (job['name']+'.log'), 'ab', buffering=0)
files.append(output)
flags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' and job['name'] != 'cabinet' else 0
process = subprocess.Popen(job['command'], env=job['env'], cwd=job['directory'], stdout=output, stderr=subprocess.STDOUT, creationflags=flags)
children.append((job, process))
if job['ready']: wait_ready(job, process)
print(f"Started {job['name']} (PID {process.pid}); log: {logs / (job['name']+'.log')}", flush=True)
if job['name'] == 'controller':
print('Operator: '+job['ready'], flush=True)
if open_browser: webbrowser.open(job['ready'])
if remote_targets:
from remote_cabinets import start_and_check
start_and_check(remote_targets, job)
print('Running. Ctrl+C stops this stack. Exiting the game also stops services started here.', flush=True)
while True:
for job, process in children:
code = process.poll()
if code is not None:
print(f"{job['name']} exited ({code}); stopping this stack.", flush=True)
return code if code else (0 if job['name'] == 'cabinet' else 1)
time.sleep(.25)
except KeyboardInterrupt:
return 0
finally:
# Only owned children; never terminate an unrelated process on a service port.
for job, process in reversed(children):
if process.poll() is None:
process.terminate()
try: process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
for output in files: output.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=['controller', 'cabinet', 'all'])
parser.add_argument('--config', default=str(Path(__file__).with_name('launcher.ini')))
parser.add_argument('--check', action='store_true', help='Validate settings and ports without starting anything')
parser.add_argument('--browser', action='store_true', help='Open the local operator browser after controller startup')
args = parser.parse_args()
def interrupted(*_): raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
try:
print('Launcher configuration: '+str(Path(args.config).resolve()), flush=True)
jobs, logs = plan(args.config, args.mode)
from remote_cabinets import load_targets
remote_targets = load_targets(args.config, args.mode)
if remote_targets:
controller = next(j for j in jobs if j['name'] == 'controller')
catalog_path = controller['command'][controller['command'].index('--catalog')+1]
known = json.loads(Path(catalog_path).read_text(encoding='utf-8'))['cabinets']
tokens = json.loads(controller['env']['ARCADE_CABINET_TOKENS'])
for target in remote_targets:
if target['id'] not in known or target['id'] not in tokens:
raise ValueError('Remote cabinet must be registered and have a controller token: '+target['id'])
if any(j['name']=='cabinet' and '--cabinet='+target['id'] in j['command'] for j in jobs):
raise ValueError('Cannot launch the same cabinet identity locally and remotely')
if args.check:
check_ports(jobs)
print('Configuration and ports OK. Planned services: '+', '.join(j['name'] for j in jobs))
print('Logs: '+str(logs))
print('Remote start targets (not contacted by --check): '+', '.join(t['id'] for t in remote_targets))
return 0
return run(jobs, logs, args.browser, remote_targets)
except (ValueError, OSError, RuntimeError, configparser.Error) as exc:
print('Startup failed: '+str(exc), file=sys.stderr)
return 1
except KeyboardInterrupt:
return 0
if __name__ == '__main__': sys.exit(main())