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
@@ -0,0 +1,213 @@
|
||||
"""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())
|
||||
Reference in new issue
Block a user