Files
room_glitch/tools/deploy/deploy.py
T
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

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())