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
138 lines
5.6 KiB
Python
138 lines
5.6 KiB
Python
#!/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())
|