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
214 lines
9.7 KiB
Python
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())
|