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:
Jeff Luke committed 2026-10-17 14:53:54 -07:00
1 parent 705ec31e0b
commit c1b2195834
90 files changed
+4969 -456

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
__pycache__/
site/
*.tar.gz
+134
View File
@@ -0,0 +1,134 @@
# Master Pi deployment: first box test
This first implementation provides a **terminal setup wizard**, portable master software, versioned cabinet uploads, configuration generation and SSH service controls. The normal browser operator remains the game-master interface. There is not yet a browser provisioning wizard or automatic network reconfiguration.
It does not connect to any Pi until you explicitly run an enrollment/deployment command. It does not pulse GPIO, trigger a puzzle, reset controller mappings, or copy the development site's credentials. Use one spare cabinet first.
## 1. Prepare clean deployment packages on the development PC
From the project root (replace the release label for each build):
```powershell
py -3 tools/deploy/deploy.py master-package --output D:/ArcadeMaster-test1
py -3 tools/deploy/deploy.py bundle --export setup/pi --output D:/arcade-test1.tar.gz --release test1
```
The output directory and bundle must not already exist. The master package is an explicit allowlist of Python/scripts, operator assets and catalog. The cabinet bundle contains the current ARM64 executable/PCK plus scripts and a checksum manifest. No live INIs, tokens, SQLite files, SSH keys or Odin files are copied. Package an export built from the same game/cabinet catalog: the packager validates ARM64/PCK headers, but cannot prove a PCK was exported from the matching source revision.
Copy `ArcadeMaster-test1` and `arcade-test1.tar.gz` to the master Pi, for example under its desktop user's home. No Godot editor is needed on the master.
## 2. Network for the box
First confirm the physical arrangement: master Ethernet to the box's private switch, cabinet Ethernet to that switch, and master Wi-Fi (or a second Ethernet adapter) to the venue LAN. Do not also connect the private switch to the venue router. Cabinet Wi-Fi must not bypass the private network.
A plain switch does not supply DHCP. For the initial box test, manually configure **unique static wired addresses** through each Pi's desktop network settings. Choose a subnet that does not overlap the venue network; an example is master `192.168.77.1/24`, cabinet 1 `.11/24`, cabinet 2 `.12/24`, cabinet 3 `.13/24`. Leave the private connection's gateway and DNS blank, and keep the master's default Internet route on its venue connection. Record the real addresses in setup below. A later installer can provide private DHCP reservations.
The deployment tool does not install a firewall or enforce network isolation. The master must not bridge the interfaces or enable IP forwarding/connection sharing. For an enforced deployment, apply a firewall policy that drops forwarding in both IPv4 and IPv6, permits cabinet controller traffic to the master on TCP 8766, and permits venue browsers to the operator port. This needs the actual interface/subnet information; do not blindly replace an existing firewall. Staff on an isolated guest Wi-Fi may not be allowed to reach the master.
You may test the software on the existing LAN first; that is **not** a closed cabinet network. The browser connects to `http://MASTER_VENUE_IP:8766`; cabinets use `http://MASTER_PRIVATE_IP:8766`. Game-master commands travel through the master. No direct staff-browser-to-cabinet route is required.
## 3. Pi prerequisites
Use Raspberry Pi OS **64-bit with a desktop** on the cabinets. Enable SSH and desktop autologin for the same ordinary user who runs the game. The master needs Python 3 and the OpenSSH client; it can be headless after initial setup.
On the master, if the commands are missing:
```sh
sudo apt install python3 openssh-client
```
On cabinets using real GPIO, install dependencies before disconnecting Internet access:
```sh
sudo apt install python3-gpiozero python3-lgpio
```
The cabinet user needs GPIO device permission (normally the Raspberry Pi OS `gpio` group). The first test may use GPIO disabled. Do not guess MOSFET/NFC pins: enable only the BCM outputs already defined and wired for that cabinet. NFC input integration is separate from deployment.
## 4. Register the site on the master
From the copied master folder:
```sh
python3 tools/deploy/deploy.py setup
```
The wizard asks for the private controller URL, each cabinet's existing catalog ID, its address and SSH user, and GPIO settings. It generates distinct site credentials and configurations in `~/.local/share/arcade-master/`, with private permissions. It never asks for or stores the Pi login password. Re-running setup edits entries and preserves tokens. Press Enter at the cabinet-ID prompt when finished.
For this release choose `cabinet_01`, `cabinet_02`, or `cabinet_03`, matching the existing exported hardware profiles. Arbitrary new IDs require a matching export/catalog; dynamic runtime profiles are not implemented yet. The GPIO controller ID must match the project's hardware action definitions, not merely a newly invented name.
To inspect/edit the inventory later, use `inventory.json` in that site directory and run `python3 tools/deploy/deploy.py generate`. Never rotate or hand out another cabinet's token accidentally. Changing an active site's master credentials/config requires a controller restart and matching cabinet reactivation. Do maintenance between sessions.
## 5. Enroll, check and upload ONE cabinet
```sh
python3 tools/deploy/deploy.py enroll cabinet_01
python3 tools/deploy/deploy.py preflight cabinet_01
python3 tools/deploy/deploy.py stage cabinet_01 --bundle ~/arcade-test1.tar.gz
```
Enrollment creates a dedicated master SSH key if none exists, asks you to verify the target host fingerprint, and uses `ssh-copy-id` with the cabinet's existing login. It preserves other authorized keys. The key has no passphrase so startup can be unattended; protect the master account and its site backup. Subsequent commands require the verified host key and key-based authentication without prompts.
Preflight reports architecture, Python version, GPIO-package availability, free space and whether the display helper has captured a desktop session. Uploads use SSH and verify an exact file allowlist and every SHA-256 checksum. Staging does not stop or start a game. An interrupted upload leaves the previous release untouched; rerun stage. Reusing a release ID with different contents is rejected.
## 6. Activate and initialize the desktop helper
Stop any manually launched game/bridge first. If the cabinet service is running, explicitly stop it between tests:
```sh
python3 tools/deploy/deploy.py stop cabinet_01
python3 tools/deploy/deploy.py activate cabinet_01 --release test1
```
Skip the stop command on a fresh cabinet with no service. Activation refuses to replace a running service and refuses to overwrite unrelated service files. It writes that cabinet's INI and installs the normal user service through SSH. The master/operator token is never sent to the cabinet. If registration fails, prior service files/config are restored.
On a **fresh cabinet**, log out and back into its desktop once after activation, or run this on its local desktop terminal:
```sh
python3 ~/.local/share/arcade-deploy/releases/test1/tools/launch/pi_service.py refresh-session
```
The desktop autostart helper refreshes this display environment at future logins. SSH cannot safely guess the active display. The first remote install therefore reports waiting for desktop readiness instead of pretending the game has started. No root process renders the game.
## 7. Start the master and verify
On the master, from its software folder:
```sh
python3 tools/deploy/deploy.py install-master
sudo loginctl enable-linger "$USER"
systemctl --user start arcade-master.service
python3 tools/deploy/deploy.py health
```
The installer enables the master user service, but does not start it until the start command. Lingering starts the master service at boot even without a desktop login. The master launcher tries to start the registered cabinet services and reports authenticated ONLINE status separately from SSH success. A cabinet must already have its display environment ready. If it logs in later, issue `start` below; startup has bounded waits, not an endless retry loop.
Open `http://MASTER_VENUE_IP:8766` in a staff browser. Read the operator token from the master site's `controller.ini`; it is separate from SSH credentials and cabinet tokens. Existing scenario controls, overrides and hardware actions remain available in the operator page. Deployment does not automatically prepare a scenario or test a hardware output.
```sh
python3 tools/deploy/deploy.py start cabinet_01
python3 tools/deploy/deploy.py status cabinet_01
python3 tools/deploy/deploy.py logs cabinet_01
python3 tools/deploy/deploy.py health
```
To diagnose the master: `journalctl --user -u arcade-master.service -n 60 --no-pager`.
## Updates, rollback and state
Use a new release label and stage it while the old game is running. Between sessions, stop the cabinet, activate the new release and start it. To roll back, stop it and activate the previous release label again. Previous releases are retained; no automatic cleanup deletes them. Reusing the same release for another cabinet identity on the same device overwrites only that release's generated INI after the service is stopped.
Game user data/control mappings stay in Godot's existing user-data directory. GPIO deduplication stays at `~/.local/state/arcade_gpio/requests.sqlite3`. Cabinet logs are under `~/.local/state/arcade_cabinet/logs`. The new master site has its own controller database under the site's `state/`; it does not silently import or overwrite the development controller database.
Back up the master site folder (inventory, tokens, SSH key and controller database) securely. After moving to a non-overlapping venue network, retain the private addresses and update the master venue connection/operator bookmark. This version does not reconfigure network interfaces automatically.
## Box acceptance checklist
- One cabinet enrolls, stages and activates; a failed upload does not stop the existing game.
- A fresh desktop login produces display readiness and the game appears on the cabinet monitor.
- Operator sees the expected cabinet ONLINE and can prepare/start/reset the existing scenario.
- Existing manual progression/hardware controls still work through the master; only test a known wired output deliberately.
- Restart the master; it comes back without a login and cabinets reconnect without replaying a reward.
- Restart a cabinet; after desktop login, explicitly start it if the master's initial startup attempt has already passed.
- Verify staff can reach the operator and, once isolation is configured, cannot directly reach cabinet addresses.
- Repeat with remaining cabinets, then run the three-cabinet test.
+313
View File
@@ -0,0 +1,313 @@
#!/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())
+213
View File
@@ -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())