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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,7 @@
|
||||
[launcher]
|
||||
logs = logs
|
||||
|
||||
[cabinet]
|
||||
id = cabinet_03
|
||||
url = 10.0.0.57
|
||||
token = qZSD9eM2dmeRtmVA
|
||||
@@ -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())
|
||||
@@ -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"}]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user