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
223 lines
12 KiB
Python
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())
|