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

214 lines
9.7 KiB
Python

"""Uploaded over authenticated SSH; no daemon and no root required."""
import argparse
import configparser
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import platform
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
FILES = (
'ArcadeEscape.arm64', 'ArcadeEscape.pck',
'tools/launch/launch.py', 'tools/launch/pi_service.py',
'tools/launch/remote_cabinets.py', 'tools/pi_gpio/pi_gpio_bridge.py',
'tools/room_controller/server.py', 'tools/room_controller/operator.html',
'tools/room_controller/catalog.json',
'tools/pi_gpio/device_feedback.py',
'tools/pi_gpio/rfid_helper/Cargo.toml', 'tools/pi_gpio/rfid_helper/src/main.rs',
)
MAX_BYTES = 1024 * 1024 * 1024
SERVICE = 'arcade-cabinet.service'
MARKER = '# Managed by Arcade Escape pi_service.py'
def release_id(value):
if not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,63}', value):
raise ValueError('Invalid release ID')
return value
def private_write(path, text):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as output:
output.write(text)
path.chmod(0o600)
def unpack(stream, destination):
"""Read only the exact regular files named by our bounded manifest."""
with tarfile.open(fileobj=stream, mode='r|gz') as archive:
first = next(iter(archive), None)
if first is None or first.name != 'manifest.json' or not first.isfile() or first.size > 65536:
raise ValueError('Release must begin with a small regular manifest.json')
manifest = json.load(archive.extractfile(first))
release_id(manifest['release'])
expected = manifest['files']
if set(expected) != set(FILES):
raise ValueError('Release contains an unexpected file set')
if any(not isinstance(v, str) or not re.fullmatch('[0-9a-f]{64}', v) for v in expected.values()):
raise ValueError('Invalid release checksums')
seen, total = set(), 0
# TarFile iteration begins at its cached first member; skip the manifest once.
for member in archive:
if member is first:
continue
if member.name not in expected or member.name in seen or not member.isfile():
raise ValueError('Unexpected, duplicate, or nonregular archive entry')
total += member.size
if total > MAX_BYTES:
raise ValueError('Release exceeds size limit')
target = destination / member.name
target.parent.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
with archive.extractfile(member) as source, target.open('xb') as output:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
output.write(chunk)
if digest.hexdigest() != expected[member.name]:
raise ValueError('Checksum mismatch: ' + member.name)
target.chmod(0o700 if member.name == 'ArcadeEscape.arm64' else 0o600)
seen.add(member.name)
if seen != set(expected):
raise ValueError('Incomplete release')
private_write(destination / 'manifest.json', json.dumps(manifest, indent=2))
return manifest
def verify_release(folder):
manifest = json.loads((folder / 'manifest.json').read_text(encoding='utf-8'))
if set(manifest['files']) != set(FILES):
raise ValueError('Unexpected release contents')
for name, expected in manifest['files'].items():
target = folder / name
if target.is_symlink() or hashlib.sha256(target.read_bytes()).hexdigest() != expected:
raise ValueError('Release verification failed: ' + name)
return manifest
def preflight():
runtime = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/' + str(os.getuid())))
return dict(architecture=platform.machine(), python=list(sys.version_info[:3]),
gpio_packages=all(importlib.util.find_spec(m) is not None for m in ('gpiozero', 'lgpio')),
display_ready=(runtime / 'arcade-cabinet-display.env').is_file(),
free_bytes=shutil.disk_usage(Path.home()).free)
def render_config(settings):
cabinet, token, url = (settings[k] for k in ('id', 'token', 'url'))
if not re.fullmatch(r'[A-Za-z0-9_]+', cabinet):
raise ValueError('Invalid cabinet ID')
if not re.fullmatch(r'[A-Za-z0-9_-]{16,128}', token):
raise ValueError('Invalid cabinet token')
from urllib.parse import urlsplit
parsed = urlsplit(url)
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.path not in ('', '/') or parsed.query or parsed.fragment or any(c.isspace() for c in url):
raise ValueError('Invalid controller URL')
if type(settings.get('gpio')) is not bool:
raise ValueError('GPIO must be true or false')
pins = settings.get('pins', [])
if not isinstance(pins, list) or any(type(p) is not int or not 0 <= p <= 27 for p in pins) or len(set(pins)) != len(pins) or (settings['gpio'] and not pins):
raise ValueError('Invalid BCM pin allowlist')
controller = settings.get('gpio_controller', cabinet + '_gpio')
if not re.fullmatch(r'[A-Za-z0-9_]+', controller):
raise ValueError('Invalid GPIO controller ID')
config = configparser.ConfigParser(interpolation=None)
config['cabinet'] = dict(executable='../../ArcadeEscape.arm64', id=cabinet,
url=url.rstrip('/'), token=token, gpio=str(settings['gpio']).lower())
config['gpio'] = dict(controller=controller, pins=','.join(map(str, pins)),
ledger='~/.local/state/arcade_gpio/requests.sqlite3')
config['launcher'] = dict(logs='~/.local/state/arcade_cabinet/logs')
output = io.StringIO()
config.write(output)
return output.getvalue()
def stage(root, stream):
releases = root / 'releases'
releases.mkdir(parents=True, exist_ok=True, mode=0o700)
with tempfile.TemporaryDirectory(prefix='.incoming-', dir=releases) as temp:
folder = Path(temp)
manifest = unpack(stream, folder)
target = releases / manifest['release']
if target.exists():
if verify_release(target) != manifest:
raise ValueError('Release ID already exists with different contents')
print('Release already staged and verified: ' + manifest['release'])
return
folder.rename(target)
print('Staged and verified: ' + manifest['release'] + '; not activated')
def activate(root, release, settings):
folder = root / 'releases' / release_id(release)
verify_release(folder)
text = render_config(settings)
catalog = json.loads((folder / 'tools/room_controller/catalog.json').read_text(encoding='utf-8'))
if settings['id'] not in catalog['cabinets']:
raise ValueError('Cabinet ID is not included in this release catalog')
if subprocess.run(['systemctl', '--user', 'is-active', '--quiet', SERVICE]).returncode == 0:
raise ValueError('Cabinet is running. Stop it explicitly before activating a release.')
home = Path.home() / '.config'
managed = [home / 'systemd/user' / SERVICE, home / 'autostart/arcade-cabinet-display.desktop']
previous = {}
for path in managed:
previous[path] = path.read_bytes() if path.exists() else None
if previous[path] is not None and not previous[path].decode('utf-8').startswith(MARKER):
raise ValueError('Refusing to replace an unrelated service/helper')
config = folder / 'tools/launch/launcher.ini'
old_config = config.read_bytes() if config.exists() else None
private_write(config, text)
try:
subprocess.run([sys.executable, str(folder / 'tools/launch/pi_service.py'), 'install',
'--remote', '--config', str(config)], check=True)
# Informational receipt; service refers directly to the immutable release path.
private_write(root / 'active.json', json.dumps(dict(release=release, cabinet=settings['id'])))
except Exception:
for path, content in previous.items():
if content is None:
path.unlink(missing_ok=True)
else:
private_write(path, content.decode('utf-8'))
if old_config is None:
config.unlink(missing_ok=True)
else:
private_write(config, old_config.decode('utf-8'))
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=False)
raise
print('Activated ' + release + '. Service is stopped; start after desktop readiness is confirmed.')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('action', choices=['preflight', 'stage', 'activate'])
parser.add_argument('--release')
args = parser.parse_args()
try:
if not sys.platform.startswith('linux') or os.getuid() == 0:
raise ValueError('Use the normal Pi desktop account, not root')
if platform.machine() not in ('aarch64', 'arm64'):
raise ValueError('A 64-bit ARM Raspberry Pi OS is required')
root = Path.home() / '.local/share/arcade-deploy'
if args.action == 'preflight':
print(json.dumps(preflight()))
elif args.action == 'stage':
stage(root, sys.stdin.buffer)
else:
activate(root, args.release, json.loads(sys.stdin.read(65536)))
return 0
except (OSError, ValueError, KeyError, TypeError, tarfile.TarError, subprocess.CalledProcessError) as exc:
print('Deployment failed: ' + str(exc), file=sys.stderr)
return 1
if __name__ == '__main__':
sys.exit(main())