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

+2
View File
@@ -1,5 +1,7 @@
# Start the Arcade stack
To start the Pi applications automatically from the PC controller command, follow the [remote Pi startup guide](../../docs/remote_pi_start.md).
For the assigned internal operator/Cabinet 1–7 tokens and per-machine Godot CFG examples, see the [cabinet setup guide](../../docs/cabinet_setup.md).
One launcher starts the needed processes, waits for services to respond, saves logs, and stops its children when you press Ctrl+C. It runs on Windows and Linux/Pi (and supports controller-only use on macOS).
-12
View File
@@ -1,12 +0,0 @@
[launcher]
logs = logs
[controller]
bind = 0.0.0.0
port = 8766
; Paths are relative to THIS file. Reuse your existing database path when upgrading.
database = state/arcade_room.sqlite3
catalog = ../room_controller/catalog.json
; Use your existing tokens. Leave blank if supplied via environment variables.
operator_token =
cabinet_tokens = {}
+32 -4
View File
@@ -70,6 +70,10 @@ def plan(config_path, mode):
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)
@@ -92,13 +96,21 @@ def plan(config_path, mode):
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 not pins or 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')
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))
@@ -127,7 +139,7 @@ def wait_ready(job, process):
raise RuntimeError(f"{job['name']} did not become ready; check its log")
def run(jobs, logs, open_browser=False):
def run(jobs, logs, open_browser=False, remote_targets=None):
check_ports(jobs)
logs.mkdir(parents=True, exist_ok=True)
children, files = [], []
@@ -144,6 +156,9 @@ def run(jobs, logs, open_browser=False):
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:
@@ -178,12 +193,25 @@ def main():
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)
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
+10
View File
@@ -0,0 +1,10 @@
[launcher]
logs = logs
[controller]
bind = 0.0.0.0
port = 8766
database = state/arcade_room.sqlite3
catalog = ../room_controller/catalog.json
operator_token = Yu1qH7nz_aMUTlkk
cabinet_tokens = {"cabinet_01":"K32qg0lYioXmzWAK","cabinet_02":"LY9ATvM3wkTxJg0x","cabinet_03":"qZSD9eM2dmeRtmVA","cabinet_04":"2xXcj0sUbOzQCn2j","cabinet_05":"weISFvFBOt6pxfk-","cabinet_06":"h-lP-SfQMXT_dLtc","cabinet_07":"pUT4GdBYtW3slhOq"}
+7
View File
@@ -0,0 +1,7 @@
[launcher]
logs = logs
[cabinet]
id = cabinet_03
url = 10.0.0.57
token = qZSD9eM2dmeRtmVA
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Install a user service from a Pi desktop terminal; no root or SSH changes."""
import argparse
import os
from pathlib import Path
import subprocess
import sys
HERE = Path(__file__).resolve().parent
MARKER = '# Managed by Arcade Escape pi_service.py'
def quoted(value):
value=str(value)
if any(c in value for c in ('\n','\r','\0')): raise ValueError('Paths must not contain control characters')
return '"'+value.replace('\\','\\\\').replace('"','\\"').replace('%','%%')+'"'
def working_directory(value):
# Unlike ExecStart arguments, systemd reads this as a single unquoted path.
value = str(value)
if any(ord(c) < 32 or ord(c) == 127 for c in value):
raise ValueError('Paths must not contain control characters')
return value.replace('%', '%%')
def reload_service():
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
result = subprocess.run(
['systemctl', '--user', 'show', 'arcade-cabinet.service',
'--property=LoadState', '--value'],
check=True, capture_output=True, text=True)
if result.stdout.strip() != 'loaded':
raise ValueError(
'systemd rejected arcade-cabinet.service (LoadState: '
+ result.stdout.strip() + '). Inspect it with: '
'systemctl --user status arcade-cabinet.service --no-pager -l')
def render_unit(python, launcher, config):
return f"""{MARKER}
[Unit]
Description=Arcade Escape managed cabinet
PartOf=graphical-session.target
[Service]
Type=exec
EnvironmentFile=%t/arcade-cabinet-display.env
UnsetEnvironment=ARCADE_ROOM_URL ARCADE_CABINET_TOKEN ARCADE_OPERATOR_TOKEN ARCADE_CABINET_TOKENS
WorkingDirectory={working_directory(Path(launcher).resolve().parents[2])}
ExecStart=:{quoted(python)} {quoted(launcher)} cabinet --config {quoted(config)}
Restart=no
KillMode=mixed
TimeoutStopSec=15
"""
def render_desktop(python, script):
# Desktop Exec quoting also needs escaping of $ and backticks inside quotes.
def desktop_quote(value):
return quoted(value).replace('$','\\$').replace('`','\\`')
return f"""{MARKER}
[Desktop Entry]
Type=Application
Name=Arcade cabinet display setup
Exec={desktop_quote(python)} {desktop_quote(script)} refresh-session
Terminal=false
NoDisplay=true
"""
def refresh_session():
if not os.environ.get('DISPLAY') and not os.environ.get('WAYLAND_DISPLAY'):
raise ValueError('Run this from a terminal on the Pi desktop, not a plain SSH session')
runtime=Path(os.environ.get('XDG_RUNTIME_DIR',f'/run/user/{os.getuid()}'))
if not runtime.is_dir(): raise ValueError('The Pi desktop user runtime directory is unavailable')
values={k:os.environ[k] for k in ('DISPLAY','WAYLAND_DISPLAY','XAUTHORITY','XDG_RUNTIME_DIR','XDG_SESSION_TYPE') if os.environ.get(k)}
text=''
for key,value in values.items():
if any(c in value for c in ('\n','\r','\0')): raise ValueError('Invalid desktop environment value')
text+=key+'="'+value.replace('\\','\\\\').replace('"','\\"')+'"\n'
target=runtime/'arcade-cabinet-display.env'
temporary=target.with_suffix('.tmp')
temporary.write_text(text,encoding='utf-8')
temporary.chmod(0o600)
temporary.replace(target)
print('Saved display environment for the current desktop login.')
def write_managed(path, text):
if path.exists() and not path.read_text(encoding='utf-8').startswith(MARKER):
raise ValueError('Refusing to overwrite an unrelated file: '+str(path))
path.parent.mkdir(parents=True,exist_ok=True)
path.write_text(text,encoding='utf-8')
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('action',choices=['install','refresh-session'])
parser.add_argument('--config',default=str(HERE/'launcher.ini'))
parser.add_argument('--remote', action='store_true', help='Register from SSH; wait for the desktop helper if no display is available')
args=parser.parse_args()
try:
if not sys.platform.startswith('linux') or os.getuid()==0:
raise ValueError('Run as the normal Pi desktop user on Linux, without sudo')
if args.action=='refresh-session':
refresh_session()
return 0
from launch import plan
config=Path(args.config).resolve()
# Validate against the user's actual config; do not start games or GPIO here.
plan(config,'cabinet')
if not args.remote or os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY'):
refresh_session()
else:
print('Remote installation: display helper will capture settings at the next desktop login.')
home=Path.home()/'.config'
unit=home/'systemd/user/arcade-cabinet.service'
desktop=home/'autostart/arcade-cabinet-display.desktop'
unit_text=render_unit(sys.executable,HERE/'launch.py',config)
desktop_text=render_desktop(sys.executable,Path(__file__).resolve())
for target in (unit,desktop):
if target.exists() and not target.read_text(encoding='utf-8').startswith(MARKER):
raise ValueError('Refusing to overwrite an unrelated file: '+str(target))
write_managed(unit,unit_text)
write_managed(desktop,desktop_text)
reload_service()
print('Installed arcade-cabinet.service (not started and not enabled at boot).')
print('Start locally: systemctl --user start arcade-cabinet.service')
print('The display helper will refresh settings at future desktop logins.')
return 0
except (ValueError,OSError,subprocess.CalledProcessError) as exc:
print('Pi service setup failed: '+str(exc),file=sys.stderr)
return 1
if __name__=='__main__': sys.exit(main())
+5
View File
@@ -0,0 +1,5 @@
; Merge this section into the PC controller's existing launcher INI.
; Replace the hosts and usernames before setting enabled = true.
[remote]
enabled = false
cabinets = [{"id":"cabinet_01","host":"PI_1_HOST","user":"PI_USER"},{"id":"cabinet_02","host":"PI_2_HOST","user":"PI_USER"},{"id":"cabinet_03","host":"PI_3_HOST","user":"PI_USER"}]
+73
View File
@@ -0,0 +1,73 @@
"""Explicit SSH service starts; no remote shell interpolation or automatic restarts."""
import concurrent.futures
import configparser
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
import urllib.request
def load_targets(config_path, mode):
if mode not in ('controller', 'all'): return []
config=configparser.ConfigParser(interpolation=None)
config.read(config_path, encoding='utf-8-sig')
if not config.getboolean('remote', 'enabled', fallback=False): return []
raw=json.loads(config.get('remote', 'cabinets', fallback='[]'))
if not isinstance(raw,list) or not raw: raise ValueError('remote.cabinets must be a nonempty JSON list')
ssh=shutil.which('ssh')
if not ssh: raise ValueError('OpenSSH client (ssh) is required for remote cabinet startup')
targets=[]
seen=set()
for item in raw:
if not isinstance(item,dict): raise ValueError('Each remote cabinet must be an object')
cabinet,host,user=(item.get(k,'') for k in ('id','host','user'))
for value,pattern,label in [(cabinet,r'[A-Za-z0-9_]+','cabinet ID'),(host,r'[A-Za-z0-9][A-Za-z0-9.-]*','host'),(user,r'[A-Za-z_][A-Za-z0-9_-]*','SSH user')]:
if not isinstance(value,str) or not re.fullmatch(pattern,value): raise ValueError('Invalid remote '+label)
if cabinet in seen: raise ValueError('Duplicate remote cabinet ID')
seen.add(cabinet)
port=item.get('port',22)
if type(port) is not int or not 1<=port<=65535: raise ValueError('Invalid SSH port')
command=[ssh,'-T','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=5','-o','ConnectionAttempts=1','-p',str(port)]
if item.get('identity_file'):
key=(Path(config_path).resolve().parent / Path(item['identity_file']).expanduser()).resolve()
if not key.is_file(): raise ValueError('SSH identity file not found: '+str(key))
command+=['-i',str(key),'-o','IdentitiesOnly=yes']
command += [user+'@'+host,'systemctl','--user','start','arcade-cabinet.service']
targets.append(dict(id=cabinet,host=host,command=command))
return targets
def start_one(target):
try:
result=subprocess.run(target['command'],stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,timeout=15,creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)
if result.returncode:
return target['id'],False,(result.stderr or result.stdout).strip()[-1500:]
return target['id'],True,'Service start accepted (already-running services are left running)'
except (OSError,subprocess.TimeoutExpired) as exc:
return target['id'],False,str(exc)
def start_and_check(targets,controller):
if not targets: return
print('Starting configured Pi services over SSH...',flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
for cabinet,ok,detail in executor.map(start_one,targets):
print(f"{cabinet}: {'SSH OK' if ok else 'SSH FAILED'} - {detail}",flush=True)
pending={t['id'] for t in targets}
deadline=time.monotonic()+15
while pending and time.monotonic()<deadline:
try:
request=urllib.request.Request(controller['ready'].rstrip('/')+'/operator/state',data=b'{}',headers={'Authorization':'Bearer '+controller['env']['ARCADE_OPERATOR_TOKEN']})
with urllib.request.urlopen(request,timeout=2) as response: state=json.load(response)
for cabinet in list(pending):
if state.get('cabinets',{}).get(cabinet,{}).get('online'):
print(cabinet+': ONLINE in room controller',flush=True)
pending.remove(cabinet)
except (OSError,ValueError): pass
if pending: time.sleep(.5)
for cabinet in sorted(pending):
print(cabinet+': OFFLINE - check SSH, Pi desktop login, service logs, and cabinet URL/token. Controller remains running.',flush=True)
-18
View File
@@ -1,18 +0,0 @@
[launcher]
logs = logs
[controller]
bind = 0.0.0.0
port = 8766
database = state/arcade_room.sqlite3
catalog = ../room_controller/catalog.json
operator_token =
cabinet_tokens = {}
[cabinet]
executable = ../../ArcadeEscape.exe
id = cabinet_01
; In all mode, blank URL/token use the local controller and this cabinet's token.
url =
token =
gpio = false