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
314 lines
15 KiB
Python
314 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""First-run master setup and explicit SSH deployment for Arcade Escape."""
|
|
import argparse
|
|
import configparser
|
|
import hashlib
|
|
import io
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
import urllib.request
|
|
from receiver import FILES, private_write, release_id, render_config
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_SITE = Path.home() / '.local/share/arcade-master'
|
|
|
|
|
|
def catalog():
|
|
return json.loads((ROOT / 'tools/room_controller/catalog.json').read_text(encoding='utf-8'))
|
|
|
|
|
|
def validate(site):
|
|
if site.get('schema') != 1 or not site.get('cabinets'):
|
|
raise ValueError('Expected schema 1 and at least one cabinet')
|
|
tokens = [site['operator_token']]
|
|
seen, targets = set(), set()
|
|
for item in site['cabinets']:
|
|
for key, pattern in [('id', r'[A-Za-z0-9_]+'), ('host', r'[A-Za-z0-9][A-Za-z0-9.-]*'),
|
|
('user', r'[A-Za-z_][A-Za-z0-9_-]*')]:
|
|
if not isinstance(item.get(key), str) or not re.fullmatch(pattern, item[key]):
|
|
raise ValueError('Invalid cabinet ' + key)
|
|
if item['id'] not in catalog()['cabinets']:
|
|
raise ValueError('Cabinet ID is not in the exported catalog: ' + item['id'])
|
|
target = (item['host'].lower(), item.get('port', 22))
|
|
if item['id'] in seen or target in targets:
|
|
raise ValueError('Duplicate cabinet ID or SSH target')
|
|
seen.add(item['id'])
|
|
targets.add(target)
|
|
if type(target[1]) is not int or not 1 <= target[1] <= 65535:
|
|
raise ValueError('Invalid SSH port')
|
|
tokens.append(item['token'])
|
|
render_config(dict(item, url=site['controller_url']))
|
|
if any(not isinstance(t, str) or not re.fullmatch(r'[A-Za-z0-9_-]{16,128}', t) for t in tokens) or len(set(tokens)) != len(tokens):
|
|
raise ValueError('Operator and cabinet tokens must be distinct, 16+ character URL-safe strings')
|
|
from urllib.parse import urlsplit
|
|
parsed = urlsplit(site['controller_url'])
|
|
# This launcher hosts HTTP locally; a TLS proxy would require additional configuration.
|
|
if parsed.scheme != 'http' or parsed.port != 8766:
|
|
raise ValueError('Use http://MASTER_PRIVATE_IP:8766 for the cabinet controller URL')
|
|
address = ipaddress.ip_address(parsed.hostname)
|
|
if address.version != 4 or address.is_loopback or address.is_unspecified or address.is_multicast:
|
|
raise ValueError('Use the master private-interface IPv4 address, not localhost')
|
|
if any(item['host'] == str(address) for item in site['cabinets']):
|
|
raise ValueError('A cabinet target cannot be the master controller IP')
|
|
|
|
|
|
def load(folder):
|
|
site = json.loads((folder / 'inventory.json').read_text(encoding='utf-8'))
|
|
validate(site)
|
|
return site
|
|
|
|
|
|
def generate(folder, site):
|
|
validate(site)
|
|
key = folder / 'ssh/arcade_cabinets'
|
|
ini = configparser.ConfigParser(interpolation=None)
|
|
ini['controller'] = dict(bind='0.0.0.0', port='8766', operator_token=site['operator_token'],
|
|
cabinet_tokens=json.dumps({c['id']: c['token'] for c in site['cabinets']}),
|
|
catalog=str(ROOT / 'tools/room_controller/catalog.json'), database=str(folder / 'state/arcade_room.sqlite3'))
|
|
ini['launcher'] = dict(logs=str(folder / 'logs'))
|
|
ini['remote'] = dict(enabled='true', cabinets=json.dumps([
|
|
{k: c[k] for k in ('id', 'host', 'user', 'port')} | {'identity_file': str(key)}
|
|
for c in site['cabinets']]))
|
|
output = io.StringIO()
|
|
ini.write(output)
|
|
private_write(folder / 'controller.ini', output.getvalue())
|
|
for c in site['cabinets']:
|
|
private_write(folder / 'cabinets' / (c['id'] + '.ini'), render_config(dict(c, url=site['controller_url'])))
|
|
|
|
|
|
def prompt(label, default=''):
|
|
value = input(label + (f' [{default}]' if default else '') + ': ').strip()
|
|
return value or default
|
|
|
|
|
|
def setup(folder):
|
|
path = folder / 'inventory.json'
|
|
if path.exists():
|
|
site = load(folder)
|
|
print('Existing site loaded; credentials will be preserved.')
|
|
else:
|
|
site = dict(schema=1, operator_token=secrets.token_urlsafe(24), controller_url='', cabinets=[])
|
|
site['controller_url'] = prompt('Master PRIVATE URL', site['controller_url'] or 'http://192.168.77.1:8766')
|
|
print('Available cabinet profiles: ' + ', '.join(catalog()['cabinets']))
|
|
existing = {c['id']: c for c in site['cabinets']}
|
|
while True:
|
|
cabinet = prompt('Cabinet ID to add/edit (Enter when done)')
|
|
if not cabinet:
|
|
break
|
|
if cabinet not in catalog()['cabinets']:
|
|
print('Choose an ID in the list. New IDs require an updated game export and catalog.')
|
|
continue
|
|
c = dict(existing.get(cabinet, dict(id=cabinet, token=secrets.token_urlsafe(24), port=22)))
|
|
c['host'] = prompt('Cabinet IP or hostname', c.get('host', ''))
|
|
c['user'] = prompt('Pi desktop / SSH username', c.get('user', ''))
|
|
c['port'] = int(prompt('SSH port', str(c.get('port', 22))))
|
|
c['gpio'] = prompt('Enable real GPIO? yes/no', 'yes' if c.get('gpio') else 'no').lower() == 'yes'
|
|
pins = prompt('Allowed BCM pins, comma-separated', ','.join(map(str, c.get('pins', []))))
|
|
c['pins'] = [int(p.strip()) for p in pins.split(',') if p.strip()]
|
|
c['gpio_controller'] = prompt('GPIO controller ID from cabinet hardware profile', c.get('gpio_controller', cabinet + '_gpio'))
|
|
existing[cabinet] = c
|
|
site['cabinets'] = list(existing.values())
|
|
validate(site)
|
|
private_write(path, json.dumps(site, indent=2))
|
|
generate(folder, site)
|
|
print('Saved site inventory and generated configs at ' + str(folder))
|
|
print('Operator credential is in controller.ini. No cabinet contacted; no network settings changed.')
|
|
|
|
|
|
def ssh_args(folder, c, batch=True):
|
|
key = folder / 'ssh/arcade_cabinets'
|
|
if not key.is_file():
|
|
raise ValueError('Run enroll first to generate and authorize the master key')
|
|
return ['ssh', '-T', '-p', str(c.get('port', 22)), '-i', str(key),
|
|
'-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=yes',
|
|
'-o', 'BatchMode=' + ('yes' if batch else 'no'),
|
|
'-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=10',
|
|
'-o', 'ServerAliveCountMax=3', c['user'] + '@' + c['host']]
|
|
|
|
|
|
def enroll(folder, c):
|
|
if not sys.platform.startswith('linux'):
|
|
raise ValueError('Run enrollment on the master Pi (requires ssh-copy-id)')
|
|
if not shutil.which('ssh-copy-id'):
|
|
raise ValueError('Install openssh-client on the master')
|
|
key = folder / 'ssh/arcade_cabinets'
|
|
key.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
if not key.exists():
|
|
subprocess.run(['ssh-keygen', '-t', 'ed25519', '-N', '', '-f', str(key), '-C', 'arcade-master'], check=True)
|
|
if not key.with_suffix('.pub').is_file():
|
|
raise ValueError('Private key exists but .pub is missing; refusing to replace the key')
|
|
print('Verify the Pi host fingerprint when prompted. Enter its existing SSH password if requested.')
|
|
subprocess.run(['ssh-copy-id', '-i', str(key) + '.pub', '-p', str(c.get('port', 22)),
|
|
'-o', 'StrictHostKeyChecking=ask', c['user'] + '@' + c['host']], check=True)
|
|
subprocess.run(ssh_args(folder, c) + ['true'], check=True, timeout=30)
|
|
print(c['id'] + ': key authentication verified')
|
|
|
|
|
|
def remote(folder, c, action, source=None, release=None):
|
|
code = Path(__file__).with_name('receiver.py').read_text(encoding='utf-8')
|
|
command = 'python3 -c ' + shlex.quote(code) + ' ' + shlex.quote(action)
|
|
if release is not None:
|
|
command += ' --release ' + shlex.quote(release_id(release))
|
|
subprocess.run(ssh_args(folder, c) + [command], stdin=source or subprocess.DEVNULL, check=True)
|
|
|
|
|
|
def bundle(export, output, release):
|
|
release_id(release)
|
|
paths = {name: (export / name if name.startswith('ArcadeEscape.') else ROOT / name) for name in FILES}
|
|
with paths['ArcadeEscape.arm64'].open('rb') as binary:
|
|
header = binary.read(20)
|
|
if header[:6] != b'\x7fELF\x02\x01' or header[18:20] != b'\xb7\x00':
|
|
raise ValueError('Expected a little-endian ARM64 Linux game export')
|
|
with paths['ArcadeEscape.pck'].open('rb') as pack:
|
|
if pack.read(4) != b'GDPC':
|
|
raise ValueError('Invalid Godot PCK')
|
|
manifest = dict(release=release, files={name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in paths.items()})
|
|
encoded = json.dumps(manifest).encode()
|
|
# Exclusive creation prevents silently replacing a released bundle.
|
|
with output.open('xb') as raw, tarfile.open(fileobj=raw, mode='w:gz') as archive:
|
|
info = tarfile.TarInfo('manifest.json')
|
|
info.size = len(encoded)
|
|
info.mode = 0o600
|
|
archive.addfile(info, io.BytesIO(encoded))
|
|
for name, path in paths.items():
|
|
info = archive.gettarinfo(str(path), arcname=name)
|
|
info.uid = info.gid = 0
|
|
info.uname = info.gname = ''
|
|
info.mode = 0o700 if name.endswith('.arm64') else 0o600
|
|
with path.open('rb') as content:
|
|
archive.addfile(info, content)
|
|
print('Created release bundle: ' + str(output))
|
|
|
|
|
|
def master_package(output):
|
|
# Explicit allowlist: never ship a developer's INI, credentials, database or keys.
|
|
output.mkdir(parents=True, exist_ok=False)
|
|
names = [name for name in FILES if not name.startswith('ArcadeEscape.')]
|
|
names += ['tools/deploy/deploy.py', 'tools/deploy/receiver.py', 'tools/deploy/README.md']
|
|
for name in names:
|
|
target = output / name
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(ROOT / name, target)
|
|
print('Portable master software: ' + str(output))
|
|
|
|
|
|
def install_master(folder):
|
|
if not sys.platform.startswith('linux') or os.getuid() == 0:
|
|
raise ValueError('Install the master service as the normal master Pi user, without sudo')
|
|
sys.path.insert(0, str(ROOT / 'tools/launch'))
|
|
from pi_service import quoted, working_directory
|
|
path = Path.home() / '.config/systemd/user/arcade-master.service'
|
|
marker = '# Managed by Arcade Escape deploy.py'
|
|
if path.exists() and not path.read_text(encoding='utf-8').startswith(marker):
|
|
raise ValueError('Refusing to overwrite an unrelated master service')
|
|
unit = f'''{marker}
|
|
[Unit]
|
|
Description=Arcade room master controller
|
|
[Service]
|
|
Type=exec
|
|
WorkingDirectory={working_directory(ROOT)}
|
|
ExecStart=:{quoted(sys.executable)} {quoted(ROOT / 'tools/launch/launch.py')} controller --config {quoted(folder / 'controller.ini')}
|
|
UnsetEnvironment=ARCADE_ROOM_URL ARCADE_CABINET_TOKEN ARCADE_OPERATOR_TOKEN ARCADE_CABINET_TOKENS
|
|
Restart=on-failure
|
|
RestartSec=10
|
|
KillMode=mixed
|
|
TimeoutStopSec=15
|
|
[Install]
|
|
WantedBy=default.target
|
|
'''
|
|
private_write(path, unit)
|
|
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
|
|
result = subprocess.check_output(['systemctl', '--user', 'show', 'arcade-master.service', '--property=LoadState', '--value'], text=True)
|
|
if result.strip() != 'loaded':
|
|
raise ValueError('systemd rejected the master unit; inspect systemctl --user status arcade-master.service')
|
|
subprocess.run(['systemctl', '--user', 'enable', 'arcade-master.service'], check=True)
|
|
print('Master service installed/enabled, not started. For startup before login run:')
|
|
print('sudo loginctl enable-linger ' + shlex.quote(os.environ.get('USER', 'YOUR_MASTER_USERNAME')))
|
|
print('Then: systemctl --user start arcade-master.service')
|
|
|
|
|
|
def health(site):
|
|
request = urllib.request.Request('http://127.0.0.1:8766/operator/state', data=b'{}',
|
|
headers={'Authorization': 'Bearer ' + site['operator_token']})
|
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
state = json.load(response)
|
|
for c in site['cabinets']:
|
|
result = state.get('cabinets', {}).get(c['id'], {})
|
|
print(c['id'] + ': ' + ('ONLINE' if result.get('online') else 'OFFLINE'))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--site', type=Path, default=DEFAULT_SITE)
|
|
sub = parser.add_subparsers(dest='action', required=True)
|
|
sub.add_parser('setup', help='Interactive inventory/configuration wizard; no remote changes')
|
|
sub.add_parser('generate', help='Validate edited inventory and regenerate configs')
|
|
sub.add_parser('install-master')
|
|
sub.add_parser('health')
|
|
master = sub.add_parser('master-package')
|
|
master.add_argument('--output', type=Path, required=True)
|
|
pack = sub.add_parser('bundle')
|
|
pack.add_argument('--export', type=Path, required=True)
|
|
pack.add_argument('--output', type=Path, required=True)
|
|
pack.add_argument('--release', required=True)
|
|
for action in ('enroll', 'preflight', 'stage', 'activate', 'start', 'stop', 'status', 'logs'):
|
|
command = sub.add_parser(action)
|
|
command.add_argument('cabinet', help='One registered cabinet ID; no implicit fleet-wide mutations')
|
|
if action == 'stage': command.add_argument('--bundle', type=Path, required=True)
|
|
if action == 'activate': command.add_argument('--release', required=True)
|
|
args = parser.parse_args()
|
|
folder = args.site.expanduser().resolve()
|
|
try:
|
|
if args.action == 'master-package':
|
|
master_package(args.output.resolve())
|
|
return 0
|
|
if args.action == 'bundle':
|
|
bundle(args.export.resolve(), args.output.resolve(), args.release)
|
|
return 0
|
|
if args.action == 'setup':
|
|
setup(folder)
|
|
return 0
|
|
site = load(folder)
|
|
if args.action == 'generate': generate(folder, site)
|
|
elif args.action == 'install-master': install_master(folder)
|
|
elif args.action == 'health': health(site)
|
|
else:
|
|
c = next((c for c in site['cabinets'] if c['id'] == args.cabinet), None)
|
|
if c is None: raise ValueError('Cabinet not registered in this site')
|
|
if args.action == 'enroll': enroll(folder, c)
|
|
elif args.action == 'preflight': remote(folder, c, 'preflight')
|
|
elif args.action == 'stage':
|
|
with args.bundle.open('rb') as source: remote(folder, c, 'stage', source)
|
|
elif args.action == 'activate':
|
|
import tempfile
|
|
with tempfile.TemporaryFile() as source:
|
|
source.write(json.dumps(dict(c, url=site['controller_url'])).encode())
|
|
source.seek(0)
|
|
remote(folder, c, 'activate', source, args.release)
|
|
else:
|
|
command = ['journalctl', '--user', '-u', 'arcade-cabinet.service', '-n', '60', '--no-pager'] if args.action == 'logs' else ['systemctl', '--user', args.action, 'arcade-cabinet.service', '--no-pager']
|
|
subprocess.run(ssh_args(folder, c) + command, check=True, timeout=45)
|
|
return 0
|
|
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc:
|
|
# CalledProcessError's command can contain configuration input only on stdin,
|
|
# but suppress the huge receiver source from diagnostics as well.
|
|
if isinstance(exc, subprocess.CalledProcessError):
|
|
print('Command failed (exit ' + str(exc.returncode) + '); see diagnostics above.', file=sys.stderr)
|
|
else:
|
|
print('Setup failed: ' + str(exc), file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|